feat: add anidb support to jellyfin scanner (#1533)

* feat: anidb on jellyfin scanner

* fix: handle 4k movies if the provider is anidb

* fix: add nullish coalescing for anidbId

* Update server/lib/scanners/jellyfin/index.ts

Co-authored-by: Gauthier <mail@gauthierth.fr>

* fix: await getEpisodes call in JellyfinScanner to handle async behavior

---------

Co-authored-by: Gauthier <mail@gauthierth.fr>
This commit is contained in:
ale183
2025-09-25 16:04:42 +02:00
committed by GitHub
parent fed772a8e7
commit b209e7fdf9
3 changed files with 118 additions and 4 deletions

View File

@@ -48,6 +48,7 @@ export interface AnidbItem {
tvdbId?: number; tvdbId?: number;
tmdbId?: number; tmdbId?: number;
imdbId?: string; imdbId?: string;
tvdbSeason?: number;
} }
class AnimeListMapping { class AnimeListMapping {
@@ -97,6 +98,7 @@ class AnimeListMapping {
tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId, tvdbId: anime.$.defaulttvdbseason === '0' ? undefined : tvdbId,
tmdbId: tmdbId, tmdbId: tmdbId,
imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping imdbId: imdbIds[0], // this is used for one AniDB -> one imdb movie mapping
tvdbSeason: Number(anime.$.defaulttvdbseason),
}; };
if (tvdbId) { if (tvdbId) {

View File

@@ -103,6 +103,7 @@ export interface JellyfinLibraryItemExtended extends JellyfinLibraryItem {
Tmdb?: string; Tmdb?: string;
Imdb?: string; Imdb?: string;
Tvdb?: string; Tvdb?: string;
AniDB?: string;
}; };
MediaSources?: JellyfinMediaSource[]; MediaSources?: JellyfinMediaSource[];
Width?: number; Width?: number;

View File

@@ -1,3 +1,4 @@
import animeList from '@server/api/animelist';
import type { JellyfinLibraryItem } from '@server/api/jellyfin'; import type { JellyfinLibraryItem } from '@server/api/jellyfin';
import JellyfinAPI from '@server/api/jellyfin'; import JellyfinAPI from '@server/api/jellyfin';
import { getMetadataProvider } from '@server/api/metadata'; import { getMetadataProvider } from '@server/api/metadata';
@@ -45,6 +46,7 @@ class JellyfinScanner {
private enable4kMovie = false; private enable4kMovie = false;
private enable4kShow = false; private enable4kShow = false;
private asyncLock = new AsyncLock(); private asyncLock = new AsyncLock();
private processedAnidbSeason: Map<number, Map<number, number>>;
constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) { constructor({ isRecentOnly }: { isRecentOnly?: boolean } = {}) {
this.tmdb = new TheMovieDb(); this.tmdb = new TheMovieDb();
@@ -66,7 +68,7 @@ class JellyfinScanner {
const mediaRepository = getRepository(Media); const mediaRepository = getRepository(Media);
try { try {
const metadata = await this.jfClient.getItemData(jellyfinitem.Id); let metadata = await this.jfClient.getItemData(jellyfinitem.Id);
const newMedia = new Media(); const newMedia = new Media();
if (!metadata?.Id) { if (!metadata?.Id) {
@@ -77,8 +79,18 @@ class JellyfinScanner {
return; return;
} }
const anidbId = Number(metadata.ProviderIds.AniDB ?? null);
newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null); newMedia.tmdbId = Number(metadata.ProviderIds.Tmdb ?? null);
newMedia.imdbId = metadata.ProviderIds.Imdb; newMedia.imdbId = metadata.ProviderIds.Imdb;
// We use anidb only if we have the anidbId and nothing else
if (anidbId && !newMedia.imdbId && !newMedia.tmdbId) {
const result = animeList.getFromAnidbId(anidbId);
newMedia.tmdbId = Number(result?.tmdbId ?? null);
newMedia.imdbId = result?.imdbId;
}
if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) { if (newMedia.imdbId && !isNaN(newMedia.tmdbId)) {
const tmdbMovie = await this.tmdb.getMediaByImdbId({ const tmdbMovie = await this.tmdb.getMediaByImdbId({
imdbId: newMedia.imdbId, imdbId: newMedia.imdbId,
@@ -89,6 +101,40 @@ class JellyfinScanner {
throw new Error('Unable to find TMDb ID'); 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;
}
const episodes = await this.jfClient.getEpisodes(
jellyfinitem.Id,
season.Id
);
if (!episodes[0]) {
this.log('No episode found for anidb movie', 'debug', {
jellyfinitem,
});
return;
}
metadata = await this.jfClient.getItemData(episodes[0].Id);
if (!metadata) {
this.log('No metadata found for anidb movie', 'debug', {
jellyfinitem,
});
return;
}
}
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'
@@ -106,6 +152,12 @@ class JellyfinScanner {
}); });
await this.asyncLock.dispatch(newMedia.tmdbId, async () => { await this.asyncLock.dispatch(newMedia.tmdbId, async () => {
if (!metadata) {
// this will never execute, but typescript thinks somebody could reset tvShow from
// outer scope back to null before this async gets called
return;
}
const existing = await this.getExisting( const existing = await this.getExisting(
newMedia.tmdbId, newMedia.tmdbId,
MediaType.MOVIE MediaType.MOVIE
@@ -274,6 +326,28 @@ class JellyfinScanner {
}); });
} }
} }
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.processMovie(jellyfinitem);
return;
}
}
if (tvShow) { if (tvShow) {
await this.asyncLock.dispatch(tvShow.id, async () => { await this.asyncLock.dispatch(tvShow.id, async () => {
@@ -302,9 +376,20 @@ class JellyfinScanner {
for (const season of seasons) { for (const season of seasons) {
const JellyfinSeasons = await this.jfClient.getSeasons(Id); const JellyfinSeasons = await this.jfClient.getSeasons(Id);
const matchedJellyfinSeason = JellyfinSeasons.find( const matchedJellyfinSeason = JellyfinSeasons.find((md) => {
(md) => Number(md.IndexNumber) === season.season_number 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;
}
});
const existingSeason = media?.seasons.find( const existingSeason = media?.seasons.find(
(es) => es.seasonNumber === season.season_number (es) => es.seasonNumber === season.season_number
@@ -357,6 +442,29 @@ class JellyfinScanner {
} }
} }
// 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) {
if (this.processedAnidbSeason.has(tvShow.id)) {
const show = this.processedAnidbSeason.get(tvShow.id)!;
if (show.has(season.season_number)) {
show.set(
season.season_number,
show.get(season.season_number)! + totalStandard
);
totalStandard = show.get(season.season_number)!;
} else {
show.set(season.season_number, totalStandard);
}
} else {
this.processedAnidbSeason.set(
tvShow.id,
new Map([[season.season_number, totalStandard]])
);
}
}
if ( if (
media && media &&
(totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) && (totalStandard > 0 || (total4k > 0 && !this.enable4kShow)) &&
@@ -569,6 +677,7 @@ class JellyfinScanner {
} }
private async processItems(slicedItems: JellyfinLibraryItem[]) { private async processItems(slicedItems: JellyfinLibraryItem[]) {
this.processedAnidbSeason = new Map();
await Promise.all( await Promise.all(
slicedItems.map(async (item) => { slicedItems.map(async (item) => {
if (item.Type === 'Movie') { if (item.Type === 'Movie') {
@@ -666,6 +775,8 @@ class JellyfinScanner {
(library) => library.enabled (library) => library.enabled
); );
await animeList.sync();
this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k); this.enable4kMovie = settings.radarr.some((radarr) => radarr.is4k);
if (this.enable4kMovie) { if (this.enable4kMovie) {
this.log( this.log(