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 implements RunnableScanner { private jfClient: JellyfinAPI; private libraries: Library[]; private currentLibrary: Library; private isRecentOnly = false; private processedAnidbSeason: Map>; 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 { 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 { if (item.Type === 'Movie') { await this.processJellyfinMovie(item); } else if (item.Type === 'Series') { await this.processJellyfinShow(item); } } public async run(): Promise { 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, });