From 5adae2d243009e5397d82c639ada80800672b5ec Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:51:21 +0800 Subject: [PATCH 1/6] fix(requests): resolve missing TVDB IDs and stop discarding series requests --- server/api/servarr/sonarr.test.ts | 35 ++ server/api/servarr/sonarr.ts | 20 + server/api/tvdb/index.ts | 29 ++ server/api/tvdb/interfaces.ts | 5 + server/entity/MediaRequest.ts | 36 ++ server/routes/request.test.ts | 59 +++ .../subscriber/MediaRequestSubscriber.test.ts | 390 ++++++++++++++++++ server/subscriber/MediaRequestSubscriber.ts | 140 ++++++- 8 files changed, 709 insertions(+), 5 deletions(-) create mode 100644 server/subscriber/MediaRequestSubscriber.test.ts diff --git a/server/api/servarr/sonarr.test.ts b/server/api/servarr/sonarr.test.ts index 015b57e900..5a628c3ec4 100644 --- a/server/api/servarr/sonarr.test.ts +++ b/server/api/servarr/sonarr.test.ts @@ -117,3 +117,38 @@ describe('SonarrAPI getSeriesByTvdbId', () => { }); }); }); + +describe('SonarrAPI getSeriesByTmdbId', () => { + afterEach(() => mock.restoreAll()); + + it('looks the series up by its tmdb term', async () => { + const sonarr = buildSonarr(); + const get = mock.method(getAxios(sonarr), 'get', async () => ({ + data: [{ id: 9, tvdbId: 184871, title: 'The Great British Bake Off' }], + })); + + const series = await sonarr.getSeriesByTmdbId(87012); + + assert.strictEqual(series?.tvdbId, 184871); + assert.strictEqual(get.mock.calls[0].arguments[0], '/series/lookup'); + assert.deepStrictEqual(get.mock.calls[0].arguments[1], { + params: { term: 'tmdb:87012' }, + }); + }); + + it('returns null when the lookup finds nothing', async () => { + const sonarr = buildSonarr(); + mock.method(getAxios(sonarr), 'get', async () => ({ data: [] })); + + assert.strictEqual(await sonarr.getSeriesByTmdbId(87012), null); + }); + + it('returns null instead of throwing when the lookup fails', async () => { + const sonarr = buildSonarr(); + mock.method(getAxios(sonarr), 'get', async () => { + throw new Error('connect ECONNREFUSED'); + }); + + assert.strictEqual(await sonarr.getSeriesByTmdbId(87012), null); + }); +}); diff --git a/server/api/servarr/sonarr.ts b/server/api/servarr/sonarr.ts index 29679d0e51..5b255fa1e6 100644 --- a/server/api/servarr/sonarr.ts +++ b/server/api/servarr/sonarr.ts @@ -207,6 +207,26 @@ class SonarrAPI extends ServarrBase<{ return response.data[0]; } + public async getSeriesByTmdbId(id: number): Promise { + try { + const response = await this.axios.get('/series/lookup', { + params: { + term: `tmdb:${id}`, + }, + }); + + return response.data[0] ?? null; + } catch (e) { + logger.error('Error retrieving series by tmdb ID', { + label: 'Sonarr API', + errorMessage: e.message, + tmdbId: id, + }); + + return null; + } + } + public async addSeries(options: AddSeriesOptions): Promise { try { const series = await this.getSeriesByTvdbId(options.tvdbid); diff --git a/server/api/tvdb/index.ts b/server/api/tvdb/index.ts index 72531780ba..f591b20780 100644 --- a/server/api/tvdb/index.ts +++ b/server/api/tvdb/index.ts @@ -13,6 +13,7 @@ import { type TvdbBaseResponse, type TvdbEpisode, type TvdbLoginResponse, + type TvdbOfficialSeason, type TvdbRemoteId, type TvdbSearchByRemoteIdResult, type TvdbSeasonDetails, @@ -289,6 +290,34 @@ class Tvdb extends ExternalAPI implements TvShowProvider { } } + public async getOfficialSeasons( + tvdbId: number + ): Promise { + await this.refreshToken(); + + const tvdbData = await this.fetchTvdbShowData(tvdbId); + + if (!tvdbData?.seasons || !tvdbData.episodes) { + return []; + } + + return tvdbData.seasons + .filter((season) => season.type?.type === 'official' && season.number > 0) + .sort((a, b) => a.number - b.number) + .map((season) => { + // the season record's own year is null on this endpoint + const years = tvdbData.episodes + .filter((episode) => episode.seasonNumber === season.number) + .map((episode) => Number(episode.aired?.slice(0, 4))) + .filter((year) => !!year); + + return { + seasonNumber: season.number, + year: years.length ? Math.min(...years) : null, + }; + }); + } + private async fetchTvdbSeriesRemoteIds( tvdbId: number ): Promise { diff --git a/server/api/tvdb/interfaces.ts b/server/api/tvdb/interfaces.ts index f1661f07ce..dfeb06a8b9 100644 --- a/server/api/tvdb/interfaces.ts +++ b/server/api/tvdb/interfaces.ts @@ -39,6 +39,11 @@ export interface TvdbRemoteId { // sourceName 'TheMovieDB.com' is shared with movie (10), person (15) and collection (28) export const TVDB_SOURCE_TYPE_TMDB_TV = 12; +export interface TvdbOfficialSeason { + seasonNumber: number; + year: number | null; +} + export interface TvdbSeriesBaseRecord { id: number; name: string; diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 4f17c81f69..e5334ed1eb 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -195,6 +195,42 @@ export class MediaRequest { ) { media.status4k = MediaStatus.PENDING; } + + const resolvedTvdbId = + media.tvdbId ?? requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id; + + if (resolvedTvdbId && resolvedTvdbId !== media.tvdbId) { + const conflict = await mediaRepository.findOne({ + where: { tvdbId: resolvedTvdbId }, + }); + + // written on its own so a unique violation cannot take the status + // changes above down with it + if (!conflict) { + try { + await mediaRepository.update(media.id, { + tvdbId: resolvedTvdbId, + }); + media.tvdbId = resolvedTvdbId; + } catch (e) { + logger.warn('Failed to persist TVDB ID for existing media', { + label: 'Media Request', + mediaId: media.id, + tvdbId: resolvedTvdbId, + errorMessage: e instanceof Error ? e.message : String(e), + }); + } + } else { + logger.info( + 'Skipped TVDB ID backfill, already owned by another media row', + { + label: 'Media Request', + mediaId: media.id, + tvdbId: resolvedTvdbId, + } + ); + } + } } const existing = await requestRepository diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index e82a76d24c..5640c76e3a 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -1093,3 +1093,62 @@ describe('DELETE /request/:requestId, orphaned season status reset', () => { assert.strictEqual(updated.seasons[0].status4k, MediaStatus.PROCESSING); }); }); + +describe('POST /request (tv), TVDB ID backfill', () => { + async function seedUntrackedShow(tmdbId: number, tvdbId?: number) { + return getRepository(Media).save( + new Media({ + mediaType: MediaType.TV, + tmdbId, + tvdbId, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + } + + it('stores a manually supplied TVDB ID on already-tracked media', async () => { + getSettings().radarr = []; + getSettings().sonarr = []; + + const media = await seedUntrackedShow(88010); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.TV, + mediaId: 88010, + seasons: [1], + tvdbId: 184871, + }); + + assert.strictEqual(res.status, 201); + + const updated = await getRepository(Media).findOneOrFail({ + where: { id: media.id }, + }); + assert.strictEqual(updated.tvdbId, 184871); + }); + + it('skips the backfill when another media row already owns the TVDB ID', async () => { + getSettings().radarr = []; + getSettings().sonarr = []; + + await seedUntrackedShow(88011, 184871); + const media = await seedUntrackedShow(88012); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.TV, + mediaId: 88012, + seasons: [1], + tvdbId: 184871, + }); + + assert.strictEqual(res.status, 201); + + const updated = await getRepository(Media).findOneOrFail({ + where: { id: media.id }, + }); + assert.strictEqual(updated.tvdbId, null); + }); +}); diff --git a/server/subscriber/MediaRequestSubscriber.test.ts b/server/subscriber/MediaRequestSubscriber.test.ts new file mode 100644 index 0000000000..aaf12ca834 --- /dev/null +++ b/server/subscriber/MediaRequestSubscriber.test.ts @@ -0,0 +1,390 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it, mock } from 'node:test'; + +import type { 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'; +import Tvdb from '@server/api/tvdb'; +import type { TvdbOfficialSeason } from '@server/api/tvdb/interfaces'; +import { + MediaRequestStatus, + MediaStatus, + MediaType, +} from '@server/constants/media'; +import { getRepository } from '@server/datasource'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import SeasonRequest from '@server/entity/SeasonRequest'; +import { User } from '@server/entity/User'; +import { Notification } from '@server/lib/notifications'; +import { getSettings } from '@server/lib/settings'; +import { MediaRequestSubscriber } from '@server/subscriber/MediaRequestSubscriber'; +import { setupTestDb } from '@server/test/db'; + +let tvShow: TmdbTvDetails; + +Object.defineProperty(TheMovieDb.prototype, 'getTvShow', { + get() { + return async () => tvShow; + }, + set() {}, + configurable: true, +}); + +function fakeShow( + tmdbId: number, + seasons: { season_number: number; air_date: string }[], + tvdbId?: number +): TmdbTvDetails { + return { + id: tmdbId, + name: 'Test Show', + external_ids: { tvdb_id: tvdbId }, + keywords: { results: [] }, + seasons: seasons.map((season) => ({ ...season, episode_count: 10 })), + } as unknown as TmdbTvDetails; +} + +function configureSonarr(): void { + getSettings().sonarr = [ + { + id: 0, + name: 'Sonarr', + hostname: 'localhost', + port: 8989, + apiKey: 'test-key', + baseUrl: '', + useSsl: false, + activeProfileId: 1, + activeProfileName: 'HD', + activeDirectory: '/tv', + activeLanguageProfileId: 1, + animeTags: [], + seriesType: 'standard', + animeSeriesType: 'anime', + monitorNewItems: 'all', + enableSeasonFolders: true, + is4k: false, + tags: [], + isDefault: true, + syncEnabled: true, + preventSearch: false, + tagRequests: false, + overrideRule: [], + externalUrl: '', + }, + ]; +} + +function mockSendNotification() { + return mock.method(MediaRequest, 'sendNotification', async () => undefined) + .mock; +} + +let sendNotification: ReturnType; + +function stubProviders({ + resolveTvdbId = null, + officialSeasons = [], + sonarrSeries = null, +}: { + resolveTvdbId?: number | null; + officialSeasons?: TvdbOfficialSeason[]; + sonarrSeries?: Partial | null; +}) { + mock.method( + Tvdb, + 'getInstance', + async () => + ({ + resolveTvdbId: async () => resolveTvdbId, + getOfficialSeasons: async () => officialSeasons, + }) as unknown as Tvdb + ); + + mock.method( + SonarrAPI.prototype, + 'getSeriesByTmdbId', + async () => sonarrSeries as SonarrSeries | null + ); + + return mock.method( + SonarrAPI.prototype, + 'addSeries', + async () => ({ id: 1, titleSlug: 'test-show' }) as unknown as SonarrSeries + ).mock; +} + +async function seedApprovedRequest( + tmdbId: number, + seasonNumbers: number[], + tvdbId?: number +) { + const requester = await getRepository(User).findOneOrFail({ + where: { email: 'friend@seerr.dev' }, + }); + + const media = await getRepository(Media).save( + new Media({ + mediaType: MediaType.TV, + tmdbId, + tvdbId, + status: MediaStatus.PENDING, + status4k: MediaStatus.UNKNOWN, + }) + ); + + // insert with listeners off so the subscriber under test does not run on seed + const inserted = await getRepository(MediaRequest) + .createQueryBuilder() + .insert() + .into(MediaRequest) + .values({ + status: MediaRequestStatus.APPROVED, + media: { id: media.id }, + requestedBy: { id: requester.id }, + type: MediaType.TV, + is4k: false, + }) + .callListeners(false) + .execute(); + + const requestId = inserted.identifiers[0].id as number; + + await getRepository(SeasonRequest).save( + seasonNumbers.map( + (seasonNumber) => + new SeasonRequest({ + seasonNumber, + status: MediaRequestStatus.APPROVED, + request: { id: requestId } as MediaRequest, + }) + ) + ); + + const entity = await getRepository(MediaRequest).findOneOrFail({ + where: { id: requestId }, + relations: { media: true, seasons: true, requestedBy: true }, + }); + + return { entity, media }; +} + +async function run(entity: MediaRequest) { + await new MediaRequestSubscriber().sendToSonarr( + entity, + getRepository(MediaRequest).manager + ); + + // let the detached addSeries chain settle + await new Promise((resolve) => setImmediate(resolve)); +} + +function storedMedia(id: number) { + return getRepository(Media).findOneOrFail({ where: { id } }); +} + +setupTestDb(); + +beforeEach(() => { + configureSonarr(); + sendNotification = mockSendNotification(); +}); + +afterEach(() => mock.restoreAll()); + +describe('MediaRequestSubscriber sendToSonarr, TVDB ID resolution', () => { + it('persists a TVDB ID resolved from TheTVDB', async () => { + tvShow = fakeShow(90001, [{ season_number: 1, air_date: '2020-01-05' }]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: [{ seasonNumber: 1, year: 2020 }], + }); + + const { entity, media } = await seedApprovedRequest(90001, [1]); + await run(entity); + + assert.strictEqual((await storedMedia(media.id)).tvdbId, 184871); + assert.strictEqual(addSeries.callCount(), 1); + }); + + it('falls back to Sonarr when TheTVDB cannot resolve the ID', async () => { + tvShow = fakeShow(90002, [{ season_number: 1, air_date: '2020-01-05' }]); + const addSeries = stubProviders({ + resolveTvdbId: null, + sonarrSeries: { tvdbId: 555555 }, + officialSeasons: [{ seasonNumber: 1, year: 2020 }], + }); + + const { entity, media } = await seedApprovedRequest(90002, [1]); + await run(entity); + + assert.strictEqual((await storedMedia(media.id)).tvdbId, 555555); + assert.strictEqual(addSeries.callCount(), 1); + }); + + it('skips the backfill when another media row already owns the ID', async () => { + tvShow = fakeShow(90003, [{ season_number: 1, air_date: '2020-01-05' }]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: [{ seasonNumber: 1, year: 2020 }], + }); + + await getRepository(Media).save( + new Media({ + mediaType: MediaType.TV, + tmdbId: 90099, + tvdbId: 184871, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }) + ); + + const { entity, media } = await seedApprovedRequest(90003, [1]); + await run(entity); + + assert.strictEqual((await storedMedia(media.id)).tvdbId, null); + assert.strictEqual(addSeries.callCount(), 1); + }); + + it('fails the request without deleting it when nothing resolves the ID', async () => { + tvShow = fakeShow(90004, [{ season_number: 1, air_date: '2020-01-05' }]); + const addSeries = stubProviders({ + resolveTvdbId: null, + sonarrSeries: null, + }); + + const { entity, media } = await seedApprovedRequest(90004, [1]); + await run(entity); + + assert.strictEqual(entity.status, MediaRequestStatus.FAILED); + assert.strictEqual(addSeries.callCount(), 0); + assert.strictEqual(sendNotification.callCount(), 1); + assert.strictEqual( + sendNotification.calls[0].arguments[2], + Notification.MEDIA_FAILED + ); + await assert.doesNotReject(() => storedMedia(media.id)); + await assert.doesNotReject(() => + getRepository(MediaRequest).findOneOrFail({ where: { id: entity.id } }) + ); + }); +}); + +describe('MediaRequestSubscriber sendToSonarr, season guard', () => { + it('sends the requested season numbers unchanged when they match TVDB', async () => { + tvShow = fakeShow(90010, [ + { season_number: 1, air_date: '2020-01-05' }, + { season_number: 2, air_date: '2021-01-05' }, + ]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: [ + { seasonNumber: 1, year: 2020 }, + { seasonNumber: 2, year: 2021 }, + ], + }); + + const { entity } = await seedApprovedRequest(90010, [1, 2]); + await run(entity); + + assert.strictEqual(addSeries.callCount(), 1); + + const options = addSeries.calls[0].arguments[0] as { + tvdbid: number; + seasons: number[]; + }; + assert.strictEqual(options.tvdbid, 184871); + assert.deepStrictEqual(options.seasons.sort(), [1, 2]); + }); + + it('fails the request when a requested season does not match TVDB', async () => { + tvShow = fakeShow(90011, [{ season_number: 8, air_date: '2024-09-24' }]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: [{ seasonNumber: 8, year: 2017 }], + }); + + const { entity } = await seedApprovedRequest(90011, [8]); + await run(entity); + + assert.strictEqual(entity.status, MediaRequestStatus.FAILED); + assert.strictEqual(addSeries.callCount(), 0); + assert.strictEqual( + sendNotification.calls[0].arguments[2], + Notification.MEDIA_FAILED + ); + }); + + it('fails the whole request when only one of two seasons matches', async () => { + tvShow = fakeShow(90012, [ + { season_number: 1, air_date: '2020-01-05' }, + { season_number: 2, air_date: '2021-01-05' }, + ]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: [ + { seasonNumber: 1, year: 2020 }, + { seasonNumber: 2, year: 2019 }, + ], + }); + + const { entity } = await seedApprovedRequest(90012, [1, 2]); + await run(entity); + + assert.strictEqual(entity.status, MediaRequestStatus.FAILED); + assert.strictEqual(addSeries.callCount(), 0); + assert.strictEqual(sendNotification.callCount(), 1); + }); + + it('dispatches a request mixing specials with a matching season', async () => { + tvShow = fakeShow(90014, [ + { season_number: 0, air_date: '2019-12-25' }, + { season_number: 1, air_date: '2020-01-05' }, + ]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: [{ seasonNumber: 1, year: 2020 }], + }); + + const { entity } = await seedApprovedRequest(90014, [0, 1]); + await run(entity); + + assert.strictEqual(addSeries.callCount(), 1); + + const options = addSeries.calls[0].arguments[0] as { seasons: number[] }; + assert.deepStrictEqual(options.seasons.sort(), [0, 1]); + }); + + it('dispatches a specials-only request', async () => { + tvShow = fakeShow(90015, [{ season_number: 0, air_date: '2019-12-25' }]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: [{ seasonNumber: 1, year: 2020 }], + }); + + const { entity } = await seedApprovedRequest(90015, [0]); + await run(entity); + + assert.strictEqual(addSeries.callCount(), 1); + assert.strictEqual(entity.status, MediaRequestStatus.APPROVED); + }); + + it('does not guard shows TMDB already has a TVDB ID for', async () => { + tvShow = fakeShow( + 90013, + [{ season_number: 8, air_date: '2024-09-24' }], + 184871 + ); + const addSeries = stubProviders({ + officialSeasons: [{ seasonNumber: 8, year: 2017 }], + }); + + const { entity } = await seedApprovedRequest(90013, [8]); + await run(entity); + + assert.strictEqual(addSeries.callCount(), 1); + assert.strictEqual(entity.status, MediaRequestStatus.APPROVED); + }); +}); diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index 77e70cfa11..e0fb3965fe 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -7,6 +7,9 @@ import type { import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; +import type { TmdbTvSeasonResult } from '@server/api/themoviedb/interfaces'; +import Tvdb from '@server/api/tvdb'; +import type { TvdbOfficialSeason } from '@server/api/tvdb/interfaces'; import { MediaRequestStatus, MediaStatus, @@ -480,6 +483,49 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { + try { + const tvdb = await Tvdb.getInstance(); + const resolved = await tvdb.resolveTvdbId(tmdbId); + + if (resolved) { + return resolved; + } + } catch { + // TheTVDB being unavailable must not skip the Sonarr fallback + } + + return (await sonarr.getSeriesByTmdbId(tmdbId))?.tvdbId; + } + + private async getOfficialTvdbSeasons( + tvdbId: number + ): Promise { + try { + const tvdb = await Tvdb.getInstance(); + + return await tvdb.getOfficialSeasons(tvdbId); + } catch { + return []; + } + } + + private seasonsMatch( + tmdbSeason: TmdbTvSeasonResult | undefined, + tvdbSeason: TvdbOfficialSeason | undefined + ): boolean { + if (!tmdbSeason?.air_date || !tvdbSeason?.year) { + return false; + } + + return Number(tmdbSeason.air_date.slice(0, 4)) === tvdbSeason.year; + } + public async sendToSonarr( entity: MediaRequest, manager: EntityManager @@ -573,13 +619,97 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface season.seasonNumber) + .filter( + (seasonNumber) => + seasonNumber > 0 && + !this.seasonsMatch( + series.seasons.find( + (season) => season.season_number === seasonNumber + ), + tvdbSeasons.find( + (season) => season.seasonNumber === seasonNumber + ) + ) + ); + + if (tvdbSeasons.length > 0 && unmatchedSeasons.length > 0) { + const requestRepository = manager.getRepository(MediaRequest); + entity.status = MediaRequestStatus.FAILED; + await requestRepository.save(entity); + + logger.warn( + 'Requested seasons do not match the TVDB season numbering, marking status as FAILED', + { + label: 'Media Request', + requestId: entity.id, + mediaId: entity.media.id, + tvdbId, + unmatchedSeasons, + } + ); + + MediaRequest.sendNotification( + entity, + media, + Notification.MEDIA_FAILED + ); + return; + } } const isAnime = series.keywords.results.some( From c455512487819284d7cbde89f53cdd545c70b0de Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:25:47 +0800 Subject: [PATCH 2/6] fix(requests): fail season checks that cannot reach TheTVDB A failed season lookup returned an empty list, which the season guard reads as a show with no official seasons and skips. Any transport error or incomplete record therefore dispatched to Sonarr unverified. Now it returns a null for both cases so they stay distinguishable from a confirmed empty list, and fail the request with a notification when the seasons cannot be checked --- server/api/tvdb/index.test.ts | 57 +++++++++++++++++++ server/api/tvdb/index.ts | 6 +- .../subscriber/MediaRequestSubscriber.test.ts | 57 ++++++++++++++++++- server/subscriber/MediaRequestSubscriber.ts | 27 ++++++++- 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/server/api/tvdb/index.test.ts b/server/api/tvdb/index.test.ts index 17f1971add..9acfde4b54 100644 --- a/server/api/tvdb/index.test.ts +++ b/server/api/tvdb/index.test.ts @@ -129,3 +129,60 @@ describe('Tvdb resolveTvdbId', () => { assert.strictEqual(await new Tvdb().resolveTvdbId(87012), null); }); }); + +describe('Tvdb getOfficialSeasons', () => { + afterEach(() => mock.restoreAll()); + + const endpoint = '/series/184871/extended?meta=episodes&short=true'; + + it('derives each official season year from its earliest episode', async () => { + stubApi({ + [endpoint]: { + data: { + seasons: [ + { number: 0, type: { type: 'official' } }, + { number: 1, type: { type: 'official' } }, + { number: 2, type: { type: 'official' } }, + { number: 1, type: { type: 'dvd' } }, + ], + episodes: [ + { seasonNumber: 1, aired: '2020-11-24' }, + { seasonNumber: 1, aired: '2020-09-22' }, + { seasonNumber: 2, aired: '2021-09-21' }, + { seasonNumber: 0, aired: '2019-12-25' }, + ], + }, + }, + }); + + assert.deepStrictEqual(await new Tvdb().getOfficialSeasons(184871), [ + { seasonNumber: 1, year: 2020 }, + { seasonNumber: 2, year: 2021 }, + ]); + }); + + it('returns null when the record has no seasons', async () => { + stubApi({ [endpoint]: { data: { episodes: [] } } }); + + assert.strictEqual(await new Tvdb().getOfficialSeasons(184871), null); + }); + + it('returns null when the record has no episodes', async () => { + stubApi({ [endpoint]: { data: { seasons: [] } } }); + + assert.strictEqual(await new Tvdb().getOfficialSeasons(184871), null); + }); + + it('returns an empty list when the show has no official seasons', async () => { + stubApi({ + [endpoint]: { + data: { + seasons: [{ number: 1, type: { type: 'dvd' } }], + episodes: [{ seasonNumber: 1, aired: '2020-09-22' }], + }, + }, + }); + + assert.deepStrictEqual(await new Tvdb().getOfficialSeasons(184871), []); + }); +}); diff --git a/server/api/tvdb/index.ts b/server/api/tvdb/index.ts index f591b20780..0c234124cd 100644 --- a/server/api/tvdb/index.ts +++ b/server/api/tvdb/index.ts @@ -292,13 +292,15 @@ class Tvdb extends ExternalAPI implements TvShowProvider { public async getOfficialSeasons( tvdbId: number - ): Promise { + ): Promise { await this.refreshToken(); const tvdbData = await this.fetchTvdbShowData(tvdbId); + // an incomplete record confirms nothing, unlike a show that really has + // no official seasons if (!tvdbData?.seasons || !tvdbData.episodes) { - return []; + return null; } return tvdbData.seasons diff --git a/server/subscriber/MediaRequestSubscriber.test.ts b/server/subscriber/MediaRequestSubscriber.test.ts index aaf12ca834..84cdfbdf2f 100644 --- a/server/subscriber/MediaRequestSubscriber.test.ts +++ b/server/subscriber/MediaRequestSubscriber.test.ts @@ -90,7 +90,7 @@ function stubProviders({ sonarrSeries = null, }: { resolveTvdbId?: number | null; - officialSeasons?: TvdbOfficialSeason[]; + officialSeasons?: TvdbOfficialSeason[] | null | Error; sonarrSeries?: Partial | null; }) { mock.method( @@ -99,7 +99,13 @@ function stubProviders({ async () => ({ resolveTvdbId: async () => resolveTvdbId, - getOfficialSeasons: async () => officialSeasons, + getOfficialSeasons: async () => { + if (officialSeasons instanceof Error) { + throw officialSeasons; + } + + return officialSeasons; + }, }) as unknown as Tvdb ); @@ -357,6 +363,53 @@ describe('MediaRequestSubscriber sendToSonarr, season guard', () => { assert.deepStrictEqual(options.seasons.sort(), [0, 1]); }); + it('fails the request when the TVDB season lookup throws', async () => { + tvShow = fakeShow(90016, [{ season_number: 1, air_date: '2020-01-05' }]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: new Error('connect ECONNREFUSED'), + }); + + const { entity } = await seedApprovedRequest(90016, [1]); + await run(entity); + + assert.strictEqual(entity.status, MediaRequestStatus.FAILED); + assert.strictEqual(addSeries.callCount(), 0); + assert.strictEqual( + sendNotification.calls[0].arguments[2], + Notification.MEDIA_FAILED + ); + }); + + it('fails the request when TheTVDB returns an incomplete record', async () => { + tvShow = fakeShow(90017, [{ season_number: 1, air_date: '2020-01-05' }]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: null, + }); + + const { entity } = await seedApprovedRequest(90017, [1]); + await run(entity); + + assert.strictEqual(entity.status, MediaRequestStatus.FAILED); + assert.strictEqual(addSeries.callCount(), 0); + assert.strictEqual(sendNotification.callCount(), 1); + }); + + it('dispatches when TheTVDB confirms the show has no official seasons', async () => { + tvShow = fakeShow(90018, [{ season_number: 1, air_date: '2020-01-05' }]); + const addSeries = stubProviders({ + resolveTvdbId: 184871, + officialSeasons: [], + }); + + const { entity } = await seedApprovedRequest(90018, [1]); + await run(entity); + + assert.strictEqual(addSeries.callCount(), 1); + assert.strictEqual(entity.status, MediaRequestStatus.APPROVED); + }); + it('dispatches a specials-only request', async () => { tvShow = fakeShow(90015, [{ season_number: 0, air_date: '2019-12-25' }]); const addSeries = stubProviders({ diff --git a/server/subscriber/MediaRequestSubscriber.ts b/server/subscriber/MediaRequestSubscriber.ts index e0fb3965fe..b3d0401a7f 100644 --- a/server/subscriber/MediaRequestSubscriber.ts +++ b/server/subscriber/MediaRequestSubscriber.ts @@ -505,13 +505,13 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface { + ): Promise { try { const tvdb = await Tvdb.getInstance(); return await tvdb.getOfficialSeasons(tvdbId); } catch { - return []; + return null; } } @@ -672,6 +672,29 @@ export class MediaRequestSubscriber implements EntitySubscriberInterface season.seasonNumber) .filter( From 6c3b0abdd38f5b9fa5d51b17a8d3b97761203c4a Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:27:22 +0800 Subject: [PATCH 3/6] fix(requests): stop a duplicate TVDB ID crashing requests and scans Two TMDB entries can map to one TVDB series, but only one media row may hold that ID. Creating a row with an ID another row already owns threw a unique constraint error, failing the request outright and, in the scanner, the whole library. This skips the ID when it is already taken and keeps the row, since the TMDB ID is what identifies it and dispatch resolves the TVDB ID per request anyways. --- server/entity/MediaRequest.ts | 54 ++++++++++++++++++++++++++++-- server/lib/scanners/baseScanner.ts | 17 +++++++++- server/routes/request.test.ts | 22 ++++++++++++ 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index e5334ed1eb..a0747c9597 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -26,6 +26,7 @@ import { PrimaryGeneratedColumn, RelationCount, UpdateDateColumn, + type Repository, } from 'typeorm'; import Media from './Media'; import SeasonRequest from './SeasonRequest'; @@ -53,6 +54,29 @@ export class MediaRequest { ); } + private static async saveMediaDroppingTvdbIdOnConflict( + mediaRepository: Repository, + media: Media + ): Promise { + try { + await mediaRepository.save(media); + } catch (e) { + if (!media.tvdbId) { + throw e; + } + + logger.warn('Dropped TVDB ID after a conflict on save', { + label: 'Media Request', + tmdbId: media.tmdbId, + tvdbId: media.tvdbId, + errorMessage: e instanceof Error ? e.message : String(e), + }); + + media.tvdbId = undefined; + await mediaRepository.save(media); + } + } + private static async createRequest( requestBody: MediaRequestBody, user: User, @@ -162,9 +186,27 @@ export class MediaRequest { }); if (!media) { + let tvdbId = requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id; + + if (tvdbId) { + const conflict = await mediaRepository.findOne({ where: { tvdbId } }); + + if (conflict) { + logger.info( + 'Skipped TVDB ID on new media, already owned by another media row', + { + label: 'Media Request', + tmdbId: tmdbMedia.id, + tvdbId, + } + ); + tvdbId = undefined; + } + } + media = new Media({ tmdbId: tmdbMedia.id, - tvdbId: requestBody.tvdbId ?? tmdbMedia.external_ids.tvdb_id, + tvdbId, status: !requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, status4k: requestBody.is4k ? MediaStatus.PENDING : MediaStatus.UNKNOWN, mediaType: requestBody.mediaType, @@ -319,7 +361,10 @@ export class MediaRequest { } if (requestBody.mediaType === MediaType.MOVIE) { - await mediaRepository.save(media); + await MediaRequest.saveMediaDroppingTvdbIdOnConflict( + mediaRepository, + media + ); const request = new MediaRequest({ type: MediaType.MOVIE, @@ -434,7 +479,10 @@ export class MediaRequest { throw new QuotaRestrictedError('Series Quota exceeded.'); } - await mediaRepository.save(media); + await MediaRequest.saveMediaDroppingTvdbIdOnConflict( + mediaRepository, + media + ); const request = new MediaRequest({ type: MediaType.TV, diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index db84cef0c3..f06882e675 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -570,11 +570,26 @@ class BaseScanner { (s) => s.status4k === MediaStatus.AVAILABLE ); + let mediaTvdbId = tvdbId; + + if (mediaTvdbId) { + const tvdbConflict = await mediaRepository.findOne({ + where: { tvdbId: mediaTvdbId }, + }); + + if (tvdbConflict) { + this.log( + `Skipping TVDB ID ${mediaTvdbId} for ${title}, already owned by TMDB ${tvdbConflict.tmdbId}` + ); + mediaTvdbId = undefined; + } + } + const newMedia = new Media({ mediaType: MediaType.TV, seasons: newSeasons, tmdbId, - tvdbId, + tvdbId: mediaTvdbId, mediaAddedAt, serviceId: !is4k ? serviceId : undefined, serviceId4k: is4k ? serviceId : undefined, diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index 5640c76e3a..fac54e7ee2 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -1129,6 +1129,28 @@ describe('POST /request (tv), TVDB ID backfill', () => { assert.strictEqual(updated.tvdbId, 184871); }); + it('creates untracked media without the TVDB ID when another row owns it', async () => { + getSettings().radarr = []; + getSettings().sonarr = []; + + await seedUntrackedShow(87012, 184871); + + const agent = await loginAs('friend@seerr.dev', 'test1234'); + const res = await agent.post('/request').send({ + mediaType: MediaType.TV, + mediaId: 34549, + seasons: [1], + tvdbId: 184871, + }); + + assert.strictEqual(res.status, 201); + + const created = await getRepository(Media).findOneOrFail({ + where: { tmdbId: 34549 }, + }); + assert.strictEqual(created.tvdbId, null); + }); + it('skips the backfill when another media row already owns the TVDB ID', async () => { getSettings().radarr = []; getSettings().sonarr = []; From a48b3f3e7b4b5c088960802a733e5ac52eb1e442 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:31:28 +0800 Subject: [PATCH 4/6] fix(scanner): retry without the TVDB ID when a concurrent scan claims it --- server/lib/scanners/baseScanner.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index f06882e675..8ad069442b 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -658,7 +658,23 @@ class BaseScanner { ? MediaStatus.PROCESSING : MediaStatus.UNKNOWN, }); - await mediaRepository.save(newMedia); + + try { + await mediaRepository.save(newMedia); + } catch (e) { + if (!newMedia.tvdbId) { + throw e; + } + + // the ownership check above is per-tmdbId, so a concurrent entry for + // the same series can claim the ID between checking and saving + this.log( + `Dropped TVDB ID ${newMedia.tvdbId} for ${title} after a conflict on save` + ); + newMedia.tvdbId = undefined; + await mediaRepository.save(newMedia); + } + this.log(`Saved ${title}`); } }); From 7e74541dd991322ef45035edf66bb6bf7e24e962 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:36:00 +0800 Subject: [PATCH 5/6] fix(requests): drop the TVDB ID only when another row owns it --- server/entity/MediaRequest.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index a0747c9597..2561d1250a 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -65,6 +65,16 @@ export class MediaRequest { throw e; } + const owner = await mediaRepository.findOne({ + where: { tvdbId: media.tvdbId }, + }); + + // a transient failure leaves the ID unowned, and dropping it there would + // persist NULL over a valid one + if (!owner || owner.id === media.id) { + throw e; + } + logger.warn('Dropped TVDB ID after a conflict on save', { label: 'Media Request', tmdbId: media.tmdbId, From 3ec89e1e5f69ea5c6228afb8966d68214c19de3f Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:38:37 +0800 Subject: [PATCH 6/6] test(requests): point the TVDB fixtures at the renamed seed user --- server/routes/request.test.ts | 6 +++--- server/subscriber/MediaRequestSubscriber.test.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index fac54e7ee2..6cdeff99ec 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -1113,7 +1113,7 @@ describe('POST /request (tv), TVDB ID backfill', () => { const media = await seedUntrackedShow(88010); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.TV, mediaId: 88010, @@ -1135,7 +1135,7 @@ describe('POST /request (tv), TVDB ID backfill', () => { await seedUntrackedShow(87012, 184871); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.TV, mediaId: 34549, @@ -1158,7 +1158,7 @@ describe('POST /request (tv), TVDB ID backfill', () => { await seedUntrackedShow(88011, 184871); const media = await seedUntrackedShow(88012); - const agent = await loginAs('friend@seerr.dev', 'test1234'); + const agent = await loginAs('demo@seerr.dev', 'test1234'); const res = await agent.post('/request').send({ mediaType: MediaType.TV, mediaId: 88012, diff --git a/server/subscriber/MediaRequestSubscriber.test.ts b/server/subscriber/MediaRequestSubscriber.test.ts index 84cdfbdf2f..caa15e786a 100644 --- a/server/subscriber/MediaRequestSubscriber.test.ts +++ b/server/subscriber/MediaRequestSubscriber.test.ts @@ -128,7 +128,7 @@ async function seedApprovedRequest( tvdbId?: number ) { const requester = await getRepository(User).findOneOrFail({ - where: { email: 'friend@seerr.dev' }, + where: { email: 'demo@seerr.dev' }, }); const media = await getRepository(Media).save(