mirror of
https://github.com/fallenbagel/jellyseerr.git
synced 2025-12-24 02:39:18 -05:00
546 lines
17 KiB
TypeScript
546 lines
17 KiB
TypeScript
import animeList from '@server/api/animelist';
|
|
import type {
|
|
JellyfinLibraryItem,
|
|
JellyfinLibraryItemExtended,
|
|
} from '@server/api/jellyfin';
|
|
import JellyfinAPI from '@server/api/jellyfin';
|
|
import { getMetadataProvider } from '@server/api/metadata';
|
|
import TheMovieDb from '@server/api/themoviedb';
|
|
import { ANIME_KEYWORD_ID } from '@server/api/themoviedb/constants';
|
|
import type {
|
|
TmdbKeyword,
|
|
TmdbTvDetails,
|
|
} from '@server/api/themoviedb/interfaces';
|
|
import { MediaServerType } from '@server/constants/server';
|
|
import { getRepository } from '@server/datasource';
|
|
import { User } from '@server/entity/User';
|
|
import type {
|
|
ProcessableSeason,
|
|
RunnableScanner,
|
|
StatusBase,
|
|
} from '@server/lib/scanners/baseScanner';
|
|
import BaseScanner from '@server/lib/scanners/baseScanner';
|
|
import type { Library } from '@server/lib/settings';
|
|
import { getSettings } from '@server/lib/settings';
|
|
import { getHostname } from '@server/utils/getHostname';
|
|
import { uniqWith } from 'lodash';
|
|
|
|
interface JellyfinSyncStatus extends StatusBase {
|
|
currentLibrary: Library;
|
|
libraries: Library[];
|
|
}
|
|
|
|
class JellyfinScanner
|
|
extends BaseScanner<JellyfinLibraryItem>
|
|
implements RunnableScanner<JellyfinSyncStatus>
|
|
{
|
|
private jfClient: JellyfinAPI;
|
|
private libraries: Library[];
|
|
private currentLibrary: Library;
|
|
private isRecentOnly = false;
|
|
private processedAnidbSeason: Map<number, Map<number, number>>;
|
|
|
|
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
|
|
super('Jellyfin Sync');
|
|
this.isRecentOnly = isRecentOnly ?? false;
|
|
}
|
|
|
|
private async extractMovieIds(jellyfinitem: JellyfinLibraryItem): Promise<{
|
|
tmdbId: number;
|
|
imdbId?: string;
|
|
metadata: JellyfinLibraryItemExtended;
|
|
} | null> {
|
|
let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
|
|
|
|
if (!metadata?.Id) {
|
|
this.log('No Id metadata for this title. Skipping', 'debug', {
|
|
jellyfinItemId: jellyfinitem.Id,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
|
|
let tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
|
|
let imdbId = metadata.ProviderIds.Imdb;
|
|
|
|
// We use anidb only if we have the anidbId and nothing else
|
|
if (anidbId && !imdbId && !tmdbId) {
|
|
const result = animeList.getFromAnidbId(anidbId);
|
|
tmdbId = Number(result?.tmdbId ?? null);
|
|
imdbId = result?.imdbId;
|
|
}
|
|
|
|
if (imdbId && !tmdbId) {
|
|
const tmdbMovie = await this.tmdb.getMediaByImdbId({
|
|
imdbId: imdbId,
|
|
});
|
|
tmdbId = tmdbMovie.id;
|
|
}
|
|
|
|
if (!tmdbId) {
|
|
throw new Error('Unable to find TMDb ID');
|
|
}
|
|
|
|
// With AniDB we can have mixed libraries with movies in a "show" library
|
|
// We take the first episode of the first season (the movie) and use it to
|
|
// get more information, like the MediaSource
|
|
if (anidbId && metadata.Type === 'Series') {
|
|
const season = (await this.jfClient.getSeasons(jellyfinitem.Id)).find(
|
|
(md) => {
|
|
return md.IndexNumber === 1;
|
|
}
|
|
);
|
|
if (!season) {
|
|
this.log('No season found for anidb movie', 'debug', {
|
|
jellyfinitem,
|
|
});
|
|
return null;
|
|
}
|
|
const episodes = await this.jfClient.getEpisodes(
|
|
jellyfinitem.Id,
|
|
season.Id
|
|
);
|
|
if (!episodes[0]) {
|
|
this.log('No episode found for anidb movie', 'debug', {
|
|
jellyfinitem,
|
|
});
|
|
return null;
|
|
}
|
|
metadata = await this.jfClient.getItemData(episodes[0].Id);
|
|
if (!metadata) {
|
|
this.log('No metadata found for anidb movie', 'debug', {
|
|
jellyfinitem,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return { tmdbId, imdbId, metadata };
|
|
}
|
|
|
|
private async processJellyfinMovie(jellyfinitem: JellyfinLibraryItem) {
|
|
try {
|
|
const extracted = await this.extractMovieIds(jellyfinitem);
|
|
if (!extracted) return;
|
|
|
|
const { tmdbId, imdbId, metadata } = extracted;
|
|
|
|
const has4k = metadata.MediaSources?.some((MediaSource) => {
|
|
return MediaSource.MediaStreams.filter(
|
|
(MediaStream) => MediaStream.Type === 'Video'
|
|
).some((MediaStream) => {
|
|
return (MediaStream.Width ?? 0) > 2000;
|
|
});
|
|
});
|
|
|
|
const hasOtherResolution = metadata.MediaSources?.some((MediaSource) => {
|
|
return MediaSource.MediaStreams.filter(
|
|
(MediaStream) => MediaStream.Type === 'Video'
|
|
).some((MediaStream) => {
|
|
return (MediaStream.Width ?? 0) <= 2000;
|
|
});
|
|
});
|
|
|
|
const mediaAddedAt = metadata.DateCreated
|
|
? new Date(metadata.DateCreated)
|
|
: undefined;
|
|
|
|
if (hasOtherResolution || (!this.enable4kMovie && has4k)) {
|
|
await this.processMovie(tmdbId, {
|
|
is4k: false,
|
|
mediaAddedAt,
|
|
jellyfinMediaId: metadata.Id,
|
|
imdbId,
|
|
title: metadata.Name,
|
|
});
|
|
}
|
|
|
|
if (has4k && this.enable4kMovie) {
|
|
await this.processMovie(tmdbId, {
|
|
is4k: true,
|
|
mediaAddedAt,
|
|
jellyfinMediaId: metadata.Id,
|
|
imdbId,
|
|
title: metadata.Name,
|
|
});
|
|
}
|
|
} catch (e) {
|
|
this.log(
|
|
`Failed to process Jellyfin item, id: ${jellyfinitem.Id}`,
|
|
'error',
|
|
{
|
|
errorMessage: e.message,
|
|
jellyfinitem,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
private async getTvShow({
|
|
tmdbId,
|
|
tvdbId,
|
|
}: {
|
|
tmdbId?: number;
|
|
tvdbId?: number;
|
|
}): Promise<TmdbTvDetails> {
|
|
let tvShow;
|
|
|
|
if (tmdbId) {
|
|
tvShow = await this.tmdb.getTvShow({
|
|
tvId: Number(tmdbId),
|
|
});
|
|
} else if (tvdbId) {
|
|
tvShow = await this.tmdb.getShowByTvdbId({
|
|
tvdbId: Number(tvdbId),
|
|
});
|
|
} else {
|
|
throw new Error('No ID provided');
|
|
}
|
|
|
|
const metadataProvider = tvShow.keywords.results.some(
|
|
(keyword: TmdbKeyword) => keyword.id === ANIME_KEYWORD_ID
|
|
)
|
|
? await getMetadataProvider('anime')
|
|
: await getMetadataProvider('tv');
|
|
|
|
if (!(metadataProvider instanceof TheMovieDb)) {
|
|
tvShow = await metadataProvider.getTvShow({
|
|
tvId: Number(tmdbId),
|
|
});
|
|
}
|
|
|
|
return tvShow;
|
|
}
|
|
|
|
private async processJellyfinShow(jellyfinitem: JellyfinLibraryItem) {
|
|
let tvShow: TmdbTvDetails | null = null;
|
|
|
|
try {
|
|
const Id =
|
|
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id;
|
|
const metadata = await this.jfClient.getItemData(Id);
|
|
|
|
if (!metadata?.Id) {
|
|
this.log('No Id metadata for this title. Skipping', 'debug', {
|
|
jellyfinItemId: jellyfinitem.Id,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (metadata.ProviderIds.Tmdb) {
|
|
try {
|
|
tvShow = await this.getTvShow({
|
|
tmdbId: Number(metadata.ProviderIds.Tmdb),
|
|
});
|
|
} catch {
|
|
this.log('Unable to find TMDb ID for this title.', 'debug', {
|
|
jellyfinitem,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!tvShow && metadata.ProviderIds.Tvdb) {
|
|
try {
|
|
tvShow = await this.getTvShow({
|
|
tvdbId: Number(metadata.ProviderIds.Tvdb),
|
|
});
|
|
} catch {
|
|
this.log('Unable to find TVDb ID for this title.', 'debug', {
|
|
jellyfinitem,
|
|
});
|
|
}
|
|
}
|
|
|
|
let tvdbSeasonFromAnidb: number | undefined;
|
|
if (!tvShow && metadata.ProviderIds.AniDB) {
|
|
const anidbId = Number(metadata.ProviderIds.AniDB);
|
|
const result = animeList.getFromAnidbId(anidbId);
|
|
tvdbSeasonFromAnidb = result?.tvdbSeason;
|
|
if (result?.tvdbId) {
|
|
try {
|
|
tvShow = await this.tmdb.getShowByTvdbId({
|
|
tvdbId: result.tvdbId,
|
|
});
|
|
} catch {
|
|
this.log('Unable to find AniDB ID for this title.', 'debug', {
|
|
jellyfinitem,
|
|
});
|
|
}
|
|
}
|
|
// With AniDB we can have mixed libraries with movies in a "show" library
|
|
else if (result?.imdbId || result?.tmdbId) {
|
|
await this.processJellyfinMovie(jellyfinitem);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (tvShow) {
|
|
const seasons = tvShow.seasons;
|
|
const jellyfinSeasons = await this.jfClient.getSeasons(Id);
|
|
|
|
const processableSeasons: ProcessableSeason[] = [];
|
|
|
|
const settings = getSettings();
|
|
const filteredSeasons = settings.main.enableSpecialEpisodes
|
|
? seasons
|
|
: seasons.filter((sn) => sn.season_number !== 0);
|
|
|
|
for (const season of filteredSeasons) {
|
|
const matchedJellyfinSeason = jellyfinSeasons.find((md) => {
|
|
if (tvdbSeasonFromAnidb) {
|
|
// In AniDB we don't have the concept of seasons,
|
|
// we have multiple shows with only Season 1 (and sometimes a season with index 0 for specials).
|
|
// We use tvdbSeasonFromAnidb to check if we are on the correct TMDB season and
|
|
// md.IndexNumber === 1 to be sure to find the correct season on jellyfin
|
|
return (
|
|
tvdbSeasonFromAnidb === season.season_number &&
|
|
md.IndexNumber === 1
|
|
);
|
|
} else {
|
|
return Number(md.IndexNumber) === season.season_number;
|
|
}
|
|
});
|
|
|
|
// Check if we found the matching season and it has all the available episodes
|
|
if (matchedJellyfinSeason) {
|
|
let totalStandard = 0;
|
|
let total4k = 0;
|
|
|
|
if (!this.enable4kShow) {
|
|
const episodes = await this.jfClient.getEpisodes(
|
|
Id,
|
|
matchedJellyfinSeason.Id
|
|
);
|
|
|
|
for (const episode of episodes) {
|
|
let episodeCount = 1;
|
|
|
|
// count number of combined episodes
|
|
if (
|
|
episode.IndexNumber !== undefined &&
|
|
episode.IndexNumberEnd !== undefined
|
|
) {
|
|
episodeCount =
|
|
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
|
}
|
|
|
|
totalStandard += episodeCount;
|
|
}
|
|
} else {
|
|
// 4K detection enabled - request media info to check resolution
|
|
const episodes = await this.jfClient.getEpisodes(
|
|
Id,
|
|
matchedJellyfinSeason.Id,
|
|
{ includeMediaInfo: true }
|
|
);
|
|
|
|
for (const episode of episodes) {
|
|
let episodeCount = 1;
|
|
|
|
// count number of combined episodes
|
|
if (
|
|
episode.IndexNumber !== undefined &&
|
|
episode.IndexNumberEnd !== undefined
|
|
) {
|
|
episodeCount =
|
|
episode.IndexNumberEnd - episode.IndexNumber + 1;
|
|
}
|
|
|
|
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
|
|
)
|
|
);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// With AniDB we can have multiple shows for one season, so we need to save
|
|
// the episode from all the jellyfin entries to get the total
|
|
if (tvdbSeasonFromAnidb) {
|
|
let show = this.processedAnidbSeason.get(tvShow.id);
|
|
|
|
if (!show) {
|
|
show = new Map([[season.season_number, totalStandard]]);
|
|
this.processedAnidbSeason.set(tvShow.id, show);
|
|
} else {
|
|
const currentCount = show.get(season.season_number) ?? 0;
|
|
const newCount = currentCount + totalStandard;
|
|
show.set(season.season_number, newCount);
|
|
totalStandard = newCount;
|
|
}
|
|
}
|
|
|
|
processableSeasons.push({
|
|
seasonNumber: season.season_number,
|
|
totalEpisodes: season.episode_count,
|
|
episodes: totalStandard,
|
|
episodes4k: total4k,
|
|
});
|
|
}
|
|
}
|
|
|
|
await this.processShow(
|
|
tvShow.id,
|
|
tvShow.external_ids?.tvdb_id,
|
|
processableSeasons,
|
|
{
|
|
mediaAddedAt: metadata.DateCreated
|
|
? new Date(metadata.DateCreated)
|
|
: undefined,
|
|
jellyfinMediaId: Id,
|
|
title: tvShow.name,
|
|
}
|
|
);
|
|
} else {
|
|
this.log(
|
|
`No information found for the show: ${metadata.Name}`,
|
|
'debug',
|
|
{
|
|
jellyfinitem,
|
|
}
|
|
);
|
|
}
|
|
} catch (e) {
|
|
this.log(
|
|
`Failed to process Jellyfin item. Id: ${
|
|
jellyfinitem.SeriesId ?? jellyfinitem.SeasonId ?? jellyfinitem.Id
|
|
}`,
|
|
'error',
|
|
{ errorMessage: e.message, jellyfinitem }
|
|
);
|
|
}
|
|
}
|
|
|
|
private async processItem(item: JellyfinLibraryItem): Promise<void> {
|
|
if (item.Type === 'Movie') {
|
|
await this.processJellyfinMovie(item);
|
|
} else if (item.Type === 'Series') {
|
|
await this.processJellyfinShow(item);
|
|
}
|
|
}
|
|
|
|
public async run(): Promise<void> {
|
|
const settings = getSettings();
|
|
|
|
if (
|
|
settings.main.mediaServerType != MediaServerType.JELLYFIN &&
|
|
settings.main.mediaServerType != MediaServerType.EMBY
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const sessionId = this.startRun();
|
|
|
|
try {
|
|
const userRepository = getRepository(User);
|
|
const admin = await userRepository.findOne({
|
|
where: { id: 1 },
|
|
select: ['id', 'jellyfinUserId', 'jellyfinDeviceId'],
|
|
order: { id: 'ASC' },
|
|
});
|
|
|
|
if (!admin) {
|
|
return this.log('No admin configured. Jellyfin sync skipped.', 'warn');
|
|
}
|
|
|
|
this.jfClient = new JellyfinAPI(
|
|
getHostname(),
|
|
settings.jellyfin.apiKey,
|
|
admin.jellyfinDeviceId
|
|
);
|
|
|
|
this.jfClient.setUserId(admin.jellyfinUserId ?? '');
|
|
|
|
this.libraries = settings.jellyfin.libraries.filter(
|
|
(library) => library.enabled
|
|
);
|
|
|
|
await animeList.sync();
|
|
|
|
if (this.isRecentOnly) {
|
|
for (const library of this.libraries) {
|
|
this.currentLibrary = library;
|
|
// Reset AniDB season tracking per library
|
|
this.processedAnidbSeason = new Map();
|
|
this.log(
|
|
`Beginning to process recently added for library: ${library.name}`,
|
|
'info'
|
|
);
|
|
const libraryItems = await this.jfClient.getRecentlyAdded(library.id);
|
|
|
|
// Bundle items up by rating keys
|
|
this.items = uniqWith(libraryItems, (mediaA, mediaB) => {
|
|
if (mediaA.SeriesId && mediaB.SeriesId) {
|
|
return mediaA.SeriesId === mediaB.SeriesId;
|
|
}
|
|
|
|
if (mediaA.SeasonId && mediaB.SeasonId) {
|
|
return mediaA.SeasonId === mediaB.SeasonId;
|
|
}
|
|
|
|
return mediaA.Id === mediaB.Id;
|
|
});
|
|
|
|
await this.loop(this.processItem.bind(this), { sessionId });
|
|
}
|
|
} else {
|
|
for (const library of this.libraries) {
|
|
this.currentLibrary = library;
|
|
// Reset AniDB season tracking per library
|
|
this.processedAnidbSeason = new Map();
|
|
this.log(`Beginning to process library: ${library.name}`, 'info');
|
|
this.items = await this.jfClient.getLibraryContents(library.id);
|
|
await this.loop(this.processItem.bind(this), { sessionId });
|
|
}
|
|
}
|
|
|
|
this.log(
|
|
this.isRecentOnly
|
|
? 'Recently Added Scan Complete'
|
|
: 'Full Scan Complete',
|
|
'info'
|
|
);
|
|
} catch (e) {
|
|
this.log('Sync interrupted', 'error', { errorMessage: e.message });
|
|
} finally {
|
|
this.endRun(sessionId);
|
|
}
|
|
}
|
|
|
|
public status(): JellyfinSyncStatus {
|
|
return {
|
|
running: this.running,
|
|
progress: this.progress,
|
|
total: this.items.length,
|
|
currentLibrary: this.currentLibrary,
|
|
libraries: this.libraries,
|
|
};
|
|
}
|
|
}
|
|
|
|
export const jellyfinFullScanner = new JellyfinScanner();
|
|
export const jellyfinRecentScanner = new JellyfinScanner({
|
|
isRecentOnly: true,
|
|
});
|