Compare commits
4 Commits
pr-2273
...
fallenbage
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fa68da481 | ||
|
|
cf5a85ba0b | ||
|
|
9cbd5f4260 | ||
|
|
09233a32b3 |
@@ -20,6 +20,7 @@ import type {
|
|||||||
StatusBase,
|
StatusBase,
|
||||||
} from '@server/lib/scanners/baseScanner';
|
} from '@server/lib/scanners/baseScanner';
|
||||||
import BaseScanner 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 type { Library } from '@server/lib/settings';
|
||||||
import { getSettings } from '@server/lib/settings';
|
import { getSettings } from '@server/lib/settings';
|
||||||
import { getHostname } from '@server/utils/getHostname';
|
import { getHostname } from '@server/utils/getHostname';
|
||||||
@@ -125,6 +126,57 @@ class JellyfinScanner
|
|||||||
|
|
||||||
const { tmdbId, imdbId, metadata } = extracted;
|
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) => {
|
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
||||||
return MediaSource.MediaStreams.filter(
|
return MediaSource.MediaStreams.filter(
|
||||||
(MediaStream) => MediaStream.Type === 'Video'
|
(MediaStream) => MediaStream.Type === 'Video'
|
||||||
@@ -141,10 +193,6 @@ class JellyfinScanner
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaAddedAt = metadata.DateCreated
|
|
||||||
? new Date(metadata.DateCreated)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
|
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
|
||||||
await this.processMovie(tmdbId, {
|
await this.processMovie(tmdbId, {
|
||||||
is4k: false,
|
is4k: false,
|
||||||
@@ -285,6 +333,34 @@ class JellyfinScanner
|
|||||||
? seasons
|
? seasons
|
||||||
: seasons.filter((sn) => sn.season_number !== 0);
|
: 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) {
|
for (const season of filteredSeasons) {
|
||||||
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
|
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
|
||||||
if (tvdbSeasonFromAnidb) {
|
if (tvdbSeasonFromAnidb) {
|
||||||
@@ -306,7 +382,16 @@ class JellyfinScanner
|
|||||||
let totalStandard = 0;
|
let totalStandard = 0;
|
||||||
let total4k = 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(
|
const episodes = await this.jfClient.getEpisodes(
|
||||||
Id,
|
Id,
|
||||||
matchedJellyfinSeason.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 (hasStandard) totalStandard += episodeCount;
|
||||||
if (has4k) total4k += episodeCount;
|
if (has4k) total4k += episodeCount;
|
||||||
}
|
}
|
||||||
@@ -452,6 +529,8 @@ class JellyfinScanner
|
|||||||
|
|
||||||
const sessionId = this.startRun();
|
const sessionId = this.startRun();
|
||||||
|
|
||||||
|
serviceAvailabilityChecker.clearCache();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const userRepository = getRepository(User);
|
const userRepository = getRepository(User);
|
||||||
const admin = await userRepository.findOne({
|
const admin = await userRepository.findOne({
|
||||||
|
|||||||
193
server/lib/scanners/serviceAvailabilityChecker.ts
Normal file
193
server/lib/scanners/serviceAvailabilityChecker.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user