From 3de15af6552474471adf7c4f133f10cf1b27d467 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:36:48 +0200 Subject: [PATCH 01/33] feat(episodes): add episode availability tracking and sync This allows Jellyseerr to track the availability status of individual episodes, enabling better status reporting for partially available seasons. --- server/api/servarr/sonarr.ts | 15 ++++ server/entity/Episode.ts | 43 ++++++++++ server/entity/Media.ts | 20 ++++- server/entity/Season.ts | 8 ++ server/lib/availabilitySync.ts | 78 +++++++++++++++++++ .../postgres/1747690625482-AddEpisodeTable.ts | 21 +++++ .../sqlite/1747690625482-AddEpisodeTable.ts | 34 ++++++++ server/models/Tv.ts | 16 +++- server/routes/tv.ts | 28 ++++++- src/components/TvDetails/Season/index.tsx | 9 +++ 10 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 server/entity/Episode.ts create mode 100755 server/migration/postgres/1747690625482-AddEpisodeTable.ts create mode 100755 server/migration/sqlite/1747690625482-AddEpisodeTable.ts diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index c5b4eec93c..2203c21be1 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -464,6 +464,21 @@ class SonarrAPI extends ServarrBase<{ }); } }; + + public async getEpisodesBySeriesId( + seriesId: number + ): Promise { + try { + const response = await this.axios.get(`/episode`, { + params: { seriesId }, + }); + return response.data; + } catch (e) { + throw new Error( + `[Sonarr] Failed to retrieve episodes for series: ${e.message}` + ); + } + } } export default SonarrAPI; diff --git a/server/entity/Episode.ts b/server/entity/Episode.ts new file mode 100644 index 0000000000..9a32788473 --- /dev/null +++ b/server/entity/Episode.ts @@ -0,0 +1,43 @@ +import { MediaStatus } from '@server/constants/media'; +import { DbAwareColumn } from '@server/utils/DbColumnHelper'; +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import Season from './Season'; + +@Entity() +class Episode { + @PrimaryGeneratedColumn() + public id: number; + + @Column() + public episodeNumber: number; + + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status: MediaStatus; + + @Column({ type: 'int', default: MediaStatus.UNKNOWN }) + public status4k: MediaStatus; + + @ManyToOne(() => Season, (season: Season) => season.episodes, { + onDelete: 'CASCADE', + nullable: true, + }) + public season?: Promise; + + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) + public createdAt: Date; + + @DbAwareColumn({ + type: 'datetime', + default: () => 'CURRENT_TIMESTAMP', + onUpdate: 'CURRENT_TIMESTAMP', + }) + public updatedAt: Date; + + constructor(init?: Partial) { + if (init) { + Object.assign(this, init); + } + } +} + +export default Episode; diff --git a/server/entity/Media.ts b/server/entity/Media.ts index 304d80e8c4..5db5bccd22 100644 --- a/server/entity/Media.ts +++ b/server/entity/Media.ts @@ -69,9 +69,27 @@ class Media { const mediaRepository = getRepository(Media); try { + const relations: { + requests: boolean; + issues: boolean; + seasons?: { + episodes: boolean; + }; + } = { + requests: true, + issues: true, + }; + + // Only load seasons for TV shows + if (mediaType === MediaType.TV) { + relations.seasons = { + episodes: true, + }; + } + const media = await mediaRepository.findOne({ where: { tmdbId: id, mediaType: mediaType }, - relations: { requests: true, issues: true }, + relations, }); return media ?? undefined; diff --git a/server/entity/Season.ts b/server/entity/Season.ts index f1e5a11d3e..07860ecbc4 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -5,9 +5,11 @@ import { Entity, Index, ManyToOne, + OneToMany, PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import Episode from './Episode'; import Media from './Media'; @Entity() @@ -30,6 +32,12 @@ class Season { @Index() public media: Promise; + @OneToMany(() => Episode, (episode) => episode.season, { + cascade: true, + eager: true, + }) + public episodes: Episode[]; + @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 1636744ad1..3c77a79472 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -10,6 +10,7 @@ import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; +import Episode from '@server/entity/Episode'; import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import type Season from '@server/entity/Season'; @@ -830,6 +831,7 @@ class AvailabilitySync { is4k: boolean ): Promise { let seasonExists = false; + const episodeRepository = getRepository(Episode); // Check each sonarr instance to see if the media still exists // If found, we will assume the media exists and prevent removal @@ -858,6 +860,82 @@ class AvailabilitySync { if (seasonIsAvailable && sonarrSeasons) { seasonExists = true; + + const sonarrApi = new SonarrAPI({ + url: SonarrAPI.buildUrl(server, '/api/v3'), + apiKey: server.apiKey, + }); + + try { + const serviceId = is4k + ? media.externalServiceId4k + : media.externalServiceId; + + if (!serviceId) { + logger.error('Missing service ID for episode sync', { + label: 'Availability Sync', + tvId: media.tmdbId, + seasonNumber: season.seasonNumber, + is4k, + }); + return seasonExists; + } + + const episodes = await sonarrApi.getEpisodesBySeriesId(serviceId); + + for (const ep of episodes) { + if (ep.seasonNumber === season.seasonNumber) { + const existingEpisode = await episodeRepository.findOne({ + where: { + episodeNumber: ep.episodeNumber, + season: { id: season.id }, + }, + relations: ['season'], + }); + + if (existingEpisode) { + existingEpisode[is4k ? 'status4k' : 'status'] = ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + await episodeRepository.save(existingEpisode); + } else { + const newEpisode = new Episode(); + newEpisode.episodeNumber = ep.episodeNumber; + newEpisode.status = is4k + ? MediaStatus.UNKNOWN + : ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newEpisode.status4k = is4k + ? ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN + : MediaStatus.UNKNOWN; + newEpisode.season = Promise.resolve(season); + + try { + await episodeRepository.save(newEpisode); + } catch (saveError) { + logger.error('Failed to save new episode', { + label: 'Availability Sync', + errorMessage: saveError.message, + tvId: media.tmdbId, + seasonNumber: season.seasonNumber, + episodeNumber: ep.episodeNumber, + }); + } + } + } + } + } catch (err) { + logger.error('Failed to update episode availability', { + label: 'Availability Sync', + errorMessage: err.message, + tvId: media.tmdbId, + seasonNumber: season.seasonNumber, + sonarrServerId: server.id, + }); + } } } diff --git a/server/migration/postgres/1747690625482-AddEpisodeTable.ts b/server/migration/postgres/1747690625482-AddEpisodeTable.ts new file mode 100755 index 0000000000..3af000c511 --- /dev/null +++ b/server/migration/postgres/1747690625482-AddEpisodeTable.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeTable1747690625482 implements MigrationInterface { + name = 'AddEpisodeTable1747690625482'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "episode" ("id" SERIAL NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "seasonId" integer, CONSTRAINT "PK_7258b95d6d2bf7f621845a0e143" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `ALTER TABLE "episode" ADD CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd" FOREIGN KEY ("seasonId") REFERENCES "season"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "episode" DROP CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd"` + ); + await queryRunner.query(`DROP TABLE "episode"`); + } +} diff --git a/server/migration/sqlite/1747690625482-AddEpisodeTable.ts b/server/migration/sqlite/1747690625482-AddEpisodeTable.ts new file mode 100755 index 0000000000..7e6cb416d1 --- /dev/null +++ b/server/migration/sqlite/1747690625482-AddEpisodeTable.ts @@ -0,0 +1,34 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeTable1747690625482 implements MigrationInterface { + name = 'AddEpisodeTable1747690625482'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer)` + ); + await queryRunner.query( + `CREATE TABLE "temporary_episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer, CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd" FOREIGN KEY ("seasonId") REFERENCES "season" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "episode"` + ); + await queryRunner.query(`DROP TABLE "episode"`); + await queryRunner.query( + `ALTER TABLE "temporary_episode" RENAME TO "episode"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "episode" RENAME TO "temporary_episode"` + ); + await queryRunner.query( + `CREATE TABLE "episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer)` + ); + await queryRunner.query( + `INSERT INTO "episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "temporary_episode"` + ); + await queryRunner.query(`DROP TABLE "temporary_episode"`); + } +} diff --git a/server/models/Tv.ts b/server/models/Tv.ts index ef676e4a8d..9c254a1421 100644 --- a/server/models/Tv.ts +++ b/server/models/Tv.ts @@ -38,6 +38,7 @@ interface Episode { stillPath?: string; voteAverage: number; voteCount: number; + available?: boolean; } interface Season { @@ -114,7 +115,10 @@ export interface TvDetails { onUserWatchlist?: boolean; } -const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ +const mapEpisodeResult = ( + episode: TmdbTvEpisodeResult, + availableMap?: Record +): Episode => ({ id: episode.id, airDate: episode.air_date, episodeNumber: episode.episode_number, @@ -126,6 +130,9 @@ const mapEpisodeResult = (episode: TmdbTvEpisodeResult): Episode => ({ voteAverage: episode.vote_average, voteCount: episode.vote_count, stillPath: episode.still_path, + available: availableMap + ? (availableMap[episode.episode_number] ?? false) + : undefined, }); const mapSeasonResult = (season: TmdbTvSeasonResult): Season => ({ @@ -139,10 +146,13 @@ const mapSeasonResult = (season: TmdbTvSeasonResult): Season => ({ }); export const mapSeasonWithEpisodes = ( - season: TmdbSeasonWithEpisodes + season: TmdbSeasonWithEpisodes, + availableMap?: Record ): SeasonWithEpisodes => ({ airDate: season.air_date, - episodes: season.episodes.map(mapEpisodeResult), + episodes: season.episodes.map((episode) => + mapEpisodeResult(episode, availableMap) + ), externalIds: mapExternalIds(season.external_ids), id: season.id, name: season.name, diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 743f407e48..7aea0b03d3 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -3,7 +3,7 @@ import RottenTomatoes from '@server/api/rating/rottentomatoes'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbKeyword } from '@server/api/themoviedb/interfaces'; -import { MediaType } from '@server/constants/media'; +import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { Watchlist } from '@server/entity/Watchlist'; @@ -28,7 +28,6 @@ tvRoutes.get('/:id', async (req, res, next) => { : await getMetadataProvider('tv'); const tv = await metadataProvider.getTvShow({ tvId: Number(req.params.id), - language: (req.query.language as string) ?? req.locale, }); const media = await Media.getMedia(tv.id, MediaType.TV); @@ -84,7 +83,30 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { language: (req.query.language as string) ?? req.locale, }); - return res.status(200).json(mapSeasonWithEpisodes(season)); + const media = await Media.getMedia(Number(req.params.id), MediaType.TV); + const availableMap: Record = {}; + + if (media?.seasons) { + const dbSeason = media.seasons.find( + (s) => s.seasonNumber === Number(req.params.seasonNumber) + ); + if (dbSeason) { + if (dbSeason.status === MediaStatus.AVAILABLE) { + for (const episode of season.episodes) { + availableMap[episode.episode_number] = true; + } + } else if (dbSeason.status === MediaStatus.PARTIALLY_AVAILABLE) { + if (dbSeason.episodes) { + for (const episode of dbSeason.episodes) { + availableMap[episode.episodeNumber] = + episode.status === MediaStatus.AVAILABLE; + } + } + } + } + } + + return res.status(200).json(mapSeasonWithEpisodes(season, availableMap)); } catch (e) { logger.debug('Something went wrong retrieving season', { label: 'API', diff --git a/src/components/TvDetails/Season/index.tsx b/src/components/TvDetails/Season/index.tsx index 95137cfdfe..ed4ee4875d 100644 --- a/src/components/TvDetails/Season/index.tsx +++ b/src/components/TvDetails/Season/index.tsx @@ -1,6 +1,8 @@ import AirDateBadge from '@app/components/AirDateBadge'; +import Badge from '@app/components/Common/Badge'; import CachedImage from '@app/components/Common/CachedImage'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; import type { SeasonWithEpisodes } from '@server/models/Tv'; import { useIntl } from 'react-intl'; @@ -52,6 +54,13 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => { {episode.airDate && ( )} + + {intl.formatMessage( + episode.available + ? globalMessages.available + : globalMessages.unavailable + )} + {episode.overview &&

{episode.overview}

} From 424686cae134ad1815ab0cc9f8229bd43fd53c61 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Wed, 3 Sep 2025 23:19:59 +0200 Subject: [PATCH 02/33] chore(tv): removed the wrong parameter. The PR should not fix the issue reported in recent tvdb PR. Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- server/routes/tv.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 7aea0b03d3..250973a2d3 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -28,6 +28,7 @@ tvRoutes.get('/:id', async (req, res, next) => { : await getMetadataProvider('tv'); const tv = await metadataProvider.getTvShow({ tvId: Number(req.params.id), + language: (req.query.language as string) ?? req.locale, }); const media = await Media.getMedia(tv.id, MediaType.TV); @@ -80,7 +81,6 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { const season = await metadataProvider.getTvSeason({ tvId: Number(req.params.id), seasonNumber: Number(req.params.seasonNumber), - language: (req.query.language as string) ?? req.locale, }); const media = await Media.getMedia(Number(req.params.id), MediaType.TV); From 08f9291cc76890c0fd90f0f4be822ac8481b18ef Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:37:40 +0200 Subject: [PATCH 03/33] feat(settings): make the feature optionnal Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- cypress/config/settings.cypress.json | 3 +- seerr-api.yml | 3 + server/interfaces/api/settingsInterfaces.ts | 1 + server/lib/availabilitySync.ts | 86 ++++++++++--------- server/lib/settings/index.ts | 4 + .../Settings/SettingsMain/index.tsx | 34 ++++++++ src/context/SettingsContext.tsx | 1 + src/i18n/locale/en.json | 2 + src/pages/_app.tsx | 1 + 9 files changed, 95 insertions(+), 40 deletions(-) diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index f485424fcc..b2661e9d8b 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -25,6 +25,7 @@ "mediaServerType": 1, "partialRequestsEnabled": true, "enableSpecialEpisodes": false, + "enableEpisodeAvailability": false, "locale": "en" }, "plex": { @@ -202,4 +203,4 @@ "forceMaxTtl": -1 } } -} +} \ No newline at end of file diff --git a/seerr-api.yml b/seerr-api.yml index 4b33bf7892..a881c42b91 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -250,6 +250,9 @@ components: enableSpecialEpisodes: type: boolean example: false + enableEpisodeAvailability: + type: boolean + example: false versionCheck: type: boolean example: false diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index f2793c9aad..841c9cd72e 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -41,6 +41,7 @@ export interface PublicSettingsResponse { mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableEpisodeAvailability: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 3c77a79472..a3e6cf67b6 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -16,7 +16,7 @@ import MediaRequest from '@server/entity/MediaRequest'; import type Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; -import { getSettings } from '@server/lib/settings'; +import { MetadataProviderType, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { getHostname } from '@server/utils/getHostname'; @@ -833,6 +833,12 @@ class AvailabilitySync { let seasonExists = false; const episodeRepository = getRepository(Episode); + const settings = getSettings(); + const shouldTrackEpisodes = + settings.main.enableEpisodeAvailability && + (settings.metadataSettings.tv === MetadataProviderType.TVDB || + settings.metadataSettings.anime === MetadataProviderType.TVDB); + // Check each sonarr instance to see if the media still exists // If found, we will assume the media exists and prevent removal // We can use the cache we built when we fetched the series with mediaExistsInSonarr @@ -881,48 +887,50 @@ class AvailabilitySync { return seasonExists; } - const episodes = await sonarrApi.getEpisodesBySeriesId(serviceId); + if (shouldTrackEpisodes) { + const episodes = await sonarrApi.getEpisodesBySeriesId(serviceId); - for (const ep of episodes) { - if (ep.seasonNumber === season.seasonNumber) { - const existingEpisode = await episodeRepository.findOne({ - where: { - episodeNumber: ep.episodeNumber, - season: { id: season.id }, - }, - relations: ['season'], - }); + for (const ep of episodes) { + if (ep.seasonNumber === season.seasonNumber) { + const existingEpisode = await episodeRepository.findOne({ + where: { + episodeNumber: ep.episodeNumber, + season: { id: season.id }, + }, + relations: ['season'], + }); - if (existingEpisode) { - existingEpisode[is4k ? 'status4k' : 'status'] = ep.hasFile - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN; - await episodeRepository.save(existingEpisode); - } else { - const newEpisode = new Episode(); - newEpisode.episodeNumber = ep.episodeNumber; - newEpisode.status = is4k - ? MediaStatus.UNKNOWN - : ep.hasFile + if (existingEpisode) { + existingEpisode[is4k ? 'status4k' : 'status'] = ep.hasFile ? MediaStatus.AVAILABLE : MediaStatus.UNKNOWN; - newEpisode.status4k = is4k - ? ep.hasFile - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN - : MediaStatus.UNKNOWN; - newEpisode.season = Promise.resolve(season); - - try { - await episodeRepository.save(newEpisode); - } catch (saveError) { - logger.error('Failed to save new episode', { - label: 'Availability Sync', - errorMessage: saveError.message, - tvId: media.tmdbId, - seasonNumber: season.seasonNumber, - episodeNumber: ep.episodeNumber, - }); + await episodeRepository.save(existingEpisode); + } else { + const newEpisode = new Episode(); + newEpisode.episodeNumber = ep.episodeNumber; + newEpisode.status = is4k + ? MediaStatus.UNKNOWN + : ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newEpisode.status4k = is4k + ? ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN + : MediaStatus.UNKNOWN; + newEpisode.season = Promise.resolve(season); + + try { + await episodeRepository.save(newEpisode); + } catch (saveError) { + logger.error('Failed to save new episode', { + label: 'Availability Sync', + errorMessage: saveError.message, + tvId: media.tmdbId, + seasonNumber: season.seasonNumber, + episodeNumber: ep.episodeNumber, + }); + } } } } diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 0a896524eb..283a3e60b6 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -154,6 +154,7 @@ export interface MainSettings { mediaServerType: number; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableEpisodeAvailability: boolean; locale: string; youtubeUrl: string; versionCheck: boolean; @@ -207,6 +208,7 @@ interface FullPublicSettings extends PublicSettings { jellyfinServerName?: string; partialRequestsEnabled: boolean; enableSpecialEpisodes: boolean; + enableEpisodeAvailability: boolean; cacheImages: boolean; vapidPublic: string; enablePushRegistration: boolean; @@ -429,6 +431,7 @@ class Settings { mediaServerType: MediaServerType.NOT_CONFIGURED, partialRequestsEnabled: true, enableSpecialEpisodes: false, + enableEpisodeAvailability: false, locale: 'en', youtubeUrl: '', versionCheck: true, @@ -728,6 +731,7 @@ class Settings { mediaServerType: this.main.mediaServerType, partialRequestsEnabled: this.data.main.partialRequestsEnabled, enableSpecialEpisodes: this.data.main.enableSpecialEpisodes, + enableEpisodeAvailability: this.data.main.enableEpisodeAvailability, cacheImages: this.data.main.cacheImages, vapidPublic: this.vapidPublic, enablePushRegistration: this.data.notifications.agents.webpush.enabled, diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 259a297a71..69d0a22781 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -70,6 +70,9 @@ const messages = defineMessages('components.Settings.SettingsMain', { validationApplicationUrlTrailingSlash: 'URL must not end in a trailing slash', partialRequestsEnabled: 'Allow Partial Series Requests', enableSpecialEpisodes: 'Allow Special Episodes Requests', + enableEpisodeAvailability: 'Enable Episode Availability Tracking', + enableEpisodeAvailabilityTip: + 'Track individual episode availability status (requires TVDB as metadata provider for TV shows or anime)', locale: 'Display Language', youtubeUrl: 'YouTube URL', youtubeUrlTip: @@ -90,6 +93,7 @@ const SettingsMain = () => { error, mutate: revalidate, } = useSWR('/api/v1/settings/main'); + const { data: userData } = useSWR( currentUser ? `/api/v1/user/${currentUser.id}/settings/main` : null ); @@ -183,6 +187,7 @@ const SettingsMain = () => { blocklistedTagsLimit: data?.blocklistedTagsLimit || 50, partialRequestsEnabled: data?.partialRequestsEnabled, enableSpecialEpisodes: data?.enableSpecialEpisodes, + enableEpisodeAvailability: data?.enableEpisodeAvailability, cacheImages: data?.cacheImages, youtubeUrl: data?.youtubeUrl, versionCheck: data?.versionCheck, @@ -206,6 +211,7 @@ const SettingsMain = () => { blocklistedTagsLimit: values.blocklistedTagsLimit, partialRequestsEnabled: values.partialRequestsEnabled, enableSpecialEpisodes: values.enableSpecialEpisodes, + enableEpisodeAvailability: values.enableEpisodeAvailability, cacheImages: values.cacheImages, youtubeUrl: values.youtubeUrl, versionCheck: values?.versionCheck, @@ -588,6 +594,34 @@ const SettingsMain = () => { /> +
+ +
+ { + setFieldValue( + 'enableEpisodeAvailability', + !values.enableEpisodeAvailability + ); + }} + /> +
+
{episode.overview &&

{episode.overview}

} From ec2da54f08b2435e72087f30cbdce9206e23bad4 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:37:45 +0200 Subject: [PATCH 05/33] fix(api): only mark episode as available if provider is tvdb Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- server/routes/tv.ts | 42 +++++++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 250973a2d3..d87a2ec011 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -7,6 +7,7 @@ import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { Watchlist } from '@server/entity/Watchlist'; +import { getSettings, MetadataProviderType } from '@server/lib/settings'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; @@ -83,23 +84,34 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { seasonNumber: Number(req.params.seasonNumber), }); - const media = await Media.getMedia(Number(req.params.id), MediaType.TV); const availableMap: Record = {}; - if (media?.seasons) { - const dbSeason = media.seasons.find( - (s) => s.seasonNumber === Number(req.params.seasonNumber) - ); - if (dbSeason) { - if (dbSeason.status === MediaStatus.AVAILABLE) { - for (const episode of season.episodes) { - availableMap[episode.episode_number] = true; - } - } else if (dbSeason.status === MediaStatus.PARTIALLY_AVAILABLE) { - if (dbSeason.episodes) { - for (const episode of dbSeason.episodes) { - availableMap[episode.episodeNumber] = - episode.status === MediaStatus.AVAILABLE; + const settings = await getSettings(); + const isAnime = tmdbTv.keywords.results.some( + (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID + ); + const isTvdbProvider = isAnime + ? settings.metadataSettings.anime === MetadataProviderType.TVDB + : settings.metadataSettings.tv === MetadataProviderType.TVDB; + + if (isTvdbProvider) { + const media = await Media.getMedia(Number(req.params.id), MediaType.TV); + + if (media?.seasons) { + const dbSeason = media.seasons.find( + (s) => s.seasonNumber === Number(req.params.seasonNumber) + ); + if (dbSeason) { + if (dbSeason.status === MediaStatus.AVAILABLE) { + for (const episode of season.episodes) { + availableMap[episode.episode_number] = true; + } + } else if (dbSeason.status === MediaStatus.PARTIALLY_AVAILABLE) { + if (dbSeason.episodes) { + for (const episode of dbSeason.episodes) { + availableMap[episode.episodeNumber] = + episode.status === MediaStatus.AVAILABLE; + } } } } From 341fc670e77b41e02449ee18253a1b56b2819c95 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:37:49 +0200 Subject: [PATCH 06/33] fix(cypress): add missing newline Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- cypress/config/settings.cypress.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index b2661e9d8b..5d7dd37029 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -203,4 +203,4 @@ "forceMaxTtl": -1 } } -} \ No newline at end of file +} From b0fbdaaa2c35ec57232032f9c59effb1dd0297e8 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:37:51 +0200 Subject: [PATCH 07/33] fix(tv): rely on provider type instead of setting Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- src/components/TvDetails/Season/index.tsx | 24 ++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/TvDetails/Season/index.tsx b/src/components/TvDetails/Season/index.tsx index cb15e1dc0d..51198e8417 100644 --- a/src/components/TvDetails/Season/index.tsx +++ b/src/components/TvDetails/Season/index.tsx @@ -2,10 +2,11 @@ import AirDateBadge from '@app/components/AirDateBadge'; import Badge from '@app/components/Common/Badge'; import CachedImage from '@app/components/Common/CachedImage'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; -import useSettings from '@app/hooks/useSettings'; +import { MetadataProviderType } from '@app/components/MetadataSelector'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; -import type { SeasonWithEpisodes } from '@server/models/Tv'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import type { SeasonWithEpisodes, TvDetails } from '@server/models/Tv'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; @@ -21,10 +22,14 @@ type SeasonProps = { const Season = ({ seasonNumber, tvId }: SeasonProps) => { const intl = useIntl(); - const settings = useSettings(); const { data, error } = useSWR( `/api/v1/tv/${tvId}/season/${seasonNumber}` ); + const { data: tvData } = useSWR(`/api/v1/tv/${tvId}`); + const { data: metadataSettings } = useSWR<{ + tv: MetadataProviderType; + anime: MetadataProviderType; + }>('/api/v1/settings/metadatas'); if (!data && !error) { return ; @@ -34,6 +39,15 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => { return
{intl.formatMessage(messages.somethingwentwrong)}
; } + const isAnime = tvData?.keywords.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ); + const isTvdbProvider = metadataSettings + ? isAnime + ? metadataSettings.anime === MetadataProviderType.TVDB + : metadataSettings.tv === MetadataProviderType.TVDB + : false; + return (
{data.episodes.length === 0 ? ( @@ -56,10 +70,10 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => { {episode.airDate && ( )} - {settings.currentSettings.enableEpisodeAvailability && + {isTvdbProvider && episode.airDate && new Date(episode.airDate) <= new Date() && - episode.available && ( + episode.available === true && ( {intl.formatMessage(globalMessages.available)} From d1575afaaacf019ea41b6c742cf77207ac8911fa Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:37:54 +0200 Subject: [PATCH 08/33] feat(settings): add metadata settings for TV and anime Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- server/interfaces/api/settingsInterfaces.ts | 4 ++++ server/lib/settings/index.ts | 2 ++ 2 files changed, 6 insertions(+) diff --git a/server/interfaces/api/settingsInterfaces.ts b/server/interfaces/api/settingsInterfaces.ts index 841c9cd72e..f007d67256 100644 --- a/server/interfaces/api/settingsInterfaces.ts +++ b/server/interfaces/api/settingsInterfaces.ts @@ -50,6 +50,10 @@ export interface PublicSettingsResponse { newPlexLogin: boolean; youtubeUrl: string; versionCheck: boolean; + metadataSettings: { + tv: string; + anime: string; + }; plexClientIdentifier: string; } diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 283a3e60b6..6f52462ad3 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -218,6 +218,7 @@ interface FullPublicSettings extends PublicSettings { newPlexLogin: boolean; youtubeUrl: string; versionCheck: boolean; + metadataSettings: MetadataSettings; plexClientIdentifier: string; } @@ -742,6 +743,7 @@ class Settings { newPlexLogin: this.data.main.newPlexLogin, youtubeUrl: this.data.main.youtubeUrl, versionCheck: this.data.main.versionCheck, + metadataSettings: this.data.metadataSettings, plexClientIdentifier: this.data.clientId, }; } From 87367d02e2edc90c8d40c795f179273d94623e79 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:37:56 +0200 Subject: [PATCH 09/33] refactor(tv): simplify episode availability checks Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- server/routes/tv.ts | 12 +++++------ src/components/TvDetails/Season/index.tsx | 26 +++++++++-------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/server/routes/tv.ts b/server/routes/tv.ts index d87a2ec011..843dff19e5 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -87,14 +87,12 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { const availableMap: Record = {}; const settings = await getSettings(); - const isAnime = tmdbTv.keywords.results.some( - (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID - ); - const isTvdbProvider = isAnime - ? settings.metadataSettings.anime === MetadataProviderType.TVDB - : settings.metadataSettings.tv === MetadataProviderType.TVDB; + const shouldTrackEpisodes = + settings.main.enableEpisodeAvailability && + (settings.metadataSettings.tv === MetadataProviderType.TVDB || + settings.metadataSettings.anime === MetadataProviderType.TVDB); - if (isTvdbProvider) { + if (shouldTrackEpisodes) { const media = await Media.getMedia(Number(req.params.id), MediaType.TV); if (media?.seasons) { diff --git a/src/components/TvDetails/Season/index.tsx b/src/components/TvDetails/Season/index.tsx index 51198e8417..c6e071e213 100644 --- a/src/components/TvDetails/Season/index.tsx +++ b/src/components/TvDetails/Season/index.tsx @@ -3,10 +3,10 @@ import Badge from '@app/components/Common/Badge'; import CachedImage from '@app/components/Common/CachedImage'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import { MetadataProviderType } from '@app/components/MetadataSelector'; +import useSettings from '@app/hooks/useSettings'; import globalMessages from '@app/i18n/globalMessages'; import defineMessages from '@app/utils/defineMessages'; -import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; -import type { SeasonWithEpisodes, TvDetails } from '@server/models/Tv'; +import type { SeasonWithEpisodes } from '@server/models/Tv'; import { useIntl } from 'react-intl'; import useSWR from 'swr'; @@ -22,14 +22,10 @@ type SeasonProps = { const Season = ({ seasonNumber, tvId }: SeasonProps) => { const intl = useIntl(); + const settings = useSettings(); const { data, error } = useSWR( `/api/v1/tv/${tvId}/season/${seasonNumber}` ); - const { data: tvData } = useSWR(`/api/v1/tv/${tvId}`); - const { data: metadataSettings } = useSWR<{ - tv: MetadataProviderType; - anime: MetadataProviderType; - }>('/api/v1/settings/metadatas'); if (!data && !error) { return ; @@ -39,14 +35,12 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => { return
{intl.formatMessage(messages.somethingwentwrong)}
; } - const isAnime = tvData?.keywords.some( - (keyword) => keyword.id === ANIME_KEYWORD_ID - ); - const isTvdbProvider = metadataSettings - ? isAnime - ? metadataSettings.anime === MetadataProviderType.TVDB - : metadataSettings.tv === MetadataProviderType.TVDB - : false; + const showEpisodeAvailability = + settings.currentSettings.enableEpisodeAvailability && + (settings.currentSettings.metadataSettings.tv === + MetadataProviderType.TVDB || + settings.currentSettings.metadataSettings.anime === + MetadataProviderType.TVDB); return (
@@ -70,7 +64,7 @@ const Season = ({ seasonNumber, tvId }: SeasonProps) => { {episode.airDate && ( )} - {isTvdbProvider && + {showEpisodeAvailability && episode.airDate && new Date(episode.airDate) <= new Date() && episode.available === true && ( From 7511054994856bbc82a2e3bc26962d8a860f1074 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:37:58 +0200 Subject: [PATCH 10/33] fix(settings): missing metadata settings Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- src/context/SettingsContext.tsx | 4 ++++ src/pages/_app.tsx | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx index c0a365ee07..9b961fe56c 100644 --- a/src/context/SettingsContext.tsx +++ b/src/context/SettingsContext.tsx @@ -33,6 +33,10 @@ const defaultSettings = { newPlexLogin: true, youtubeUrl: '', versionCheck: true, + metadataSettings: { + tv: 'tmdb', + anime: 'tmdb', + }, plexClientIdentifier: '', }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index fc0a0f88f9..9742beb416 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -263,6 +263,10 @@ CoreApp.getInitialProps = async (initialProps) => { newPlexLogin: true, youtubeUrl: '', versionCheck: true, + metadataSettings: { + tv: 'tmdb', + anime: 'tmdb', + }, plexClientIdentifier: '', }; From 32b513f734fb5e7202acb689effef1d48ff85567 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:01 +0200 Subject: [PATCH 11/33] feat(availability): implement episode caching Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- server/api/servarr/sonarr.ts | 2 +- server/lib/availabilitySync.ts | 152 +++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 63 deletions(-) diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 2203c21be1..f7aadb17b6 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -14,7 +14,7 @@ export interface SonarrSeason { percentOfEpisodes: number; }; } -interface EpisodeResult { +export interface EpisodeResult { seriesId: number; episodeFileId: number; seasonNumber: number; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index a3e6cf67b6..82ad86e708 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -3,7 +3,11 @@ import JellyfinAPI from '@server/api/jellyfin'; import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; -import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; +import type { + EpisodeResult, + SonarrSeason, + SonarrSeries, +} from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; @@ -31,6 +35,7 @@ class AvailabilitySync { private jellyfinEpisodeExistsCache: Record; private sonarrSeasonsCache: Record; + private sonarrEpisodesCache: Record; private radarrServers: RadarrSettings[]; private sonarrServers: SonarrSettings[]; private enable4kMovie: boolean; @@ -47,6 +52,7 @@ class AvailabilitySync { this.jellyfinSeasonsCache = {}; this.jellyfinEpisodeExistsCache = {}; this.sonarrSeasonsCache = {}; + this.sonarrEpisodesCache = {}; this.radarrServers = settings.radarr; this.sonarrServers = settings.sonarr; this.enable4kMovie = this.radarrServers.some((server) => server.is4k); @@ -743,6 +749,12 @@ class AvailabilitySync { let existsInSonarr = false; let preventSeasonSearch = false; + const settings = getSettings(); + const shouldTrackEpisodes = + settings.main.enableEpisodeAvailability && + (settings.metadataSettings.tv === MetadataProviderType.TVDB || + settings.metadataSettings.anime === MetadataProviderType.TVDB); + // Check for availability in all of the available sonarr servers // If any find the media, we will assume the media exists for (const server of this.sonarrServers.filter((server) => { @@ -755,26 +767,44 @@ class AvailabilitySync { try { let sonarr: SonarrSeries | undefined; + let serviceId: number | undefined; if (media.externalServiceId && !is4k) { sonarr = await sonarrAPI.getSeriesById(media.externalServiceId); + serviceId = media.externalServiceId; } if (media.externalServiceId4k && is4k) { sonarr = await sonarrAPI.getSeriesById(media.externalServiceId4k); + serviceId = media.externalServiceId4k; } if (sonarr && media.tvdbId != null && sonarr.tvdbId !== media.tvdbId) { continue; } - if (sonarr) { - const externalServiceId = is4k - ? media.externalServiceId4k - : media.externalServiceId; - this.sonarrSeasonsCache[`${server.id}-${externalServiceId}`] = + if (sonarr && serviceId) { + this.sonarrSeasonsCache[`${server.id}-${serviceId}`] = sonarr.seasons; + if ( + shouldTrackEpisodes && + sonarr.statistics.episodeFileCount > 0 + ) { + try { + const episodes = await sonarrAPI.getEpisodesBySeriesId(serviceId); + this.sonarrEpisodesCache[`${server.id}-${serviceId}`] = + episodes; + } catch (err) { + logger.error('Failed to fetch episodes for caching', { + label: 'Availability Sync', + errorMessage: err.message, + tvId: media.tmdbId, + sonarrServerId: server.id, + }); + } + } + if (sonarr.statistics.episodeFileCount > 0) { existsInSonarr = true; } @@ -867,12 +897,7 @@ class AvailabilitySync { if (seasonIsAvailable && sonarrSeasons) { seasonExists = true; - const sonarrApi = new SonarrAPI({ - url: SonarrAPI.buildUrl(server, '/api/v3'), - apiKey: server.apiKey, - }); - - try { + if (shouldTrackEpisodes) { const serviceId = is4k ? media.externalServiceId4k : media.externalServiceId; @@ -884,65 +909,68 @@ class AvailabilitySync { seasonNumber: season.seasonNumber, is4k, }); - return seasonExists; + continue; } - if (shouldTrackEpisodes) { - const episodes = await sonarrApi.getEpisodesBySeriesId(serviceId); - - for (const ep of episodes) { - if (ep.seasonNumber === season.seasonNumber) { - const existingEpisode = await episodeRepository.findOne({ - where: { - episodeNumber: ep.episodeNumber, - season: { id: season.id }, - }, - relations: ['season'], - }); - - if (existingEpisode) { - existingEpisode[is4k ? 'status4k' : 'status'] = ep.hasFile - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN; - await episodeRepository.save(existingEpisode); - } else { - const newEpisode = new Episode(); - newEpisode.episodeNumber = ep.episodeNumber; - newEpisode.status = is4k - ? MediaStatus.UNKNOWN - : ep.hasFile + const cacheKey = `${server.id}-${serviceId}`; + const episodes = this.sonarrEpisodesCache[cacheKey]; + + if (episodes) { + try { + for (const ep of episodes) { + if (ep.seasonNumber === season.seasonNumber) { + const existingEpisode = await episodeRepository.findOne({ + where: { + episodeNumber: ep.episodeNumber, + season: { id: season.id }, + }, + relations: ['season'], + }); + + if (existingEpisode) { + existingEpisode[is4k ? 'status4k' : 'status'] = ep.hasFile ? MediaStatus.AVAILABLE : MediaStatus.UNKNOWN; - newEpisode.status4k = is4k - ? ep.hasFile - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN - : MediaStatus.UNKNOWN; - newEpisode.season = Promise.resolve(season); - - try { - await episodeRepository.save(newEpisode); - } catch (saveError) { - logger.error('Failed to save new episode', { - label: 'Availability Sync', - errorMessage: saveError.message, - tvId: media.tmdbId, - seasonNumber: season.seasonNumber, - episodeNumber: ep.episodeNumber, - }); + await episodeRepository.save(existingEpisode); + } else { + const newEpisode = new Episode(); + newEpisode.episodeNumber = ep.episodeNumber; + newEpisode.status = is4k + ? MediaStatus.UNKNOWN + : ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + newEpisode.status4k = is4k + ? ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN + : MediaStatus.UNKNOWN; + newEpisode.season = Promise.resolve(season); + + try { + await episodeRepository.save(newEpisode); + } catch (saveError) { + logger.error('Failed to save new episode', { + label: 'Availability Sync', + errorMessage: saveError.message, + tvId: media.tmdbId, + seasonNumber: season.seasonNumber, + episodeNumber: ep.episodeNumber, + }); + } } } } + } catch (err) { + logger.error('Failed to update episode availability', { + label: 'Availability Sync', + errorMessage: err.message, + tvId: media.tmdbId, + seasonNumber: season.seasonNumber, + sonarrServerId: server.id, + }); } } - } catch (err) { - logger.error('Failed to update episode availability', { - label: 'Availability Sync', - errorMessage: err.message, - tvId: media.tmdbId, - seasonNumber: season.seasonNumber, - sonarrServerId: server.id, - }); } } } From bf4b330e6a205028ef723700a0162bcea20b5da1 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:03 +0200 Subject: [PATCH 12/33] refactor(season): make episodes property optional and remove eager loading Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- server/entity/Season.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/entity/Season.ts b/server/entity/Season.ts index 07860ecbc4..d45de8a9a0 100644 --- a/server/entity/Season.ts +++ b/server/entity/Season.ts @@ -34,9 +34,8 @@ class Season { @OneToMany(() => Episode, (episode) => episode.season, { cascade: true, - eager: true, }) - public episodes: Episode[]; + public episodes?: Episode[]; @DbAwareColumn({ type: 'datetime', default: () => 'CURRENT_TIMESTAMP' }) public createdAt: Date; From 9bc7ef6717022df26dca0d6046f9a0ebf6d614f2 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:05 +0200 Subject: [PATCH 13/33] refactor(api): remove getEpisodesBySeriesId method and update availabilitySync to use getEpisodes Signed-off-by: 0xsysr3ll <0xsysr3ll@pm.me> --- server/api/servarr/sonarr.ts | 14 -------------- server/lib/availabilitySync.ts | 13 ++++--------- 2 files changed, 4 insertions(+), 23 deletions(-) diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index f7aadb17b6..3469377f55 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -465,20 +465,6 @@ class SonarrAPI extends ServarrBase<{ } }; - public async getEpisodesBySeriesId( - seriesId: number - ): Promise { - try { - const response = await this.axios.get(`/episode`, { - params: { seriesId }, - }); - return response.data; - } catch (e) { - throw new Error( - `[Sonarr] Failed to retrieve episodes for series: ${e.message}` - ); - } - } } export default SonarrAPI; diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 82ad86e708..55d5de42c2 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -784,17 +784,12 @@ class AvailabilitySync { } if (sonarr && serviceId) { - this.sonarrSeasonsCache[`${server.id}-${serviceId}`] = - sonarr.seasons; + this.sonarrSeasonsCache[`${server.id}-${serviceId}`] = sonarr.seasons; - if ( - shouldTrackEpisodes && - sonarr.statistics.episodeFileCount > 0 - ) { + if (shouldTrackEpisodes && sonarr.statistics.episodeFileCount > 0) { try { - const episodes = await sonarrAPI.getEpisodesBySeriesId(serviceId); - this.sonarrEpisodesCache[`${server.id}-${serviceId}`] = - episodes; + const episodes = await sonarrAPI.getEpisodes(serviceId); + this.sonarrEpisodesCache[`${server.id}-${serviceId}`] = episodes; } catch (err) { logger.error('Failed to fetch episodes for caching', { label: 'Availability Sync', From c865f883f25cc13e0245464f11ecb789e07d3b9e Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:08 +0200 Subject: [PATCH 14/33] chore: apply linting --- bin/duplicate-detector/detect.mjs | 4 +++- server/api/servarr/sonarr.ts | 1 - server/routes/tv.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/duplicate-detector/detect.mjs b/bin/duplicate-detector/detect.mjs index 1b002b4a4f..f395029a1f 100644 --- a/bin/duplicate-detector/detect.mjs +++ b/bin/duplicate-detector/detect.mjs @@ -188,7 +188,9 @@ function formatComment(candidates) { '', 'A maintainer will review this. If this is **not** a duplicate, no action is needed.', '', - `` + `` ); return lines.join('\n'); diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 3469377f55..81205edcc5 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -464,7 +464,6 @@ class SonarrAPI extends ServarrBase<{ }); } }; - } export default SonarrAPI; diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 843dff19e5..5191ffc39c 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -7,7 +7,7 @@ import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import { Watchlist } from '@server/entity/Watchlist'; -import { getSettings, MetadataProviderType } from '@server/lib/settings'; +import { MetadataProviderType, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { mapTvResult } from '@server/models/Search'; import { mapSeasonWithEpisodes, mapTvDetails } from '@server/models/Tv'; From 25334fad5814139fb7a2996811c4b7f2484b0343 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:11 +0200 Subject: [PATCH 15/33] refactor(tv): change getSettings to synchronous call --- server/routes/tv.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 5191ffc39c..8a6b2e3f0f 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -86,7 +86,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { const availableMap: Record = {}; - const settings = await getSettings(); + const settings = getSettings(); const shouldTrackEpisodes = settings.main.enableEpisodeAvailability && (settings.metadataSettings.tv === MetadataProviderType.TVDB || From 27082937c45d9543216f6d0931020b54d032a94f Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:13 +0200 Subject: [PATCH 16/33] refactor(availability): enhance episode tracking logic in availability sync --- server/lib/availabilitySync.ts | 109 ++++++++++++++++----------------- 1 file changed, 52 insertions(+), 57 deletions(-) diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 55d5de42c2..1073366cdd 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -57,6 +57,10 @@ class AvailabilitySync { this.sonarrServers = settings.sonarr; this.enable4kMovie = this.radarrServers.some((server) => server.is4k); this.enable4kShow = this.sonarrServers.some((server) => server.is4k); + const shouldTrackEpisodes = + settings.main.enableEpisodeAvailability && + (settings.metadataSettings.tv === MetadataProviderType.TVDB || + settings.metadataSettings.anime === MetadataProviderType.TVDB); try { logger.info(`Starting availability sync...`, { @@ -251,11 +255,11 @@ class AvailabilitySync { } = await this.mediaExistsInJellyfin(media, true); const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = - await this.mediaExistsInSonarr(media, false); + await this.mediaExistsInSonarr(media, false, shouldTrackEpisodes); const { existsInSonarr: existsInSonarr4k, seasonsMap: sonarrSeasonsMap4k, - } = await this.mediaExistsInSonarr(media, true); + } = await this.mediaExistsInSonarr(media, true, shouldTrackEpisodes); //plex if (mediaServerType === MediaServerType.PLEX) { @@ -744,17 +748,12 @@ class AvailabilitySync { private async mediaExistsInSonarr( media: Media, - is4k: boolean + is4k: boolean, + shouldTrackEpisodes: boolean ): Promise<{ existsInSonarr: boolean; seasonsMap: Map }> { let existsInSonarr = false; let preventSeasonSearch = false; - const settings = getSettings(); - const shouldTrackEpisodes = - settings.main.enableEpisodeAvailability && - (settings.metadataSettings.tv === MetadataProviderType.TVDB || - settings.metadataSettings.anime === MetadataProviderType.TVDB); - // Check for availability in all of the available sonarr servers // If any find the media, we will assume the media exists for (const server of this.sonarrServers.filter((server) => { @@ -838,7 +837,8 @@ class AvailabilitySync { const seasonExists = await this.seasonExistsInSonarr( media, season, - is4k + is4k, + shouldTrackEpisodes ); if (seasonExists) { @@ -853,17 +853,12 @@ class AvailabilitySync { private async seasonExistsInSonarr( media: Media, season: Season, - is4k: boolean + is4k: boolean, + shouldTrackEpisodes: boolean ): Promise { let seasonExists = false; const episodeRepository = getRepository(Episode); - const settings = getSettings(); - const shouldTrackEpisodes = - settings.main.enableEpisodeAvailability && - (settings.metadataSettings.tv === MetadataProviderType.TVDB || - settings.metadataSettings.anime === MetadataProviderType.TVDB); - // Check each sonarr instance to see if the media still exists // If found, we will assume the media exists and prevent removal // We can use the cache we built when we fetched the series with mediaExistsInSonarr @@ -912,50 +907,50 @@ class AvailabilitySync { if (episodes) { try { - for (const ep of episodes) { - if (ep.seasonNumber === season.seasonNumber) { - const existingEpisode = await episodeRepository.findOne({ - where: { - episodeNumber: ep.episodeNumber, - season: { id: season.id }, - }, - relations: ['season'], - }); - - if (existingEpisode) { - existingEpisode[is4k ? 'status4k' : 'status'] = ep.hasFile + const seasonEpisodes = episodes.filter( + (ep) => ep.seasonNumber === season.seasonNumber + ); + if (seasonEpisodes.length === 0) { + continue; + } + + const existingEpisodes = await episodeRepository.find({ + where: { season: { id: season.id } }, + relations: ['season'], + }); + const existingByNumber = new Map( + existingEpisodes.map((e) => [e.episodeNumber, e]) + ); + + const toSave: Episode[] = []; + for (const ep of seasonEpisodes) { + const existingEpisode = existingByNumber.get(ep.episodeNumber); + if (existingEpisode) { + existingEpisode[is4k ? 'status4k' : 'status'] = ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN; + toSave.push(existingEpisode); + } else { + const newEpisode = new Episode(); + newEpisode.episodeNumber = ep.episodeNumber; + newEpisode.status = is4k + ? MediaStatus.UNKNOWN + : ep.hasFile ? MediaStatus.AVAILABLE : MediaStatus.UNKNOWN; - await episodeRepository.save(existingEpisode); - } else { - const newEpisode = new Episode(); - newEpisode.episodeNumber = ep.episodeNumber; - newEpisode.status = is4k - ? MediaStatus.UNKNOWN - : ep.hasFile - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN; - newEpisode.status4k = is4k - ? ep.hasFile - ? MediaStatus.AVAILABLE - : MediaStatus.UNKNOWN - : MediaStatus.UNKNOWN; - newEpisode.season = Promise.resolve(season); - - try { - await episodeRepository.save(newEpisode); - } catch (saveError) { - logger.error('Failed to save new episode', { - label: 'Availability Sync', - errorMessage: saveError.message, - tvId: media.tmdbId, - seasonNumber: season.seasonNumber, - episodeNumber: ep.episodeNumber, - }); - } - } + newEpisode.status4k = is4k + ? ep.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN + : MediaStatus.UNKNOWN; + newEpisode.season = Promise.resolve(season); + toSave.push(newEpisode); } } + + if (toSave.length > 0) { + await episodeRepository.save(toSave); + } } catch (err) { logger.error('Failed to update episode availability', { label: 'Availability Sync', From 3750ddeb6b5f2a372d888fc421844ff550b06a8b Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:16 +0200 Subject: [PATCH 17/33] chore: restore old changes --- bin/duplicate-detector/detect.mjs | 4 +--- server/routes/tv.ts | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/bin/duplicate-detector/detect.mjs b/bin/duplicate-detector/detect.mjs index f395029a1f..1b002b4a4f 100644 --- a/bin/duplicate-detector/detect.mjs +++ b/bin/duplicate-detector/detect.mjs @@ -188,9 +188,7 @@ function formatComment(candidates) { '', 'A maintainer will review this. If this is **not** a duplicate, no action is needed.', '', - `` + `` ); return lines.join('\n'); diff --git a/server/routes/tv.ts b/server/routes/tv.ts index 8a6b2e3f0f..c5dcbbb1aa 100644 --- a/server/routes/tv.ts +++ b/server/routes/tv.ts @@ -82,6 +82,7 @@ tvRoutes.get('/:id/season/:seasonNumber', async (req, res, next) => { const season = await metadataProvider.getTvSeason({ tvId: Number(req.params.id), seasonNumber: Number(req.params.seasonNumber), + language: (req.query.language as string) ?? req.locale, }); const availableMap: Record = {}; From fc4262652d897de8001392bdb65605400c029ed8 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:18 +0200 Subject: [PATCH 18/33] chore: reapply linting --- server/lib/availabilitySync.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 1073366cdd..832808671b 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -951,6 +951,7 @@ class AvailabilitySync { if (toSave.length > 0) { await episodeRepository.save(toSave); } + break; } catch (err) { logger.error('Failed to update episode availability', { label: 'Availability Sync', From 0bfba8614c661a58e653bda0d8191f48c3b31702 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:20 +0200 Subject: [PATCH 19/33] feat(migration): add episode table migrations --- ...le.ts => 1771451593380-AddEpisodeTable.ts} | 14 ++- .../sqlite/1747690625482-AddEpisodeTable.ts | 34 ------- .../sqlite/1771451593380-AddEpisodeTable.ts | 91 +++++++++++++++++++ 3 files changed, 103 insertions(+), 36 deletions(-) rename server/migration/postgres/{1747690625482-AddEpisodeTable.ts => 1771451593380-AddEpisodeTable.ts} (65%) mode change 100755 => 100644 delete mode 100755 server/migration/sqlite/1747690625482-AddEpisodeTable.ts create mode 100644 server/migration/sqlite/1771451593380-AddEpisodeTable.ts diff --git a/server/migration/postgres/1747690625482-AddEpisodeTable.ts b/server/migration/postgres/1771451593380-AddEpisodeTable.ts old mode 100755 new mode 100644 similarity index 65% rename from server/migration/postgres/1747690625482-AddEpisodeTable.ts rename to server/migration/postgres/1771451593380-AddEpisodeTable.ts index 3af000c511..a4583a7960 --- a/server/migration/postgres/1747690625482-AddEpisodeTable.ts +++ b/server/migration/postgres/1771451593380-AddEpisodeTable.ts @@ -1,12 +1,18 @@ import type { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddEpisodeTable1747690625482 implements MigrationInterface { - name = 'AddEpisodeTable1747690625482'; +export class AddEpisodeTable1771451593380 implements MigrationInterface { + name = 'AddEpisodeTable1771451593380'; public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `CREATE TABLE "episode" ("id" SERIAL NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT '1', "status4k" integer NOT NULL DEFAULT '1', "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "seasonId" integer, CONSTRAINT "PK_7258b95d6d2bf7f621845a0e143" PRIMARY KEY ("id"))` ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS "blocklist_id_seq" OWNED BY "blocklist"."id"` + ); + await queryRunner.query( + `ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('"blocklist_id_seq"')` + ); await queryRunner.query( `ALTER TABLE "episode" ADD CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd" FOREIGN KEY ("seasonId") REFERENCES "season"("id") ON DELETE CASCADE ON UPDATE NO ACTION` ); @@ -16,6 +22,10 @@ export class AddEpisodeTable1747690625482 implements MigrationInterface { await queryRunner.query( `ALTER TABLE "episode" DROP CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd"` ); + await queryRunner.query( + `ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT` + ); + await queryRunner.query(`DROP SEQUENCE "blocklist_id_seq"`); await queryRunner.query(`DROP TABLE "episode"`); } } diff --git a/server/migration/sqlite/1747690625482-AddEpisodeTable.ts b/server/migration/sqlite/1747690625482-AddEpisodeTable.ts deleted file mode 100755 index 7e6cb416d1..0000000000 --- a/server/migration/sqlite/1747690625482-AddEpisodeTable.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddEpisodeTable1747690625482 implements MigrationInterface { - name = 'AddEpisodeTable1747690625482'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TABLE "episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer)` - ); - await queryRunner.query( - `CREATE TABLE "temporary_episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer, CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd" FOREIGN KEY ("seasonId") REFERENCES "season" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` - ); - await queryRunner.query( - `INSERT INTO "temporary_episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "episode"` - ); - await queryRunner.query(`DROP TABLE "episode"`); - await queryRunner.query( - `ALTER TABLE "temporary_episode" RENAME TO "episode"` - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "episode" RENAME TO "temporary_episode"` - ); - await queryRunner.query( - `CREATE TABLE "episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer)` - ); - await queryRunner.query( - `INSERT INTO "episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "temporary_episode"` - ); - await queryRunner.query(`DROP TABLE "temporary_episode"`); - } -} diff --git a/server/migration/sqlite/1771451593380-AddEpisodeTable.ts b/server/migration/sqlite/1771451593380-AddEpisodeTable.ts new file mode 100644 index 0000000000..6b04cf5784 --- /dev/null +++ b/server/migration/sqlite/1771451593380-AddEpisodeTable.ts @@ -0,0 +1,91 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEpisodeTable1771451593380 implements MigrationInterface { + name = 'AddEpisodeTable1771451593380'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `CREATE TABLE "episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer)` + ); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer, CONSTRAINT "FK_e73d28c1e5e3c85125163f7c9cd" FOREIGN KEY ("seasonId") REFERENCES "season" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "episode"` + ); + await queryRunner.query(`DROP TABLE "episode"`); + await queryRunner.query( + `ALTER TABLE "temporary_episode" RENAME TO "episode"` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "episode" RENAME TO "temporary_episode"` + ); + await queryRunner.query( + `CREATE TABLE "episode" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "episodeNumber" integer NOT NULL, "status" integer NOT NULL DEFAULT (1), "status4k" integer NOT NULL DEFAULT (1), "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "seasonId" integer)` + ); + await queryRunner.query( + `INSERT INTO "episode"("id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId") SELECT "id", "episodeNumber", "status", "status4k", "createdAt", "updatedAt", "seasonId" FROM "temporary_episode"` + ); + await queryRunner.query(`DROP TABLE "temporary_episode"`); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query(`DROP TABLE "episode"`); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + } +} From b0207aedd24e19be1884c3e2530ed61bcc306db1 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:23 +0200 Subject: [PATCH 20/33] feat(episode): add index on season --- server/entity/Episode.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/entity/Episode.ts b/server/entity/Episode.ts index 9a32788473..474414c2d8 100644 --- a/server/entity/Episode.ts +++ b/server/entity/Episode.ts @@ -1,9 +1,16 @@ import { MediaStatus } from '@server/constants/media'; import { DbAwareColumn } from '@server/utils/DbColumnHelper'; -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; import Season from './Season'; @Entity() +@Index(['season']) class Episode { @PrimaryGeneratedColumn() public id: number; From 8cfa30e428202dc7e32d06fbc6ab4158d85f00c5 Mon Sep 17 00:00:00 2001 From: 0xsysr3ll <0xsysr3ll@pm.me> Date: Sat, 25 Apr 2026 09:38:26 +0200 Subject: [PATCH 21/33] feat(settings): add warning for episode availability without TVDB set --- src/components/Settings/SettingsMain/index.tsx | 16 ++++++++++++++++ src/i18n/locale/en.json | 1 + 2 files changed, 17 insertions(+) diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 69d0a22781..f1fb2cf419 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -1,4 +1,5 @@ import BlocklistedTagsSelector from '@app/components/BlocklistedTagsSelector'; +import Alert from '@app/components/Common/Alert'; import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; @@ -9,6 +10,7 @@ import CopyButton from '@app/components/Settings/CopyButton'; import SettingsBadge from '@app/components/Settings/SettingsBadge'; import { availableLanguages } from '@app/context/LanguageContext'; import useLocale from '@app/hooks/useLocale'; +import useSettings from '@app/hooks/useSettings'; import useToasts from '@app/hooks/useToasts'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -73,6 +75,8 @@ const messages = defineMessages('components.Settings.SettingsMain', { enableEpisodeAvailability: 'Enable Episode Availability Tracking', enableEpisodeAvailabilityTip: 'Track individual episode availability status (requires TVDB as metadata provider for TV shows or anime)', + enableEpisodeAvailabilityNoTvdbWarning: + 'This setting has no effect because neither TV shows nor anime use TVDB as the metadata provider.', locale: 'Display Language', youtubeUrl: 'YouTube URL', youtubeUrlTip: @@ -88,6 +92,7 @@ const SettingsMain = () => { const { user: currentUser, hasPermission: userHasPermission } = useUser(); const intl = useIntl(); const { setLocale } = useLocale(); + const { currentSettings } = useSettings(); const { data, error, @@ -621,6 +626,17 @@ const SettingsMain = () => { }} />
+ {values.enableEpisodeAvailability && + currentSettings.metadataSettings?.tv !== 'tvdb' && + currentSettings.metadataSettings?.anime !== 'tvdb' && ( +
+ + {intl.formatMessage( + messages.enableEpisodeAvailabilityNoTvdbWarning + )} + +
+ )}