import assert from 'node:assert/strict'; import { beforeEach, describe, it } from 'node:test'; import type { JellyfinLibraryItem, JellyfinLibraryItemExtended, } from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin'; import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import type { TmdbTvDetails, TmdbTvSeasonResult, } from '@server/api/themoviedb/interfaces'; import { MediaStatus, MediaType } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import type { SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { setupTestDb } from '@server/test/db'; // --- Mock JellyfinAPI --- let getSystemInfoImpl: () => Promise> = async () => ({ ServerName: 'Test', }); let getItemDataImpl: ( id: string ) => Promise = async () => undefined; let getSeasonsImpl: ( seriesID: string ) => Promise = async () => []; let getEpisodesImpl: ( seriesID: string, seasonID: string ) => Promise = async () => []; Object.defineProperty(JellyfinAPI.prototype, 'getSystemInfo', { get() { return async () => getSystemInfoImpl(); }, set() {}, configurable: true, }); Object.defineProperty(JellyfinAPI.prototype, 'getItemData', { get() { return async (id: string) => getItemDataImpl(id); }, set() {}, configurable: true, }); Object.defineProperty(JellyfinAPI.prototype, 'getSeasons', { get() { return async (seriesID: string) => getSeasonsImpl(seriesID); }, set() {}, configurable: true, }); Object.defineProperty(JellyfinAPI.prototype, 'getEpisodes', { get() { return async (seriesID: string, seasonID: string) => getEpisodesImpl(seriesID, seasonID); }, set() {}, configurable: true, }); Object.defineProperty(JellyfinAPI.prototype, 'setUserId', { get() { return () => {}; }, set() {}, configurable: true, }); // --- Mock PlexAPI --- let getMetadataImpl: ( key: string, options?: { includeChildren?: boolean } ) => Promise = async () => { throw new Error('404'); }; let getChildrenMetadataImpl: ( key: string ) => Promise = async () => []; Object.defineProperty(PlexAPI.prototype, 'getMetadata', { get() { return async (key: string, options?: { includeChildren?: boolean }) => getMetadataImpl(key, options); }, set() {}, configurable: true, }); Object.defineProperty(PlexAPI.prototype, 'getChildrenMetadata', { get() { return async (key: string) => getChildrenMetadataImpl(key); }, set() {}, configurable: true, }); // --- Mock SonarrAPI --- let getSeriesByIdImpl: (id: number) => Promise = async () => { throw new Error('404'); }; Object.defineProperty(SonarrAPI.prototype, 'getSeriesById', { get() { return async (id: number) => getSeriesByIdImpl(id); }, set() {}, configurable: true, }); // --- Mock TheMovieDb --- let getTvShowImpl: (args: { tvId: number; language?: string; }) => Promise = async () => fakeTmdbShow(1); let getShowByTvdbIdImpl: (args: { tvdbId: number; language?: string; }) => Promise = async () => fakeTmdbShow(1); Object.defineProperty(TheMovieDb.prototype, 'getTvShow', { get() { return async (args: { tvId: number; language?: string }) => getTvShowImpl(args); }, set() {}, configurable: true, }); Object.defineProperty(TheMovieDb.prototype, 'getShowByTvdbId', { get() { return async (args: { tvdbId: number; language?: string }) => getShowByTvdbIdImpl(args); }, set() {}, configurable: true, }); // --- Helpers --- function fakeTmdbShow( tmdbId: number, seasons: TmdbTvSeasonResult[] = [ { id: 1, air_date: '2024-01-01', episode_count: 10, name: 'Season 1', overview: '', season_number: 1, }, ] ): TmdbTvDetails { return { id: tmdbId, content_ratings: { results: [] }, created_by: [], episode_run_time: [], first_air_date: '2024-01-01', genres: [], homepage: '', in_production: false, languages: ['en'], last_air_date: '2024-01-01', name: 'Test Show', networks: [], number_of_episodes: 10, number_of_seasons: seasons.length, origin_country: ['US'], original_language: 'en', original_name: 'Test Show', overview: '', popularity: 0, production_companies: [], production_countries: [], spoken_languages: [], seasons, status: 'Ended', type: 'Scripted', vote_average: 0, vote_count: 0, aggregate_credits: { cast: [] }, credits: { crew: [] }, external_ids: {}, keywords: { results: [] }, videos: { results: [] }, }; } import availabilitySync from '@server/lib/availabilitySync'; setupTestDb(); function configureSonarr(overrides: Partial[] = [{}]): void { const settings = getSettings(); settings.sonarr = overrides.map((o, i) => ({ id: i, name: `Sonarr ${i}`, hostname: 'localhost', port: 8989, apiKey: 'test-key', baseUrl: '', useSsl: false, activeProfileId: 1, activeDirectory: '/tv', activeLanguageProfileId: 1, activeAnimeProfileId: undefined, activeAnimeDirectory: '', activeAnimeLanguageProfileId: undefined, animeTags: [], is4k: false, enableSeasonFolders: true, tags: [], isDefault: i === 0, syncEnabled: true, preventSearch: false, externalUrl: '', ...o, })) as SonarrSettings[]; settings.radarr = []; } function configureJellyfin(): void { const settings = getSettings(); settings.main.mediaServerType = MediaServerType.JELLYFIN; settings.jellyfin = { ...settings.jellyfin, apiKey: 'test-api-key', }; } function configurePlex(): void { const settings = getSettings(); settings.main.mediaServerType = MediaServerType.PLEX; } // --- Jellyfin helpers --- function fakeJellyfinSeason( seasonNumber: number, id?: string ): JellyfinLibraryItem { return { Name: `Season ${seasonNumber}`, Id: id ?? `jellyfin-season-${seasonNumber}-id`, IndexNumber: seasonNumber, Type: 'Season' as const, HasSubtitles: false, LocationType: 'FileSystem' as const, MediaType: 'Video', }; } function fakeJellyfinEpisodes(count: number): JellyfinLibraryItem[] { return Array.from({ length: count }, (_, i) => ({ Name: `Episode ${i + 1}`, Id: `ep-${i}`, IndexNumber: i + 1, Type: 'Episode' as const, HasSubtitles: false, LocationType: 'FileSystem' as const, MediaType: 'Video', })); } function fakeJellyfinShow( id: string, tmdbId: string ): JellyfinLibraryItemExtended { return { Name: 'Test Show', Id: id, Type: 'Series', HasSubtitles: false, LocationType: 'FileSystem', MediaType: 'Video', ProviderIds: { Tmdb: tmdbId }, }; } // --- Plex helpers --- function fakePlexSeason(seasonNumber: number, ratingKey: string): PlexMetadata { return { ratingKey, guid: `plex://season/${ratingKey}`, type: 'season', title: `Season ${seasonNumber}`, Guid: [], index: seasonNumber, leafCount: 0, viewedLeafCount: 0, addedAt: 0, updatedAt: 0, Media: [], }; } function fakePlexEpisodes(count: number): PlexMetadata[] { return Array.from({ length: count }, (_, i) => ({ ratingKey: `ep-${i}`, guid: `plex://episode/ep-${i}`, type: 'movie' as const, title: `Episode ${i + 1}`, Guid: [], index: i + 1, leafCount: 0, viewedLeafCount: 0, addedAt: 0, updatedAt: 0, Media: [ { id: i, duration: 2400, bitrate: 4000, width: 1920, height: 1080, aspectRatio: 1.78, audioChannels: 2, audioCodec: 'aac', videoCodec: 'h264', videoResolution: '1080', container: 'mkv', videoFrameRate: '24p', videoProfile: 'high', }, ], })); } function fakePlexShow(ratingKey: string): PlexMetadata { return { ratingKey, guid: `plex://show/${ratingKey}`, type: 'show', title: 'Test Show', Guid: [], index: 1, leafCount: 0, viewedLeafCount: 0, addedAt: 0, updatedAt: 0, Media: [], }; } // --- Sonarr helpers --- function fakeSonarrSeasons( totalSeasons: number, seasonsWithFiles: Record ): SonarrSeason[] { return Array.from({ length: totalSeasons }, (_, i) => ({ seasonNumber: i + 1, monitored: true, statistics: { episodeFileCount: seasonsWithFiles[i + 1] ?? 0, totalEpisodeCount: 10, episodeCount: 10, percentOfEpisodes: seasonsWithFiles[i + 1] ? 100 : 0, sizeOnDisk: seasonsWithFiles[i + 1] ? 7516192768 : 0, previousAiring: undefined, }, })); } describe('AvailabilitySync', () => { beforeEach(async () => { getSystemInfoImpl = async () => ({ ServerName: 'Test' }); getItemDataImpl = async () => undefined; getSeasonsImpl = async () => []; getEpisodesImpl = async () => []; getMetadataImpl = async () => { throw new Error('404'); }; getChildrenMetadataImpl = async () => []; getSeriesByIdImpl = async () => { throw new Error('404'); }; getTvShowImpl = async ({ tvId }) => fakeTmdbShow( tvId, Array.from({ length: 4 }, (_, i) => ({ id: i + 1, air_date: '2024-01-01', episode_count: 10, name: `Season ${i + 1}`, overview: '', season_number: i + 1, })) ); getShowByTvdbIdImpl = async ({ tvdbId }) => fakeTmdbShow( tvdbId, Array.from({ length: 4 }, (_, i) => ({ id: i + 1, air_date: '2024-01-01', episode_count: 10, name: `Season ${i + 1}`, overview: '', season_number: i + 1, })) ); const userRepository = getRepository(User); const existingAdmin = await userRepository.findOne({ where: { id: 1 } }); if (!existingAdmin) { const admin = new User(); admin.id = 1; admin.plexToken = 'test-plex-token'; admin.jellyfinUserId = 'admin-user-id'; admin.jellyfinDeviceId = 'admin-device-id'; admin.email = 'admin@test.com'; admin.permissions = 2; admin.username = 'admin'; await userRepository.save(admin); } }); describe('TV season availability - Jellyfin', () => { it('should mark deleted seasons as DELETED when only some seasons exist in Jellyfin and Sonarr', async () => { configureJellyfin(); configureSonarr([{ syncEnabled: true }]); const mediaRepository = getRepository(Media); const media = new Media(); media.tmdbId = 1408; media.mediaType = MediaType.TV; media.status = MediaStatus.AVAILABLE; media.jellyfinMediaId = 'jellyfin-house-id'; media.externalServiceId = 100; media.seasons = []; for (let i = 1; i <= 8; i++) { media.seasons.push( new Season({ seasonNumber: i, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }) ); } await mediaRepository.save(media); getItemDataImpl = async (id: string) => { if (id === 'jellyfin-house-id') { return fakeJellyfinShow('jellyfin-house-id', '1408'); } return undefined; }; getSeasonsImpl = async (seriesID: string) => { if (seriesID === 'jellyfin-house-id') { return [fakeJellyfinSeason(6)]; } return []; }; getEpisodesImpl = async (_seriesID: string, seasonID: string) => { if (seasonID === 'jellyfin-season-6-id') { return fakeJellyfinEpisodes(10); } return []; }; getSeriesByIdImpl = async (id: number) => { if (id === 100) { return { tvdbId: 73255, id: 100, title: 'House', titleSlug: 'house', monitored: true, statistics: { episodeFileCount: 21, totalEpisodeCount: 10, episodeCount: 10, percentOfEpisodes: 100, sizeOnDisk: 0, seasonCount: 8, }, seasons: fakeSonarrSeasons(8, { 6: 10 }), } as unknown as SonarrSeries; } throw new Error('404'); }; await availabilitySync.run(); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1408 }, relations: ['seasons'], }); const s6 = updated.seasons.find((s) => s.seasonNumber === 6); assert.strictEqual( s6?.status, MediaStatus.AVAILABLE, 'Season 6 should remain AVAILABLE' ); for (const season of updated.seasons) { if (season.seasonNumber !== 6) { assert.strictEqual( season.status, MediaStatus.DELETED, `Season ${season.seasonNumber} should be DELETED but was ${season.status}` ); } } assert.strictEqual( updated.status, MediaStatus.PARTIALLY_AVAILABLE, 'Show should be PARTIALLY_AVAILABLE after season removal' ); }); it('should still mark deleted seasons when externalServiceId is null (no Sonarr link)', async () => { configureJellyfin(); configureSonarr([{ syncEnabled: true }]); const mediaRepository = getRepository(Media); const media = new Media(); media.tmdbId = 1409; media.mediaType = MediaType.TV; media.status = MediaStatus.AVAILABLE; media.jellyfinMediaId = 'jellyfin-house2-id'; media.externalServiceId = undefined as unknown as number; media.seasons = []; for (let i = 1; i <= 8; i++) { media.seasons.push( new Season({ seasonNumber: i, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }) ); } await mediaRepository.save(media); getItemDataImpl = async (id: string) => { if (id === 'jellyfin-house2-id') { return fakeJellyfinShow('jellyfin-house2-id', '1409'); } return undefined; }; getSeasonsImpl = async (seriesID: string) => { if (seriesID === 'jellyfin-house2-id') { return [fakeJellyfinSeason(6, 'jellyfin-house2-s6-id')]; } return []; }; getEpisodesImpl = async (_seriesID: string, seasonID: string) => { if (seasonID === 'jellyfin-house2-s6-id') { return fakeJellyfinEpisodes(21); } return []; }; getSeriesByIdImpl = async () => { throw new Error('404'); }; await availabilitySync.run(); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1409 }, relations: ['seasons'], }); const s6 = updated.seasons.find((s) => s.seasonNumber === 6); assert.strictEqual( s6?.status, MediaStatus.AVAILABLE, 'Season 6 should remain AVAILABLE' ); for (const season of updated.seasons) { if (season.seasonNumber !== 6) { assert.strictEqual( season.status, MediaStatus.DELETED, `Season ${season.seasonNumber} should be DELETED but was ${season.status}` ); } } assert.strictEqual( updated.status, MediaStatus.PARTIALLY_AVAILABLE, 'Show should be PARTIALLY_AVAILABLE after season removal' ); }); it('should mark deleted seasons even when Jellyfin returns empty season metadata entries (real-world behavior)', async () => { configureJellyfin(); configureSonarr([{ syncEnabled: true }]); const mediaRepository = getRepository(Media); const media = new Media(); media.tmdbId = 1410; media.mediaType = MediaType.TV; media.status = MediaStatus.AVAILABLE; media.jellyfinMediaId = 'jellyfin-house3-id'; media.externalServiceId = 101; media.seasons = []; for (let i = 1; i <= 8; i++) { media.seasons.push( new Season({ seasonNumber: i, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }) ); } await mediaRepository.save(media); getItemDataImpl = async (id: string) => { if (id === 'jellyfin-house3-id') { return fakeJellyfinShow('jellyfin-house3-id', '1410'); } return undefined; }; // MOCK REAL BEHAVIOR: Jellyfin returns ALL 8 season metadata entries // even though only season 6 has actual episode files. getSeasonsImpl = async (seriesID: string) => { if (seriesID === 'jellyfin-house3-id') { return Array.from({ length: 8 }, (_, i) => fakeJellyfinSeason(i + 1, `jellyfin-house3-s${i + 1}-id`) ); } return []; }; // Only season 6 has actual episodes getEpisodesImpl = async (_seriesID: string, seasonID: string) => { if (seasonID === 'jellyfin-house3-s6-id') { return fakeJellyfinEpisodes(21); } return []; }; getSeriesByIdImpl = async (id: number) => { if (id === 101) { return { tvdbId: 73255, id: 101, title: 'House', titleSlug: 'house', monitored: true, statistics: { episodeFileCount: 21, totalEpisodeCount: 177, episodeCount: 177, percentOfEpisodes: 11.86, sizeOnDisk: 0, seasonCount: 8, }, seasons: fakeSonarrSeasons(8, { 6: 21 }), } as unknown as SonarrSeries; } throw new Error('404'); }; await availabilitySync.run(); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1410 }, relations: ['seasons'], }); const s6 = updated.seasons.find((s) => s.seasonNumber === 6); assert.strictEqual( s6?.status, MediaStatus.AVAILABLE, 'Season 6 should remain AVAILABLE' ); for (const season of updated.seasons) { if (season.seasonNumber !== 6) { assert.strictEqual( season.status, MediaStatus.DELETED, `Season ${season.seasonNumber} should be DELETED but was ${season.status}` ); } } assert.strictEqual( updated.status, MediaStatus.PARTIALLY_AVAILABLE, 'Show should be PARTIALLY_AVAILABLE after season removal' ); }); it('should assume season exists when getEpisodes fails (safe fallback)', async () => { configureJellyfin(); configureSonarr([{ syncEnabled: true }]); const mediaRepository = getRepository(Media); const media = new Media(); media.tmdbId = 1411; media.mediaType = MediaType.TV; media.status = MediaStatus.AVAILABLE; media.jellyfinMediaId = 'jellyfin-house4-id'; media.externalServiceId = 102; media.seasons = [ new Season({ seasonNumber: 1, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }), ]; await mediaRepository.save(media); getItemDataImpl = async (id: string) => { if (id === 'jellyfin-house4-id') { return fakeJellyfinShow('jellyfin-house4-id', '1411'); } return undefined; }; getSeasonsImpl = async (seriesID: string) => { if (seriesID === 'jellyfin-house4-id') { return [fakeJellyfinSeason(1, 'jellyfin-house4-s1-id')]; } return []; }; getEpisodesImpl = async () => { throw new Error('Connection refused'); }; getSeriesByIdImpl = async (id: number) => { if (id === 102) { return { tvdbId: 99999, id: 102, title: 'House 4', titleSlug: 'house-4', monitored: true, statistics: { episodeFileCount: 10, totalEpisodeCount: 10, episodeCount: 10, percentOfEpisodes: 100, sizeOnDisk: 0, seasonCount: 1, }, seasons: fakeSonarrSeasons(1, { 1: 10 }), } as unknown as SonarrSeries; } throw new Error('404'); }; await availabilitySync.run(); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1411 }, relations: ['seasons'], }); assert.strictEqual( updated.seasons[0].status, MediaStatus.AVAILABLE, 'Season should remain AVAILABLE when getEpisodes fails' ); assert.strictEqual( updated.status, MediaStatus.AVAILABLE, 'Show should remain AVAILABLE when getEpisodes fails' ); }); it('should mark show as PARTIALLY_AVAILABLE when some seasons are available and some are unknown', async () => { configureJellyfin(); configureSonarr([{ syncEnabled: true }]); const mediaRepository = getRepository(Media); const media = new Media(); media.tmdbId = 1412; media.mediaType = MediaType.TV; media.status = MediaStatus.AVAILABLE; media.jellyfinMediaId = 'jellyfin-partial-id'; media.externalServiceId = 103; media.seasons = [ new Season({ seasonNumber: 1, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }), new Season({ seasonNumber: 2, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }), new Season({ seasonNumber: 3, status: MediaStatus.UNKNOWN, status4k: MediaStatus.UNKNOWN, }), new Season({ seasonNumber: 4, status: MediaStatus.UNKNOWN, status4k: MediaStatus.UNKNOWN, }), ]; await mediaRepository.save(media); getItemDataImpl = async (id: string) => { if (id === 'jellyfin-partial-id') { return fakeJellyfinShow('jellyfin-partial-id', '1412'); } return undefined; }; getSeasonsImpl = async (seriesID: string) => { if (seriesID === 'jellyfin-partial-id') { return [ fakeJellyfinSeason(1, 'jellyfin-partial-s1-id'), fakeJellyfinSeason(2, 'jellyfin-partial-s2-id'), ]; } return []; }; getEpisodesImpl = async (_seriesID: string, seasonID: string) => { if (seasonID === 'jellyfin-partial-s1-id') { return fakeJellyfinEpisodes(10); } if (seasonID === 'jellyfin-partial-s2-id') { return fakeJellyfinEpisodes(10); } return []; }; getSeriesByIdImpl = async (id: number) => { if (id === 103) { return { tvdbId: 99997, id: 103, title: 'Partial Show', titleSlug: 'partial-show', monitored: true, statistics: { episodeFileCount: 20, totalEpisodeCount: 40, episodeCount: 40, percentOfEpisodes: 50, sizeOnDisk: 0, seasonCount: 4, }, seasons: fakeSonarrSeasons(4, { 1: 10, 2: 10 }), } as unknown as SonarrSeries; } throw new Error('404'); }; await availabilitySync.run(); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 1412 }, relations: ['seasons'], }); assert.strictEqual( updated.status, MediaStatus.PARTIALLY_AVAILABLE, 'Show should be PARTIALLY_AVAILABLE when some seasons are available and some are unknown' ); }); }); describe('TV season availability - Plex', () => { it('should mark deleted seasons when Plex returns empty season metadata entries', async () => { configurePlex(); configureSonarr([{ syncEnabled: true }]); const mediaRepository = getRepository(Media); const media = new Media(); media.tmdbId = 2000; media.mediaType = MediaType.TV; media.status = MediaStatus.AVAILABLE; media.ratingKey = 'plex-house-rk'; media.externalServiceId = 200; media.seasons = []; for (let i = 1; i <= 8; i++) { media.seasons.push( new Season({ seasonNumber: i, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }) ); } await mediaRepository.save(media); getMetadataImpl = async (key: string) => { if (key === 'plex-house-rk') { return fakePlexShow('plex-house-rk'); } throw new Error('404'); }; // Plex returns ALL 8 season metadata entries, // but only season 6 has episode files getChildrenMetadataImpl = async (key: string) => { if (key === 'plex-house-rk') { return Array.from({ length: 8 }, (_, i) => fakePlexSeason(i + 1, `plex-house-s${i + 1}-rk`) ); } if (key === 'plex-house-s6-rk') { return fakePlexEpisodes(21); } return []; }; // Sonarr: only season 6 has files getSeriesByIdImpl = async (id: number) => { if (id === 200) { return { tvdbId: 73255, id: 200, title: 'House', titleSlug: 'house', monitored: true, statistics: { episodeFileCount: 21, totalEpisodeCount: 177, episodeCount: 177, percentOfEpisodes: 11.86, sizeOnDisk: 0, seasonCount: 8, }, seasons: fakeSonarrSeasons(8, { 6: 21 }), } as unknown as SonarrSeries; } throw new Error('404'); }; await availabilitySync.run(); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 2000 }, relations: ['seasons'], }); const s6 = updated.seasons.find((s) => s.seasonNumber === 6); assert.strictEqual( s6?.status, MediaStatus.AVAILABLE, 'Season 6 should remain AVAILABLE' ); for (const season of updated.seasons) { if (season.seasonNumber !== 6) { assert.strictEqual( season.status, MediaStatus.DELETED, `Season ${season.seasonNumber} should be DELETED but was ${season.status}` ); } } assert.strictEqual( updated.status, MediaStatus.PARTIALLY_AVAILABLE, 'Show should be PARTIALLY_AVAILABLE after season removal' ); }); it('should assume season exists when getChildrenMetadata fails for episodes (safe fallback)', async () => { configurePlex(); configureSonarr([{ syncEnabled: true }]); const mediaRepository = getRepository(Media); const media = new Media(); media.tmdbId = 2001; media.mediaType = MediaType.TV; media.status = MediaStatus.AVAILABLE; media.ratingKey = 'plex-house2-rk'; media.externalServiceId = 201; media.seasons = [ new Season({ seasonNumber: 1, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }), ]; await mediaRepository.save(media); getMetadataImpl = async (key: string) => { if (key === 'plex-house2-rk') { return fakePlexShow('plex-house2-rk'); } throw new Error('404'); }; getChildrenMetadataImpl = async (key: string) => { if (key === 'plex-house2-rk') { return [fakePlexSeason(1, 'plex-house2-s1-rk')]; } throw new Error('Connection refused'); }; getSeriesByIdImpl = async (id: number) => { if (id === 201) { return { tvdbId: 99999, id: 201, title: 'House 2', titleSlug: 'house-2', monitored: true, statistics: { episodeFileCount: 10, totalEpisodeCount: 10, episodeCount: 10, percentOfEpisodes: 100, sizeOnDisk: 0, seasonCount: 1, }, seasons: fakeSonarrSeasons(1, { 1: 10 }), } as unknown as SonarrSeries; } throw new Error('404'); }; await availabilitySync.run(); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 2001 }, relations: ['seasons'], }); assert.strictEqual( updated.seasons[0].status, MediaStatus.AVAILABLE, 'Season should remain AVAILABLE when getChildrenMetadata fails' ); assert.strictEqual( updated.status, MediaStatus.AVAILABLE, 'Show should remain AVAILABLE when getChildrenMetadata fails' ); }); it('should mark deleted seasons when only some seasons have episodes in Plex (no Sonarr link)', async () => { configurePlex(); configureSonarr([{ syncEnabled: true }]); const mediaRepository = getRepository(Media); const media = new Media(); media.tmdbId = 2002; media.mediaType = MediaType.TV; media.status = MediaStatus.AVAILABLE; media.ratingKey = 'plex-house3-rk'; media.externalServiceId = undefined as unknown as number; media.seasons = []; for (let i = 1; i <= 4; i++) { media.seasons.push( new Season({ seasonNumber: i, status: MediaStatus.AVAILABLE, status4k: MediaStatus.UNKNOWN, }) ); } await mediaRepository.save(media); getMetadataImpl = async (key: string) => { if (key === 'plex-house3-rk') { return fakePlexShow('plex-house3-rk'); } throw new Error('404'); }; getChildrenMetadataImpl = async (key: string) => { if (key === 'plex-house3-rk') { return Array.from({ length: 4 }, (_, i) => fakePlexSeason(i + 1, `plex-house3-s${i + 1}-rk`) ); } // Only seasons 2 and 4 have episodes if (key === 'plex-house3-s2-rk' || key === 'plex-house3-s4-rk') { return fakePlexEpisodes(10); } return []; }; getSeriesByIdImpl = async () => { throw new Error('404'); }; await availabilitySync.run(); const updated = await mediaRepository.findOneOrFail({ where: { tmdbId: 2002 }, relations: ['seasons'], }); const s2 = updated.seasons.find((s) => s.seasonNumber === 2); const s4 = updated.seasons.find((s) => s.seasonNumber === 4); assert.strictEqual( s2?.status, MediaStatus.AVAILABLE, 'Season 2 should remain AVAILABLE' ); assert.strictEqual( s4?.status, MediaStatus.AVAILABLE, 'Season 4 should remain AVAILABLE' ); const s1 = updated.seasons.find((s) => s.seasonNumber === 1); const s3 = updated.seasons.find((s) => s.seasonNumber === 3); assert.strictEqual( s1?.status, MediaStatus.DELETED, 'Season 1 should be DELETED' ); assert.strictEqual( s3?.status, MediaStatus.DELETED, 'Season 3 should be DELETED' ); assert.strictEqual( updated.status, MediaStatus.PARTIALLY_AVAILABLE, 'Show should be PARTIALLY_AVAILABLE after season removal' ); }); }); });