diff --git a/server/lib/availabilitySync.test.ts b/server/lib/availabilitySync.test.ts index 4bb68e483e..3566706615 100644 --- a/server/lib/availabilitySync.test.ts +++ b/server/lib/availabilitySync.test.ts @@ -10,7 +10,11 @@ import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import type { RadarrMovie } from '@server/api/servarr/radarr'; import RadarrAPI from '@server/api/servarr/radarr'; -import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; +import type { + EpisodeResult, + SonarrSeason, + SonarrSeries, +} from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import type { @@ -24,12 +28,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 type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; -import { getSettings } from '@server/lib/settings'; +import { MetadataProviderType, getSettings } from '@server/lib/settings'; import { setupTestDb } from '@server/test/db'; // --- Mock JellyfinAPI --- @@ -120,6 +125,9 @@ Object.defineProperty(PlexAPI.prototype, 'getChildrenMetadata', { let getSeriesByIdImpl: (id: number) => Promise = async () => { throw new Error('404'); }; +let getSonarrEpisodesImpl: ( + seriesId: number +) => Promise = async () => []; Object.defineProperty(SonarrAPI.prototype, 'getSeriesById', { get() { @@ -129,6 +137,14 @@ Object.defineProperty(SonarrAPI.prototype, 'getSeriesById', { configurable: true, }); +Object.defineProperty(SonarrAPI.prototype, 'getEpisodes', { + get() { + return async (seriesId: number) => getSonarrEpisodesImpl(seriesId); + }, + set() {}, + configurable: true, +}); + // --- Mock RadarrAPI --- let getMovieImpl: (id: number) => Promise = async () => { throw new Error('404'); @@ -435,9 +451,16 @@ describe('AvailabilitySync', () => { getSeriesByIdImpl = async () => { throw new Error('404'); }; + getSonarrEpisodesImpl = async () => []; getMovieImpl = async () => { throw new Error('404'); }; + const settings = getSettings(); + settings.main.enableEpisodeAvailability = false; + settings.metadataSettings = { + tv: MetadataProviderType.TMDB, + anime: MetadataProviderType.TMDB, + }; getTvShowImpl = async ({ tvId }) => fakeTmdbShow( tvId, @@ -2308,4 +2331,407 @@ describe('AvailabilitySync', () => { ); }); }); + + describe('TV episode availability - Sonarr', () => { + function enableEpisodeTracking(): void { + const settings = getSettings(); + settings.main.enableEpisodeAvailability = true; + settings.metadataSettings = { + tv: MetadataProviderType.TVDB, + anime: MetadataProviderType.TMDB, + }; + } + + it('should mark AVAILABLE episodes DELETED when a season is removed even if Sonarr episode fetch fails', async () => { + configurePlex(); + configureSonarr([{ syncEnabled: true }]); + enableEpisodeTracking(); + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const media = new Media(); + media.tmdbId = 4101; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-ep-sync-rk'; + media.externalServiceId = 410; + media.seasons = [ + new Season({ + seasonNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + new Season({ + seasonNumber: 2, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + }), + ]; + + const saved = await mediaRepository.save(media); + const season1 = saved.seasons.find((season) => season.seasonNumber === 1); + const season2 = saved.seasons.find((season) => season.seasonNumber === 2); + assert.ok(season1); + assert.ok(season2); + + await episodeRepository.save([ + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season1), + }), + new Episode({ + episodeNumber: 1, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season2), + }), + ]); + + getTvShowImpl = async () => + fakeTmdbShow(4101, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 1, + name: 'Season 1', + overview: '', + season_number: 1, + }, + { + id: 2, + air_date: '2024-01-01', + episode_count: 1, + name: 'Season 2', + overview: '', + season_number: 2, + }, + ]); + + getMetadataImpl = async (key: string) => { + if (key === 'plex-ep-sync-rk') { + return fakePlexShow('plex-ep-sync-rk'); + } + throw new Error('404'); + }; + + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-ep-sync-rk') { + return [fakePlexSeason(2, 'plex-ep-sync-s2-rk')]; + } + if (key === 'plex-ep-sync-s2-rk') { + return fakePlexEpisodes(1); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 410) { + return { + tvdbId: 4101, + id: 410, + title: 'Test Show', + titleSlug: 'test-show', + monitored: true, + statistics: { + episodeFileCount: 1, + totalEpisodeCount: 2, + episodeCount: 2, + percentOfEpisodes: 50, + sizeOnDisk: 0, + seasonCount: 2, + }, + seasons: fakeSonarrSeasons(2, { 2: 1 }), + } as unknown as SonarrSeries; + } + throw new Error('404'); + }; + + getSonarrEpisodesImpl = async () => { + throw new Error('Sonarr episode fetch failed'); + }; + + await availabilitySync.run(); + + const updated = await mediaRepository.findOneOrFail({ + where: { tmdbId: 4101 }, + relations: ['seasons'], + }); + const updatedSeason1 = updated.seasons.find( + (season) => season.seasonNumber === 1 + ); + const updatedSeason2 = updated.seasons.find( + (season) => season.seasonNumber === 2 + ); + assert.ok(updatedSeason1); + assert.ok(updatedSeason2); + assert.strictEqual(updatedSeason1.status, MediaStatus.DELETED); + assert.strictEqual(updatedSeason2.status, MediaStatus.AVAILABLE); + + const season1Episodes = await episodeRepository.find({ + where: { season: { id: updatedSeason1.id } }, + }); + const season2Episodes = await episodeRepository.find({ + where: { season: { id: updatedSeason2.id } }, + }); + + assert.strictEqual(season1Episodes[0]?.status, MediaStatus.DELETED); + assert.strictEqual(season2Episodes[0]?.status, MediaStatus.AVAILABLE); + }); + + it('should mark an episode DELETED when Sonarr reports hasFile false or omits it', async () => { + configurePlex(); + configureSonarr([{ syncEnabled: true }]); + enableEpisodeTracking(); + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const media = new Media(); + media.tmdbId = 4102; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-ep-file-rk'; + media.externalServiceId = 411; + 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), + }), + new Episode({ + episodeNumber: 3, + status: MediaStatus.AVAILABLE, + status4k: MediaStatus.UNKNOWN, + season: Promise.resolve(season1), + }), + ]); + + getTvShowImpl = async () => + fakeTmdbShow(4102, [ + { + 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-file-rk') { + return fakePlexShow('plex-ep-file-rk'); + } + throw new Error('404'); + }; + + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-ep-file-rk') { + return [fakePlexSeason(1, 'plex-ep-file-s1-rk')]; + } + if (key === 'plex-ep-file-s1-rk') { + return fakePlexEpisodes(2); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 411) { + return { + tvdbId: 4102, + id: 411, + 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: 411, + seasonNumber: 1, + episodeNumber: 1, + hasFile: true, + }, + { + seriesId: 411, + seasonNumber: 1, + episodeNumber: 2, + hasFile: false, + }, + ] as EpisodeResult[]; + + await availabilitySync.run(); + + const updated = await mediaRepository.findOneOrFail({ + where: { tmdbId: 4102 }, + relations: ['seasons'], + }); + const updatedSeason = updated.seasons[0]; + assert.ok(updatedSeason); + assert.strictEqual(updatedSeason.status, MediaStatus.AVAILABLE); + + const episodes = await episodeRepository.find({ + where: { season: { id: updatedSeason.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes[0]?.status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1]?.status, MediaStatus.DELETED); + assert.strictEqual(episodes[2]?.status, MediaStatus.DELETED); + }); + + it('should still mark Sonarr episodes DELETED when TMDB show lookup fails', async () => { + configurePlex(); + configureSonarr([{ syncEnabled: true }]); + enableEpisodeTracking(); + + const mediaRepository = getRepository(Media); + const episodeRepository = getRepository(Episode); + + const media = new Media(); + media.tmdbId = 4103; + media.mediaType = MediaType.TV; + media.status = MediaStatus.AVAILABLE; + media.ratingKey = 'plex-ep-tmdb-fail-rk'; + media.externalServiceId = 412; + 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 () => { + throw new Error('TMDB unavailable'); + }; + + getMetadataImpl = async (key: string) => { + if (key === 'plex-ep-tmdb-fail-rk') { + return fakePlexShow('plex-ep-tmdb-fail-rk'); + } + throw new Error('404'); + }; + + getChildrenMetadataImpl = async (key: string) => { + if (key === 'plex-ep-tmdb-fail-rk') { + return [fakePlexSeason(1, 'plex-ep-tmdb-fail-s1-rk')]; + } + if (key === 'plex-ep-tmdb-fail-s1-rk') { + return fakePlexEpisodes(2); + } + return []; + }; + + getSeriesByIdImpl = async (id: number) => { + if (id === 412) { + return { + tvdbId: 4103, + id: 412, + 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: 412, + seasonNumber: 1, + episodeNumber: 1, + hasFile: true, + }, + { + seriesId: 412, + seasonNumber: 1, + episodeNumber: 2, + hasFile: false, + }, + ] as EpisodeResult[]; + + await availabilitySync.run(); + + const updated = await mediaRepository.findOneOrFail({ + where: { tmdbId: 4103 }, + relations: ['seasons'], + }); + const updatedSeason = updated.seasons[0]; + assert.ok(updatedSeason); + assert.strictEqual(updatedSeason.status, MediaStatus.AVAILABLE); + + const episodes = await episodeRepository.find({ + where: { season: { id: updatedSeason.id } }, + order: { episodeNumber: 'ASC' }, + }); + + assert.strictEqual(episodes[0]?.status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[1]?.status, MediaStatus.DELETED); + }); + }); }); diff --git a/server/lib/availabilitySync.ts b/server/lib/availabilitySync.ts index 9a129090b3..ec52d1b76c 100644 --- a/server/lib/availabilitySync.ts +++ b/server/lib/availabilitySync.ts @@ -3,21 +3,28 @@ import JellyfinAPI from '@server/api/jellyfin'; import type { PlexMetadata } from '@server/api/plexapi'; import PlexAPI from '@server/api/plexapi'; import RadarrAPI, { type RadarrMovie } from '@server/api/servarr/radarr'; -import type { SonarrSeason, SonarrSeries } from '@server/api/servarr/sonarr'; +import type { + EpisodeResult, + SonarrSeason, + SonarrSeries, +} from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; +import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants'; import type { TmdbTvDetails } from '@server/api/themoviedb/interfaces'; import { MediaRequestStatus, MediaStatus } 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 type Season from '@server/entity/Season'; import { User } from '@server/entity/User'; import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; -import { getSettings } from '@server/lib/settings'; +import { MetadataProviderType, getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import { getHostname } from '@server/utils/getHostname'; +import { In } from 'typeorm'; class AvailabilitySync { public running = false; @@ -30,6 +37,7 @@ class AvailabilitySync { private jellyfinEpisodeExistsCache: Record; private sonarrSeasonsCache: Record; + private sonarrEpisodesCache: Record; private radarrServers: RadarrSettings[]; private sonarrServers: SonarrSettings[]; private enable4kMovie: boolean; @@ -46,10 +54,12 @@ class AvailabilitySync { this.jellyfinSeasonsCache = {}; this.jellyfinEpisodeExistsCache = {}; this.sonarrSeasonsCache = {}; + this.sonarrEpisodesCache = {}; this.radarrServers = settings.radarr; this.sonarrServers = settings.sonarr; this.enable4kMovie = this.radarrServers.some((server) => server.is4k); this.enable4kShow = this.sonarrServers.some((server) => server.is4k); + const episodeTrackingEnabled = settings.main.enableEpisodeAvailability; try { logger.info(`Starting availability sync...`, { @@ -245,6 +255,15 @@ class AvailabilitySync { //plex + const isAnime = !!tvShow?.keywords.results.some( + (keyword) => keyword.id === ANIME_KEYWORD_ID + ); + const shouldTrackEpisodes = + episodeTrackingEnabled && + (isAnime + ? settings.metadataSettings.anime === MetadataProviderType.TVDB + : settings.metadataSettings.tv === MetadataProviderType.TVDB); + const { existsInPlex, seasonsMap: plexSeasonsMap = new Map() } = await this.mediaExistsInPlex(media, false); const { @@ -263,11 +282,11 @@ class AvailabilitySync { } = await this.mediaExistsInJellyfin(media, true); const { existsInSonarr, seasonsMap: sonarrSeasonsMap } = - await this.mediaExistsInSonarr(media, false); + await this.mediaExistsInSonarr(media, false, shouldTrackEpisodes); const { existsInSonarr: existsInSonarr4k, seasonsMap: sonarrSeasonsMap4k, - } = await this.mediaExistsInSonarr(media, true); + } = await this.mediaExistsInSonarr(media, true, shouldTrackEpisodes); //plex if (mediaServerType === MediaServerType.PLEX) { @@ -609,6 +628,8 @@ class AvailabilitySync { const nonSpecialSeasonKeys = seasonKeys.filter((key) => key !== 0); try { + const deletedSeasonIds: number[] = []; + for (const mediaSeason of media.seasons) { if ( seasonsPendingRemoval.has(mediaSeason.seasonNumber) && @@ -618,6 +639,35 @@ class AvailabilitySync { MediaStatus.PARTIALLY_AVAILABLE) ) { mediaSeason[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED; + deletedSeasonIds.push(mediaSeason.id); + } + } + + if (deletedSeasonIds.length > 0) { + try { + const existingEpisodes = await getRepository(Episode).find({ + where: { season: { id: In(deletedSeasonIds) } }, + }); + const toSave: Episode[] = []; + + for (const existingEpisode of existingEpisodes) { + const currentStatus = existingEpisode[is4k ? 'status4k' : 'status']; + + if ( + currentStatus !== MediaStatus.DELETED && + currentStatus !== MediaStatus.UNKNOWN + ) { + existingEpisode[is4k ? 'status4k' : 'status'] = + MediaStatus.DELETED; + toSave.push(existingEpisode); + } + } + + if (toSave.length > 0) { + await getRepository(Episode).save(toSave); + } + } catch { + // Keep season deletion even if episode cleanup fails } } @@ -736,7 +786,8 @@ class AvailabilitySync { private async mediaExistsInSonarr( media: Media, - is4k: boolean + is4k: boolean, + shouldTrackEpisodes: boolean ): Promise<{ existsInSonarr: boolean; seasonsMap: Map }> { let existsInSonarr = false; let preventSeasonSearch = false; @@ -773,6 +824,22 @@ class AvailabilitySync { this.sonarrSeasonsCache[`${server.id}-${externalServiceId}`] = sonarr.seasons; + if (shouldTrackEpisodes && externalServiceId) { + try { + const episodes = await sonarrAPI.getEpisodes(externalServiceId); + this.sonarrEpisodesCache[`${server.id}-${externalServiceId}`] = + episodes; + } catch (e) { + logger.error( + `Failed to fetch episodes for show [TMDB ID ${media.tmdbId}] from Sonarr.`, + { + errorMessage: e.message, + label: 'AvailabilitySync', + } + ); + } + } + if (sonarr.statistics.episodeFileCount > 0) { existsInSonarr = true; } @@ -807,11 +874,46 @@ 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 + is4k, + shouldTrackEpisodes, + dbEpisodesBySeasonId ); if (seasonExists) { @@ -826,9 +928,13 @@ class AvailabilitySync { private async seasonExistsInSonarr( media: Media, season: Season, - is4k: boolean + is4k: boolean, + shouldTrackEpisodes: boolean, + dbEpisodesBySeasonId: Map ): Promise { let seasonExists = false; + const episodeHasFileByNumber = new Map(); + let hasEpisodeCache = false; // Check each sonarr instance to see if the media still exists // If found, we will assume the media exists and prevent removal @@ -837,15 +943,20 @@ class AvailabilitySync { (server) => server.is4k === is4k )) { let sonarrSeasons: SonarrSeason[] | undefined; + let sonarrEpisodes: EpisodeResult[] | undefined; if (media.externalServiceId && !is4k) { sonarrSeasons = this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId}`]; + sonarrEpisodes = + this.sonarrEpisodesCache[`${server.id}-${media.externalServiceId}`]; } if (media.externalServiceId4k && is4k) { sonarrSeasons = this.sonarrSeasonsCache[`${server.id}-${media.externalServiceId4k}`]; + sonarrEpisodes = + this.sonarrEpisodesCache[`${server.id}-${media.externalServiceId4k}`]; } const seasonIsAvailable = sonarrSeasons?.find( @@ -858,6 +969,56 @@ class AvailabilitySync { if (seasonIsAvailable && sonarrSeasons) { seasonExists = true; } + + if (shouldTrackEpisodes && sonarrEpisodes) { + hasEpisodeCache = true; + for (const episode of sonarrEpisodes) { + if (episode.seasonNumber !== season.seasonNumber) { + continue; + } + + if (episode.hasFile) { + episodeHasFileByNumber.set(episode.episodeNumber, true); + } else if (!episodeHasFileByNumber.has(episode.episodeNumber)) { + episodeHasFileByNumber.set(episode.episodeNumber, false); + } + } + } + } + + if (shouldTrackEpisodes && hasEpisodeCache) { + const existingEpisodes = dbEpisodesBySeasonId.get(season.id) ?? []; + const toSave: Episode[] = []; + + for (const existingEpisode of existingEpisodes) { + const hasFile = episodeHasFileByNumber.get( + existingEpisode.episodeNumber + ); + const currentStatus = existingEpisode[is4k ? 'status4k' : 'status']; + + if ( + hasFile !== true && + currentStatus !== MediaStatus.DELETED && + currentStatus !== MediaStatus.UNKNOWN + ) { + existingEpisode[is4k ? 'status4k' : 'status'] = MediaStatus.DELETED; + toSave.push(existingEpisode); + } + } + + if (toSave.length > 0) { + try { + await getRepository(Episode).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 seasonExists; diff --git a/server/lib/scanners/baseScanner.ts b/server/lib/scanners/baseScanner.ts index 2547b7a1d8..ceb83b9b32 100644 --- a/server/lib/scanners/baseScanner.ts +++ b/server/lib/scanners/baseScanner.ts @@ -5,6 +5,7 @@ import { MediaType, } from '@server/constants/media'; 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'; @@ -12,6 +13,7 @@ import { getSettings } from '@server/lib/settings'; import logger from '@server/logger'; import AsyncLock from '@server/utils/asyncLock'; import { randomUUID } from 'crypto'; +import { In } from 'typeorm'; // Default scan rates (can be overidden) const BUNDLE_SIZE = 20; @@ -49,6 +51,11 @@ interface ProcessOptions { hasFile?: boolean; } +export interface ProcessableEpisode { + episodeNumber: number; + hasFile: boolean; +} + export interface ProcessableSeason { seasonNumber: number; totalEpisodes: number; @@ -56,6 +63,7 @@ export interface ProcessableSeason { episodes4k: number; is4kOverride?: boolean; processing?: boolean; + episodeDetails?: ProcessableEpisode[]; } class BaseScanner { @@ -540,6 +548,7 @@ class BaseScanner { ? MediaStatus.DELETED : MediaStatus.UNKNOWN; await mediaRepository.save(media); + await this.syncEpisodeDetails(seasons, media.seasons, is4k); this.log(`Updating existing title: ${title}`); } else { // For new media, check actual newSeasons objects instead of scanner @@ -637,11 +646,95 @@ class BaseScanner { : MediaStatus.UNKNOWN, }); await mediaRepository.save(newMedia); + await this.syncEpisodeDetails(seasons, newMedia.seasons, is4k); this.log(`Saved ${title}`); } }); } + private async syncEpisodeDetails( + seasons: ProcessableSeason[], + dbSeasons: Season[], + is4k: boolean + ): Promise { + const seasonsWithDetails = seasons.filter( + (season) => season.episodeDetails && season.episodeDetails.length > 0 + ); + if (seasonsWithDetails.length === 0) { + return; + } + + const seasonByNumber = new Map( + dbSeasons.map((season) => [season.seasonNumber, season]) + ); + const targetSeasonIds = seasonsWithDetails + .map((season) => seasonByNumber.get(season.seasonNumber)?.id) + .filter((id): id is number => id != null); + if (targetSeasonIds.length === 0) { + return; + } + + const episodeRepository = getRepository(Episode); + const existingEpisodes = await episodeRepository.find({ + where: { season: { id: In(targetSeasonIds) } }, + relations: ['season'], + }); + + const existingBySeasonAndNumber = new Map(); + for (const episode of existingEpisodes) { + const episodeSeason = await episode.season; + if (!episodeSeason) { + continue; + } + existingBySeasonAndNumber.set( + `${episodeSeason.id}-${episode.episodeNumber}`, + episode + ); + } + + const toSave: Episode[] = []; + for (const season of seasonsWithDetails) { + const dbSeason = seasonByNumber.get(season.seasonNumber); + if (!dbSeason?.id || !season.episodeDetails) { + continue; + } + + for (const episodeDetail of season.episodeDetails) { + const key = `${dbSeason.id}-${episodeDetail.episodeNumber}`; + const existingEpisode = existingBySeasonAndNumber.get(key); + + if (existingEpisode) { + if (episodeDetail.hasFile) { + existingEpisode[is4k ? 'status4k' : 'status'] = + MediaStatus.AVAILABLE; + toSave.push(existingEpisode); + } + } else { + const newEpisode = new Episode({ + episodeNumber: episodeDetail.episodeNumber, + status: is4k + ? MediaStatus.UNKNOWN + : episodeDetail.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN, + status4k: is4k + ? episodeDetail.hasFile + ? MediaStatus.AVAILABLE + : MediaStatus.UNKNOWN + : MediaStatus.UNKNOWN, + season: Promise.resolve(dbSeason), + }); + toSave.push(newEpisode); + existingBySeasonAndNumber.set(key, newEpisode); + } + } + } + + if (toSave.length > 0) { + await episodeRepository.save(toSave); + } + } + /** * Declines APPROVED requests bound to media that has been orphaned before completion. * DECLINED clears the duplicate-request guard so the user can re-request it. diff --git a/server/lib/scanners/sonarr/index.ts b/server/lib/scanners/sonarr/index.ts index 0702f4dc59..66826142ab 100644 --- a/server/lib/scanners/sonarr/index.ts +++ b/server/lib/scanners/sonarr/index.ts @@ -11,13 +11,14 @@ import { MediaStatus, MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import Media from '@server/entity/Media'; import type { + ProcessableEpisode, ProcessableSeason, RunnableScanner, StatusBase, } from '@server/lib/scanners/baseScanner'; import BaseScanner from '@server/lib/scanners/baseScanner'; import type { SonarrSettings } from '@server/lib/settings'; -import { getSettings } from '@server/lib/settings'; +import { MetadataProviderType, getSettings } from '@server/lib/settings'; import { uniqWith } from 'lodash'; type SyncStatus = StatusBase & { @@ -172,9 +173,10 @@ class SonarrScanner } const tmdbId = tvShow.id; - const metadataProvider = tvShow.keywords.results.some( + const isAnime = tvShow.keywords.results.some( (keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID - ) + ); + const metadataProvider = isAnime ? await getMetadataProvider('anime') : await getMetadataProvider('tv'); @@ -183,6 +185,36 @@ class SonarrScanner } const settings = getSettings(); + const shouldTrackEpisodes = + settings.main.enableEpisodeAvailability && + (isAnime + ? settings.metadataSettings.anime === MetadataProviderType.TVDB + : settings.metadataSettings.tv === MetadataProviderType.TVDB); + + let episodesBySeason = new Map(); + if (shouldTrackEpisodes && sonarrSeries.id != null) { + try { + const episodes = await this.sonarrApi.getEpisodes(sonarrSeries.id); + episodesBySeason = episodes.reduce((map, episode) => { + const seasonEpisodes = map.get(episode.seasonNumber) ?? []; + seasonEpisodes.push({ + episodeNumber: episode.episodeNumber, + hasFile: episode.hasFile, + }); + map.set(episode.seasonNumber, seasonEpisodes); + return map; + }, new Map()); + } catch (e) { + this.log( + 'Failed to fetch Sonarr episodes for availability', + 'error', + { + errorMessage: e.message, + title: sonarrSeries.title, + } + ); + } + } const filteredSeasons = tvShow.seasons .filter( @@ -217,6 +249,7 @@ class SonarrScanner totalEpisodes: season.statistics?.totalEpisodeCount ?? 0, processing: season.monitored && totalAvailableEpisodes === 0, is4kOverride: server4k, + episodeDetails: episodesBySeason.get(season.seasonNumber), }); } diff --git a/server/lib/scanners/sonarr/sonarr.test.ts b/server/lib/scanners/sonarr/sonarr.test.ts index e012c979eb..65930d5c3d 100644 --- a/server/lib/scanners/sonarr/sonarr.test.ts +++ b/server/lib/scanners/sonarr/sonarr.test.ts @@ -1,4 +1,4 @@ -import type { SonarrSeries } from '@server/api/servarr/sonarr'; +import type { EpisodeResult, SonarrSeries } from '@server/api/servarr/sonarr'; import SonarrAPI from '@server/api/servarr/sonarr'; import TheMovieDb from '@server/api/themoviedb'; import type { @@ -11,13 +11,14 @@ import { MediaType, } from '@server/constants/media'; 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 { sonarrScanner } from '@server/lib/scanners/sonarr'; import type { SonarrSettings } from '@server/lib/settings'; -import { getSettings } from '@server/lib/settings'; +import { MetadataProviderType, getSettings } from '@server/lib/settings'; import { setupTestDb } from '@server/test/db'; import assert from 'node:assert/strict'; import { beforeEach, describe, it, mock } from 'node:test'; @@ -31,6 +32,17 @@ Object.defineProperty(SonarrAPI.prototype, 'getSeries', { configurable: true, }); +let getEpisodesImpl: ( + seriesId: number +) => Promise = async () => []; +Object.defineProperty(SonarrAPI.prototype, 'getEpisodes', { + set() {}, + get() { + return async (seriesId: number) => getEpisodesImpl(seriesId); + }, + configurable: true, +}); + function fakeTmdbShow( tmdbId: number, seasons: TmdbTvSeasonResult[] = [ @@ -164,8 +176,15 @@ function configureSonarr(overrides: Partial[] = [{}]): void { describe('Sonarr Scanner', () => { beforeEach(() => { getSeriesImpl = async () => []; + getEpisodesImpl = async () => []; getShowByTvdbIdImpl = async () => fakeTmdbShow(1); getTvShowImpl = async () => fakeTmdbShow(1); + const settings = getSettings(); + settings.main.enableEpisodeAvailability = false; + settings.metadataSettings = { + tv: MetadataProviderType.TMDB, + anime: MetadataProviderType.TMDB, + }; }); describe('orphaned show cleanup', () => { @@ -822,4 +841,122 @@ describe('Sonarr Scanner', () => { assert.strictEqual(updated4k.status, MediaRequestStatus.DECLINED); }); }); + + describe('episode availability tracking', () => { + it('persists AVAILABLE episodes when tracking is enabled with TVDB', 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 }]); + getSeriesImpl = async () => [ + fakeSonarrSeries({ + tvdbId: 700, + id: 42, + seasons: [ + { + seasonNumber: 1, + monitored: true, + statistics: { + episodeFileCount: 2, + totalEpisodeCount: 2, + episodeCount: 2, + percentOfEpisodes: 100, + sizeOnDisk: 0, + previousAiring: undefined, + }, + }, + ], + }), + ]; + getEpisodesImpl = async () => + [ + { + seriesId: 42, + seasonNumber: 1, + episodeNumber: 1, + hasFile: true, + }, + { + seriesId: 42, + seasonNumber: 1, + episodeNumber: 2, + hasFile: true, + }, + { + seriesId: 42, + seasonNumber: 1, + episodeNumber: 3, + hasFile: false, + }, + ] as EpisodeResult[]; + + getShowByTvdbIdImpl = async () => + fakeTmdbShow(3001, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 3, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + getTvShowImpl = async () => + fakeTmdbShow(3001, [ + { + id: 1, + air_date: '2024-01-01', + episode_count: 3, + name: 'Season 1', + overview: '', + season_number: 1, + }, + ]); + + await sonarrScanner.run(); + + const media = await mediaRepository.findOneOrFail({ + where: { tmdbId: 3001 }, + 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[1].status, MediaStatus.AVAILABLE); + assert.strictEqual(episodes[2].status, MediaStatus.UNKNOWN); + }); + + it('does not fetch or persist episodes when tracking is disabled', async () => { + const episodeRepository = getRepository(Episode); + let getEpisodesCalled = false; + getEpisodesImpl = async () => { + getEpisodesCalled = true; + return []; + }; + + configureSonarr([{ syncEnabled: true }]); + getSeriesImpl = async () => [fakeSonarrSeries({ tvdbId: 701, id: 43 })]; + getShowByTvdbIdImpl = async () => fakeTmdbShow(3002); + getTvShowImpl = async () => fakeTmdbShow(3002); + + await sonarrScanner.run(); + + assert.strictEqual(getEpisodesCalled, false); + const episodeCount = await episodeRepository.count(); + assert.strictEqual(episodeCount, 0); + }); + }); });