1
0
mirror of https://github.com/fallenbagel/jellyseerr.git synced 2026-01-11 17:16:50 -05:00

Compare commits

...

9 Commits

Author SHA1 Message Date
gauthier-th
9e1e7030a7 feat(ui): rebrand Jellyseerr logos to Seerr 2026-01-07 21:49:12 +01:00
Someone
adbcf80333 fix(ui): remove duplicate download items in manage slide over (#1916)
* fix(ui): filter duplicate downloads in ManageSlideOver using downloadId

Apply the same logic as PR #927 to deduplicate season pack downloads
in the "Manage Series" slide-over panel.

* Update src/components/ManageSlideOver/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/components/ManageSlideOver/index.tsx

Co-authored-by: Gauthier <mail@gauthierth.fr>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gauthier <mail@gauthierth.fr>
2026-01-07 16:06:11 +00:00
fallenbagel
f91a26befe fix(servarr): replace spaces in arr user tags with - (#2231)
* fix: sanitize disallowed characters in arr tags

Updates the tag creation to normalize diacritics, replace spaces with hyphens and stip any
non-alphanumeric characters from display name

fix #2229, fix #1897

* refactor: improve display name sanitization in tag creation

* fix: include displayName in user selection for tag migration

* fix(migrator): retrieve all user fields in tag migration

This is a one time migration so performance is neglible. This should trigger the @AfterLoad hooks
which sets the `displayName`
2026-01-06 03:18:06 +08:00
0xsysr3ll
0c95b5ec91 fix(migration): add cleanup step for duplicate push subscriptions before enforcing unique constraint (#2269)
Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me>
2026-01-05 10:54:31 +01:00
fallenbagel
193d4dc668 docs: temporarily make it clear seerr is not released (#2273) 2026-01-03 04:53:18 +00:00
0xsysr3ll
d0c9afc16e fix(webpush): improve iOS push subscription endpoint cleanup (#2140) 2025-12-31 13:44:45 +01:00
fallenbagel
57d583e1bd refactor(jellyfin-scanner): extend BaseScanner for jellyfin scanner (#2226)
* refactor(jellyfin-scanner): extend BaseScanner for jellyfin scanner

Refactors JellyfinScanner to extend BaseScanner class to align the jellyfin scanner architecture
with the plex scanner and reduce code duplication.

* fix(jellyfin-scanner): add imdbId handling back to fix a regression from original behaviour

* fix: add imdbId assignment for existing media entries

* fix: include imdbId in processed 4k media items and improve 4k detection

* fix(jellyfin-scanner): filter seasons based on settings for special episodes (regression)
2025-12-29 20:05:47 +08:00
samohtxotom
8bbe7864af chore(metadata-settings): add autoDismiss to toast notifications (#2254) 2025-12-27 06:27:12 +08:00
Gauthier
66b4e2c871 chore(issuetemplate): add a checkbox to search for existing issues (#2255) 2025-12-27 06:26:16 +08:00
69 changed files with 728 additions and 1112 deletions

View File

@@ -91,6 +91,14 @@ body:
attributes: attributes:
label: Additional Context label: Additional Context
description: Please provide any additional information that may be relevant or helpful. 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 - type: checkboxes
id: terms id: terms
attributes: attributes:

View File

@@ -27,6 +27,14 @@ body:
attributes: attributes:
label: Additional Context label: Additional Context
description: Provide any additional information or screenshots that may be relevant or helpful. 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 - type: checkboxes
id: terms id: terms
attributes: attributes:

View File

@@ -32,10 +32,28 @@ With more features on the way! Check out our [issue tracker](/../../issues) to s
## Getting Started ## 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/ 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 ## Preview
<img src="./public/preview.jpg"> <img src="./public/preview.jpg">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 B

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 410 KiB

View File

@@ -1,8 +1,15 @@
import { DbAwareColumn } from '@server/utils/DbColumnHelper'; 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'; import { User } from './User';
@Entity() @Entity()
@Unique(['endpoint', 'user'])
export class UserPushSubscription { export class UserPushSubscription {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
public id: number; public id: number;

View File

@@ -24,6 +24,15 @@ interface PushNotificationPayload {
isAdmin?: boolean; isAdmin?: boolean;
} }
interface WebPushError extends Error {
statusCode?: number;
status?: number;
body?: string | unknown;
response?: {
body?: string | unknown;
};
}
class WebPushAgent class WebPushAgent
extends BaseAgent<NotificationAgentConfig> extends BaseAgent<NotificationAgentConfig>
implements NotificationAgent implements NotificationAgent
@@ -188,19 +197,30 @@ class WebPushAgent
notificationPayload notificationPayload
); );
} catch (e) { } 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( 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', label: 'Notifications',
recipient: pushSub.user.displayName, recipient: pushSub.user.displayName,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage,
statusCode: statusCode || 'unknown',
} }
); );
// Failed to send notification so we need to remove the subscription if (isPermanentFailure) {
userPushSubRepository.remove(pushSub); await userPushSubRepository.remove(pushSub);
}
} }
}; };

View File

@@ -34,6 +34,8 @@ interface ProcessOptions {
is4k?: boolean; is4k?: boolean;
mediaAddedAt?: Date; mediaAddedAt?: Date;
ratingKey?: string; ratingKey?: string;
jellyfinMediaId?: string;
imdbId?: string;
serviceId?: number; serviceId?: number;
externalServiceId?: number; externalServiceId?: number;
externalServiceSlug?: string; externalServiceSlug?: string;
@@ -95,6 +97,8 @@ class BaseScanner<T> {
is4k = false, is4k = false,
mediaAddedAt, mediaAddedAt,
ratingKey, ratingKey,
jellyfinMediaId,
imdbId,
serviceId, serviceId,
externalServiceId, externalServiceId,
externalServiceSlug, externalServiceSlug,
@@ -133,6 +137,21 @@ class BaseScanner<T> {
changedExisting = true; 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 ( if (
serviceId !== undefined && serviceId !== undefined &&
existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId existing[is4k ? 'serviceId4k' : 'serviceId'] !== serviceId
@@ -173,6 +192,7 @@ class BaseScanner<T> {
} else { } else {
const newMedia = new Media(); const newMedia = new Media();
newMedia.tmdbId = tmdbId; newMedia.tmdbId = tmdbId;
newMedia.imdbId = imdbId;
newMedia.status = newMedia.status =
!is4k && !processing !is4k && !processing
@@ -203,6 +223,13 @@ class BaseScanner<T> {
newMedia.ratingKey4k = newMedia.ratingKey4k =
is4k && this.enable4kMovie ? ratingKey : undefined; is4k && this.enable4kMovie ? ratingKey : undefined;
} }
if (jellyfinMediaId) {
newMedia.jellyfinMediaId = !is4k ? jellyfinMediaId : undefined;
newMedia.jellyfinMediaId4k =
is4k && this.enable4kMovie ? jellyfinMediaId : undefined;
}
await mediaRepository.save(newMedia); await mediaRepository.save(newMedia);
this.log(`Saved new media: ${title}`); this.log(`Saved new media: ${title}`);
} }
@@ -221,11 +248,12 @@ class BaseScanner<T> {
*/ */
protected async processShow( protected async processShow(
tmdbId: number, tmdbId: number,
tvdbId: number, tvdbId: number | undefined,
seasons: ProcessableSeason[], seasons: ProcessableSeason[],
{ {
mediaAddedAt, mediaAddedAt,
ratingKey, ratingKey,
jellyfinMediaId,
serviceId, serviceId,
externalServiceId, externalServiceId,
externalServiceSlug, externalServiceSlug,
@@ -257,7 +285,7 @@ class BaseScanner<T> {
(es) => es.seasonNumber === season.seasonNumber (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) { if (media && season.episodes > 0 && media.ratingKey !== ratingKey) {
media.ratingKey = ratingKey; media.ratingKey = ratingKey;
} }
@@ -271,6 +299,23 @@ class BaseScanner<T> {
media.ratingKey4k = ratingKey; 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) { if (existingSeason) {
// Here we update seasons if they already exist. // Here we update seasons if they already exist.
// If the season is already marked as available, we // If the season is already marked as available, we
@@ -491,6 +536,22 @@ class BaseScanner<T> {
) )
? ratingKey ? ratingKey
: undefined, : 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 status: isAllStandardSeasons
? MediaStatus.AVAILABLE ? MediaStatus.AVAILABLE
: newSeasons.some( : newSeasons.some(

View File

@@ -1,5 +1,8 @@
import animeList from '@server/api/animelist'; import animeList from '@server/api/animelist';
import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import type {
JellyfinLibraryItem,
JellyfinLibraryItemExtended,
} from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin';
import { getMetadataProvider } from '@server/api/metadata'; import { getMetadataProvider } from '@server/api/metadata';
import TheMovieDb from '@server/api/themoviedb'; import TheMovieDb from '@server/api/themoviedb';
@@ -8,96 +11,73 @@ import type {
TmdbKeyword, TmdbKeyword,
TmdbTvDetails, TmdbTvDetails,
} from '@server/api/themoviedb/interfaces'; } from '@server/api/themoviedb/interfaces';
import { MediaStatus, MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { getRepository } from '@server/datasource'; import { getRepository } from '@server/datasource';
import Media from '@server/entity/Media';
import Season from '@server/entity/Season';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
import type {
ProcessableSeason,
RunnableScanner,
StatusBase,
} from '@server/lib/scanners/baseScanner';
import BaseScanner from '@server/lib/scanners/baseScanner';
import type { Library } from '@server/lib/settings'; import type { Library } from '@server/lib/settings';
import { getSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings';
import logger from '@server/logger';
import AsyncLock from '@server/utils/asyncLock';
import { getHostname } from '@server/utils/getHostname'; import { getHostname } from '@server/utils/getHostname';
import { randomUUID as uuid } from 'crypto';
import { uniqWith } from 'lodash'; import { uniqWith } from 'lodash';
const BUNDLE_SIZE = 20; interface JellyfinSyncStatus extends StatusBase {
const UPDATE_RATE = 4 * 1000;
interface SyncStatus {
running: boolean;
progress: number;
total: number;
currentLibrary: Library; currentLibrary: Library;
libraries: Library[]; libraries: Library[];
} }
class JellyfinScanner { class JellyfinScanner
private sessionId: string; extends BaseScanner<JellyfinLibraryItem>
private tmdb: TheMovieDb; implements RunnableScanner<JellyfinSyncStatus>
{
private jfClient: JellyfinAPI; private jfClient: JellyfinAPI;
private items: JellyfinLibraryItem[] = [];
private progress = 0;
private libraries: Library[]; private libraries: Library[];
private currentLibrary: Library; private currentLibrary: Library;
private running = false;
private isRecentOnly = false; private isRecentOnly = false;
private enable4kMovie = false;
private enable4kShow = false;
private asyncLock = new AsyncLock();
private processedAnidbSeason: Map<number, Map<number, number>>; private processedAnidbSeason: Map<number, Map<number, number>>;
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb(); super('Jellyfin Sync');
this.isRecentOnly = isRecentOnly ?? false; this.isRecentOnly = isRecentOnly ?? false;
} }
private async getExisting(tmdbId: number, mediaType: MediaType) { private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{
const mediaRepository = getRepository(Media); tmdbId: number;
imdbId?: string;
const existing = await mediaRepository.findOne({ metadata: JellyfinLibraryItemExtended;
where: { tmdbId: tmdbId, mediaType }, } | null> {
});
return existing;
}
private async processMovie(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
try {
let metadata = await this.jfClient.getItemData(jellyfinitem.Id); let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const newMedia = new Media();
if (!metadata?.Id) { if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', { this.log('No Id metadata for this title. Skipping', 'debug', {
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id, jellyfinItemId: jellyfinitem.Id,
}); });
return; return null;
} }
const anidbId = Number(metadata.ProviderIds.AniDB ?? null); const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
let tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null); let imdbId = metadata.ProviderIds.Imdb;
newMedia.imdbId = metadata.ProviderIds.Imdb;
// We use anidb only if we have the anidbId and nothing else // We use anidb only if we have the anidbId and nothing else
if (anidbId && !newMedia.imdbId && !newMedia.tmdbId) { if (anidbId && !imdbId && !tmdbId) {
const result = animeList.getFromAnidbId(anidbId); const result = animeList.getFromAnidbId(anidbId);
newMedia.tmdbId = Number(result?.tmdbId ?? null); tmdbId = Number(result?.tmdbId ?? null);
newMedia.imdbId = result?.imdbId; imdbId = result?.imdbId;
} }
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) { if (imdbId && !tmdbId) {
const tmdbMovie = await this.tmdb.getMediaByImdbId({ const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: newMedia.imdbId, imdbId: imdbId,
}); });
newMedia.tmdbId = tmdbMovie.id; tmdbId = tmdbMovie.id;
} }
if (!newMedia.tmdbId) {
if (!tmdbId) {
throw new Error('Unable to find TMDb ID'); throw new Error('Unable to find TMDb ID');
} }
@@ -114,7 +94,7 @@ class JellyfinScanner {
this.log('No season found for anidb movie', 'debug', { this.log('No season found for anidb movie', 'debug', {
jellyfinitem, jellyfinitem,
}); });
return; return null;
} }
const episodes = await this.jfClient.getEpisodes( const episodes = await this.jfClient.getEpisodes(
jellyfinitem.Id, jellyfinitem.Id,
@@ -124,17 +104,27 @@ class JellyfinScanner {
this.log('No episode found for anidb movie', 'debug', { this.log('No episode found for anidb movie', 'debug', {
jellyfinitem, jellyfinitem,
}); });
return; return null;
} }
metadata = await this.jfClient.getItemData(episodes[0].Id); metadata = await this.jfClient.getItemData(episodes[0].Id);
if (!metadata) { if (!metadata) {
this.log('No metadata found for anidb movie', 'debug', { this.log('No metadata found for anidb movie', 'debug', {
jellyfinitem, jellyfinitem,
}); });
return; return null;
} }
} }
return { tmdbId, imdbId, metadata };
}
private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) {
try {
const extracted = await this.extractMovieIds(jellyfinitem);
if (!extracted) return;
const { tmdbId, imdbId, metadata } = extracted;
const has4k = metadata.MediaSources?.some((MediaSource) => { const has4k = metadata.MediaSources?.some((MediaSource) => {
return MediaSource.MediaStreams.filter( return MediaSource.MediaStreams.filter(
(MediaStream) => MediaStream.Type === 'Video' (MediaStream) => MediaStream.Type === 'Video'
@@ -151,93 +141,29 @@ class JellyfinScanner {
}); });
}); });
await this.asyncLock.dispatch(newMedia.tmdbId, async () => { const mediaAddedAt = metadata.DateCreated
if (!metadata) { ? new Date(metadata.DateCreated)
// this will never execute, but typescript thinks somebody could reset tvShow from : undefined;
// outer scope back to null before this async gets called
return;
}
const existing = await this.getExisting( if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
newMedia.tmdbId, await this.processMovie(tmdbId, {
MediaType.MOVIE is4k: false,
); mediaAddedAt,
jellyfinMediaId: metadata.Id,
if (existing) { imdbId,
let changedExisting = false; title: metadata.Name,
if (
(hasOtherResolution || (!this.enable4kMovie && has4k)) &&
existing.status !== MediaStatus.AVAILABLE
) {
existing.status = MediaStatus.AVAILABLE;
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.status4k !== MediaStatus.AVAILABLE
) {
existing.status4k = MediaStatus.AVAILABLE;
changedExisting = true;
}
if (!existing.mediaAddedAt && !changedExisting) {
existing.mediaAddedAt = new Date(metadata.DateCreated ?? '');
changedExisting = true;
}
if (
(hasOtherResolution || (has4k && !this.enable4kMovie)) &&
existing.jellyfinMediaId !== metadata.Id
) {
existing.jellyfinMediaId = metadata.Id;
changedExisting = true;
}
if (
has4k &&
this.enable4kMovie &&
existing.jellyfinMediaId4k !== metadata.Id
) {
existing.jellyfinMediaId4k = metadata.Id;
changedExisting = true;
}
if (changedExisting) {
await mediaRepository.save(existing);
this.log(
`Request for ${metadata.Name} exists. New media types set to AVAILABLE`,
'info'
);
} else {
this.log(
`Title already exists and no new media types found ${metadata.Name}`
);
}
} else {
newMedia.status =
hasOtherResolution || (!this.enable4kMovie && has4k)
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.status4k =
has4k && this.enable4kMovie
? MediaStatus.AVAILABLE
: MediaStatus.UNKNOWN;
newMedia.mediaType = MediaType.MOVIE;
newMedia.mediaAddedAt = new Date(metadata.DateCreated ?? '');
newMedia.jellyfinMediaId =
hasOtherResolution || (!this.enable4kMovie && has4k)
? metadata.Id
: null;
newMedia.jellyfinMediaId4k =
has4k && this.enable4kMovie ? metadata.Id : null;
await mediaRepository.save(newMedia);
this.log(`Saved ${metadata.Name}`);
}
}); });
}
if (has4k && this.enable4kMovie) {
await this.processMovie(tmdbId, {
is4k: true,
mediaAddedAt,
jellyfinMediaId: metadata.Id,
imdbId,
title: metadata.Name,
});
}
} catch (e) { } catch (e) {
this.log( this.log(
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`, `Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
@@ -286,9 +212,7 @@ class JellyfinScanner {
return tvShow; return tvShow;
} }
private async processShow(jellyfinitem: JellyfinLibraryItem) { private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) {
const mediaRepository = getRepository(Media);
let tvShow: TmdbTvDetails | null = null; let tvShow: TmdbTvDetails | null = null;
try { try {
@@ -297,8 +221,7 @@ class JellyfinScanner {
const metadata = await this.jfClient.getItemData(Id); const metadata = await this.jfClient.getItemData(Id);
if (!metadata?.Id) { if (!metadata?.Id) {
logger.debug('No Id metadata for this title. Skipping', { this.log('No Id metadata for this title. Skipping', 'debug', {
label: 'Jellyfin Sync',
jellyfinItemId: jellyfinitem.Id, jellyfinItemId: jellyfinitem.Id,
}); });
return; return;
@@ -315,6 +238,7 @@ class JellyfinScanner {
}); });
} }
} }
if (!tvShow && metadata.ProviderIds.Tvdb) { if (!tvShow && metadata.ProviderIds.Tvdb) {
try { try {
tvShow = await this.getTvShow({ tvShow = await this.getTvShow({
@@ -326,6 +250,7 @@ class JellyfinScanner {
}); });
} }
} }
let tvdbSeasonFromAnidb: number | undefined; let tvdbSeasonFromAnidb: number | undefined;
if (!tvShow && metadata.ProviderIds.AniDB) { if (!tvShow && metadata.ProviderIds.AniDB) {
const anidbId = Number(metadata.ProviderIds.AniDB); const anidbId = Number(metadata.ProviderIds.AniDB);
@@ -344,39 +269,23 @@ class JellyfinScanner {
} }
// With AniDB we can have mixed libraries with movies in a "show" library // With AniDB we can have mixed libraries with movies in a "show" library
else if (result?.imdbId || result?.tmdbId) { else if (result?.imdbId || result?.tmdbId) {
await this.processMovie(jellyfinitem); await this.processJellyfinMovie(jellyfinitem);
return; return;
} }
} }
if (tvShow) { if (tvShow) {
await this.asyncLock.dispatch(tvShow.id, async () => {
if (!tvShow) {
// this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
// Lets get the available seasons from Jellyfin
const seasons = tvShow.seasons; const seasons = tvShow.seasons;
const media = await this.getExisting(tvShow.id, MediaType.TV);
const newSeasons: Season[] = [];
const currentStandardSeasonAvailable = (
media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const current4kSeasonAvailable = (
media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
const jellyfinSeasons = await this.jfClient.getSeasons(Id); const jellyfinSeasons = await this.jfClient.getSeasons(Id);
for (const season of seasons) { const processableSeasons: ProcessableSeason[] = [];
const settings = getSettings();
const filteredSeasons = settings.main.enableSpecialEpisodes
? seasons
: seasons.filter((sn) => sn.season_number !== 0);
for (const season of filteredSeasons) {
const matchedJellyfinSeason = jellyfinSeasons.find((md) => { const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
if (tvdbSeasonFromAnidb) { if (tvdbSeasonFromAnidb) {
// In AniDB we don't have the concept of seasons, // In AniDB we don't have the concept of seasons,
@@ -392,10 +301,6 @@ class JellyfinScanner {
} }
}); });
const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number
);
// Check if we found the matching season and it has all the available episodes // Check if we found the matching season and it has all the available episodes
if (matchedJellyfinSeason) { if (matchedJellyfinSeason) {
let totalStandard = 0; let totalStandard = 0;
@@ -441,233 +346,72 @@ class JellyfinScanner {
episode.IndexNumberEnd - episode.IndexNumber + 1; episode.IndexNumberEnd - episode.IndexNumber + 1;
} }
// MediaSources field is included in response when includeMediaInfo is true const has4k = episode.MediaSources?.some((MediaSource) =>
// We iterate all MediaSources to detect if episode has both standard AND 4K versions MediaSource.MediaStreams.some(
episode.MediaSources?.some((MediaSource) => { (MediaStream) =>
return MediaSource.MediaStreams.some((MediaStream) => { MediaStream.Type === 'Video' &&
if (MediaStream.Type === 'Video') { (MediaStream.Width ?? 0) > 2000
if ((MediaStream.Width ?? 0) >= 2000) { )
total4k += episodeCount; );
} else {
totalStandard += episodeCount; const hasStandard = episode.MediaSources?.some((MediaSource) =>
} MediaSource.MediaStreams.some(
} (MediaStream) =>
}); MediaStream.Type === 'Video' &&
}); (MediaStream.Width ?? 0) <= 2000
)
);
// Count in both if episode has both versions
// TODO: Make this more robust in the future
// Currently, this detection is based solely on file resolution, not which
// Radarr/Sonarr instance the file came from. If a 4K request results in
// 1080p files (no 4K release available yet), those files will be counted
// as "standard" even though they're in the 4K library. This can cause
// non-4K users to see content as "available" when they can't access it.
// See issue https://github.com/seerr-team/seerr/issues/1744 for details.
if (hasStandard) totalStandard += episodeCount;
if (has4k) total4k += episodeCount;
} }
} }
// With AniDB we can have multiple shows for one season, so we need to save // With AniDB we can have multiple shows for one season, so we need to save
// the episode from all the jellyfin entries to get the total // the episode from all the jellyfin entries to get the total
if (tvdbSeasonFromAnidb) { if (tvdbSeasonFromAnidb) {
if (this.processedAnidbSeason.has(tvShow.id)) { let show = this.processedAnidbSeason.get(tvShow.id);
const show = this.processedAnidbSeason.get(tvShow.id)!;
if (show.has(season.season_number)) {
show.set(
season.season_number,
show.get(season.season_number)! + totalStandard
);
totalStandard = show.get(season.season_number)!; if (!show) {
show = new Map([[season.season_number, totalStandard]]);
this.processedAnidbSeason.set(tvShow.id, show);
} else { } else {
show.set(season.season_number, totalStandard); const currentCount = show.get(season.season_number) ?? 0;
} const newCount = currentCount + totalStandard;
} else { show.set(season.season_number, newCount);
this.processedAnidbSeason.set( totalStandard = newCount;
tvShow.id,
new Map([[season.season_number, totalStandard]])
);
} }
} }
if ( processableSeasons.push({
media &&
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
media.jellyfinMediaId !== Id
) {
media.jellyfinMediaId = Id;
}
if (
media &&
total4k > 0 &&
this.enable4kShow &&
media.jellyfinMediaId4k !== Id
) {
media.jellyfinMediaId4k = Id;
}
if (existingSeason) {
// These ternary statements look super confusing, but they are simply
// setting the status to AVAILABLE if all of a type is there, partially if some,
// and then not modifying the status if there are 0 items
existingSeason.status =
totalStandard >= season.episode_count ||
existingSeason.status === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status;
existingSeason.status4k =
(this.enable4kShow && total4k >= season.episode_count) ||
existingSeason.status4k === MediaStatus.AVAILABLE
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: existingSeason.status4k;
} else {
newSeasons.push(
new Season({
seasonNumber: season.season_number, seasonNumber: season.season_number,
// This ternary is the same as the ones above, but it just falls back to "UNKNOWN" totalEpisodes: season.episode_count,
// if we dont have any items for the season episodes: totalStandard,
status: episodes4k: total4k,
totalStandard >= season.episode_count
? MediaStatus.AVAILABLE
: totalStandard > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
this.enable4kShow && total4k >= season.episode_count
? MediaStatus.AVAILABLE
: this.enable4kShow && total4k > 0
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
})
);
}
}
}
// Remove extras season. We dont count it for determining availability
const filteredSeasons = tvShow.seasons.filter(
(season) => season.season_number !== 0
);
const isAllStandardSeasons =
newSeasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
const isAll4kSeasons =
newSeasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length +
(media?.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
).length ?? 0) >=
filteredSeasons.length;
if (media) {
// Update existing
media.seasons = [...media.seasons, ...newSeasons];
const newStandardSeasonAvailable = (
media.seasons.filter(
(season) => season.status === MediaStatus.AVAILABLE
) ?? []
).length;
const new4kSeasonAvailable = (
media.seasons.filter(
(season) => season.status4k === MediaStatus.AVAILABLE
) ?? []
).length;
// If at least one new season has become available, update
// the lastSeasonChange field so we can trigger notifications
if (newStandardSeasonAvailable > currentStandardSeasonAvailable) {
this.log(
`Detected ${
newStandardSeasonAvailable - currentStandardSeasonAvailable
} new standard season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
if (new4kSeasonAvailable > current4kSeasonAvailable) {
this.log(
`Detected ${
new4kSeasonAvailable - current4kSeasonAvailable
} new 4K season(s) for ${tvShow.name}`,
'debug'
);
media.lastSeasonChange = new Date();
}
if (!media.mediaAddedAt) {
media.mediaAddedAt = new Date(metadata.DateCreated ?? '');
}
// If the show is already available, and there are no new seasons, dont adjust
// the status
const shouldStayAvailable =
media.status === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status !== MediaStatus.UNKNOWN
).length === 0;
const shouldStayAvailable4k =
media.status4k === MediaStatus.AVAILABLE &&
newSeasons.filter(
(season) => season.status4k !== MediaStatus.UNKNOWN
).length === 0;
media.status =
isAllStandardSeasons || shouldStayAvailable
? MediaStatus.AVAILABLE
: media.seasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
media.status4k =
(isAll4kSeasons || shouldStayAvailable4k) && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
media.seasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN;
await mediaRepository.save(media);
this.log(`Updating existing title: ${tvShow.name}`);
} else {
const newMedia = new Media({
mediaType: MediaType.TV,
seasons: newSeasons,
tmdbId: tvShow.id,
tvdbId: tvShow.external_ids.tvdb_id,
mediaAddedAt: new Date(metadata.DateCreated ?? ''),
jellyfinMediaId: isAllStandardSeasons ? Id : null,
jellyfinMediaId4k:
isAll4kSeasons && this.enable4kShow ? Id : null,
status: isAllStandardSeasons
? MediaStatus.AVAILABLE
: newSeasons.some(
(season) => season.status !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
status4k:
isAll4kSeasons && this.enable4kShow
? MediaStatus.AVAILABLE
: this.enable4kShow &&
newSeasons.some(
(season) => season.status4k !== MediaStatus.UNKNOWN
)
? MediaStatus.PARTIALLY_AVAILABLE
: MediaStatus.UNKNOWN,
}); });
await mediaRepository.save(newMedia);
this.log(`Saved ${tvShow.name}`);
} }
}); }
await this.processShow(
tvShow.id,
tvShow.external_ids?.tvdb_id,
processableSeasons,
{
mediaAddedAt: metadata.DateCreated
? new Date(metadata.DateCreated)
: undefined,
jellyfinMediaId: Id,
title: tvShow.name,
}
);
} else { } else {
this.log( this.log(
`No information found for the show: ${metadata.Name}`, `No information found for the show: ${metadata.Name}`,
@@ -683,70 +427,17 @@ class JellyfinScanner {
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
}`, }`,
'error', 'error',
{ { errorMessage: e.message, jellyfinitem }
errorMessage: e.message,
jellyfinitem,
}
); );
} }
} }
private async processItems(slicedItems: JellyfinLibraryItem[]) { private async processItem(item: JellyfinLibraryItem): Promise<void> {
this.processedAnidbSeason = new Map();
await Promise.all(
slicedItems.map(async (item) => {
if (item.Type === 'Movie') { if (item.Type === 'Movie') {
await this.processMovie(item); await this.processJellyfinMovie(item);
} else if (item.Type === 'Series') { } else if (item.Type === 'Series') {
await this.processShow(item); await this.processJellyfinShow(item);
} }
})
);
}
private async loop({
start = 0,
end = BUNDLE_SIZE,
sessionId,
}: {
start?: number;
end?: number;
sessionId?: string;
} = {}) {
const slicedItems = this.items.slice(start, end);
if (!this.running) {
throw new Error('Sync was aborted.');
}
if (this.sessionId !== sessionId) {
throw new Error('New session was started. Old session aborted.');
}
if (start < this.items.length) {
this.progress = start;
await this.processItems(slicedItems);
await new Promise<void>((resolve, reject) =>
setTimeout(() => {
this.loop({
start: start + BUNDLE_SIZE,
end: end + BUNDLE_SIZE,
sessionId,
})
.then(() => resolve())
.catch((e) => reject(new Error(e.message)));
}, UPDATE_RATE)
);
}
}
private log(
message: string,
level: 'info' | 'error' | 'debug' | 'warn' = 'debug',
optional?: Record<string, unknown>
): void {
logger[level](message, { label: 'Jellyfin Sync', ...optional });
} }
public async run(): Promise<void> { public async run(): Promise<void> {
@@ -759,14 +450,9 @@ class JellyfinScanner {
return; return;
} }
const sessionId = uuid(); const sessionId = this.startRun();
this.sessionId = sessionId;
logger.info('Jellyfin Sync Starting', {
sessionId,
label: 'Jellyfin Sync',
});
try { try {
this.running = true;
const userRepository = getRepository(User); const userRepository = getRepository(User);
const admin = await userRepository.findOne({ const admin = await userRepository.findOne({
where: { id: 1 }, where: { id: 1 },
@@ -792,25 +478,11 @@ class JellyfinScanner {
await animeList.sync(); await animeList.sync();
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) {
this.log(
'At least one 4K Radarr server was detected. 4K movie detection is now enabled',
'info'
);
}
this.enable4kShow = settings.sonarr.some((sonarr) => sonarr.is4k);
if (this.enable4kShow) {
this.log(
'At least one 4K Sonarr server was detected. 4K series detection is now enabled',
'info'
);
}
if (this.isRecentOnly) { if (this.isRecentOnly) {
for (const library of this.libraries) { for (const library of this.libraries) {
this.currentLibrary = library; this.currentLibrary = library;
// Reset AniDB season tracking per library
this.processedAnidbSeason = new Map();
this.log( this.log(
`Beginning to process recently added for library: ${library.name}`, `Beginning to process recently added for library: ${library.name}`,
'info' 'info'
@@ -830,16 +502,19 @@ class JellyfinScanner {
return mediaA.Id === mediaB.Id; return mediaA.Id === mediaB.Id;
}); });
await this.loop({ sessionId }); await this.loop(this.processItem.bind(this), { sessionId });
} }
} else { } else {
for (const library of this.libraries) { for (const library of this.libraries) {
this.currentLibrary = library; this.currentLibrary = library;
// Reset AniDB season tracking per library
this.processedAnidbSeason = new Map();
this.log(`Beginning to process library: ${library.name}`, 'info'); this.log(`Beginning to process library: ${library.name}`, 'info');
this.items = await this.jfClient.getLibraryContents(library.id); this.items = await this.jfClient.getLibraryContents(library.id);
await this.loop({ sessionId }); await this.loop(this.processItem.bind(this), { sessionId });
} }
} }
this.log( this.log(
this.isRecentOnly this.isRecentOnly
? 'Recently Added Scan Complete' ? 'Recently Added Scan Complete'
@@ -847,19 +522,13 @@ class JellyfinScanner {
'info' 'info'
); );
} catch (e) { } catch (e) {
logger.error('Sync interrupted', { this.log('Sync interrupted', 'error', { errorMessage: e.message });
label: 'Jellyfin Sync',
errorMessage: e.message,
});
} finally { } finally {
// If a new scanning session hasnt started, set running back to false this.endRun(sessionId);
if (this.sessionId === sessionId) {
this.running = false;
}
} }
} }
public status(): SyncStatus { public status(): JellyfinSyncStatus {
return { return {
running: this.running, running: this.running,
progress: this.progress, progress: this.progress,
@@ -868,10 +537,6 @@ class JellyfinScanner {
libraries: this.libraries, libraries: this.libraries,
}; };
} }
public cancel(): void {
this.running = false;
}
} }
export const jellyfinFullScanner = new JellyfinScanner(); export const jellyfinFullScanner = new JellyfinScanner();

View File

@@ -13,9 +13,7 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
} }
const userRepository = getRepository(User); const userRepository = getRepository(User);
const users = await userRepository.find({ const users = await userRepository.find();
select: ['id'],
});
let errorOccurred = false; let errorOccurred = false;
@@ -30,15 +28,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}); });
const radarrTags = await radarr.getTags(); const radarrTags = await radarr.getTags();
for (const user of users) { for (const user of users) {
const userTag = radarrTags.find((v) => const userTag = radarrTags.find(
v.label.startsWith(user.id + ' - ') (v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
); );
if (!userTag) { if (!userTag) {
continue; continue;
} }
await radarr.renameTag({ await radarr.renameTag({
id: userTag.id, 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) { } catch (error) {
@@ -61,15 +70,26 @@ const migrationArrTags = async (settings: any): Promise<AllSettings> => {
}); });
const sonarrTags = await sonarr.getTags(); const sonarrTags = await sonarr.getTags();
for (const user of users) { for (const user of users) {
const userTag = sonarrTags.find((v) => const userTag = sonarrTags.find(
v.label.startsWith(user.id + ' - ') (v) =>
v.label.startsWith(user.id + ' - ') ||
v.label.startsWith(user.id + '-')
); );
if (!userTag) { if (!userTag) {
continue; continue;
} }
await sonarr.renameTag({ await sonarr.renameTag({
id: userTag.id, 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) { } catch (error) {

View File

@@ -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"`
);
}
}

View File

@@ -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"`);
}
}

View File

@@ -4,7 +4,7 @@ import TautulliAPI from '@server/api/tautulli';
import { MediaType } from '@server/constants/media'; import { MediaType } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import { UserType } from '@server/constants/user'; import { UserType } from '@server/constants/user';
import { getRepository } from '@server/datasource'; import dataSource, { getRepository } from '@server/datasource';
import Media from '@server/entity/Media'; import Media from '@server/entity/Media';
import { MediaRequest } from '@server/entity/MediaRequest'; import { MediaRequest } from '@server/entity/MediaRequest';
import { User } from '@server/entity/User'; import { User } from '@server/entity/User';
@@ -25,7 +25,8 @@ import { getHostname } from '@server/utils/getHostname';
import { Router } from 'express'; import { Router } from 'express';
import gravatarUrl from 'gravatar-url'; import gravatarUrl from 'gravatar-url';
import { findIndex, sortBy } from 'lodash'; import { findIndex, sortBy } from 'lodash';
import { In } from 'typeorm'; import type { EntityManager } from 'typeorm';
import { In, Not } from 'typeorm';
import userSettingsRoutes from './usersettings'; import userSettingsRoutes from './usersettings';
const router = Router(); const router = Router();
@@ -188,19 +189,69 @@ router.post<
} }
>('/registerPushSubscription', async (req, res, next) => { >('/registerPushSubscription', async (req, res, next) => {
try { 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({ // Check for existing subscription by auth or endpoint within transaction
const existingSubscription = await transactionalRepo.findOne({
relations: { user: true }, relations: { user: true },
where: { auth: req.body.auth, user: { id: req.user?.id } }, where: [
{ auth: req.body.auth, user: { id: req.user?.id } },
{ endpoint: req.body.endpoint, user: { id: req.user?.id } },
],
}); });
if (existingSubs.length > 0) { 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;
await transactionalRepo.save(existingSubscription);
logger.debug( logger.debug(
'User push subscription already exists. Skipping registration.', 'Updated existing push subscription with new keys for same endpoint.',
{ label: 'API' } { label: 'API' }
); );
return res.status(204).send(); 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({ const userPushSubscription = new UserPushSubscription({
@@ -211,7 +262,9 @@ router.post<
user: req.user, user: req.user,
}); });
userPushSubRepository.save(userPushSubscription); await transactionalRepo.save(userPushSubscription);
}
);
return res.status(204).send(); return res.status(204).send();
} catch (e) { } catch (e) {

View File

@@ -29,6 +29,16 @@ import type {
} from 'typeorm'; } from 'typeorm';
import { EventSubscriber } 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() @EventSubscriber()
export class MediaRequestSubscriber export class MediaRequestSubscriber
implements EntitySubscriberInterface<MediaRequest> implements EntitySubscriberInterface<MediaRequest>
@@ -310,11 +320,15 @@ export class MediaRequestSubscriber
mediaId: entity.media.id, mediaId: entity.media.id,
userId: entity.requestedBy.id, userId: entity.requestedBy.id,
newTag: newTag:
entity.requestedBy.id + '-' + entity.requestedBy.displayName, entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
userTag = await radarr.createTag({ userTag = await radarr.createTag({
label: label:
entity.requestedBy.id + '-' + entity.requestedBy.displayName, entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
} }
if (userTag.id) { if (userTag.id) {
@@ -631,11 +645,15 @@ export class MediaRequestSubscriber
mediaId: entity.media.id, mediaId: entity.media.id,
userId: entity.requestedBy.id, userId: entity.requestedBy.id,
newTag: newTag:
entity.requestedBy.id + '-' + entity.requestedBy.displayName, entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
userTag = await sonarr.createTag({ userTag = await sonarr.createTag({
label: label:
entity.requestedBy.id + '-' + entity.requestedBy.displayName, entity.requestedBy.id +
'-' +
sanitizeDisplayName(entity.requestedBy.displayName),
}); });
} }
if (userTag.id) { if (userTag.id) {

View File

@@ -25,6 +25,7 @@ import {
} from '@server/constants/media'; } from '@server/constants/media';
import { MediaServerType } from '@server/constants/server'; import { MediaServerType } from '@server/constants/server';
import type { MediaWatchDataResponse } from '@server/interfaces/api/mediaInterfaces'; 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 { RadarrSettings, SonarrSettings } from '@server/lib/settings';
import type { MovieDetails } from '@server/models/Movie'; import type { MovieDetails } from '@server/models/Movie';
import type { TvDetails } from '@server/models/Tv'; import type { TvDetails } from '@server/models/Tv';
@@ -33,6 +34,17 @@ import Link from 'next/link';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import useSWR from 'swr'; 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', { const messages = defineMessages('components.ManageSlideOver', {
manageModalTitle: 'Manage {mediaType}', manageModalTitle: 'Manage {mediaType}',
manageModalIssues: 'Open Issues', manageModalIssues: 'Open Issues',
@@ -230,7 +242,8 @@ const ManageSlideOver = ({
</h3> </h3>
<div className="overflow-hidden rounded-md border border-gray-700 shadow"> <div className="overflow-hidden rounded-md border border-gray-700 shadow">
<ul> <ul>
{data.mediaInfo?.downloadStatus?.map((status, index) => ( {filterDuplicateDownloads(data.mediaInfo?.downloadStatus).map(
(status, index) => (
<Tooltip <Tooltip
key={`dl-status-${status.externalId}-${index}`} key={`dl-status-${status.externalId}-${index}`}
content={status.title} content={status.title}
@@ -239,17 +252,20 @@ const ManageSlideOver = ({
<DownloadBlock downloadItem={status} /> <DownloadBlock downloadItem={status} />
</li> </li>
</Tooltip> </Tooltip>
))} )
{data.mediaInfo?.downloadStatus4k?.map((status, index) => ( )}
{filterDuplicateDownloads(data.mediaInfo?.downloadStatus4k).map(
(status, index) => (
<Tooltip <Tooltip
key={`dl-status-${status.externalId}-${index}`} key={`dl-status-4k-${status.externalId}-${index}`}
content={status.title} content={status.title}
> >
<li className="border-b border-gray-700 last:border-b-0"> <li className="border-b border-gray-700 last:border-b-0">
<DownloadBlock downloadItem={status} is4k /> <DownloadBlock downloadItem={status} is4k />
</li> </li>
</Tooltip> </Tooltip>
))} )
)}
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -320,12 +320,14 @@ const SettingsMetadata = () => {
addToast(intl.formatMessage(messages.metadataSettingsSaved), { addToast(intl.formatMessage(messages.metadataSettingsSaved), {
appearance: 'success', appearance: 'success',
autoDismiss: true,
}); });
} catch (e) { } catch (e) {
addToast( addToast(
intl.formatMessage(messages.failedToSaveMetadataSettings), intl.formatMessage(messages.failedToSaveMetadataSettings),
{ {
appearance: 'error', appearance: 'error',
autoDismiss: true,
} }
); );
} }
@@ -422,6 +424,7 @@ const SettingsMetadata = () => {
), ),
{ {
appearance: 'success', appearance: 'success',
autoDismiss: true,
} }
); );
} }

View File

@@ -109,15 +109,28 @@ const UserWebPushSettings = () => {
// Deletes/disables corresponding push subscription from database // Deletes/disables corresponding push subscription from database
const disablePushNotifications = async (endpoint?: string) => { const disablePushNotifications = async (endpoint?: string) => {
try { try {
await unsubscribeToPushNotifications(user?.id, endpoint); const unsubscribedEndpoint = await unsubscribeToPushNotifications(
user?.id,
// Delete from backend if endpoint is available endpoint
if (subEndpoint) { );
await deletePushSubscriptionFromBackend(subEndpoint);
}
localStorage.setItem('pushNotificationsEnabled', 'false'); localStorage.setItem('pushNotificationsEnabled', 'false');
setWebPushEnabled(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), { addToast(intl.formatMessage(messages.webpushhasbeendisabled), {
autoDismiss: true, autoDismiss: true,
appearance: 'success', appearance: 'success',
@@ -157,7 +170,33 @@ const UserWebPushSettings = () => {
useEffect(() => { useEffect(() => {
const verifyWebPush = async () => { const verifyWebPush = async () => {
const enabled = await verifyPushSubscription(user?.id, currentSettings); 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) { if (user?.id) {

View File

@@ -49,13 +49,17 @@ export const verifyPushSubscription = async (
currentSettings.vapidPublic currentSettings.vapidPublic
).toString(); ).toString();
if (currentServerKey !== expectedServerKey) {
return false;
}
const endpoint = subscription.endpoint; const endpoint = subscription.endpoint;
const { data } = await axios.get<UserPushSubscription>( const { data } = await axios.get<UserPushSubscription>(
`/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}` `/api/v1/user/${userId}/pushSubscription/${encodeURIComponent(endpoint)}`
); );
return expectedServerKey === currentServerKey && data.endpoint === endpoint; return data.endpoint === endpoint;
} catch { } catch {
return false; return false;
} }
@@ -65,20 +69,39 @@ export const verifyAndResubscribePushSubscription = async (
userId: number | undefined, userId: number | undefined,
currentSettings: PublicSettingsResponse currentSettings: PublicSettingsResponse
): Promise<boolean> => { ): Promise<boolean> => {
if (!userId) {
return false;
}
const { subscription } = await getPushSubscription();
const isValid = await verifyPushSubscription(userId, currentSettings); const isValid = await verifyPushSubscription(userId, currentSettings);
if (isValid) { if (isValid) {
return true; return true;
} }
if (subscription) {
return false;
}
if (currentSettings.enablePushRegistration) { if (currentSettings.enablePushRegistration) {
try { try {
// Unsubscribe from the backend to clear the existing push subscription (keys and endpoint) const oldEndpoint = await unsubscribeToPushNotifications(userId);
await unsubscribeToPushNotifications(userId);
// Subscribe again to generate a fresh push subscription with updated keys and endpoint
await subscribeToPushNotifications(userId, currentSettings); 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; return true;
} catch (error) { } catch (error) {
throw new Error(`[SW] Resubscribe failed: ${error.message}`); throw new Error(`[SW] Resubscribe failed: ${error.message}`);
@@ -136,24 +159,26 @@ export const subscribeToPushNotifications = async (
export const unsubscribeToPushNotifications = async ( export const unsubscribeToPushNotifications = async (
userId: number | undefined, userId: number | undefined,
endpoint?: string endpoint?: string
) => { ): Promise<string | null> => {
if (!('serviceWorker' in navigator) || !userId) { if (!('serviceWorker' in navigator) || !userId) {
return; return null;
} }
try { try {
const { subscription } = await getPushSubscription(); const { subscription } = await getPushSubscription();
if (!subscription) { if (!subscription) {
return false; return null;
} }
const { endpoint: currentEndpoint } = subscription.toJSON(); const { endpoint: currentEndpoint } = subscription.toJSON();
if (!endpoint || endpoint === currentEndpoint) { if (!endpoint || endpoint === currentEndpoint) {
await subscription.unsubscribe(); await subscription.unsubscribe();
return true; return currentEndpoint ?? null;
} }
return null;
} catch (error) { } catch (error) {
throw new Error( throw new Error(
`Issue unsubscribing to push notifications: ${error.message}` `Issue unsubscribing to push notifications: ${error.message}`