diff --git a/server/lib/scanners/jellyfin/index.ts b/server/lib/scanners/jellyfin/index.ts index bb3456c7..237a445e 100644 --- a/server/lib/scanners/jellyfin/index.ts +++ b/server/lib/scanners/jellyfin/index.ts @@ -20,6 +20,7 @@ import type { StatusBase, } from '@server/lib/scanners/baseScanner'; import BaseScanner from '@server/lib/scanners/baseScanner'; +import serviceAvailabilityChecker from '@server/lib/scanners/serviceAvailabilityChecker'; import type { Library } from '@server/lib/settings'; import { getSettings } from '@server/lib/settings'; import { getHostname } from '@server/utils/getHostname'; @@ -125,6 +126,57 @@ class JellyfinScanner const { tmdbId, imdbId, metadata } = extracted; + const mediaAddedAt = metadata.DateCreated + ? new Date(metadata.DateCreated) + : undefined; + + if (this.enable4kMovie) { + const instanceAvailibility = + await serviceAvailabilityChecker.checkMovieAvailability(tmdbId); + + if (instanceAvailibility.hasStandard || instanceAvailibility.has4k) { + if (instanceAvailibility.hasStandard) { + await this.processMovie(tmdbId, { + is4k: false, + mediaAddedAt, + jellyfinMediaId: metadata.Id, + imdbId, + title: metadata.Name, + }); + } + + if (instanceAvailibility.has4k) { + await this.processMovie(tmdbId, { + is4k: true, + mediaAddedAt, + jellyfinMediaId: metadata.Id, + imdbId, + title: metadata.Name, + }); + } + + this.log( + `Processed movie with service availability check: ${metadata.Name}`, + 'debug', + { + tmdbId, + hasStandard: instanceAvailibility.hasStandard, + has4k: instanceAvailibility.has4k, + } + ); + + return; + } + + this.log( + `Movie not found in any Radarr instance, using resolution-based detection: ${metadata.Name}`, + 'debug', + { + tmdbId, + } + ); + } + const has4k = metadata.MediaSources?.some((MediaSource) => { return MediaSource.MediaStreams.filter( (MediaStream) => MediaStream.Type === 'Video' @@ -141,10 +193,6 @@ class JellyfinScanner }); }); - const mediaAddedAt = metadata.DateCreated - ? new Date(metadata.DateCreated) - : undefined; - if (hasOtherResolution || (!this.enable4kMovie && has4k)) { await this.processMovie(tmdbId, { is4k: false, @@ -285,6 +333,34 @@ class JellyfinScanner ? seasons : seasons.filter((sn) => sn.season_number !== 0); + let instanceAvailibility: Awaited< + ReturnType + > | null = null; + let useServiceBasedDetection = false; + + if (this.enable4kShow && tvShow.external_ids?.tvdb_id) { + instanceAvailibility = + await serviceAvailabilityChecker.checkShowAvailability( + tvShow.external_ids.tvdb_id + ); + + useServiceBasedDetection = + instanceAvailibility.hasStandard || instanceAvailibility.has4k; + + if (useServiceBasedDetection) { + this.log( + `Using service availability check for show: ${tvShow.name}`, + 'debug', + { + tvdbId: tvShow.external_ids.tvdb_id, + hasStandard: instanceAvailibility.hasStandard, + has4k: instanceAvailibility.has4k, + seasons: instanceAvailibility.seasons.length, + } + ); + } + } + for (const season of filteredSeasons) { const matchedJellyfinSeason = jellyfinSeasons.find((md) => { if (tvdbSeasonFromAnidb) { @@ -306,7 +382,16 @@ class JellyfinScanner let totalStandard = 0; let total4k = 0; - if (!this.enable4kShow) { + if (useServiceBasedDetection && instanceAvailibility) { + const serviceSeason = instanceAvailibility.seasons.find( + (s) => s.seasonNumber === season.season_number + ); + + if (serviceSeason) { + totalStandard = serviceSeason.episodesStandard; + total4k = serviceSeason.episodes4k; + } + } else if (!this.enable4kShow) { const episodes = await this.jfClient.getEpisodes( Id, matchedJellyfinSeason.Id @@ -362,14 +447,6 @@ class JellyfinScanner ) ); - // Count in both if episode has both versions - // TODO: Make this more robust in the future - // Currently, this detection is based solely on file resolution, not which - // Radarr/Sonarr instance the file came from. If a 4K request results in - // 1080p files (no 4K release available yet), those files will be counted - // as "standard" even though they're in the 4K library. This can cause - // non-4K users to see content as "available" when they can't access it. - // See issue https://github.com/seerr-team/seerr/issues/1744 for details. if (hasStandard) totalStandard += episodeCount; if (has4k) total4k += episodeCount; } @@ -452,6 +529,8 @@ class JellyfinScanner const sessionId = this.startRun(); + serviceAvailabilityChecker.clearCache(); + try { const userRepository = getRepository(User); const admin = await userRepository.findOne({ diff --git a/server/lib/scanners/serviceAvailabilityChecker.ts b/server/lib/scanners/serviceAvailabilityChecker.ts new file mode 100644 index 00000000..aabeddf2 --- /dev/null +++ b/server/lib/scanners/serviceAvailabilityChecker.ts @@ -0,0 +1,193 @@ +import RadarrAPI from '@server/api/servarr/radarr'; +import SonarrAPI from '@server/api/servarr/sonarr'; +import type { RadarrSettings, SonarrSettings } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; + +interface InstanceAvailability { + hasStandard: boolean; + has4k: boolean; + serviceStandardId?: number; + service4kId?: number; + externalStandardId?: number; + external4kId?: number; +} + +interface SeasonInstanceAvailability { + seasonNumber: number; + episodesStandard: number; + episodes4k: number; +} + +interface ShowInstanceAvailability extends InstanceAvailability { + seasons: SeasonInstanceAvailability[]; +} + +class ServiceAvailabilityChecker { + private movieCache: Map; + private showCache: Map; + + constructor() { + this.movieCache = new Map(); + this.showCache = new Map(); + } + + public clearCache(): void { + this.movieCache.clear(); + this.showCache.clear(); + } + + public async checkMovieAvailability( + tmdbid: number + ): Promise { + const cached = this.movieCache.get(tmdbid); + if (cached) { + return cached; + } + + const settings = getSettings(); + const result: InstanceAvailability = { + hasStandard: false, + has4k: false, + }; + + if (!settings.radarr || settings.radarr.length === 0) { + return result; + } + + for (const radarrSettings of settings.radarr) { + try { + const radarr = this.createRadarrClient(radarrSettings); + const movie = await radarr.getMovieByTmdbId(tmdbid); + + if (movie?.hasFile) { + if (radarrSettings.is4k) { + result.has4k = true; + result.service4kId = radarrSettings.id; + result.external4kId = movie.id; + } else { + result.hasStandard = true; + result.serviceStandardId = radarrSettings.id; + result.externalStandardId = movie.id; + } + } + + logger.debug( + `Found movie (TMDB: ${tmdbid}) in ${ + radarrSettings.is4k ? '4K' : 'Standard' + } Radarr instance (name: ${radarrSettings.name})`, + { + label: 'Service Availability', + radarrId: radarrSettings.id, + movieId: movie?.id, + } + ); + } catch { + // movie not found in this instance, continue + } + } + + this.movieCache.set(tmdbid, result); + return result; + } + + public async checkShowAvailability( + tvdbid: number + ): Promise { + const cached = this.showCache.get(tvdbid); + if (cached) { + return cached; + } + + const settings = getSettings(); + const result: ShowInstanceAvailability = { + hasStandard: false, + has4k: false, + seasons: [], + }; + + if (!settings.sonarr || settings.sonarr.length === 0) { + return result; + } + const standardSeasons = new Map(); + const seasons4k = new Map(); + + for (const sonarrSettings of settings.sonarr) { + try { + const sonarr = this.createSonarrClient(sonarrSettings); + const series = await sonarr.getSeriesByTvdbId(tvdbid); + + if (series?.id && series.statistics?.episodeFileCount > 0) { + if (sonarrSettings.is4k) { + result.has4k = true; + result.service4kId = sonarrSettings.id; + result.external4kId = series.id; + } else { + result.hasStandard = true; + result.serviceStandardId = sonarrSettings.id; + result.externalStandardId = series.id; + } + + for (const season of series.seasons) { + const episodeCount = season.statistics?.episodeFileCount ?? 0; + if (episodeCount > 0) { + const targetMap = sonarrSettings.is4k + ? seasons4k + : standardSeasons; + const current = targetMap.get(season.seasonNumber) ?? 0; + targetMap.set( + season.seasonNumber, + Math.max(current, episodeCount) + ); + } + } + + logger.debug( + `Found series (TVDB: ${tvdbid}) in ${ + sonarrSettings.is4k ? '4K' : 'Standard' + } Sonarr instance (name: ${sonarrSettings.name}`, + { + label: 'Service Availability', + sonarrId: sonarrSettings.id, + seriesId: series.id, + } + ); + } + } catch { + // series not found in this instance, continue + } + } + + const allSeasonNumbers = new Set({ + ...standardSeasons.keys(), + ...seasons4k.keys(), + }); + + result.seasons = Array.from(allSeasonNumbers).map((seasonNumber) => ({ + seasonNumber, + episodesStandard: standardSeasons.get(seasonNumber) ?? 0, + episodes4k: seasons4k.get(seasonNumber) ?? 0, + })); + + this.showCache.set(tvdbid, result); + return result; + } + + private createRadarrClient(settings: RadarrSettings): RadarrAPI { + return new RadarrAPI({ + url: RadarrAPI.buildUrl(settings, '/api/v3'), + apiKey: settings.apiKey, + }); + } + + private createSonarrClient(settings: SonarrSettings): SonarrAPI { + return new SonarrAPI({ + url: SonarrAPI.buildUrl(settings, '/api/v3'), + apiKey: settings.apiKey, + }); + } +} + +const serviceAvailabilityChecker = new ServiceAvailabilityChecker(); + +export default serviceAvailabilityChecker;