From 784faa9f84542dc32fa06150d49761b46be76efe Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+fallenbagel@users.noreply.github.com> Date: Fri, 12 Jun 2026 06:04:36 +0800 Subject: [PATCH] fix: availability sync demotion and orphan season rollup edge cases (#3148) --- server/lib/availabilitySync.test.ts | 282 ++++++++++++++++++ server/lib/availabilitySync.ts | 14 +- server/lib/scanners/baseScanner.ts | 50 ++-- server/lib/scanners/jellyfin/jellyfin.test.ts | 155 ++++++++++ 4 files changed, 478 insertions(+), 23 deletions(-) diff --git a/server/lib/availabilitySync.test.ts b/server/lib/availabilitySync.test.ts index f43b70112..b876cc579 100644 --- a/server/lib/availabilitySync.test.ts +++ b/server/lib/availabilitySync.test.ts @@ -1167,4 +1167,286 @@ describe('AvailabilitySync', () => { ); }); }); + + describe('specials season handling', () => { + const tmdbSeasonsWithSpecials = [ + { + id: 100, + air_date: '2024-01-01', + episode_count: 3, + name: 'Specials', + overview: '', + season_number: 0, + }, + { + id: 101, + air_date: '2024-01-01', + episode_count: 10, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]; + + it('should not demote an available show when only the specials season is missing (Jellyfin)', async () => { + configureJellyfin(); + configureSonarr([{ syncEnabled: true }]); + + const mediaRepository = getRepository(Media); + + const media = new Media(); + media.tmdbId = 13862; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.jellyfinMediaId = 'jellyfin-shogun-id'; + media.externalServiceId = 300; + media.seasons = [ + new Season({ + seasonNumber: 0, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }), + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + await mediaRepository.save(media); + + getTvShowImpl = async () => fakeTmdbShow(13862, tmdbSeasonsWithSpecials); + + getItemDataImpl = async (id: string) => { + if (id === 'jellyfin-shogun-id') { + return fakeJellyfinShow('jellyfin-shogun-id', '13862'); + } + return undefined; + }; + + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jellyfin-shogun-id') { + return [fakeJellyfinSeason(1, 'jellyfin-shogun-s1-id')]; + } + return []; + }; + + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jellyfin-shogun-s1-id') { + return fakeJellyfinEpisodes(10); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 300) { + return { + tvdbId: 70814, + id: 300, + title: 'Shogun', + titleSlug: 'shogun', + 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: 13862 }, + relations: ['seasons'], + }); + + assert.strictEqual( + updated.status, + MediaStatus.AVAILABLE, + 'Show should stay AVAILABLE when only the specials season is missing' + ); + }); + + it('should not demote an available show when only the specials season is missing (Plex)', async () => { + configurePlex(); + configureSonarr([{ syncEnabled: true }]); + + const mediaRepository = getRepository(Media); + + const media = new Media(); + media.tmdbId = 13863; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-shogun-rk'; + media.externalServiceId = 301; + media.seasons = [ + new Season({ + seasonNumber: 0, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }), + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + await mediaRepository.save(media); + + getTvShowImpl = async () => fakeTmdbShow(13863, tmdbSeasonsWithSpecials); + + getMetadataImpl = async (key: string) => { + if (key === 'plex-shogun-rk') { + return fakePlexShow('plex-shogun-rk'); + } + throw new Error('404'); + }; + + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-shogun-rk') { + return [fakePlexSeason(1, 'plex-shogun-s1-rk')]; + } + if (key === 'plex-shogun-s1-rk') { + return fakePlexEpisodes(10); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 301) { + return { + tvdbId: 70814, + id: 301, + title: 'Shogun', + titleSlug: 'shogun', + 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: 13863 }, + relations: ['seasons'], + }); + + assert.strictEqual( + updated.status, + MediaStatus.AVAILABLE, + 'Show should stay AVAILABLE when only the specials season is missing' + ); + }); + + it('should mark a removed specials season as DELETED without demoting the show (Jellyfin)', async () => { + configureJellyfin(); + configureSonarr([{ syncEnabled: true }]); + + const mediaRepository = getRepository(Media); + + const media = new Media(); + media.tmdbId = 13864; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.jellyfinMediaId = 'jellyfin-specials-id'; + media.externalServiceId = 302; + media.seasons = [ + new Season({ + seasonNumber: 0, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + await mediaRepository.save(media); + + getTvShowImpl = async () => fakeTmdbShow(13864, tmdbSeasonsWithSpecials); + + getItemDataImpl = async (id: string) => { + if (id === 'jellyfin-specials-id') { + return fakeJellyfinShow('jellyfin-specials-id', '13864'); + } + return undefined; + }; + + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jellyfin-specials-id') { + return [fakeJellyfinSeason(1, 'jellyfin-specials-s1-id')]; + } + return []; + }; + + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jellyfin-specials-s1-id') { + return fakeJellyfinEpisodes(10); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 302) { + return { + tvdbId: 70814, + id: 302, + title: 'Shogun', + titleSlug: 'shogun', + 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: 13864 }, + relations: ['seasons'], + }); + + const specials = updated.seasons.find((s) => s.seasonNumber === 0); + assert.strictEqual( + specials?.status, + MediaStatus.DELETED, + 'Removed specials season should be marked DELETED' + ); + + assert.strictEqual( + updated.status, + MediaStatus.AVAILABLE, + 'Show should stay AVAILABLE when only specials were removed' + ); + }); + }); }); diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 33e166e6e..580a75113 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -379,6 +379,11 @@ class AvailabilitySync { if (tvShow) { // fill the finalSeasons and finalSeasons4k maps with false for missing seasons media.seasons.forEach((season) => { + // Specials don't count towards availability (baseScanner skips them too) + // TODO: doesn't respect enableSpecialEpisodes; needs a shared predicate with baseScanner.ts + if (season.seasonNumber === 0) { + return; + } if ( !finalSeasons.has(season.seasonNumber) && tvShow.seasons.find( @@ -599,6 +604,8 @@ class AvailabilitySync { ); // Retrieve the season keys to pass into our log const seasonKeys = [...seasonsPendingRemoval.keys()]; + // Specials can still be marked DELETED below, but shouldn't demote the show + const nonSpecialSeasonKeys = seasonKeys.filter((key) => key !== 0); try { for (const mediaSeason of media.seasons) { @@ -613,12 +620,15 @@ class AvailabilitySync { } } - if (media[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE) { + if ( + nonSpecialSeasonKeys.length > 0 && + media[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE + ) { media[is4k ? 'status4k' : 'status'] = MediaStatus.PARTIALLY_AVAILABLE; logger.debug( `Marking the ${ is4k ? '4K' : 'non-4K' - } show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season(s) [${seasonKeys}] was not found in any ${ + } show [TMDB ID ${media.tmdbId}] as PARTIALLY_AVAILABLE because season(s) [${nonSpecialSeasonKeys}] was not found in any ${ media.mediaType === 'tv' ? 'Sonarr' : 'Radarr' } and ${ mediaServerType === MediaServerType.PLEX diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index e43e77731..c609e57b3 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -467,10 +467,25 @@ class BaseScanner { (s) => s.seasonNumber !== 0 ); - const standardSeasonsForRollup = nonSpecialSeasons.filter( - (s) => - (seasons.find((season) => season.seasonNumber === s.seasonNumber) - ?.totalEpisodes ?? Infinity) > 0 + // DB-only seasons block the rollup unless UNKNOWN (orphan placeholders + // can never be revisited by a scan and would pin the show forever). + const countsTowardsRollup = ( + s: Season, + statusKey: 'status' | 'status4k' + ): boolean => { + const scannedSeason = seasons.find( + (season) => season.seasonNumber === s.seasonNumber + ); + + if (scannedSeason) { + return scannedSeason.totalEpisodes > 0; + } + + return s[statusKey] !== MediaStatus.UNKNOWN; + }; + + const standardSeasonsForRollup = nonSpecialSeasons.filter((s) => + countsTowardsRollup(s, 'status') ); const isAllStandardSeasonsAvailable = standardSeasonsForRollup.length > 0 && @@ -478,10 +493,8 @@ class BaseScanner { (s) => s.status === MediaStatus.AVAILABLE ); - const seasons4kForRollup = nonSpecialSeasons.filter( - (s) => - (seasons.find((season) => season.seasonNumber === s.seasonNumber) - ?.totalEpisodes ?? Infinity) > 0 + const seasons4kForRollup = nonSpecialSeasons.filter((s) => + countsTowardsRollup(s, 'status4k') ); const isAll4kSeasonsAvailable = seasons4kForRollup.length > 0 && @@ -530,25 +543,20 @@ class BaseScanner { (s) => s.seasonNumber !== 0 ); - const standardSeasonsForRollup = nonSpecialNewSeasons.filter( + const newSeasonsForRollup = nonSpecialNewSeasons.filter( (s) => (seasons.find((season) => season.seasonNumber === s.seasonNumber) - ?.totalEpisodes ?? Infinity) > 0 + ?.totalEpisodes ?? 0) > 0 ); const isAllStandardSeasonsAvailable = - standardSeasonsForRollup.length > 0 && - standardSeasonsForRollup.every( - (s) => s.status === MediaStatus.AVAILABLE - ); + newSeasonsForRollup.length > 0 && + newSeasonsForRollup.every((s) => s.status === MediaStatus.AVAILABLE); - const seasons4kForRollup = nonSpecialNewSeasons.filter( - (s) => - (seasons.find((season) => season.seasonNumber === s.seasonNumber) - ?.totalEpisodes ?? Infinity) > 0 - ); const isAll4kSeasonsAvailable = - seasons4kForRollup.length > 0 && - seasons4kForRollup.every((s) => s.status4k === MediaStatus.AVAILABLE); + newSeasonsForRollup.length > 0 && + newSeasonsForRollup.every( + (s) => s.status4k === MediaStatus.AVAILABLE + ); const newMedia = new Media({ mediaType: MediaType.TV, diff --git a/server/lib/scanners/jellyfin/jellyfin.test.ts b/server/lib/scanners/jellyfin/jellyfin.test.ts index ed7b206b9..51f97d09a 100644 --- a/server/lib/scanners/jellyfin/jellyfin.test.ts +++ b/server/lib/scanners/jellyfin/jellyfin.test.ts @@ -346,5 +346,160 @@ describe('Jellyfin Scanner', () => { 'Show should be AVAILABLE when all non-empty TMDB seasons are fully scanned, ignoring empty placeholder seasons' ); }); + + it('should mark show as available when an orphan UNKNOWN season exists in the DB but not in TMDB', async () => { + configureJellyfinWithLibrary(); + + const mediaRepository = getRepository(Media); + + const media = new Media(); + media.tmdbId = 5001; + media.mediaType = MediaType.TV; + media.status = MediaStatus.PARTIALLY_AVAILABLE; + media.jellyfinMediaId = 'jf-orphan-show-id'; + media.seasons = [ + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + // Not present in the TMDB season list below + new Season({ + seasonNumber: 2, + status: MediaStatus.UNKNOWN, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + await mediaRepository.save(media); + + getTvShowImpl = async () => + fakeTmdbShow(5001, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 10, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getLibraryContentsImpl = async (id: string) => { + if (id === 'test-library-id') { + return [fakeJellyfinSeriesItem('jf-orphan-show-id')]; + } + return []; + }; + + getItemDataImpl = async (id: string) => { + if (id === 'jf-orphan-show-id') { + return fakeJellyfinShowMetadata('jf-orphan-show-id', '5001'); + } + return undefined; + }; + + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-orphan-show-id') { + return [fakeJellyfinSeason(1, 'jf-orphan-s1-id')]; + } + return []; + }; + + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-orphan-s1-id') return fakeJellyfinEpisodes(10); + return []; + }; + + await jellyfinFullScanner.run(); + + const updated = await mediaRepository.findOneOrFail({ + where: { tmdbId: 5001 }, + relations: ['seasons'], + }); + + assert.strictEqual( + updated.status, + MediaStatus.AVAILABLE, + 'Show should be AVAILABLE when the only DB season missing from TMDB is an UNKNOWN orphan placeholder' + ); + }); + + it('should keep show partially available when a season missing from TMDB was previously available', async () => { + configureJellyfinWithLibrary(); + + const mediaRepository = getRepository(Media); + + const media = new Media(); + media.tmdbId = 5002; + media.mediaType = MediaType.TV; + media.status = MediaStatus.PARTIALLY_AVAILABLE; + media.jellyfinMediaId = 'jf-deleted-show-id'; + media.seasons = [ + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + new Season({ + seasonNumber: 2, + status: MediaStatus.DELETED, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + await mediaRepository.save(media); + + getTvShowImpl = async () => + fakeTmdbShow(5002, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 10, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getLibraryContentsImpl = async (id: string) => { + if (id === 'test-library-id') { + return [fakeJellyfinSeriesItem('jf-deleted-show-id')]; + } + return []; + }; + + getItemDataImpl = async (id: string) => { + if (id === 'jf-deleted-show-id') { + return fakeJellyfinShowMetadata('jf-deleted-show-id', '5002'); + } + return undefined; + }; + + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-deleted-show-id') { + return [fakeJellyfinSeason(1, 'jf-deleted-s1-id')]; + } + return []; + }; + + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-deleted-s1-id') return fakeJellyfinEpisodes(10); + return []; + }; + + await jellyfinFullScanner.run(); + + const updated = await mediaRepository.findOneOrFail({ + where: { tmdbId: 5002 }, + relations: ['seasons'], + }); + + assert.strictEqual( + updated.status, + MediaStatus.PARTIALLY_AVAILABLE, + 'Show should stay PARTIALLY_AVAILABLE when a DELETED season is missing from the metadata provider' + ); + }); }); });