Compare commits
11 Commits
fallenbage
...
new-logo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e1e7030a7 | ||
|
|
adbcf80333 | ||
|
|
f91a26befe | ||
|
|
0c95b5ec91 | ||
|
|
193d4dc668 | ||
|
|
d0c9afc16e | ||
|
|
57d583e1bd | ||
|
|
8bbe7864af | ||
|
|
66b4e2c871 | ||
|
|
3ee69663dc | ||
|
|
539d49879d |
8
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -91,6 +91,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Please provide any additional information that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this bug has already been reported?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
8
.github/ISSUE_TEMPLATE/enhancement.yml
vendored
@@ -27,6 +27,14 @@ body:
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Provide any additional information or screenshots that may be relevant or helpful.
|
||||
- type: checkboxes
|
||||
id: search-existing
|
||||
attributes:
|
||||
label: Search Existing Issues
|
||||
description: Have you searched existing issues to see if this feature has already been requested?
|
||||
options:
|
||||
- label: Yes, I have searched existing issues.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
22
README.md
@@ -8,7 +8,7 @@
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/seerr"><img src="https://img.shields.io/discord/783137440809746482" alt="Discord"></a>
|
||||
<a href="https://hub.docker.com/r/seerr/seerr"><img src="https://img.shields.io/docker/pulls/seerr/seerr" alt="Docker pulls"></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/seerr-frontend/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://translate.seerr.dev/engage/seerr/"><img src="https://translate.seerr.dev/widget/seerr/svg-badge.svg" alt="Translation status" /></a>
|
||||
<a href="https://github.com/seerr-team/seerr/blob/develop/LICENSE"><img alt="GitHub" src="https://img.shields.io/github/license/seerr-team/seerr"></a>
|
||||
|
||||
**Seerr** is a free and open source software application for managing requests for your media library. It integrates with the media server of your choice: [Jellyfin](https://jellyfin.org), [Plex](https://plex.tv), and [Emby](https://emby.media/). In addition, it integrates with your existing services, such as **[Sonarr](https://sonarr.tv/)**, **[Radarr](https://radarr.video/)**.
|
||||
@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our documentation for instructions on how to install and run Seerr:
|
||||
For instructions on how to install and run **Jellyseerr**, please refer to the official documentation:
|
||||
|
||||
https://docs.seerr.dev/getting-started/
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Seerr is not officially released yet.**
|
||||
> The project is currently available **only on the `develop` branch** and is intended for **beta testing only**.
|
||||
|
||||
The documentation linked above is for running the **latest Jellyseerr** release.
|
||||
|
||||
> [!WARNING]
|
||||
> If you are migrating from **Overseerr** to **Seerr** for beta testing, **do not follow the Jellyseerr latest setup guide**.
|
||||
|
||||
Instead, follow the dedicated migration guide (with `:develop` tag):
|
||||
https://github.com/seerr-team/seerr/blob/develop/docs/migration-guide.mdx
|
||||
|
||||
> [!CAUTION]
|
||||
> **DO NOT run Jellyseerr (latest) using an existing Overseerr database. This includes third-party images with `seerr:latest` (as it points to jellyseerr 2.7.3 and not seerr.**
|
||||
> Doing so **may cause database corruption and/or irreversible data loss and/or weird unintended behaviour**.
|
||||
|
||||
For migration assistance, beta testing questions, or troubleshooting, please join our **Discord** and ask for support there.
|
||||
|
||||
## Preview
|
||||
|
||||
<img src="./public/preview.jpg">
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 821 B After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 410 KiB |
@@ -1,8 +1,15 @@
|
||||
import { DbAwareColumn } from '@server/utils/DbColumnHelper';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from './User';
|
||||
|
||||
@Entity()
|
||||
@Unique(['endpoint', 'user'])
|
||||
export class UserPushSubscription {
|
||||
@PrimaryGeneratedColumn()
|
||||
public id: number;
|
||||
|
||||
@@ -24,6 +24,15 @@ interface PushNotificationPayload {
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface WebPushError extends Error {
|
||||
statusCode?: number;
|
||||
status?: number;
|
||||
body?: string | unknown;
|
||||
response?: {
|
||||
body?: string | unknown;
|
||||
};
|
||||
}
|
||||
|
||||
class WebPushAgent
|
||||
extends BaseAgent<NotificationAgentConfig>
|
||||
implements NotificationAgent
|
||||
@@ -188,19 +197,30 @@ class WebPushAgent
|
||||
notificationPayload
|
||||
);
|
||||
} catch (e) {
|
||||
const webPushError = e as WebPushError;
|
||||
const statusCode = webPushError.statusCode || webPushError.status;
|
||||
const errorMessage = webPushError.message || String(e);
|
||||
|
||||
// RFC 8030: 410/404 are permanent failures, others are transient
|
||||
const isPermanentFailure = statusCode === 410 || statusCode === 404;
|
||||
|
||||
logger.error(
|
||||
'Error sending web push notification; removing subscription',
|
||||
isPermanentFailure
|
||||
? 'Error sending web push notification; removing invalid subscription'
|
||||
: 'Error sending web push notification (transient error, keeping subscription)',
|
||||
{
|
||||
label: 'Notifications',
|
||||
recipient: pushSub.user.displayName,
|
||||
type: Notification[type],
|
||||
subject: payload.subject,
|
||||
errorMessage: e.message,
|
||||
errorMessage,
|
||||
statusCode: statusCode || 'unknown',
|
||||
}
|
||||
);
|
||||
|
||||
// Failed to send notification so we need to remove the subscription
|
||||
userPushSubRepository.remove(pushSub);
|
||||
if (isPermanentFailure) {
|
||||
await userPushSubRepository.remove(pushSub);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ interface ProcessOptions {
|
||||
is4k?: boolean;
|
||||
mediaAddedAt?: Date;
|
||||
ratingKey?: string;
|
||||
jellyfinMediaId?: string;
|
||||
imdbId?: string;
|
||||
serviceId?: number;
|
||||
externalServiceId?: number;
|
||||
externalServiceSlug?: string;
|
||||
@@ -95,6 +97,8 @@ class BaseScanner<T> {
|
||||
is4k = false,
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
imdbId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -133,6 +137,21 @@ class BaseScanner<T> {
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
jellyfinMediaId &&
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] !==
|
||||
jellyfinMediaId
|
||||
) {
|
||||
existing[is4k ? 'jellyfinMediaId4k' : 'jellyfinMediaId'] =
|
||||
jellyfinMediaId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (imdbId && !existing.imdbId) {
|
||||
existing.imdbId = imdbId;
|
||||
changedExisting = true;
|
||||
}
|
||||
|
||||
if (
|
||||
serviceId !== undefined &&
|
||||
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
|
||||
@@ -173,6 +192,7 @@ class BaseScanner<T> {
|
||||
} else {
|
||||
const newMedia = new Media();
|
||||
newMedia.tmdbId = tmdbId;
|
||||
newMedia.imdbId = imdbId;
|
||||
|
||||
newMedia.status =
|
||||
!is4k && !processing
|
||||
@@ -203,6 +223,13 @@ class BaseScanner<T> {
|
||||
newMedia.ratingKey4k =
|
||||
is4k && this.enable4kMovie ? ratingKey : undefined;
|
||||
}
|
||||
|
||||
if (jellyfinMediaId) {
|
||||
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
|
||||
newMedia.jellyfinMediaId4k =
|
||||
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
|
||||
}
|
||||
|
||||
await mediaRepository.save(newMedia);
|
||||
this.log(`Saved new media: ${title}`);
|
||||
}
|
||||
@@ -221,11 +248,12 @@ class BaseScanner<T> {
|
||||
*/
|
||||
protected async processShow(
|
||||
tmdbId: number,
|
||||
tvdbId: number,
|
||||
tvdbId: number | undefined,
|
||||
seasons: ProcessableSeason[],
|
||||
{
|
||||
mediaAddedAt,
|
||||
ratingKey,
|
||||
jellyfinMediaId,
|
||||
serviceId,
|
||||
externalServiceId,
|
||||
externalServiceSlug,
|
||||
@@ -257,7 +285,7 @@ class BaseScanner<T> {
|
||||
(es) => es.seasonNumber === season.seasonNumber
|
||||
);
|
||||
|
||||
// We update the rating keys in the seasons loop because we need episode counts
|
||||
// We update the rating keys and jellyfinMediaId in the seasons loop because we need episode counts
|
||||
if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
|
||||
media.ratingKey = ratingKey;
|
||||
}
|
||||
@@ -271,6 +299,23 @@ class BaseScanner<T> {
|
||||
media.ratingKey4k = ratingKey;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes > 0 &&
|
||||
media.jellyfinMediaId !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (
|
||||
media &&
|
||||
season.episodes4k > 0 &&
|
||||
this.enable4kShow &&
|
||||
media.jellyfinMediaId4k !== jellyfinMediaId
|
||||
) {
|
||||
media.jellyfinMediaId4k = jellyfinMediaId;
|
||||
}
|
||||
|
||||
if (existingSeason) {
|
||||
// Here we update seasons if they already exist.
|
||||
// If the season is already marked as available, we
|
||||
@@ -491,6 +536,22 @@ class BaseScanner<T> {
|
||||
)
|
||||
? ratingKey
|
||||
: undefined,
|
||||
jellyfinMediaId: newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
jellyfinMediaId4k:
|
||||
this.enable4kShow &&
|
||||
newSeasons.some(
|
||||
(sn) =>
|
||||
sn.status4k === MediaStatus.PARTIALLY_AVAILABLE ||
|
||||
sn.status4k === MediaStatus.AVAILABLE
|
||||
)
|
||||
? jellyfinMediaId
|
||||
: undefined,
|
||||
status: isAllStandardSeasons
|
||||
? MediaStatus.AVAILABLE
|
||||
: newSeasons.some(
|
||||
|
||||
@@ -13,9 +13,7 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
}
|
||||
|
||||
const userRepository = getRepository(User);
|
||||
const users = await userRepository.find({
|
||||
select: ['id'],
|
||||
});
|
||||
const users = await userRepository.find();
|
||||
|
||||
let errorOccurred = false;
|
||||
|
||||
@@ -30,15 +28,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
});
|
||||
const radarrTags = await radarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = radarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
const userTag = radarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await radarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -61,15 +70,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
|
||||
});
|
||||
const sonarrTags = await sonarr.getTags();
|
||||
for (const user of users) {
|
||||
const userTag = sonarrTags.find((v) =>
|
||||
v.label.startsWith(user.id + ' - ')
|
||||
const userTag = sonarrTags.find(
|
||||
(v) =>
|
||||
v.label.startsWith(user.id + ' - ') ||
|
||||
v.label.startsWith(user.id + '-')
|
||||
);
|
||||
if (!userTag) {
|
||||
continue;
|
||||
}
|
||||
await sonarr.renameTag({
|
||||
id: userTag.id,
|
||||
label: userTag.label.replace(`${user.id} - `, `${user.id}-`),
|
||||
label:
|
||||
user.id +
|
||||
'-' +
|
||||
user.displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, ''),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "user_push_subscription"
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM "user_push_subscription"
|
||||
GROUP BY "endpoint", "userId"
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" ADD CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "user_push_subscription" DROP CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUniqueConstraintToPushSubscription1765233385034
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddUniqueConstraintToPushSubscription1765233385034';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
DELETE FROM "user_push_subscription"
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM "user_push_subscription"
|
||||
GROUP BY "endpoint", "userId"
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "UQ_6427d07d9a171a3a1ab87480005" ON "user_push_subscription" ("endpoint", "userId")`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX "UQ_6427d07d9a171a3a1ab87480005"`);
|
||||
}
|
||||
}
|
||||
@@ -626,76 +626,6 @@ authRoutes.post('/local', async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
const mainUser = await userRepository.findOneOrFail({
|
||||
select: { id: true, plexToken: true, plexId: true },
|
||||
where: { id: 1 },
|
||||
});
|
||||
const mainPlexTv = new PlexTvAPI(mainUser.plexToken ?? '');
|
||||
|
||||
if (!user.plexId) {
|
||||
try {
|
||||
const plexUsersResponse = await mainPlexTv.getUsers();
|
||||
const account = plexUsersResponse.MediaContainer.User.find(
|
||||
(account) =>
|
||||
account.$.email &&
|
||||
account.$.email.toLowerCase() === user.email.toLowerCase()
|
||||
)?.$;
|
||||
|
||||
if (
|
||||
account &&
|
||||
(await mainPlexTv.checkUserAccess(parseInt(account.id)))
|
||||
) {
|
||||
logger.info(
|
||||
'Found matching Plex user; updating user with Plex data',
|
||||
{
|
||||
label: 'API',
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
userId: user.id,
|
||||
plexId: account.id,
|
||||
plexUsername: account.username,
|
||||
}
|
||||
);
|
||||
|
||||
user.plexId = parseInt(account.id);
|
||||
user.avatar = account.thumb;
|
||||
user.email = account.email;
|
||||
user.plexUsername = account.username;
|
||||
user.userType = UserType.PLEX;
|
||||
|
||||
await userRepository.save(user);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Something went wrong fetching Plex users', {
|
||||
label: 'API',
|
||||
errorMessage: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
user.plexId &&
|
||||
user.plexId !== mainUser.plexId &&
|
||||
!(await mainPlexTv.checkUserAccess(user.plexId))
|
||||
) {
|
||||
logger.warn(
|
||||
'Failed sign-in attempt from Plex user without access to the media server',
|
||||
{
|
||||
label: 'API',
|
||||
account: {
|
||||
ip: req.ip,
|
||||
email: body.email,
|
||||
userId: user.id,
|
||||
plexId: user.plexId,
|
||||
},
|
||||
}
|
||||
);
|
||||
return next({
|
||||
status: 403,
|
||||
message: 'Access denied.',
|
||||
});
|
||||
}
|
||||
|
||||
// Set logged in session
|
||||
if (user && req.session) {
|
||||
req.session.userId = user.id;
|
||||
@@ -775,7 +705,7 @@ authRoutes.post('/logout', async (req, res, next) => {
|
||||
});
|
||||
return next({ status: 500, message: 'Failed to destroy session.' });
|
||||
}
|
||||
logger.info('Successfully logged out user', {
|
||||
logger.debug('Successfully logged out user', {
|
||||
label: 'Auth',
|
||||
userId,
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
|
||||
import { MediaType } from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import { UserType } from '@server/constants/user';
|
||||
import { getRepository } from '@server/datasource';
|
||||
import dataSource, { getRepository } from '@server/datasource';
|
||||
import Media from '@server/entity/Media';
|
||||
import { MediaRequest } from '@server/entity/MediaRequest';
|
||||
import { User } from '@server/entity/User';
|
||||
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
|
||||
import { Router } from 'express';
|
||||
import gravatarUrl from 'gravatar-url';
|
||||
import { findIndex, sortBy } from 'lodash';
|
||||
import { In } from 'typeorm';
|
||||
import type { EntityManager } from 'typeorm';
|
||||
import { In, Not } from 'typeorm';
|
||||
import userSettingsRoutes from './usersettings';
|
||||
|
||||
const router = Router();
|
||||
@@ -188,30 +189,82 @@ router.post<
|
||||
}
|
||||
>('/registerPushSubscription', async (req, res, next) => {
|
||||
try {
|
||||
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||
// This prevents race conditions where two requests both pass the checks
|
||||
await dataSource.transaction(
|
||||
async (transactionalEntityManager: EntityManager) => {
|
||||
const transactionalRepo =
|
||||
transactionalEntityManager.getRepository(UserPushSubscription);
|
||||
|
||||
const existingSubs = await userPushSubRepository.find({
|
||||
relations: { user: true },
|
||||
where: { auth: req.body.auth, user: { id: req.user?.id } },
|
||||
});
|
||||
// Check for existing subscription by auth or endpoint within transaction
|
||||
const existingSubscription = await transactionalRepo.findOne({
|
||||
relations: { user: true },
|
||||
where: [
|
||||
{ auth: req.body.auth, user: { id: req.user?.id } },
|
||||
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
|
||||
],
|
||||
});
|
||||
|
||||
if (existingSubs.length > 0) {
|
||||
logger.debug(
|
||||
'User push subscription already exists. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return res.status(204).send();
|
||||
}
|
||||
if (existingSubscription) {
|
||||
// If endpoint matches but auth is different, update with new keys (iOS refresh case)
|
||||
if (
|
||||
existingSubscription.endpoint === req.body.endpoint &&
|
||||
existingSubscription.auth !== req.body.auth
|
||||
) {
|
||||
existingSubscription.auth = req.body.auth;
|
||||
existingSubscription.p256dh = req.body.p256dh;
|
||||
existingSubscription.userAgent = req.body.userAgent;
|
||||
|
||||
const userPushSubscription = new UserPushSubscription({
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
await transactionalRepo.save(existingSubscription);
|
||||
|
||||
userPushSubRepository.save(userPushSubscription);
|
||||
logger.debug(
|
||||
'Updated existing push subscription with new keys for same endpoint.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'Duplicate subscription detected. Skipping registration.',
|
||||
{ label: 'API' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up old subscriptions from the same device (userAgent) for this user
|
||||
// iOS can silently refresh endpoints, leaving stale subscriptions in the database
|
||||
// Only clean up if we're creating a new subscription (not updating an existing one)
|
||||
if (req.body.userAgent) {
|
||||
const staleSubscriptions = await transactionalRepo.find({
|
||||
relations: { user: true },
|
||||
where: {
|
||||
userAgent: req.body.userAgent,
|
||||
user: { id: req.user?.id },
|
||||
// Only remove subscriptions with different endpoints (stale ones)
|
||||
// Keep subscriptions that might be from different browsers/tabs
|
||||
endpoint: Not(req.body.endpoint),
|
||||
},
|
||||
});
|
||||
|
||||
if (staleSubscriptions.length > 0) {
|
||||
await transactionalRepo.remove(staleSubscriptions);
|
||||
logger.debug(
|
||||
`Removed ${staleSubscriptions.length} stale push subscription(s) from same device.`,
|
||||
{ label: 'API' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const userPushSubscription = new UserPushSubscription({
|
||||
auth: req.body.auth,
|
||||
endpoint: req.body.endpoint,
|
||||
p256dh: req.body.p256dh,
|
||||
userAgent: req.body.userAgent,
|
||||
user: req.user,
|
||||
});
|
||||
|
||||
await transactionalRepo.save(userPushSubscription);
|
||||
}
|
||||
);
|
||||
|
||||
return res.status(204).send();
|
||||
} catch (e) {
|
||||
|
||||
@@ -29,6 +29,16 @@ import type {
|
||||
} from 'typeorm';
|
||||
import { EventSubscriber } from 'typeorm';
|
||||
|
||||
const sanitizeDisplayName = (displayName: string): string => {
|
||||
return displayName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/gi, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
@EventSubscriber()
|
||||
export class MediaRequestSubscriber
|
||||
implements EntitySubscriberInterface<MediaRequest>
|
||||
@@ -310,11 +320,15 @@ export class MediaRequestSubscriber
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
userTag = await radarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
@@ -631,11 +645,15 @@ export class MediaRequestSubscriber
|
||||
mediaId: entity.media.id,
|
||||
userId: entity.requestedBy.id,
|
||||
newTag:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
userTag = await sonarr.createTag({
|
||||
label:
|
||||
entity.requestedBy.id + '-' + entity.requestedBy.displayName,
|
||||
entity.requestedBy.id +
|
||||
'-' +
|
||||
sanitizeDisplayName(entity.requestedBy.displayName),
|
||||
});
|
||||
}
|
||||
if (userTag.id) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '@server/constants/media';
|
||||
import { MediaServerType } from '@server/constants/server';
|
||||
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces';
|
||||
import type { DownloadingItem } from '@server/lib/downloadtracker';
|
||||
import type { RadarrSettings, SonarrSettings } from '@server/lib/settings';
|
||||
import type { MovieDetails } from '@server/models/Movie';
|
||||
import type { TvDetails } from '@server/models/Tv';
|
||||
@@ -33,6 +34,17 @@ import Link from 'next/link';
|
||||
import { useIntl } from 'react-intl';
|
||||
import useSWR from 'swr';
|
||||
|
||||
const filterDuplicateDownloads = (
|
||||
items: DownloadingItem[] = []
|
||||
): DownloadingItem[] => {
|
||||
const seen = new Set<string>();
|
||||
return items.filter((item) => {
|
||||
if (seen.has(item.downloadId)) return false;
|
||||
seen.add(item.downloadId);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const messages = defineMessages('components.ManageSlideOver', {
|
||||
manageModalTitle: 'Manage {mediaType}',
|
||||
manageModalIssues: 'Open Issues',
|
||||
@@ -230,26 +242,30 @@ const ManageSlideOver = ({
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-md border border-gray-700 shadow">
|
||||
<ul>
|
||||
{data.mediaInfo?.downloadStatus?.map((status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
{data.mediaInfo?.downloadStatus4k?.map((status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
</Tooltip>
|
||||
))}
|
||||
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map(
|
||||
(status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} />
|
||||
</li>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map(
|
||||
(status, index) => (
|
||||
<Tooltip
|
||||
key={`dl-status-4k-${status.externalId}-${index}`}
|
||||
content={status.title}
|
||||
>
|
||||
<li className="border-b border-gray-700 last:border-b-0">
|
||||
<DownloadBlock downloadItem={status} is4k />
|
||||
</li>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
|
||||
|
||||
addToast(intl.formatMessage(messages.metadataSettingsSaved), {
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
});
|
||||
} catch (e) {
|
||||
addToast(
|
||||
intl.formatMessage(messages.failedToSaveMetadataSettings),
|
||||
{
|
||||
appearance: 'error',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
|
||||
),
|
||||
{
|
||||
appearance: 'success',
|
||||
autoDismiss: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -109,15 +109,28 @@ const UserWebPushSettings = () => {
|
||||
// Deletes/disables corresponding push subscription from database
|
||||
const disablePushNotifications = async (endpoint?: string) => {
|
||||
try {
|
||||
await unsubscribeToPushNotifications(user?.id, endpoint);
|
||||
|
||||
// Delete from backend if endpoint is available
|
||||
if (subEndpoint) {
|
||||
await deletePushSubscriptionFromBackend(subEndpoint);
|
||||
}
|
||||
const unsubscribedEndpoint = await unsubscribeToPushNotifications(
|
||||
user?.id,
|
||||
endpoint
|
||||
);
|
||||
|
||||
localStorage.setItem('pushNotificationsEnabled', 'false');
|
||||
setWebPushEnabled(false);
|
||||
|
||||
// Only delete the current browser's subscription, not all devices
|
||||
const endpointToDelete = unsubscribedEndpoint || subEndpoint || endpoint;
|
||||
if (endpointToDelete) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/user/${user?.id}/pushSubscription/${encodeURIComponent(
|
||||
endpointToDelete
|
||||
)}`
|
||||
);
|
||||
} catch {
|
||||
// Ignore deletion failures - backend cleanup is best effort
|
||||
}
|
||||
}
|
||||
|
||||
addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
|
||||
autoDismiss: true,
|
||||
appearance: 'success',
|
||||
@@ -157,7 +170,33 @@ const UserWebPushSettings = () => {
|
||||
useEffect(() => {
|
||||
const verifyWebPush = async () => {
|
||||
const enabled = await verifyPushSubscription(user?.id, currentSettings);
|
||||
setWebPushEnabled(enabled);
|
||||
let isEnabled = enabled;
|
||||
|
||||
if (!enabled && 'serviceWorker' in navigator) {
|
||||
const { subscription } = await getPushSubscription();
|
||||
if (subscription) {
|
||||
isEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isEnabled && dataDevices && dataDevices.length > 0) {
|
||||
const currentUserAgent = navigator.userAgent;
|
||||
const hasMatchingDevice = dataDevices.some(
|
||||
(device) => device.userAgent === currentUserAgent
|
||||
);
|
||||
|
||||
if (hasMatchingDevice) {
|
||||
isEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
setWebPushEnabled(isEnabled);
|
||||
if (localStorage.getItem('pushNotificationsEnabled') === null) {
|
||||
localStorage.setItem(
|
||||
'pushNotificationsEnabled',
|
||||
isEnabled ? 'true' : 'false'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (user?.id) {
|
||||
|
||||
@@ -49,13 +49,17 @@ export const verifyPushSubscription = async (
|
||||
currentSettings.vapidPublic
|
||||
).toString();
|
||||
|
||||
if (currentServerKey !== expectedServerKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endpoint = subscription.endpoint;
|
||||
|
||||
const { data } = await axios.get<UserPushSubscription>(
|
||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
|
||||
);
|
||||
|
||||
return expectedServerKey === currentServerKey && data.endpoint === endpoint;
|
||||
return data.endpoint === endpoint;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -65,20 +69,39 @@ export const verifyAndResubscribePushSubscription = async (
|
||||
userId: number | undefined,
|
||||
currentSettings: PublicSettingsResponse
|
||||
): Promise<boolean> => {
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { subscription } = await getPushSubscription();
|
||||
const isValid = await verifyPushSubscription(userId, currentSettings);
|
||||
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSettings.enablePushRegistration) {
|
||||
try {
|
||||
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint)
|
||||
await unsubscribeToPushNotifications(userId);
|
||||
const oldEndpoint = await unsubscribeToPushNotifications(userId);
|
||||
|
||||
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
|
||||
await subscribeToPushNotifications(userId, currentSettings);
|
||||
|
||||
if (oldEndpoint) {
|
||||
try {
|
||||
await axios.delete(
|
||||
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(
|
||||
oldEndpoint
|
||||
)}`
|
||||
);
|
||||
} catch (error) {
|
||||
// Ignore errors when deleting old endpoint (it might not exist)
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new Error(`[SW] Resubscribe failed: ${error.message}`);
|
||||
@@ -136,24 +159,26 @@ export const subscribeToPushNotifications = async (
|
||||
export const unsubscribeToPushNotifications = async (
|
||||
userId: number | undefined,
|
||||
endpoint?: string
|
||||
) => {
|
||||
): Promise<string | null> => {
|
||||
if (!('serviceWorker' in navigator) || !userId) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { subscription } = await getPushSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
const { endpoint: currentEndpoint } = subscription.toJSON();
|
||||
|
||||
if (!endpoint || endpoint === currentEndpoint) {
|
||||
await subscription.unsubscribe();
|
||||
return true;
|
||||
return currentEndpoint ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Issue unsubscribing to push notifications: ${error.message}`
|
||||
|
||||