diff --git a/server/lib/availabilitySync.test.ts b/server/lib/availabilitySync.test.ts index c2b0a6a109..4ad50eb4eb 100644 --- a/server/lib/availabilitySync.test.ts +++ b/server/lib/availabilitySync.test.ts @@ -346,6 +346,37 @@ function fakeJellyfinEpisodes(count: number): JellyfinLibraryItem[] { })); } +function fakeJellyfinEpisode( + episodeNumber: number, + widths: number[] +): JellyfinLibraryItemExtended { + return { + Name: `Episode ${episodeNumber}`, + Id: `ep-${episodeNumber}`, + IndexNumber: episodeNumber, + Type: 'Episode', + HasSubtitles: false, + LocationType: 'FileSystem', + MediaType: 'Video', + ProviderIds: {}, + MediaSources: widths.map((width, i) => ({ + Protocol: 'File', + Id: `src-${episodeNumber}-${i}`, + Path: `/ep-${episodeNumber}-${width}.mkv`, + Type: 'Default', + VideoType: 'VideoFile', + MediaStreams: [ + { + Codec: 'h264', + Type: 'Video' as const, + Width: width, + DisplayTitle: `${width}p`, + }, + ], + })), + }; +} + function fakeJellyfinShow( id: string, tmdbId: string @@ -493,6 +524,8 @@ describe('AvailabilitySync', () => { })) ); + getSettings().main.enableEpisodeAvailability = false; + const userRepository = getRepository(User); const existingAdmin = await userRepository.findOne({ where: { id: 1 } }); if (!existingAdmin) { @@ -2488,7 +2521,7 @@ describe('AvailabilitySync', () => { assert.strictEqual(season2Episodes[0]?.status, MediaStatus.AVAILABLE); }); - it('should soft-delete episodes without a file and hard-delete omitted numbers', async () => { + it('should soft-delete episodes without a file (union with Plex)', async () => { configurePlex(); configureSonarr([{ syncEnabled: true }]); enableEpisodeTracking(); @@ -2559,7 +2592,7 @@ describe('AvailabilitySync', () => { return [fakePlexSeason(1, 'plex-ep-file-s1-rk')]; } if (key === 'plex-ep-file-s1-rk') { - return fakePlexEpisodes(2); + return fakePlexEpisodes(1); } return []; }; @@ -2624,6 +2657,246 @@ describe('AvailabilitySync', () => { assert.strictEqual(episodes[1]?.status, MediaStatus.DELETED); }); + it('should keep an episode AVAILABLE when Sonarr lacks the file but Plex has it', async () => { + configurePlex(); + configureSonarr([{ syncEnabled: true }]); + enableEpisodeTracking(); + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const media = new Media(); + media.tmdbId = 4104; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-ep-union-rk'; + media.externalServiceId = 414; + media.seasons = [ + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + const saved = await mediaRepository.save(media); + const season1 = saved.seasons.find((season) => season.seasonNumber === 1); + assert.ok(season1); + + await episodeRepository.save([ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season1), + }), + new Episode({ + episodeNumber: 2, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season1), + }), + ]); + + getTvShowImpl = async () => + fakeTmdbShow(4104, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 2, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getMetadataImpl = async (key: string) => { + if (key === 'plex-ep-union-rk') { + return fakePlexShow('plex-ep-union-rk'); + } + throw new Error('404'); + }; + + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-ep-union-rk') { + return [fakePlexSeason(1, 'plex-ep-union-s1-rk')]; + } + if (key === 'plex-ep-union-s1-rk') { + return fakePlexEpisodes(2); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 414) { + return { + tvdbId: 4104, + id: 414, + title: 'Test Show', + titleSlug: 'test-show', + monitored: true, + statistics: { + episodeFileCount: 1, + totalEpisodeCount: 2, + episodeCount: 2, + percentOfEpisodes: 50, + sizeOnDisk: 0, + seasonCount: 1, + }, + seasons: fakeSonarrSeasons(1, { 1: 1 }), + } as unknown as SonarrSeries; + } + throw new Error('404'); + }; + + getSonarrEpisodesImpl = async () => + [ + { + seriesId: 414, + seasonNumber: 1, + episodeNumber: 1, + hasFile: true, + }, + { + seriesId: 414, + seasonNumber: 1, + episodeNumber: 2, + hasFile: false, + }, + ] as EpisodeResult[]; + + await availabilitySync.run(); + + const episodes = await episodeRepository.find({ + where: { season: { id: season1.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 2); + assert.strictEqual(episodes[0]?.status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1]?.status, MediaStatus.AVAILABLE); + }); + + it('should not demote episodes when a Plex season fetch fails', async () => { + configurePlex(); + configureSonarr([{ syncEnabled: true }]); + enableEpisodeTracking(); + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const media = new Media(); + media.tmdbId = 4105; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-ep-fail-rk'; + media.externalServiceId = 415; + media.seasons = [ + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + const saved = await mediaRepository.save(media); + const season1 = saved.seasons.find((season) => season.seasonNumber === 1); + assert.ok(season1); + + await episodeRepository.save([ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season1), + }), + new Episode({ + episodeNumber: 2, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season1), + }), + ]); + + getTvShowImpl = async () => + fakeTmdbShow(4105, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 2, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getMetadataImpl = async (key: string) => { + if (key === 'plex-ep-fail-rk') { + return fakePlexShow('plex-ep-fail-rk'); + } + throw new Error('404'); + }; + + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-ep-fail-rk') { + return [fakePlexSeason(1, 'plex-ep-fail-s1-rk')]; + } + if (key === 'plex-ep-fail-s1-rk') { + throw new Error('Plex season fetch failed'); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 415) { + return { + tvdbId: 4105, + id: 415, + title: 'Test Show', + titleSlug: 'test-show', + monitored: true, + statistics: { + episodeFileCount: 0, + totalEpisodeCount: 2, + episodeCount: 2, + percentOfEpisodes: 0, + sizeOnDisk: 0, + seasonCount: 1, + }, + seasons: fakeSonarrSeasons(1, { 1: 0 }), + } as unknown as SonarrSeries; + } + throw new Error('404'); + }; + + getSonarrEpisodesImpl = async () => + [ + { + seriesId: 415, + seasonNumber: 1, + episodeNumber: 1, + hasFile: false, + }, + { + seriesId: 415, + seasonNumber: 1, + episodeNumber: 2, + hasFile: false, + }, + ] as EpisodeResult[]; + + await availabilitySync.run(); + + const episodes = await episodeRepository.find({ + where: { season: { id: season1.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 2); + assert.strictEqual(episodes[0]?.status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1]?.status, MediaStatus.AVAILABLE); + }); + it('should still mark Sonarr episodes DELETED when TMDB show lookup fails', async () => { configurePlex(); configureSonarr([{ syncEnabled: true }]); @@ -2681,7 +2954,7 @@ describe('AvailabilitySync', () => { return [fakePlexSeason(1, 'plex-ep-tmdb-fail-s1-rk')]; } if (key === 'plex-ep-tmdb-fail-s1-rk') { - return fakePlexEpisodes(2); + return fakePlexEpisodes(1); } return []; }; @@ -2743,4 +3016,568 @@ describe('AvailabilitySync', () => { assert.strictEqual(episodes[1]?.status, MediaStatus.DELETED); }); }); + + describe('episode availability tracking', () => { + it('marks missing Plex episodes as DELETED', async () => { + configurePlex(); + getSettings().sonarr = []; + getSettings().radarr = []; + getSettings().main.enableEpisodeAvailability = true; + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const season = new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }); + season.episodes = [ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + new Episode({ + episodeNumber: 2, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + new Episode({ + episodeNumber: 3, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + const media = new Media(); + media.tmdbId = 9101; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-ep-show'; + media.seasons = [season]; + await mediaRepository.save(media); + + getMetadataImpl = async (key: string) => { + if (key === 'plex-ep-show') { + return fakePlexShow('plex-ep-show'); + } + throw new Error('404'); + }; + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-ep-show') { + return [fakePlexSeason(1, 'plex-ep-s1')]; + } + if (key === 'plex-ep-s1') { + return fakePlexEpisodes(2); + } + return []; + }; + + await availabilitySync.run(); + + const savedSeason = await mediaRepository.findOneOrFail({ + where: { tmdbId: 9101 }, + relations: ['seasons'], + }); + const seasonOne = savedSeason.seasons.find((s) => s.seasonNumber === 1); + assert.ok(seasonOne); + + const episodes = await episodeRepository.find({ + where: { season: { id: seasonOne.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 2); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1].status, MediaStatus.AVAILABLE); + }); + + it('marks stale 4K Plex episode status when only a standard file remains', async () => { + configurePlex(); + configureSonarr([ + { syncEnabled: true }, + { is4k: true, syncEnabled: true }, + ]); + getSettings().main.enableEpisodeAvailability = true; + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const season = new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.AVAILABLE, + }); + season.episodes = [ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.AVAILABLE, + }), + ]; + + const media = new Media(); + media.tmdbId = 9102; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.status4k = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-4k-ep-show'; + media.ratingKey4k = 'plex-4k-ep-show'; + media.seasons = [season]; + await mediaRepository.save(media); + + getMetadataImpl = async (key: string) => { + if (key === 'plex-4k-ep-show') { + return fakePlexShow('plex-4k-ep-show'); + } + throw new Error('404'); + }; + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-4k-ep-show') { + return [fakePlexSeason(1, 'plex-4k-ep-s1')]; + } + if (key === 'plex-4k-ep-s1') { + return fakePlexEpisodes(1); + } + return []; + }; + + await availabilitySync.run(); + + const savedSeason = await mediaRepository.findOneOrFail({ + where: { tmdbId: 9102 }, + relations: ['seasons'], + }); + const seasonOne = savedSeason.seasons.find((s) => s.seasonNumber === 1); + assert.ok(seasonOne); + + const episodes = await episodeRepository.find({ + where: { season: { id: seasonOne.id } }, + }); + + assert.strictEqual(episodes.length, 1); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[0].status4k, MediaStatus.DELETED); + }); + + it('marks missing Jellyfin episodes as DELETED', async () => { + configureJellyfin(); + getSettings().sonarr = []; + getSettings().radarr = []; + getSettings().main.enableEpisodeAvailability = true; + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const season = new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }); + season.episodes = [ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + new Episode({ + episodeNumber: 2, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + const media = new Media(); + media.tmdbId = 9103; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.jellyfinMediaId = 'jf-ep-show'; + media.seasons = [season]; + await mediaRepository.save(media); + + getItemDataImpl = async (id: string) => { + if (id === 'jf-ep-show') { + return fakeJellyfinShow('jf-ep-show', '9103'); + } + return undefined; + }; + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-ep-show') { + return [fakeJellyfinSeason(1, 'jf-ep-s1')]; + } + return []; + }; + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-ep-s1') { + return fakeJellyfinEpisodes(1); + } + return []; + }; + + await availabilitySync.run(); + + const savedSeason = await mediaRepository.findOneOrFail({ + where: { tmdbId: 9103 }, + relations: ['seasons'], + }); + const seasonOne = savedSeason.seasons.find((s) => s.seasonNumber === 1); + assert.ok(seasonOne); + + const episodes = await episodeRepository.find({ + where: { season: { id: seasonOne.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 1); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + }); + + it('marks stale 4K Jellyfin episode status when only a standard file remains', async () => { + configureJellyfin(); + configureSonarr([ + { syncEnabled: true }, + { is4k: true, syncEnabled: true }, + ]); + getSettings().main.enableEpisodeAvailability = true; + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const season = new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.AVAILABLE, + }); + season.episodes = [ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.AVAILABLE, + }), + ]; + + const media = new Media(); + media.tmdbId = 9106; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.status4k = MediaStatus.AVAILABLE; + // Distinct 4K id so we do not depend on the develop-bound Jellyfin cache-key fix. + media.jellyfinMediaId = 'jf-4k-ep-show'; + media.jellyfinMediaId4k = 'jf-4k-ep-show-4k'; + media.seasons = [season]; + await mediaRepository.save(media); + + getItemDataImpl = async (id: string) => { + if (id === 'jf-4k-ep-show' || id === 'jf-4k-ep-show-4k') { + return fakeJellyfinShow(id, '9106'); + } + return undefined; + }; + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-4k-ep-show') { + return [fakeJellyfinSeason(1, 'jf-4k-ep-s1')]; + } + if (seriesID === 'jf-4k-ep-show-4k') { + return [fakeJellyfinSeason(1, 'jf-4k-ep-s1-4k')]; + } + return []; + }; + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-4k-ep-s1' || seasonID === 'jf-4k-ep-s1-4k') { + return [fakeJellyfinEpisode(1, [1920])]; + } + return []; + }; + + await availabilitySync.run(); + + const savedSeason = await mediaRepository.findOneOrFail({ + where: { tmdbId: 9106 }, + relations: ['seasons'], + }); + const seasonOne = savedSeason.seasons.find((s) => s.seasonNumber === 1); + assert.ok(seasonOne); + + const episodes = await episodeRepository.find({ + where: { season: { id: seasonOne.id } }, + }); + + assert.strictEqual(episodes.length, 1); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[0].status4k, MediaStatus.DELETED); + assert.strictEqual(seasonOne.status4k, MediaStatus.DELETED); + }); + + it('does not let Sonarr demote a tier skipped by a Jellyfin episode cache hit', async () => { + configureJellyfin(); + configureSonarr([ + { syncEnabled: true }, + { is4k: true, syncEnabled: true }, + ]); + const settings = getSettings(); + settings.main.enableEpisodeAvailability = true; + settings.metadataSettings = { + tv: MetadataProviderType.TVDB, + anime: MetadataProviderType.TMDB, + }; + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const season = new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.AVAILABLE, + }); + season.episodes = [ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.AVAILABLE, + }), + ]; + + const media = new Media(); + media.tmdbId = 9107; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.status4k = MediaStatus.AVAILABLE; + // Same series id for both tiers so the 4K pass hits the episode cache. + media.jellyfinMediaId = 'jf-cache-hit-show'; + media.jellyfinMediaId4k = 'jf-cache-hit-show'; + media.externalServiceId = 417; + media.externalServiceId4k = 418; + media.seasons = [season]; + await mediaRepository.save(media); + + getTvShowImpl = async () => + fakeTmdbShow(9107, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 1, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getItemDataImpl = async (id: string) => { + if (id === 'jf-cache-hit-show') { + return fakeJellyfinShow(id, '9107'); + } + return undefined; + }; + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-cache-hit-show') { + return [fakeJellyfinSeason(1, 'jf-cache-hit-s1')]; + } + return []; + }; + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-cache-hit-s1') { + return [fakeJellyfinEpisode(1, [1920, 3840])]; + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 417 || id === 418) { + return { + tvdbId: 9107, + id, + title: 'Test Show', + titleSlug: 'test-show', + monitored: true, + statistics: { + episodeFileCount: id === 417 ? 1 : 1, + totalEpisodeCount: id === 417 ? 1 : 2, + episodeCount: id === 417 ? 1 : 2, + percentOfEpisodes: id === 417 ? 100 : 50, + sizeOnDisk: 0, + seasonCount: 1, + }, + seasons: fakeSonarrSeasons(1, { 1: 1 }), + } as unknown as SonarrSeries; + } + throw new Error('404'); + }; + + getSonarrEpisodesImpl = async (seriesId?: number) => { + if (seriesId === 418) { + return [ + { + seriesId: 418, + seasonNumber: 1, + episodeNumber: 1, + hasFile: false, + }, + { + seriesId: 418, + seasonNumber: 1, + episodeNumber: 2, + hasFile: true, + }, + ] as EpisodeResult[]; + } + return [ + { + seriesId: 417, + seasonNumber: 1, + episodeNumber: 1, + hasFile: true, + }, + ] as EpisodeResult[]; + }; + + await availabilitySync.run(); + + const savedSeason = await mediaRepository.findOneOrFail({ + where: { tmdbId: 9107 }, + relations: ['seasons'], + }); + const seasonOne = savedSeason.seasons.find((s) => s.seasonNumber === 1); + assert.ok(seasonOne); + + const episodes = await episodeRepository.find({ + where: { season: { id: seasonOne.id } }, + }); + + assert.strictEqual(episodes.length, 1); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[0].status4k, MediaStatus.AVAILABLE); + }); + + it('does not unmark Plex episodes when tracking is disabled', async () => { + configurePlex(); + getSettings().sonarr = []; + getSettings().radarr = []; + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const season = new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }); + season.episodes = [ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + new Episode({ + episodeNumber: 2, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + const media = new Media(); + media.tmdbId = 9104; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-ep-off'; + media.seasons = [season]; + await mediaRepository.save(media); + + getMetadataImpl = async (key: string) => { + if (key === 'plex-ep-off') { + return fakePlexShow('plex-ep-off'); + } + throw new Error('404'); + }; + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-ep-off') { + return [fakePlexSeason(1, 'plex-ep-off-s1')]; + } + if (key === 'plex-ep-off-s1') { + return fakePlexEpisodes(1); + } + return []; + }; + + await availabilitySync.run(); + + const savedSeason = await mediaRepository.findOneOrFail({ + where: { tmdbId: 9104 }, + relations: ['seasons'], + }); + const seasonOne = savedSeason.seasons.find((s) => s.seasonNumber === 1); + assert.ok(seasonOne); + + const episodes = await episodeRepository.find({ + where: { season: { id: seasonOne.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 2); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1].status, MediaStatus.AVAILABLE); + }); + }); + + describe('regression: episode tracking disabled', () => { + it('keeps Jellyfin 4K season availability when only standard files exist', async () => { + configureJellyfin(); + configureSonarr([ + { syncEnabled: true }, + { is4k: true, syncEnabled: true }, + ]); + assert.strictEqual(getSettings().main.enableEpisodeAvailability, false); + + const mediaRepository = getRepository(Media); + const media = new Media(); + media.tmdbId = 9201; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.status4k = MediaStatus.AVAILABLE; + media.jellyfinMediaId = 'jf-reg-show'; + media.jellyfinMediaId4k = 'jf-reg-show-4k'; + media.seasons = [ + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.AVAILABLE, + }), + ]; + await mediaRepository.save(media); + + getItemDataImpl = async (id: string) => { + if (id === 'jf-reg-show' || id === 'jf-reg-show-4k') { + return fakeJellyfinShow(id, '9201'); + } + return undefined; + }; + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-reg-show') { + return [fakeJellyfinSeason(1, 'jf-reg-s1')]; + } + if (seriesID === 'jf-reg-show-4k') { + return [fakeJellyfinSeason(1, 'jf-reg-s1-4k')]; + } + return []; + }; + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-reg-s1' || seasonID === 'jf-reg-s1-4k') { + return [fakeJellyfinEpisode(1, [1920])]; + } + return []; + }; + + await availabilitySync.run(); + + const updated = await mediaRepository.findOneOrFail({ + where: { tmdbId: 9201 }, + relations: ['seasons'], + }); + const seasonOne = updated.seasons.find((s) => s.seasonNumber === 1); + assert.ok(seasonOne); + assert.strictEqual(seasonOne.status, MediaStatus.AVAILABLE); + assert.strictEqual(seasonOne.status4k, MediaStatus.AVAILABLE); + assert.strictEqual(updated.status4k, MediaStatus.AVAILABLE); + }); + }); }); diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 819085f11c..a5636cb6c3 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -43,6 +43,10 @@ class AvailabilitySync { private sonarrSeasonsCache: Record; private sonarrEpisodesCache: Record; + private episodePresenceBySeasonTier: Map>; + private episodePresenceCensusBySeasonTier: Set; + private episodePresenceFailedBySeasonTier: Set; + private episodePresenceUnknownBySeasonTier: Map>; private radarrServers: RadarrSettings[]; private sonarrServers: SonarrSettings[]; private enable4kMovie: boolean; @@ -60,6 +64,10 @@ class AvailabilitySync { this.jellyfinEpisodeExistsCache = {}; this.sonarrSeasonsCache = {}; this.sonarrEpisodesCache = {}; + this.episodePresenceBySeasonTier = new Map(); + this.episodePresenceCensusBySeasonTier = new Set(); + this.episodePresenceFailedBySeasonTier = new Set(); + this.episodePresenceUnknownBySeasonTier = new Map(); this.radarrServers = settings.radarr; this.sonarrServers = settings.sonarr; this.enable4kMovie = this.radarrServers.some((server) => server.is4k); @@ -269,22 +277,35 @@ class AvailabilitySync { ? settings.metadataSettings.anime === MetadataProviderType.TVDB : settings.metadataSettings.tv === MetadataProviderType.TVDB); + this.episodePresenceBySeasonTier.clear(); + this.episodePresenceCensusBySeasonTier.clear(); + this.episodePresenceFailedBySeasonTier.clear(); + this.episodePresenceUnknownBySeasonTier.clear(); + const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = - await this.mediaExistsInPlex(media, false); + await this.mediaExistsInPlex(media, false, episodeTrackingEnabled); const { existsInPlex: existsInPlex4k, seasonsMap: plexSeasonsMap4k = new Map(), - } = await this.mediaExistsInPlex(media, true); + } = await this.mediaExistsInPlex(media, true, episodeTrackingEnabled); //jellyfin const { existsInJellyfin, seasonsMap: jellyfinSeasonsMap = new Map(), - } = await this.mediaExistsInJellyfin(media, false); + } = await this.mediaExistsInJellyfin( + media, + false, + episodeTrackingEnabled + ); const { existsInJellyfin: existsInJellyfin4k, seasonsMap: jellyfinSeasonsMap4k = new Map(), - } = await this.mediaExistsInJellyfin(media, true); + } = await this.mediaExistsInJellyfin( + media, + true, + episodeTrackingEnabled + ); const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = await this.mediaExistsInSonarr(media, false, shouldTrackEpisodes); @@ -293,6 +314,10 @@ class AvailabilitySync { seasonsMap: sonarrSeasonsMap4k, } = await this.mediaExistsInSonarr(media, true, shouldTrackEpisodes); + if (episodeTrackingEnabled || shouldTrackEpisodes) { + await this.applyEpisodeDemotions(media); + } + //plex if (mediaServerType === MediaServerType.PLEX) { if (existsInPlex || existsInSonarr) { @@ -884,46 +909,12 @@ class AvailabilitySync { MediaStatus.PARTIALLY_AVAILABLE ); - const dbEpisodesBySeasonId = new Map(); - if (shouldTrackEpisodes && filteredSeasons.length > 0) { - try { - const existingEpisodes = await getRepository(Episode).find({ - where: { - season: { id: In(filteredSeasons.map((season) => season.id)) }, - }, - relations: ['season'], - }); - - for (const episode of existingEpisodes) { - const episodeSeason = await episode.season; - if (!episodeSeason) { - continue; - } - const seasonEpisodes = dbEpisodesBySeasonId.get(episodeSeason.id); - if (seasonEpisodes) { - seasonEpisodes.push(episode); - } else { - dbEpisodesBySeasonId.set(episodeSeason.id, [episode]); - } - } - } catch (e) { - logger.error( - `Failed to load episodes for show [TMDB ID ${media.tmdbId}].`, - { - errorMessage: e.message, - label: 'AvailabilitySync', - } - ); - } - } - for (const season of filteredSeasons) { const seasonExists = await this.seasonExistsInSonarr( media, season, is4k, - shouldTrackEpisodes, - dbEpisodesBySeasonId + shouldTrackEpisodes ); if (seasonExists) { @@ -950,8 +941,7 @@ class AvailabilitySync { media: Media, season: Season, is4k: boolean, - shouldTrackEpisodes: boolean, - dbEpisodesBySeasonId: Map + shouldTrackEpisodes: boolean ): Promise { let seasonExists = false; const episodeHasFileByNumber = new Map(); @@ -1008,86 +998,250 @@ class AvailabilitySync { } if (shouldTrackEpisodes && hasEpisodeCache) { - const existingEpisodes = dbEpisodesBySeasonId.get(season.id) ?? []; - const toSave: Episode[] = []; - const toRemove: Episode[] = []; + this.mergeEpisodePresence(season.id, is4k, episodeHasFileByNumber); + this.markEpisodePresenceCensus(season.id, is4k); + } - for (const existingEpisode of existingEpisodes) { - const hasFile = episodeHasFileByNumber.get( - existingEpisode.episodeNumber - ); - const currentStatus = existingEpisode[is4k ? 'status4k' : 'status']; - const oppositeStatus = existingEpisode[is4k ? 'status' : 'status4k']; - const oppositeActive = - oppositeStatus !== MediaStatus.DELETED && - oppositeStatus !== MediaStatus.UNKNOWN; - - if (!episodeHasFileByNumber.has(existingEpisode.episodeNumber)) { - // only drop the row when neither standard nor 4K is still active - if (oppositeActive) { - if ( - currentStatus !== MediaStatus.DELETED && - currentStatus !== MediaStatus.UNKNOWN - ) { - existingEpisode[is4k ? 'status4k' : 'status'] = - MediaStatus.DELETED; - toSave.push(existingEpisode); - } - } else { - toRemove.push(existingEpisode); - } - } else if ( - hasFile !== true && - currentStatus !== MediaStatus.DELETED && - currentStatus !== MediaStatus.UNKNOWN - ) { - existingEpisode[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED; - toSave.push(existingEpisode); + return seasonExists; + } + + private async loadDbEpisodesBySeasonId( + seasons: Season[], + media: Media + ): Promise> { + const dbEpisodesBySeasonId = new Map(); + if (seasons.length === 0) { + return dbEpisodesBySeasonId; + } + + try { + const existingEpisodes = await getRepository(Episode).find({ + where: { + season: { id: In(seasons.map((season) => season.id)) }, + }, + relations: ['season'], + }); + + for (const episode of existingEpisodes) { + const episodeSeason = await episode.season; + if (!episodeSeason) { + continue; + } + const seasonEpisodes = dbEpisodesBySeasonId.get(episodeSeason.id); + if (seasonEpisodes) { + seasonEpisodes.push(episode); + } else { + dbEpisodesBySeasonId.set(episodeSeason.id, [episode]); } } + } catch (e) { + logger.error( + `Failed to load episodes for show [TMDB ID ${media.tmdbId}].`, + { + errorMessage: e.message, + label: 'AvailabilitySync', + } + ); + } - const episodeRepository = getRepository(Episode); + return dbEpisodesBySeasonId; + } - if (toRemove.length > 0) { - try { - await episodeRepository.remove(toRemove); - dbEpisodesBySeasonId.set( - season.id, - existingEpisodes.filter((episode) => !toRemove.includes(episode)) - ); - } catch (e) { - logger.debug( - `Failed to remove obsolete episodes for show [TMDB ID ${media.tmdbId}] season ${season.seasonNumber}.`, - { - errorMessage: e.message, - label: 'AvailabilitySync', - } - ); - } + private episodePresenceKey(seasonId: number, is4k: boolean): string { + return `${seasonId}:${is4k ? '4k' : 'std'}`; + } + + private markEpisodePresenceCensus(seasonId: number, is4k: boolean): void { + this.episodePresenceCensusBySeasonTier.add( + this.episodePresenceKey(seasonId, is4k) + ); + } + + private markEpisodePresenceFailed(seasonId: number, is4k: boolean): void { + this.episodePresenceFailedBySeasonTier.add( + this.episodePresenceKey(seasonId, is4k) + ); + } + + private markEpisodePresenceUnknown( + seasonId: number, + is4k: boolean, + episodeNumbers: number[] + ): void { + const key = this.episodePresenceKey(seasonId, is4k); + let unknown = this.episodePresenceUnknownBySeasonTier.get(key); + if (!unknown) { + unknown = new Set(); + this.episodePresenceUnknownBySeasonTier.set(key, unknown); + } + for (const episodeNumber of episodeNumbers) { + unknown.add(episodeNumber); + } + } + + private mergeEpisodePresence( + seasonId: number, + is4k: boolean, + updates: Map + ): void { + const key = this.episodePresenceKey(seasonId, is4k); + let presence = this.episodePresenceBySeasonTier.get(key); + if (!presence) { + presence = new Map(); + this.episodePresenceBySeasonTier.set(key, presence); + } + + for (const [episodeNumber, hasFile] of updates) { + if (hasFile) { + presence.set(episodeNumber, true); + } else if (!presence.has(episodeNumber)) { + presence.set(episodeNumber, false); } + } + } - if (toSave.length > 0) { - try { - await episodeRepository.save(toSave); - } catch (e) { - logger.error( - `Failed to soft-remove episodes for show [TMDB ID ${media.tmdbId}] season ${season.seasonNumber}.`, - { - errorMessage: e.message, - label: 'AvailabilitySync', - } - ); + private async applyEpisodeDemotions(media: Media): Promise { + const presenceKeys = new Set([ + ...this.episodePresenceBySeasonTier.keys(), + ...this.episodePresenceCensusBySeasonTier, + ...this.episodePresenceFailedBySeasonTier, + ]); + if (presenceKeys.size === 0) { + return; + } + + const seasonsById = new Map( + media.seasons.map((season) => [season.id, season]) + ); + const seasonIds = [ + ...new Set([...presenceKeys].map((key) => Number(key.split(':')[0]))), + ]; + const dbEpisodesBySeasonId = await this.loadDbEpisodesBySeasonId( + seasonIds + .map((id) => seasonsById.get(id)) + .filter((season): season is Season => season != null), + media + ); + + for (const key of presenceKeys) { + if (this.episodePresenceFailedBySeasonTier.has(key)) { + continue; + } + + const [seasonIdRaw, tier] = key.split(':'); + const seasonId = Number(seasonIdRaw); + const season = seasonsById.get(seasonId); + if (!season) { + continue; + } + const existingEpisodes = dbEpisodesBySeasonId.get(seasonId) ?? []; + const remainingEpisodes = await this.unmarkMissingEpisodes( + media, + season, + tier === '4k', + this.episodePresenceBySeasonTier.get(key) ?? new Map(), + existingEpisodes, + this.episodePresenceUnknownBySeasonTier.get(key), + this.episodePresenceCensusBySeasonTier.has(key) + ); + dbEpisodesBySeasonId.set(seasonId, remainingEpisodes); + } + } + + private async unmarkMissingEpisodes( + media: Media, + season: Season, + is4k: boolean, + episodeHasFileByNumber: Map, + existingEpisodes: Episode[], + unknownEpisodeNumbers?: Set, + censusComplete = false + ): Promise { + const toSave: Episode[] = []; + const toRemove: Episode[] = []; + + for (const existingEpisode of existingEpisodes) { + if (unknownEpisodeNumbers?.has(existingEpisode.episodeNumber)) { + continue; + } + + const hasKey = episodeHasFileByNumber.has(existingEpisode.episodeNumber); + const hasFile = episodeHasFileByNumber.get(existingEpisode.episodeNumber); + const currentStatus = existingEpisode[is4k ? 'status4k' : 'status']; + const oppositeStatus = existingEpisode[is4k ? 'status' : 'status4k']; + const oppositeActive = + oppositeStatus !== MediaStatus.DELETED && + oppositeStatus !== MediaStatus.UNKNOWN; + + if (!hasKey) { + if (!censusComplete) { + continue; + } + + // only drop the row when neither standard nor 4K is still active + if (oppositeActive) { + if ( + currentStatus !== MediaStatus.DELETED && + currentStatus !== MediaStatus.UNKNOWN + ) { + existingEpisode[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED; + toSave.push(existingEpisode); + } + } else { + toRemove.push(existingEpisode); } + } else if ( + hasFile !== true && + currentStatus !== MediaStatus.DELETED && + currentStatus !== MediaStatus.UNKNOWN + ) { + existingEpisode[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED; + toSave.push(existingEpisode); } } - return seasonExists; + const episodeRepository = getRepository(Episode); + let remainingEpisodes = existingEpisodes; + + if (toRemove.length > 0) { + try { + await episodeRepository.remove(toRemove); + remainingEpisodes = existingEpisodes.filter( + (episode) => !toRemove.includes(episode) + ); + } catch (e) { + logger.debug( + `Failed to remove obsolete episodes for show [TMDB ID ${media.tmdbId}] season ${season.seasonNumber}.`, + { + errorMessage: e.message, + label: 'AvailabilitySync', + } + ); + } + } + + if (toSave.length > 0) { + try { + await episodeRepository.save(toSave); + } catch (e) { + logger.error( + `Failed to soft-remove episodes for show [TMDB ID ${media.tmdbId}] season ${season.seasonNumber}.`, + { + errorMessage: e.message, + label: 'AvailabilitySync', + } + ); + } + } + + return remainingEpisodes; } // Plex private async mediaExistsInPlex( media: Media, - is4k: boolean + is4k: boolean, + shouldTrackEpisodes = false ): Promise<{ existsInPlex: boolean; seasonsMap?: Map }> { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; @@ -1196,19 +1350,26 @@ class AvailabilitySync { if (media.mediaType === 'tv') { const seasonsMap: Map = new Map(); - if (!preventSeasonSearch) { - const filteredSeasons = media.seasons.filter( - (season) => - season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || - season[is4k ? 'status4k' : 'status'] === - MediaStatus.PARTIALLY_AVAILABLE - ); + const filteredSeasons = media.seasons.filter( + (season) => + season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || + season[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE + ); + if (preventSeasonSearch) { + if (shouldTrackEpisodes) { + for (const season of filteredSeasons) { + this.markEpisodePresenceFailed(season.id, is4k); + } + } + } else { for (const season of filteredSeasons) { const seasonExists = await this.seasonExistsInPlex( media, season, - is4k + is4k, + shouldTrackEpisodes ); if (seasonExists) { @@ -1226,7 +1387,8 @@ class AvailabilitySync { private async seasonExistsInPlex( media: Media, season: Season, - is4k: boolean + is4k: boolean, + shouldTrackEpisodes: boolean ): Promise { const ratingKey = media.ratingKey; const ratingKey4k = media.ratingKey4k; @@ -1273,10 +1435,45 @@ class AvailabilitySync { } else { seasonExistsInPlex = episodeVersions.length > 0; } + + if (shouldTrackEpisodes) { + const episodeHasFileByNumber = new Map(); + + for (const episode of episodes ?? []) { + if (episode.index == null) { + continue; + } + + const versions = episode.Media ?? []; + let hasFile = false; + + if (is4k) { + hasFile = versions.some( + (mediaItem) => (mediaItem.width ?? 0) >= 2000 + ); + } else if (this.enable4kShow) { + hasFile = versions.some( + (mediaItem) => (mediaItem.width ?? 0) < 2000 + ); + } else { + hasFile = versions.length > 0; + } + + if (hasFile) { + episodeHasFileByNumber.set(episode.index, true); + } + } + + this.mergeEpisodePresence(season.id, is4k, episodeHasFileByNumber); + this.markEpisodePresenceCensus(season.id, is4k); + } } catch { // If we can't fetch episodes, assume the season exists // to avoid false removal seasonExistsInPlex = true; + if (shouldTrackEpisodes) { + this.markEpisodePresenceFailed(season.id, is4k); + } } this.plexEpisodeExistsCache[cacheKey] = seasonExistsInPlex; @@ -1289,7 +1486,8 @@ class AvailabilitySync { // Jellyfin private async mediaExistsInJellyfin( media: Media, - is4k: boolean + is4k: boolean, + shouldTrackEpisodes = false ): Promise<{ existsInJellyfin: boolean; seasonsMap?: Map }> { const ratingKey = media.jellyfinMediaId; const ratingKey4k = media.jellyfinMediaId4k; @@ -1345,19 +1543,26 @@ class AvailabilitySync { if (media.mediaType === 'tv') { const seasonsMap: Map = new Map(); - if (!preventSeasonSearch) { - const filteredSeasons = media.seasons.filter( - (season) => - season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || - season[is4k ? 'status4k' : 'status'] === - MediaStatus.PARTIALLY_AVAILABLE - ); + const filteredSeasons = media.seasons.filter( + (season) => + season[is4k ? 'status4k' : 'status'] === MediaStatus.AVAILABLE || + season[is4k ? 'status4k' : 'status'] === + MediaStatus.PARTIALLY_AVAILABLE + ); + if (preventSeasonSearch) { + if (shouldTrackEpisodes) { + for (const season of filteredSeasons) { + this.markEpisodePresenceFailed(season.id, is4k); + } + } + } else { for (const season of filteredSeasons) { const seasonExists = await this.seasonExistsInJellyfin( media, season, - is4k + is4k, + shouldTrackEpisodes ); if (seasonExists) { @@ -1375,7 +1580,8 @@ class AvailabilitySync { private async seasonExistsInJellyfin( media: Media, season: Season, - is4k: boolean + is4k: boolean, + shouldTrackEpisodes: boolean ): Promise { const ratingKey = media.jellyfinMediaId; const ratingKey4k = media.jellyfinMediaId4k; @@ -1399,26 +1605,127 @@ class AvailabilitySync { const seriesId = is4k ? ratingKey4k : ratingKey; if (seriesId) { - const cacheKey = `${seriesId}-${seasonMeta.Id}`; + const cacheKey = `${is4k ? '4k' : 'std'}-${seriesId}-${seasonMeta.Id}`; if (cacheKey in this.jellyfinEpisodeExistsCache) { seasonExistsInJellyfin = this.jellyfinEpisodeExistsCache[cacheKey]; + if (shouldTrackEpisodes) { + this.markEpisodePresenceFailed(season.id, is4k); + } } 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 - ); + if (shouldTrackEpisodes && this.enable4kShow) { + const episodes = await this.jellyfinClient.getEpisodes( + seriesId, + seasonMeta.Id, + { includeMediaInfo: true } + ); + const episodeHasFileByNumber = new Map(); + + for (const episode of episodes) { + const episodeNumbers = + episode.IndexNumber == null + ? [] + : Array.from( + { + length: + (episode.IndexNumberEnd ?? episode.IndexNumber) - + episode.IndexNumber + + 1, + }, + (_, i) => episode.IndexNumber! + i + ); + + if (episode.MediaSources == null) { + if (episodeNumbers.length > 0) { + this.markEpisodePresenceUnknown( + season.id, + is4k, + episodeNumbers + ); + } + seasonExistsInJellyfin = true; + continue; + } + + const has4k = episode.MediaSources.some((MediaSource) => + MediaSource.MediaStreams.some( + (MediaStream) => + MediaStream.Type === 'Video' && + (MediaStream.Width ?? 0) >= 2000 + ) + ); + const hasStandard = episode.MediaSources.some((MediaSource) => + MediaSource.MediaStreams.some( + (MediaStream) => + MediaStream.Type === 'Video' && + (MediaStream.Width ?? 0) < 2000 + ) + ); + + if (!(is4k ? has4k : hasStandard)) { + continue; + } + + seasonExistsInJellyfin = true; - seasonExistsInJellyfin = episodes.length > 0; + for (const episodeNumber of episodeNumbers) { + episodeHasFileByNumber.set(episodeNumber, true); + } + } + + this.mergeEpisodePresence( + season.id, + is4k, + episodeHasFileByNumber + ); + this.markEpisodePresenceCensus(season.id, is4k); + } else { + const episodes = await this.jellyfinClient.getEpisodes( + seriesId, + seasonMeta.Id + ); + + seasonExistsInJellyfin = episodes.length > 0; + + if (shouldTrackEpisodes) { + const episodeHasFileByNumber = new Map(); + + for (const episode of episodes) { + if (episode.IndexNumber == null) { + continue; + } + + const lastEpisodeNumber = + episode.IndexNumberEnd ?? episode.IndexNumber; + for ( + let episodeNumber = episode.IndexNumber; + episodeNumber <= lastEpisodeNumber; + episodeNumber++ + ) { + episodeHasFileByNumber.set(episodeNumber, true); + } + } + + this.mergeEpisodePresence( + season.id, + is4k, + episodeHasFileByNumber + ); + this.markEpisodePresenceCensus(season.id, is4k); + } + } } catch { // If we can't fetch episodes, assume the season exists // to avoid false removal seasonExistsInJellyfin = true; + if (shouldTrackEpisodes) { + this.markEpisodePresenceFailed(season.id, is4k); + } } this.jellyfinEpisodeExistsCache[cacheKey] = seasonExistsInJellyfin; diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index 8b97919739..2a7098916e 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -54,6 +54,7 @@ interface ProcessOptions { export interface ProcessableEpisode { episodeNumber: number; hasFile: boolean; + hasFile4k?: boolean; } export interface ProcessableSeason { @@ -744,16 +745,45 @@ class BaseScanner { const existingEpisode = existingBySeasonAndNumber.get(key); if (existingEpisode) { + let changed = false; + const statusField = is4k ? 'status4k' : 'status'; + const currentStatus = existingEpisode[statusField]; + if (episodeDetail.hasFile) { - existingEpisode[is4k ? 'status4k' : 'status'] = - MediaStatus.AVAILABLE; + if (currentStatus !== MediaStatus.AVAILABLE) { + existingEpisode[statusField] = MediaStatus.AVAILABLE; + changed = true; + } + } + + if (this.enable4kShow && episodeDetail.hasFile4k && !is4k) { + if (existingEpisode.status4k !== MediaStatus.AVAILABLE) { + existingEpisode.status4k = MediaStatus.AVAILABLE; + changed = true; + } + } + + if (changed) { toSave.push(existingEpisode); } - } else if (episodeDetail.hasFile) { + } else if ( + episodeDetail.hasFile || + (this.enable4kShow && episodeDetail.hasFile4k) + ) { + // Only persist episodes that have a file on at least one tier. const newEpisode = new Episode({ episodeNumber: episodeDetail.episodeNumber, - status: is4k ? MediaStatus.UNKNOWN : MediaStatus.AVAILABLE, - status4k: is4k ? MediaStatus.AVAILABLE : MediaStatus.UNKNOWN, + status: episodeDetail.hasFile + ? is4k + ? MediaStatus.UNKNOWN + : MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN, + status4k: + this.enable4kShow && episodeDetail.hasFile4k + ? MediaStatus.AVAILABLE + : is4k && episodeDetail.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN, season: Promise.resolve(dbSeason), }); toSave.push(newEpisode); diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index f7b22f3386..e58eae5938 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -16,6 +16,7 @@ import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { + ProcessableEpisode, ProcessableSeason, RunnableScanner, StatusBase, @@ -310,6 +311,8 @@ class JellyfinScanner let totalStandard = 0; let total4k = 0; + let episodeDetails: ProcessableEpisode[] | undefined; + if (!this.enable4kShow) { const episodes = await this.jfClient.getEpisodes( Id, @@ -330,6 +333,10 @@ class JellyfinScanner totalStandard += episodeCount; } + + if (settings.main.enableEpisodeAvailability) { + episodeDetails = this.toProcessableEpisodes(episodes); + } } else { // 4K detection enabled - request media info to check resolution const episodes = await this.jfClient.getEpisodes( @@ -376,6 +383,19 @@ class JellyfinScanner // See issue https://github.com/seerr-team/seerr/issues/1744 for details. if (hasStandard) totalStandard += episodeCount; if (has4k) total4k += episodeCount; + + if ( + settings.main.enableEpisodeAvailability && + (hasStandard || has4k) + ) { + episodeDetails ??= []; + episodeDetails.push( + ...this.toProcessableEpisodes([episode], { + hasFile: !!hasStandard, + hasFile4k: !!has4k, + }) + ); + } } } @@ -400,6 +420,7 @@ class JellyfinScanner totalEpisodes: season.episode_count, episodes: totalStandard, episodes4k: total4k, + episodeDetails: tvdbSeasonFromAnidb ? undefined : episodeDetails, }); } else { processableSeasons.push({ @@ -443,6 +464,32 @@ class JellyfinScanner } } + private toProcessableEpisodes( + episodes: JellyfinLibraryItem[], + availability: { hasFile: boolean; hasFile4k?: boolean } = { hasFile: true } + ): ProcessableEpisode[] { + return episodes.flatMap((episode) => { + if (episode.IndexNumber == null) { + return []; + } + + const lastEpisodeNumber = episode.IndexNumberEnd ?? episode.IndexNumber; + const details: ProcessableEpisode[] = []; + for ( + let episodeNumber = episode.IndexNumber; + episodeNumber <= lastEpisodeNumber; + episodeNumber++ + ) { + details.push({ + episodeNumber, + hasFile: availability.hasFile, + hasFile4k: availability.hasFile4k, + }); + } + return details; + }); + } + private async processItem(item: JellyfinLibraryItem): Promise { if (item.Type === 'Movie') { await this.processJellyfinMovie(item); diff --git a/server/lib/scanners/jellyfin/jellyfin.test.ts b/server/lib/scanners/jellyfin/jellyfin.test.ts index 654788b4da..129e31f339 100644 --- a/server/lib/scanners/jellyfin/jellyfin.test.ts +++ b/server/lib/scanners/jellyfin/jellyfin.test.ts @@ -16,12 +16,13 @@ import { } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; +import Episode from '@server/entity/Episode'; import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import { jellyfinFullScanner } from '@server/lib/scanners/jellyfin'; -import type { Library } from '@server/lib/settings'; +import type { Library, SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { setupTestDb } from '@server/test/db'; import { runWithMockTimers } from '@server/test/runWithMockTimers'; @@ -231,6 +232,44 @@ function fakeJellyfinEpisodes(count: number): JellyfinLibraryItem[] { })); } +function fakeJellyfinEpisode( + episodeNumber: number, + widths: number[] = [] +): JellyfinLibraryItem { + return { + Name: `Episode ${episodeNumber}`, + Id: `ep-${episodeNumber}`, + IndexNumber: episodeNumber, + Type: 'Episode' as const, + HasSubtitles: false, + LocationType: 'FileSystem' as const, + MediaType: 'Video', + ...(widths.length + ? { + MediaSources: widths.map((width, i) => ({ + Protocol: 'File', + Id: `src-${episodeNumber}-${i}`, + Path: `/ep-${episodeNumber}-${width}.mkv`, + Type: 'Default', + VideoType: 'VideoFile', + MediaStreams: [ + { + Codec: 'h264', + Type: 'Video' as const, + Width: width, + DisplayTitle: `${width}p`, + }, + ], + })), + } + : {}), + } as JellyfinLibraryItem; +} + +function enable4kShowDetection(): void { + getSettings().sonarr = [{ is4k: true } as SonarrSettings]; +} + function configureJellyfinWithLibrary( libraries: Library[] = [ { id: 'test-library-id', name: 'TV Shows', enabled: true, type: 'show' }, @@ -253,6 +292,9 @@ describe('Jellyfin Scanner', () => { getEpisodesImpl = async () => []; getTvShowImpl = async () => fakeTmdbShow(1); + getSettings().main.enableEpisodeAvailability = false; + getSettings().sonarr = []; + const userRepository = getRepository(User); const existingAdmin = await userRepository.findOne({ where: { id: 1 } }); if (!existingAdmin) { @@ -600,4 +642,251 @@ describe('Jellyfin Scanner', () => { ); }); }); + + describe('episode availability tracking', () => { + it('persists AVAILABLE episodes when tracking is enabled with TMDB', async () => { + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + const settings = getSettings(); + settings.main.enableEpisodeAvailability = true; + + configureJellyfinWithLibrary(); + + getTvShowImpl = async () => + fakeTmdbShow(6000, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 3, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getLibraryContentsImpl = async (id: string) => { + if (id === 'test-library-id') { + return [fakeJellyfinSeriesItem('jf-ep-show-id')]; + } + return []; + }; + + getItemDataImpl = async (id: string) => { + if (id === 'jf-ep-show-id') { + return fakeJellyfinShowMetadata('jf-ep-show-id', '6000'); + } + return undefined; + }; + + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-ep-show-id') { + return [fakeJellyfinSeason(1, 'jf-ep-s1-id')]; + } + return []; + }; + + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-ep-s1-id') { + return fakeJellyfinEpisodes(2); + } + return []; + }; + + await jellyfinFullScanner.run(); + + const media = await mediaRepository.findOneOrFail({ + where: { tmdbId: 6000 }, + relations: ['seasons'], + }); + const season = media.seasons.find((s) => s.seasonNumber === 1); + assert.ok(season); + + const episodes = await episodeRepository.find({ + where: { season: { id: season.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 2); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1].status, MediaStatus.AVAILABLE); + }); + + it('expands combined Jellyfin episode ranges', async () => { + const episodeRepository = getRepository(Episode); + getSettings().main.enableEpisodeAvailability = true; + configureJellyfinWithLibrary(); + + getTvShowImpl = async () => + fakeTmdbShow(6001, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 2, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getLibraryContentsImpl = async (id: string) => { + if (id === 'test-library-id') { + return [fakeJellyfinSeriesItem('jf-range-show-id')]; + } + return []; + }; + getItemDataImpl = async (id: string) => { + if (id === 'jf-range-show-id') { + return fakeJellyfinShowMetadata('jf-range-show-id', '6001'); + } + return undefined; + }; + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-range-show-id') { + return [fakeJellyfinSeason(1, 'jf-range-s1-id')]; + } + return []; + }; + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-range-s1-id') { + return [ + { + Name: 'Episodes 1-2', + Id: 'ep-range', + IndexNumber: 1, + IndexNumberEnd: 2, + Type: 'Episode' as const, + HasSubtitles: false, + LocationType: 'FileSystem' as const, + MediaType: 'Video', + }, + ]; + } + return []; + }; + + await jellyfinFullScanner.run(); + + const media = await getRepository(Media).findOneOrFail({ + where: { tmdbId: 6001 }, + relations: ['seasons'], + }); + const season = media.seasons.find((s) => s.seasonNumber === 1); + assert.ok(season); + + const episodes = await episodeRepository.find({ + where: { season: { id: season.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 2); + assert.strictEqual(episodes[0].episodeNumber, 1); + assert.strictEqual(episodes[1].episodeNumber, 2); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1].status, MediaStatus.AVAILABLE); + }); + + it('does not persist episodes when tracking is disabled', async () => { + const episodeRepository = getRepository(Episode); + configureJellyfinWithLibrary(); + + getTvShowImpl = async () => fakeTmdbShow(6002); + getLibraryContentsImpl = async (id: string) => { + if (id === 'test-library-id') { + return [fakeJellyfinSeriesItem('jf-off-show-id')]; + } + return []; + }; + getItemDataImpl = async (id: string) => { + if (id === 'jf-off-show-id') { + return fakeJellyfinShowMetadata('jf-off-show-id', '6002'); + } + return undefined; + }; + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-off-show-id') { + return [fakeJellyfinSeason(1, 'jf-off-s1-id')]; + } + return []; + }; + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-off-s1-id') return fakeJellyfinEpisodes(2); + return []; + }; + + await jellyfinFullScanner.run(); + + const episodeCount = await episodeRepository.count(); + assert.strictEqual(episodeCount, 0); + }); + + it('splits standard and 4K episode availability when 4K detection is enabled', async () => { + const episodeRepository = getRepository(Episode); + getSettings().main.enableEpisodeAvailability = true; + enable4kShowDetection(); + configureJellyfinWithLibrary(); + + getTvShowImpl = async () => + fakeTmdbShow(6003, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 3, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getLibraryContentsImpl = async (id: string) => { + if (id === 'test-library-id') { + return [fakeJellyfinSeriesItem('jf-4k-show-id')]; + } + return []; + }; + getItemDataImpl = async (id: string) => { + if (id === 'jf-4k-show-id') { + return fakeJellyfinShowMetadata('jf-4k-show-id', '6003'); + } + return undefined; + }; + getSeasonsImpl = async (seriesID: string) => { + if (seriesID === 'jf-4k-show-id') { + return [fakeJellyfinSeason(1, 'jf-4k-s1-id')]; + } + return []; + }; + getEpisodesImpl = async (_seriesID: string, seasonID: string) => { + if (seasonID === 'jf-4k-s1-id') { + return [ + fakeJellyfinEpisode(1, [1920]), + fakeJellyfinEpisode(2, [3840]), + fakeJellyfinEpisode(3, [1920, 3840]), + ]; + } + return []; + }; + + await jellyfinFullScanner.run(); + + const media = await getRepository(Media).findOneOrFail({ + where: { tmdbId: 6003 }, + relations: ['seasons'], + }); + const season = media.seasons.find((s) => s.seasonNumber === 1); + assert.ok(season); + + const episodes = await episodeRepository.find({ + where: { season: { id: season.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 3); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[0].status4k, MediaStatus.UNKNOWN); + assert.strictEqual(episodes[1].status, MediaStatus.UNKNOWN); + assert.strictEqual(episodes[1].status4k, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[2].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[2].status4k, MediaStatus.AVAILABLE); + }); + }); }); diff --git a/server/lib/scanners/plex/index.ts b/server/lib/scanners/plex/index.ts index f3eda4a3bc..f35c8198b8 100644 --- a/server/lib/scanners/plex/index.ts +++ b/server/lib/scanners/plex/index.ts @@ -13,6 +13,7 @@ import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import type { MediaIds, + ProcessableEpisode, ProcessableSeason, RunnableScanner, StatusBase, @@ -340,25 +341,48 @@ class PlexScanner const episodes = await this.plexClient.getChildrenMetadata( matchedPlexSeason.ratingKey ); - // Total episodes that are in standard definition (not 4k) - const totalStandard = episodes.filter((episode) => - !this.enable4kShow - ? true - : episode.Media.some((media) => media.videoResolution !== '4k') - ).length; - - // Total episodes that are in 4k - const total4k = this.enable4kShow - ? episodes.filter((episode) => - episode.Media.some((media) => media.videoResolution === '4k') - ).length - : 0; + let totalStandard = 0; + let total4k = 0; + const episodeDetails: ProcessableEpisode[] | undefined = settings.main + .enableEpisodeAvailability + ? [] + : undefined; + + for (const episode of episodes) { + const versions = episode.Media ?? []; + const hasStandardFile = versions.some((media) => + this.enable4kShow ? media.videoResolution !== '4k' : true + ); + const has4kFile = + this.enable4kShow && + versions.some((media) => media.videoResolution === '4k'); + + if (!this.enable4kShow || hasStandardFile) { + totalStandard += 1; + } + if (has4kFile) { + total4k += 1; + } + + if ( + episodeDetails && + episode.index != null && + (hasStandardFile || has4kFile) + ) { + episodeDetails.push({ + episodeNumber: episode.index, + hasFile: hasStandardFile, + hasFile4k: has4kFile, + }); + } + } processableSeasons.push({ seasonNumber: season.season_number, episodes: totalStandard, episodes4k: total4k, totalEpisodes: season.episode_count, + episodeDetails, }); } else { processableSeasons.push({ diff --git a/server/lib/scanners/plex/plex.test.ts b/server/lib/scanners/plex/plex.test.ts index 49089a6592..f275dbe39a 100644 --- a/server/lib/scanners/plex/plex.test.ts +++ b/server/lib/scanners/plex/plex.test.ts @@ -17,13 +17,14 @@ import { } from '@server/constants/media'; import { MediaServerType } from '@server/constants/server'; import { getRepository } from '@server/datasource'; +import Episode from '@server/entity/Episode'; import Media from '@server/entity/Media'; import MediaRequest from '@server/entity/MediaRequest'; import Season from '@server/entity/Season'; import SeasonRequest from '@server/entity/SeasonRequest'; import { User } from '@server/entity/User'; import { plexFullScanner } from '@server/lib/scanners/plex'; -import type { Library } from '@server/lib/settings'; +import type { Library, SonarrSettings } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { setupTestDb } from '@server/test/db'; import { runWithMockTimers } from '@server/test/runWithMockTimers'; @@ -207,6 +208,41 @@ function fakePlexSeasonMetadata( }; } +function fakePlexMedia(videoResolution: string) { + return { + id: 1, + duration: 0, + bitrate: 0, + width: videoResolution === '4k' ? 3840 : 1920, + height: videoResolution === '4k' ? 2160 : 1080, + aspectRatio: 1.78, + audioChannels: 2, + audioCodec: 'aac', + videoCodec: 'h264', + videoResolution, + container: 'mkv', + videoFrameRate: '24p', + videoProfile: 'high', + }; +} + +function fakePlexMetadata( + overrides: Partial & Pick +): PlexMetadata { + return { + guid: '', + type: 'season', + title: 'Item', + Guid: [], + leafCount: 0, + viewedLeafCount: 0, + addedAt: 1700000000, + updatedAt: 1700000000, + Media: [], + ...overrides, + }; +} + function configurePlexWithLibrary( libraries: Library[] = [ { id: 'test-library-id', name: 'TV Shows', enabled: true, type: 'show' }, @@ -222,8 +258,103 @@ function configurePlexWithLibrary( }; } +function mockPlexShow( + tmdbId: number, + episodeCount: number, + episodeResolutions?: (string | string[])[] +): void { + const settings = getSettings(); + settings.main.mediaServerType = MediaServerType.PLEX; + settings.plex = { + ...settings.plex, + ip: '127.0.0.1', + libraries: [ + { id: 'test-library-id', name: 'TV Shows', enabled: true, type: 'show' }, + ], + }; + + getTvShowImpl = async () => + fakeTmdbShow(tmdbId, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: episodeCount, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + getLibraryContentsImpl = async (id: string) => { + if (id !== 'test-library-id') { + return { totalSize: 0, items: [] }; + } + return { + totalSize: 1, + items: [ + { + ratingKey: 'show-1', + title: 'Test Show', + guid: `themoviedb://${tmdbId}`, + addedAt: 1700000000, + updatedAt: 1700000000, + type: 'show', + Media: [], + }, + ], + }; + }; + + getMetadataImpl = async () => + fakePlexMetadata({ + ratingKey: 'show-1', + guid: `themoviedb://${tmdbId}`, + type: 'show', + title: 'Test Show', + index: 1, + Children: { + size: 12, + Metadata: [ + fakePlexMetadata({ + ratingKey: 'season-1', + type: 'season', + title: 'Season 1', + index: 1, + }), + ], + }, + }); + + getChildrenMetadataImpl = async (key: string) => { + if (key !== 'season-1') { + return []; + } + return Array.from({ length: episodeCount }, (_, i) => { + const resolution = + episodeResolutions?.[i] ?? + (episodeResolutions === undefined ? '1080' : undefined); + const resolutions = resolution + ? Array.isArray(resolution) + ? resolution + : [resolution] + : []; + + return fakePlexMetadata({ + ratingKey: `ep-${i + 1}`, + title: `Episode ${i + 1}`, + index: i + 1, + Media: resolutions.map((value) => fakePlexMedia(value)), + }); + }); + }; +} + +function enable4kShowDetection(): void { + getSettings().sonarr = [{ is4k: true } as SonarrSettings]; +} + describe('Plex Scanner', () => { - beforeEach(() => { + beforeEach(async () => { getLibrariesImpl = async () => [ { key: 'test-library-id', @@ -236,6 +367,23 @@ describe('Plex Scanner', () => { getMetadataImpl = async () => undefined; getChildrenMetadataImpl = async () => []; getTvShowImpl = async () => fakeTmdbShow(1); + getSettings().main.enableEpisodeAvailability = false; + getSettings().sonarr = []; + + 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.email = 'admin@test.com'; + admin.permissions = 2; + admin.username = 'admin'; + await userRepository.save(admin); + } else if (!existingAdmin.plexToken) { + existingAdmin.plexToken = 'test-plex-token'; + await userRepository.save(existingAdmin); + } }); describe('in-flight request handling', () => { @@ -321,4 +469,123 @@ describe('Plex Scanner', () => { ); }); }); + + describe('episode availability tracking', () => { + it('persists AVAILABLE episodes when tracking is enabled with TMDB', async () => { + getSettings().main.enableEpisodeAvailability = true; + mockPlexShow(7000, 2); + + await plexFullScanner.run(); + + const media = await getRepository(Media).findOneOrFail({ + where: { tmdbId: 7000 }, + relations: ['seasons'], + }); + const season = media.seasons.find((s) => s.seasonNumber === 1); + assert.ok(season); + + const episodes = await getRepository(Episode).find({ + where: { season: { id: season.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 2); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[0].status4k, MediaStatus.UNKNOWN); + assert.strictEqual(episodes[1].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1].status4k, MediaStatus.UNKNOWN); + }); + + it('treats 4K files as standard when 4K detection is disabled', async () => { + getSettings().main.enableEpisodeAvailability = true; + mockPlexShow(7002, 1, ['4k']); + + await plexFullScanner.run(); + + const media = await getRepository(Media).findOneOrFail({ + where: { tmdbId: 7002 }, + relations: ['seasons'], + }); + const season = media.seasons.find((s) => s.seasonNumber === 1); + assert.ok(season); + + const episodes = await getRepository(Episode).find({ + where: { season: { id: season.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 1); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[0].status4k, MediaStatus.UNKNOWN); + }); + + it('splits standard and 4K episode availability when 4K detection is enabled', async () => { + getSettings().main.enableEpisodeAvailability = true; + enable4kShowDetection(); + mockPlexShow(7003, 3, ['1080', '4k', ['1080', '4k']]); + + await plexFullScanner.run(); + + const media = await getRepository(Media).findOneOrFail({ + where: { tmdbId: 7003 }, + relations: ['seasons'], + }); + const season = media.seasons.find((s) => s.seasonNumber === 1); + assert.ok(season); + + const episodes = await getRepository(Episode).find({ + where: { season: { id: season.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes.length, 3); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[0].status4k, MediaStatus.UNKNOWN); + assert.strictEqual(episodes[1].status, MediaStatus.UNKNOWN); + assert.strictEqual(episodes[1].status4k, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[2].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[2].status4k, MediaStatus.AVAILABLE); + }); + + it('does not persist episodes when tracking is disabled', async () => { + mockPlexShow(7001, 2); + + await plexFullScanner.run(); + + const episodeCount = await getRepository(Episode).count(); + assert.strictEqual(episodeCount, 0); + }); + + it('does not persist episodes with empty or missing Media', async () => { + getSettings().main.enableEpisodeAvailability = true; + mockPlexShow(7004, 2, []); + + const originalGetChildrenMetadata = getChildrenMetadataImpl; + getChildrenMetadataImpl = async (key: string) => { + const episodes = await originalGetChildrenMetadata(key); + if (key !== 'season-1' || episodes.length < 2) { + return episodes; + } + + const missingMedia = { ...episodes[1] }; + delete (missingMedia as { Media?: unknown }).Media; + return [episodes[0], missingMedia]; + }; + + await plexFullScanner.run(); + + const media = await getRepository(Media).findOneOrFail({ + where: { tmdbId: 7004 }, + relations: ['seasons'], + }); + const season = media.seasons.find((s) => s.seasonNumber === 1); + assert.ok(season); + assert.strictEqual(media.seasons.length, 1); + + const episodeCount = await getRepository(Episode).count({ + where: { season: { id: season.id } }, + }); + assert.strictEqual(episodeCount, 0); + }); + }); }); diff --git a/server/lib/scanners/sonarr/sonarr.test.ts b/server/lib/scanners/sonarr/sonarr.test.ts index 2d735c89f4..651af34240 100644 --- a/server/lib/scanners/sonarr/sonarr.test.ts +++ b/server/lib/scanners/sonarr/sonarr.test.ts @@ -1175,6 +1175,122 @@ describe('Sonarr Scanner', () => { assert.strictEqual(episodes[1].status, MediaStatus.AVAILABLE); }); + it('does not demote episodes when Sonarr reports hasFile false', async () => { + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + const settings = getSettings(); + settings.main.enableEpisodeAvailability = true; + settings.metadataSettings = { + tv: MetadataProviderType.TVDB, + anime: MetadataProviderType.TMDB, + }; + + configureSonarr([{ syncEnabled: true }]); + + const media = new Media(); + media.tmdbId = 3003; + media.tvdbId = 702; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.externalServiceId = 44; + media.seasons = [ + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + const saved = await mediaRepository.save(media); + const season = saved.seasons.find((s) => s.seasonNumber === 1); + assert.ok(season); + + await episodeRepository.save([ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season), + }), + new Episode({ + episodeNumber: 2, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season), + }), + ]); + + getSeriesImpl = async () => [ + fakeSonarrSeries({ + tvdbId: 702, + id: 44, + seasons: [ + { + seasonNumber: 1, + monitored: true, + statistics: { + episodeFileCount: 1, + totalEpisodeCount: 2, + episodeCount: 2, + percentOfEpisodes: 50, + sizeOnDisk: 0, + previousAiring: undefined, + }, + }, + ], + }), + ]; + getEpisodesImpl = async () => + [ + { + seriesId: 44, + seasonNumber: 1, + episodeNumber: 1, + hasFile: true, + }, + { + seriesId: 44, + seasonNumber: 1, + episodeNumber: 2, + hasFile: false, + }, + ] as EpisodeResult[]; + + getShowByTvdbIdImpl = async () => + fakeTmdbShow(3003, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 2, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + getTvShowImpl = async () => + fakeTmdbShow(3003, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 2, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + await sonarrScanner.run(); + + const episodes = await episodeRepository.find({ + where: { season: { id: season.id } }, + order: { episodeNumber: 'ASC' }, + }); + + // Scanners only promote; AvailabilitySync demotes after unioning sources. + assert.strictEqual(episodes.length, 2); + assert.strictEqual(episodes[0].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1].status, MediaStatus.AVAILABLE); + }); + it('does not fetch or persist episodes when tracking is disabled', async () => { const episodeRepository = getRepository(Episode); let getEpisodesCalled = false; diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index 2694509508..53010c4887 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -75,7 +75,7 @@ const messages = defineMessages('components.Settings.SettingsMain', { enableSpecialEpisodes: 'Allow Special Episodes Requests', enableEpisodeAvailability: 'Enable Episode Availability Tracking', enableEpisodeAvailabilityTip: - "Track individual episode availability. Match Seerr's metadata provider to your library agent (TMDB or TVDB).", + "Track individual episode availability. Match Seerr's metadata provider to your library agent (TMDB or TVDB). Plex and Jellyfin agents are per-library, so mixed-agent libraries cannot all match a single Seerr provider.", locale: 'Display Language', youtubeUrl: 'YouTube URL', youtubeUrlTip: diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index a955115373..04f06e15d9 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1031,7 +1031,7 @@ "components.Settings.SettingsMain.discoverRegion": "Discover Region", "components.Settings.SettingsMain.discoverRegionTip": "Filter content by regional availability", "components.Settings.SettingsMain.enableEpisodeAvailability": "Enable Episode Availability Tracking", - "components.Settings.SettingsMain.enableEpisodeAvailabilityTip": "Track individual episode availability. Match Seerr's metadata provider to your library agent (TMDB or TVDB).", + "components.Settings.SettingsMain.enableEpisodeAvailabilityTip": "Track individual episode availability. Match Seerr's metadata provider to your library agent (TMDB or TVDB). Plex and Jellyfin agents are per-library, so mixed-agent libraries cannot all match a single Seerr provider.", "components.Settings.SettingsMain.enableSpecialEpisodes": "Allow Special Episodes Requests", "components.Settings.SettingsMain.general": "General", "components.Settings.SettingsMain.generalsettings": "General Settings",