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.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 72531780ba..0c234124cd 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,36 @@ class Tvdb extends ExternalAPI implements TvShowProvider { } } + public async getOfficialSeasons( + tvdbId: number + ): 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 null; + } + + 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..2561d1250a 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,39 @@ export class MediaRequest { ); } + private static async saveMediaDroppingTvdbIdOnConflict( + mediaRepository: Repository, + media: Media + ): Promise { + try { + await mediaRepository.save(media); + } catch (e) { + if (!media.tvdbId) { + 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, + 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 +196,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, @@ -195,6 +247,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 @@ -283,7 +371,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, @@ -398,7 +489,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..8ad069442b 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, @@ -643,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}`); } }); diff --git a/server/routes/request.test.ts b/server/routes/request.test.ts index e82a76d24c..6cdeff99ec 100644 --- a/server/routes/request.test.ts +++ b/server/routes/request.test.ts @@ -1093,3 +1093,84 @@ 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('demo@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('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('demo@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 = []; + + await seedUntrackedShow(88011, 184871); + const media = await seedUntrackedShow(88012); + + const agent = await loginAs('demo@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..caa15e786a --- /dev/null +++ b/server/subscriber/MediaRequestSubscriber.test.ts @@ -0,0 +1,443 @@ +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[] | null | Error; + sonarrSeries?: Partial | null; +}) { + mock.method( + Tvdb, + 'getInstance', + async () => + ({ + resolveTvdbId: async () => resolveTvdbId, + getOfficialSeasons: async () => { + if (officialSeasons instanceof Error) { + throw officialSeasons; + } + + return 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: 'demo@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('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({ + 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..b3d0401a7f 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 null; + } + } + + 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,120 @@ 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(