From 9c34864ce6a61e302126e7a993359239e2b1b9ec Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Sun, 3 May 2026 23:22:55 +0800 Subject: [PATCH] fix(availability-sync): detect deleted seasons when media server retains empty season metadata (#2850) --- server/lib/availabilitySync.test.ts | 962 ++++++++++++++++++++++++++++ server/lib/availabilitySync.ts | 66 +- 2 files changed, 1020 insertions(+), 8 deletions(-) create mode 100644 server/lib/availabilitySync.test.ts diff --git a/server/lib/availabilitySync.test.ts b/server/lib/availabilitySync.test.ts new file mode 100644 index 000000000..cbfa74d80 --- /dev/null +++ b/server/lib/availabilitySync.test.ts @@ -0,0 +1,962 @@ +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 { 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, +}); + +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: 22, + episodeCount: 22, + 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'); + }; + + 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(21); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 100) { + return { + tvdbId: 73255, + id: 100, + 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: 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' + ); + }); + }); + + 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' + ); + }); + }); +}); diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index c6c09cb55..cdbe80b54 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -21,9 +21,11 @@ class AvailabilitySync { public running = false; private plexClient: PlexAPI; private plexSeasonsCache: Record; + private plexEpisodeExistsCache: Record; private jellyfinClient: JellyfinAPI; private jellyfinSeasonsCache: Record; + private jellyfinEpisodeExistsCache: Record; private sonarrSeasonsCache: Record; private radarrServers: RadarrSettings[]; @@ -34,7 +36,9 @@ class AvailabilitySync { const mediaServerType = getSettings().main.mediaServerType; this.running = true; this.plexSeasonsCache = {}; + this.plexEpisodeExistsCache = {}; this.jellyfinSeasonsCache = {}; + this.jellyfinEpisodeExistsCache = {}; this.sonarrSeasonsCache = {}; this.radarrServers = settings.radarr.filter((server) => server.syncEnabled); this.sonarrServers = settings.sonarr.filter((server) => server.syncEnabled); @@ -939,7 +943,6 @@ class AvailabilitySync { const ratingKey4k = media.ratingKey4k; let seasonExistsInPlex = false; - // Check each plex instance to see if the season exists let plexSeasons: PlexMetadata[] | undefined; if (ratingKey && !is4k) { @@ -950,12 +953,33 @@ class AvailabilitySync { plexSeasons = this.plexSeasonsCache[ratingKey4k]; } - const seasonIsAvailable = plexSeasons?.find( + const seasonMeta = plexSeasons?.find( (plexSeason) => plexSeason.index === season.seasonNumber ); - if (seasonIsAvailable) { - seasonExistsInPlex = true; + if (seasonMeta) { + const cacheKey = seasonMeta.ratingKey; + + if (cacheKey in this.plexEpisodeExistsCache) { + seasonExistsInPlex = this.plexEpisodeExistsCache[cacheKey]; + } else { + try { + // Season metadata exists, but we need to verify it has actual + // episode files. Plex can keep empty season entries. + const episodes = await this.plexClient?.getChildrenMetadata( + seasonMeta.ratingKey + ); + + seasonExistsInPlex = + episodes?.some((episode) => episode.Media?.length > 0) ?? false; + } catch { + // If we can't fetch episodes, assume the season exists + // to avoid false removal + seasonExistsInPlex = true; + } + + this.plexEpisodeExistsCache[cacheKey] = seasonExistsInPlex; + } } return seasonExistsInPlex; @@ -1056,7 +1080,6 @@ class AvailabilitySync { const ratingKey4k = media.jellyfinMediaId4k; let seasonExistsInJellyfin = false; - // Check each jellyfin instance to see if the season exists let jellyfinSeasons: JellyfinLibraryItem[] | undefined; if (ratingKey && !is4k) { @@ -1067,12 +1090,39 @@ class AvailabilitySync { jellyfinSeasons = this.jellyfinSeasonsCache[ratingKey4k]; } - const seasonIsAvailable = jellyfinSeasons?.find( + const seasonMeta = jellyfinSeasons?.find( (jellyfinSeason) => jellyfinSeason.IndexNumber === season.seasonNumber ); - if (seasonIsAvailable) { - seasonExistsInJellyfin = true; + if (seasonMeta) { + const seriesId = is4k ? ratingKey4k : ratingKey; + + if (seriesId) { + const cacheKey = `${seriesId}-${seasonMeta.Id}`; + + if (cacheKey in this.jellyfinEpisodeExistsCache) { + seasonExistsInJellyfin = this.jellyfinEpisodeExistsCache[cacheKey]; + } else { + try { + // Season metadata exists, but we need to verify it has actual + // episode files. Jellyfin keeps season entries even after all + // episodes are deleted. getEpisodes already filters out + // virtual episodes. + const episodes = await this.jellyfinClient.getEpisodes( + seriesId, + seasonMeta.Id + ); + + seasonExistsInJellyfin = episodes.length > 0; + } catch { + // If we can't fetch episodes, assume the season exists + // to avoid false removal + seasonExistsInJellyfin = true; + } + + this.jellyfinEpisodeExistsCache[cacheKey] = seasonExistsInJellyfin; + } + } } return seasonExistsInJellyfin;