1
0
mirror of https://github.com/fallenbagel/jellyseerr.git synced 2026-01-11 09:06:55 -05:00

Compare commits

...

4 Commits

Author SHA1 Message Date
fallenbagel
8fa68da481 refactor(serviceavailabilitychecker): add missing closing parenthesis in debug log 2025-12-30 05:01:03 +08:00
fallenbagel
cf5a85ba0b refactor(serviceavailabilitychecker): correct set initialization for season numbers 2025-12-30 04:59:14 +08:00
fallenbagel
9cbd5f4260 refactor(jellyfin-scanner): correct variable name for 4k availability checks 2025-12-30 04:57:08 +08:00
fallenbagel
09233a32b3 fix(jellyfin-scanner): use service instance for 4k availability detection
when 4k services are enabled, jellyfin scanner will now check which arr instance has the file
todetermine availability tier instead of relying solely on resolution detection. This should fix
theincorrecta availability status when a 4k request results in a lower resolution file. Fallbacks
tooriginal resolution based detection when media not found.

fix #1744
2025-12-29 23:29:27 +08:00
2 changed files with 285 additions and 13 deletions

View File

@@ -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 instanceAvailability =
await serviceAvailabilityChecker.checkMovieAvailability(tmdbId);
if (instanceAvailability.hasStandard || instanceAvailability.has4k) {
if (instanceAvailability.hasStandard) {
await this.processMovie(tmdbId, {
is4k: false,
mediaAddedAt,
jellyfinMediaId: metadata.Id,
imdbId,
title: metadata.Name,
});
}
if (instanceAvailability.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: instanceAvailability.hasStandard,
has4k: instanceAvailability.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 instanceAvailability: Awaited<
ReturnType<typeof serviceAvailabilityChecker.checkShowAvailability>
> | null = null;
let useServiceBasedDetection = false;
if (this.enable4kShow && tvShow.external_ids?.tvdb_id) {
instanceAvailability =
await serviceAvailabilityChecker.checkShowAvailability(
tvShow.external_ids.tvdb_id
);
useServiceBasedDetection =
instanceAvailability.hasStandard || instanceAvailability.has4k;
if (useServiceBasedDetection) {
this.log(
`Using service availability check for show: ${tvShow.name}`,
'debug',
{
tvdbId: tvShow.external_ids.tvdb_id,
hasStandard: instanceAvailability.hasStandard,
has4k: instanceAvailability.has4k,
seasons: instanceAvailability.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 && instanceAvailability) {
const serviceSeason = instanceAvailability.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({

View File

@@ -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<number, InstanceAvailability>;
private showCache: Map<number, ShowInstanceAvailability>;
constructor() {
this.movieCache = new Map();
this.showCache = new Map();
}
public clearCache(): void {
this.movieCache.clear();
this.showCache.clear();
}
public async checkMovieAvailability(
tmdbid: number
): Promise<InstanceAvailability> {
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<ShowInstanceAvailability> {
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<number, number>();
const seasons4k = new Map<number, number>();
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;