aiostreams / packages /core /src /utils /anime-database.ts
f4b404's picture
Upload folder using huggingface_hub
f55c62a verified
Raw
History Blame Contribute Delete
52.9 kB
import path from 'path';
import fs from 'fs/promises';
import {
appConfig,
getDataFolder,
makeRequest,
getTimeTakenSincePoint,
IdParser,
IdType,
ID_TYPES,
Env,
withRetry,
ParsedId,
} from './index.js';
import { createWriteStream } from 'fs';
import { createLogger } from '../logging/logger.js';
import { Parser } from 'xml2js';
import { TaskManager } from '../tasks/index.js';
const logger = createLogger('anime-database');
// --- Constants for Data Sources ---
const ANIME_DATABASE_PATH = path.join(getDataFolder(), 'anime-database');
const DATA_SOURCES = {
fribbMappings: {
name: 'Fribb Mappings',
url: 'https://raw.githubusercontent.com/Fribb/anime-lists/refs/heads/master/anime-list-full.json',
filePath: path.join(ANIME_DATABASE_PATH, 'fribb-mappings.json'),
etagPath: path.join(ANIME_DATABASE_PATH, 'fribb-mappings.etag'),
loader: 'loadFribbMappings',
get refreshInterval() {
return appConfig.metadata.animeDb.refresh.fribbMappings * 1000;
},
dataKey: 'fribbMappingsById',
},
manami: {
name: 'Manami DB',
url: 'https://github.com/manami-project/anime-offline-database/releases/download/latest/anime-offline-database-minified.json',
filePath: path.join(ANIME_DATABASE_PATH, 'manami-db.json'),
etagPath: path.join(ANIME_DATABASE_PATH, 'manami-db.etag'),
loader: 'loadManamiDb',
get refreshInterval() {
return appConfig.metadata.animeDb.refresh.manamiDb * 1000;
},
dataKey: 'manamiById',
},
kitsuImdb: {
name: 'Kitsu IMDB Mapping',
url: 'https://raw.githubusercontent.com/TheBeastLT/stremio-kitsu-anime/master/static/data/imdb_mapping.json',
filePath: path.join(ANIME_DATABASE_PATH, 'kitsu-imdb-mapping.json'),
etagPath: path.join(ANIME_DATABASE_PATH, 'kitsu-imdb-mapping.etag'),
loader: 'loadKitsuImdbMapping',
get refreshInterval() {
return appConfig.metadata.animeDb.refresh.kitsuImdbMapping * 1000;
},
dataKey: 'kitsuById',
},
anitraktMovies: {
name: 'Extended Anitrakt Movies',
url: 'https://github.com/rensetsu/db.trakt.extended-anitrakt/releases/download/latest/movies_ex.json',
filePath: path.join(ANIME_DATABASE_PATH, 'anitrakt-movies-ex.json'),
etagPath: path.join(ANIME_DATABASE_PATH, 'anitrakt-movies-ex.etag'),
loader: 'loadExtendedAnitraktMovies',
get refreshInterval() {
return appConfig.metadata.animeDb.refresh.extendedAnitraktMovies * 1000;
},
dataKey: 'extendedAnitraktMoviesById',
},
anitraktTv: {
name: 'Extended Anitrakt TV',
url: 'https://github.com/rensetsu/db.trakt.extended-anitrakt/releases/download/latest/tv_ex.json',
filePath: path.join(ANIME_DATABASE_PATH, 'anitrakt-tv-ex.json'),
etagPath: path.join(ANIME_DATABASE_PATH, 'anitrakt-tv-ex.etag'),
loader: 'loadExtendedAnitraktTv',
get refreshInterval() {
return appConfig.metadata.animeDb.refresh.extendedAnitraktTv * 1000;
},
dataKey: 'extendedAnitraktTvById',
},
animeList: {
name: 'Anime Lists XML',
url: 'https://raw.githubusercontent.com/Anime-Lists/anime-lists/refs/heads/master/anime-list-master.xml',
filePath: path.join(ANIME_DATABASE_PATH, 'anime-list-master.xml'),
etagPath: path.join(ANIME_DATABASE_PATH, 'anime-list-master.etag'),
loader: 'loadAnimeList',
get refreshInterval() {
return appConfig.metadata.animeDb.refresh.animeList * 1000;
},
dataKey: 'animeListById',
},
} as const;
const extractIdFromUrl: {
[K in
| 'anidbId'
| 'anilistId'
| 'animePlanetId'
| 'animecountdownId'
| 'anisearchId'
| 'imdbId'
| 'kitsuId'
| 'livechartId'
| 'malId'
| 'notifyMoeId'
| 'simklId'
| 'themoviedbId'
| 'thetvdbId']?: (url: string) => string | null;
} = {
anidbId: (url: string) => {
const match = url.match(/anidb\.net\/anime\/(\d+)/);
return match ? match[1] : null;
},
anilistId: (url: string) => {
const match = url.match(/anilist\.co\/anime\/(\d+)/);
return match ? match[1] : null;
},
animePlanetId: (url: string) => {
const match = url.match(/anime-planet\.com\/anime\/(\w+)/);
return match ? match[1] : null;
},
animecountdownId: (url: string) => {
const match = url.match(/animecountdown\.com\/(\d+)/);
return match ? match[1] : null;
},
anisearchId: (url: string) => {
const match = url.match(/anisearch\.com\/anime\/(\d+)/);
return match ? match[1] : null;
},
kitsuId: (url: string) => {
const match = url.match(/kitsu\.app\/anime\/(\d+)/);
return match ? match[1] : null;
},
livechartId: (url: string) => {
const match = url.match(/livechart\.me\/anime\/(\d+)/);
return match ? match[1] : null;
},
malId: (url: string) => {
const match = url.match(/myanimelist\.net\/anime\/(\d+)/);
return match ? match[1] : null;
},
notifyMoeId: (url: string) => {
const match = url.match(/notify\.moe\/anime\/(\w+)/);
return match ? match[1] : null;
},
simklId: (url: string) => {
const match = url.match(/simkl\.com\/anime\/(\d+)/);
return match ? match[1] : null;
},
};
// --- Types and Interfaces ---
enum AnimeType {
TV = 'TV',
SPECIAL = 'SPECIAL',
OVA = 'OVA',
MOVIE = 'MOVIE',
ONA = 'ONA',
UNKNOWN = 'UNKNOWN',
}
enum AnimeStatus {
CURRENT = 'CURRENT',
FINISHED = 'FINISHED',
UPCOMING = 'UPCOMING',
UNKNOWN = 'UNKNOWN',
ONGOING = 'ONGOING',
}
enum AnimeSeason {
WINTER = 'WINTER',
SPRING = 'SPRING',
SUMMER = 'SUMMER',
FALL = 'FALL',
UNDEFINED = 'UNDEFINED',
}
// Interfaces and Types
interface MappingEntry {
animePlanetId?: string | number;
animecountdownId?: number;
anidbId?: number;
anilistId?: number;
anisearchId?: number;
imdbId?: string | null;
kitsuId?: number;
livechartId?: number;
malId?: number;
notifyMoeId?: string;
simklId?: number;
themoviedbId?: number;
thetvdbId?: number | null;
traktId?: number;
type: AnimeType;
season?:
| {
tvdb?: number;
tmdb?: number;
}
| undefined;
}
interface ManamiEntry {
sources: string[];
title: string;
type: AnimeType;
episodes: number;
status: AnimeStatus;
animeSeason: {
season: AnimeSeason;
year: number | null;
};
picture: string | null;
thumbnail: string | null;
duration: {
value: number;
unit: 'SECONDS';
} | null;
score: {
arithmeticGeometricMean: number;
arithmeticMean: number;
median: number;
} | null;
synonyms: string[];
studios: string[];
producers: string[];
relatedAnime: string[];
tags: string[];
}
interface MinimisedManamiEntry {
title: string;
animeSeason: {
season: AnimeSeason;
year: number | null;
};
synonyms: string[];
}
interface KitsuEntry {
fanartLogoId?: number;
tvdbId?: number;
imdbId?: string;
title?: string;
fromSeason?: number;
fromEpisode?: number;
nonImdbEpisodes?: number[];
}
interface ExtendedAnitraktMovieEntry {
myanimelist: {
title: string;
id: number;
};
trakt: {
title: string;
id: number;
slug: string;
type: 'movies';
};
releaseYear: number;
externals: {
tmdb?: number | null;
imdb?: string | null;
letterboxd?: {
slug: string | null;
lid: string | null;
uid: number | null;
} | null;
};
}
interface ExtendedAnitraktTvEntry {
myanimelist: {
title: string;
id: number;
};
trakt: {
title: string;
id: number;
slug: string;
type: 'shows';
isSplitCour: boolean;
season: {
id: number;
number: number;
externals: {
tvdb: number | null;
tmdb: number | null;
};
} | null;
};
releaseYear: number;
externals: {
tvdb?: number | null;
tmdb?: number | null;
imdb?: string | null;
};
}
interface AnimeListMapping {
anidbSeason: number;
tvdbSeason?: number;
tmdbSeason?: number;
start?: number;
end?: number;
offset?: number;
episodes?: string;
}
interface AnimeListEntry {
anidbId: number;
tvdbId?: number | null;
defaultTvdbSeason?: number | 'a' | null; // 'a' means absolute numbering
episodeOffset?: number | null;
tmdbTv?: number | null;
tmdbSeason?: number | null;
tmdbOffset?: number | null;
tmdbId?: number | null;
imdbId?: string | null;
mappings?: AnimeListMapping[];
before?: string;
}
export interface AnimeEntry {
mappings?: Omit<MappingEntry, 'type'>;
type: AnimeType;
imdb?: {
seasonNumber?: number;
fromEpisode?: number;
nonImdbEpisodes?: number[];
title?: string;
} | null;
fanart?: {
logoId: number;
} | null;
trakt?: {
title: string;
slug: string;
isSplitCour?: boolean;
seasonId?: number | null;
seasonNumber?: number | null;
} | null;
tmdb: {
seasonNumber: number | null;
seasonId: number | null;
fromEpisode?: number | null; // Episode offset from AnimeList
};
tvdb: {
seasonNumber: number | null;
seasonId: number | null;
fromEpisode?: number | null; // Episode offset from AnimeList
};
title?: string;
animeSeason?: {
season: AnimeSeason;
year: number | null;
};
synonyms?: string[];
episodeMappings?: AnimeListMapping[];
}
// Validation functions
function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
function validateMappingEntry(data: any): MappingEntry | null {
if (!data || typeof data !== 'object') return null;
const season = data['season'];
if (season !== undefined && typeof season !== 'object') return null;
if (
season &&
((season.tmdb !== undefined && typeof season.tmdb !== 'number') ||
(season.tvdb !== undefined && typeof season.tvdb !== 'number'))
)
return null;
// Transform raw data to match our interface
const entry: MappingEntry = {
animePlanetId: data['anime-planet_id'],
animecountdownId: data['animecountdown_id'],
anidbId: data['anidb_id'],
anilistId: data['anilist_id'],
anisearchId: data['anisearch_id'],
imdbId: data['imdb_id'],
kitsuId: data['kitsu_id'],
livechartId: data['livechart_id'],
malId: data['mal_id'],
notifyMoeId: data['notify.moe_id'],
simklId: data['simkl_id'],
themoviedbId:
typeof data['themoviedb_id'] === 'string'
? parseInt(data['themoviedb_id'])
: data['themoviedb_id'],
thetvdbId: data['thetvdb_id'] || data['tvdb_id'],
traktId: data['trakt_id'],
type: data['type'] ?? AnimeType.UNKNOWN,
season: data['season'],
};
// Validate type
if (!Object.values(AnimeType).includes(entry.type)) {
return null;
}
return entry;
}
function validateManamiEntry(data: any): ManamiEntry | null {
if (!data || typeof data !== 'object') return null;
// Basic type checks
if (!Array.isArray(data.sources) || !data.sources.every(isValidUrl))
return null;
if (typeof data.title !== 'string') return null;
if (!Object.values(AnimeType).includes(data.type)) return null;
if (typeof data.episodes !== 'number') return null;
if (!Object.values(AnimeStatus).includes(data.status)) return null;
// Validate animeSeason
if (
!data.animeSeason ||
!Object.values(AnimeSeason).includes(data.animeSeason.season) ||
(data.animeSeason.year !== null &&
data.animeSeason.year !== undefined &&
typeof data.animeSeason.year !== 'number')
) {
return null;
}
// Validate arrays
if (
!Array.isArray(data.synonyms) ||
!data.synonyms.every((s: unknown) => typeof s === 'string')
)
return null;
if (
!Array.isArray(data.studios) ||
!data.studios.every((s: unknown) => typeof s === 'string')
)
return null;
if (
!Array.isArray(data.producers) ||
!data.producers.every((s: unknown) => typeof s === 'string')
)
return null;
if (
!Array.isArray(data.relatedAnime) ||
!data.relatedAnime.every((s: unknown) => isValidUrl(s as string))
)
return null;
if (
!Array.isArray(data.tags) ||
!data.tags.every((s: unknown) => typeof s === 'string')
)
return null;
return data as ManamiEntry;
}
function validateKitsuEntry(data: any): KitsuEntry | null {
if (!data || typeof data !== 'object') return null;
const entry: KitsuEntry = {
fanartLogoId:
typeof data.fanartLogoId === 'string'
? parseInt(data.fanartLogoId)
: data.fanartLogoId,
tvdbId:
typeof (data.tvdb_id || data.tvdbId) === 'string'
? parseInt(data.tvdb_id || data.tvdbId)
: data.tvdb_id || data.tvdbId,
imdbId: data.imdb_id || data.imdbId,
title: data.title,
fromSeason: data.fromSeason,
fromEpisode: data.fromEpisode,
nonImdbEpisodes: data.nonImdbEpisodes,
};
// All fields are optional, just validate types
if (
entry.fanartLogoId !== undefined &&
typeof entry.fanartLogoId !== 'number'
)
return null;
if (entry.tvdbId !== undefined && typeof entry.tvdbId !== 'number')
return null;
if (entry.imdbId !== undefined && typeof entry.imdbId !== 'string')
return null;
if (entry.title !== undefined && typeof entry.title !== 'string') return null;
if (entry.fromSeason !== undefined && typeof entry.fromSeason !== 'number')
return null;
if (entry.fromEpisode !== undefined && typeof entry.fromEpisode !== 'number')
return null;
return entry;
}
function validateExtendedAnitraktMovieEntry(
data: any
): ExtendedAnitraktMovieEntry | null {
if (!data || typeof data !== 'object') return null;
// Validate required nested objects
if (!data.myanimelist?.title || typeof data.myanimelist.id !== 'number')
return null;
if (
!data.trakt?.title ||
typeof data.trakt.id !== 'number' ||
typeof data.trakt.slug !== 'string' ||
data.trakt.type !== 'movies'
)
return null;
if (typeof data.release_year !== 'number') return null;
return {
myanimelist: data.myanimelist,
trakt: data.trakt,
releaseYear: data.release_year,
externals: data.externals,
};
}
function validateExtendedAnitraktTvEntry(
data: any
): ExtendedAnitraktTvEntry | null {
if (!data || typeof data !== 'object') return null;
// Validate required nested objects
if (!data.myanimelist?.title || typeof data.myanimelist.id !== 'number')
return null;
if (
!data.trakt?.title ||
typeof data.trakt.id !== 'number' ||
typeof data.trakt.slug !== 'string' ||
data.trakt.type !== 'shows' ||
typeof data.trakt.is_split_cour !== 'boolean'
)
return null;
if (typeof data.release_year !== 'number') return null;
return {
myanimelist: data.myanimelist,
trakt: {
title: data.trakt.title,
id: data.trakt.id,
slug: data.trakt.slug,
type: data.trakt.type,
isSplitCour: data.trakt.is_split_cour,
season: data.trakt.season,
},
releaseYear: data.release_year,
externals: data.externals,
};
}
function validateAnimeListEntry(data: any): AnimeListEntry | null {
if (!data || typeof data !== 'object') return null;
const attrs = data.$;
if (!attrs) return null;
const parseNum = (val: any): number | null => {
if (val === undefined || val === null || val === '') return null;
if (typeof val === 'number') return val;
if (typeof val !== 'string') return null;
if (['unknown', 'hentai', 'a'].includes(val.toLowerCase())) return null;
const num = parseInt(val, 10);
return isNaN(num) ? null : num;
};
const parseSeason = (val: any): number | 'a' | null => {
if (val === undefined || val === null || val === '') return null;
if (val === 'a' || val === 'A') return 'a';
return parseNum(val);
};
const anidbId = parseNum(attrs.anidbid);
if (anidbId === null) return null;
const entry: AnimeListEntry = {
anidbId,
tvdbId: parseNum(attrs.tvdbid),
defaultTvdbSeason: parseSeason(attrs.defaulttvdbseason),
episodeOffset: parseNum(attrs.episodeoffset),
tmdbTv: parseNum(attrs.tmdbtv),
tmdbSeason: parseNum(attrs.tmdbseason),
tmdbOffset: parseNum(attrs.tmdboffset),
tmdbId: parseNum(attrs.tmdbid),
imdbId: attrs.imdbid && attrs.imdbid !== '' ? attrs.imdbid : null,
};
if (data.before?.[0]) {
entry.before = data.before[0];
}
if (Array.isArray(data['mapping-list'])) {
const mappingList = data['mapping-list'][0];
if (mappingList?.mapping && Array.isArray(mappingList.mapping)) {
const mappings = mappingList.mapping
.map((m: any) => {
const mAttrs = m.$;
if (!mAttrs) return null;
const anidbSeason = parseNum(mAttrs.anidbseason);
if (anidbSeason === null) return null;
const mapping: AnimeListMapping = {
anidbSeason,
tvdbSeason: parseNum(mAttrs.tvdbseason) ?? undefined,
tmdbSeason: parseNum(mAttrs.tmdbseason) ?? undefined,
start: parseNum(mAttrs.start) ?? undefined,
end: parseNum(mAttrs.end) ?? undefined,
offset: parseNum(mAttrs.offset) ?? undefined,
};
if (m._ && typeof m._ === 'string') {
mapping.episodes = m._;
}
return mapping;
})
.filter((m: any): m is AnimeListMapping => m !== null);
if (mappings.length > 0) {
entry.mappings = mappings;
}
}
}
return entry;
}
type MappingIdMap = Map<IdType, Map<string | number, MappingEntry[]>>;
type ManamiIdMap = Map<
IdType,
Map<string | number, ManamiEntry | MinimisedManamiEntry>
>;
type KitsuIdMap = Map<number, KitsuEntry>;
type ExtendedAnitraktMoviesIdMap = Map<number, ExtendedAnitraktMovieEntry>;
type ExtendedAnitraktTvIdMap = Map<number, ExtendedAnitraktTvEntry>;
type AnimeListIdMap = Map<number, AnimeListEntry>;
type AnimeListByTvdbIdMap = Map<number, AnimeListEntry[]>;
export class AnimeDatabase {
private static instance: AnimeDatabase;
private isInitialised = false;
private dataStore: {
fribbMappingsById: MappingIdMap;
manamiById: ManamiIdMap;
kitsuById: KitsuIdMap;
extendedAnitraktMoviesById: ExtendedAnitraktMoviesIdMap;
extendedAnitraktTvById: ExtendedAnitraktTvIdMap;
animeListById: AnimeListIdMap;
animeListByTvdbId: AnimeListByTvdbIdMap;
} = {
fribbMappingsById: new Map(),
manamiById: new Map(),
kitsuById: new Map(),
extendedAnitraktMoviesById: new Map(),
extendedAnitraktTvById: new Map(),
animeListById: new Map(),
animeListByTvdbId: new Map(),
};
// Refresh timers
private refreshTimers: NodeJS.Timeout[] = [];
private constructor() {}
public static getInstance(): AnimeDatabase {
if (!this.instance) {
this.instance = new AnimeDatabase();
}
return this.instance;
}
public async initialise(): Promise<void> {
if (this.isInitialised) {
logger.warn('already initialised');
return;
}
if (appConfig.metadata.animeDb.levelOfDetail === 'none') {
logger.info('detail level is none, skipping initialisation');
this.isInitialised = true;
return;
}
// Register every source's refresh task first, then trigger an immediate
// run
this.setupAllRefreshIntervals();
logger.info('starting initial refresh of all data sources');
for (const [key, source] of Object.entries(DATA_SOURCES)) {
const result = await TaskManager.runNow(`anime-db-refresh-${key}`);
if (!result.ok) {
logger.error(
{ source: source.name, error: result.message },
'failed to refresh data source'
);
}
}
this.isInitialised = true;
logger.info('initialised successfully');
}
// --- Public Methods for Data Access ---
public isAnime(id: string): boolean {
const parsedId = IdParser.parse(id, 'unknown');
if (
parsedId &&
this.getEntryById(
parsedId.type,
parsedId.value,
parsedId.season ? Number(parsedId.season) : undefined,
parsedId.episode ? Number(parsedId.episode) : undefined
) !== null
) {
return true;
}
return false;
}
public getEntryById(
idType: IdType,
idValue: string | number,
season?: number,
episode?: number
): AnimeEntry | null {
const getFromMap = <T>(map: Map<any, T> | undefined, key: any) =>
map?.get(key) || map?.get(key.toString()) || map?.get(Number(key));
let mappingsList = getFromMap(
this.dataStore.fribbMappingsById.get(idType),
idValue
);
mappingsList = this.filterMappingsBySeasonType(mappingsList, season);
const { mappings, details, animeListEntry } =
this.selectBestMappingAndDetails(
mappingsList,
idType,
idValue,
season,
episode
);
const malId =
mappings?.malId ?? (idType === 'malId' ? Number(idValue) : null);
const kitsuId =
mappings?.kitsuId ?? (idType === 'kitsuId' ? Number(idValue) : null);
const anidbId =
mappings?.anidbId ?? (idType === 'anidbId' ? Number(idValue) : null);
const kitsuEntry = kitsuId ? this.dataStore.kitsuById.get(kitsuId) : null;
const tvAnitraktEntry = malId
? this.dataStore.extendedAnitraktTvById.get(malId)
: null;
const movieAnitraktEntry = malId
? this.dataStore.extendedAnitraktMoviesById.get(malId)
: null;
const finalAnimeListEntry =
animeListEntry ??
(anidbId ? this.dataStore.animeListById.get(anidbId) : null);
if (
!details &&
!mappings &&
!kitsuEntry &&
!tvAnitraktEntry &&
!movieAnitraktEntry &&
!finalAnimeListEntry
) {
return null;
}
return this.buildAnimeEntry(
mappings,
details,
kitsuEntry ?? null,
tvAnitraktEntry ?? null,
movieAnitraktEntry ?? null,
finalAnimeListEntry ?? null
);
}
private filterMappingsBySeasonType(
mappingsList: MappingEntry[] | undefined,
season?: number
): MappingEntry[] | undefined {
if (!mappingsList) return mappingsList;
const seasonFiltered = mappingsList.filter((entry) => {
if (entry.type === AnimeType.UNKNOWN) return true;
if (season === undefined) return entry.type === AnimeType.MOVIE;
if (season === 0)
return [AnimeType.SPECIAL, AnimeType.OVA, AnimeType.ONA].includes(
entry.type
);
if (
entry.type !== AnimeType.TV &&
((entry.season?.tvdb ?? 0) > 1 || (entry.season?.tmdb ?? 0) > 1)
)
return true;
return [AnimeType.TV].includes(entry.type);
});
return seasonFiltered.length > 0 ? seasonFiltered : mappingsList;
}
private findManamiDetailsFromMapping(
mapping: MappingEntry
): MinimisedManamiEntry | undefined {
const getFromMap = <T>(map: Map<any, T> | undefined, key: any) =>
map?.get(key) || map?.get(key.toString()) || map?.get(Number(key));
// Try all available ID types in the mapping
for (const [type, id] of Object.entries(mapping)) {
if (!id) continue;
const details = getFromMap(
this.dataStore.manamiById.get(type as IdType),
id
);
if (details) return details;
}
return undefined;
}
private selectBestMappingAndDetails(
mappingsList: MappingEntry[] | undefined,
idType: IdType,
idValue: string | number,
season?: number,
episode?: number
): {
mappings?: MappingEntry;
details?: MinimisedManamiEntry;
animeListEntry?: AnimeListEntry;
} {
if (!mappingsList?.length) {
return {};
}
if (mappingsList.length === 1) {
return {
mappings: mappingsList[0],
details: this.findManamiDetailsFromMapping(mappingsList[0]),
};
}
if (season !== undefined && episode !== undefined) {
const match = this.findBestMatchForSeasonEpisode(
mappingsList,
season,
episode,
idType,
idValue
);
if (match.mappings) return match;
}
logger.debug('no detailed match found, defaulting to first mapping entry');
const mappings = mappingsList[0];
return { mappings, details: this.findManamiDetailsFromMapping(mappings) };
}
private findBestMatchForSeasonEpisode(
mappingsList: MappingEntry[],
season: number,
episode: number,
idType: IdType,
idValue: string | number
): {
mappings?: MappingEntry;
details?: MinimisedManamiEntry;
animeListEntry?: AnimeListEntry;
} {
const getFromMap = <T>(map: Map<any, T> | undefined, key: any) =>
map?.get(key) || map?.get(key.toString()) || map?.get(Number(key));
logger.debug(
{
id: `${idType}:${idValue}`,
mappingsList: mappingsList.map(
(m) =>
`${m.anidbId ? 'anidb:' : m.kitsuId ? 'kitsu:' : ''}${m.anidbId ?? m.kitsuId ?? 'UNKNOWN'}`
),
},
'multiple mapping entries found, attempting to find matching details'
);
// Collect all potential matches from both Kitsu and AnimeList
type CandidateMatch = {
mapping: MappingEntry;
fromEpisode: number;
source: 'kitsu' | 'animeList';
animeListEntry?: AnimeListEntry;
};
const candidates: CandidateMatch[] = [];
// Check Kitsu entries
for (const mappingEntry of mappingsList) {
if (mappingEntry.kitsuId) {
const kitsuEntry = this.dataStore.kitsuById.get(mappingEntry.kitsuId);
if (kitsuEntry?.fromSeason === season) {
const fromEpisode = kitsuEntry.fromEpisode ?? 1;
if (episode >= fromEpisode) {
candidates.push({
mapping: mappingEntry,
fromEpisode,
source: 'kitsu',
});
}
}
}
}
// Check AnimeList entries
let tvdbId: number | null = null;
if (idType === 'thetvdbId') {
tvdbId =
typeof idValue === 'number' ? idValue : parseInt(idValue.toString());
} else if (idType === 'imdbId') {
tvdbId = this.getTvdbIdFromImdbId(idValue.toString());
}
if (tvdbId) {
const animeListMatch = this.findAnimeListMatchForTvdbSeason(
tvdbId,
season,
episode
);
if (animeListMatch) {
// Find the mapping entry that has this anidbId
const matchingMapping = mappingsList.find(
(m) => m.anidbId === animeListMatch.anidbId
);
if (matchingMapping) {
const fromEpisode = (animeListMatch.episodeOffset ?? 0) + 1;
candidates.push({
mapping: matchingMapping,
fromEpisode,
source: 'animeList',
animeListEntry: animeListMatch,
});
}
}
}
// Select the best match: highest fromEpisode that episode still qualifies for
if (candidates.length > 0) {
const bestMatch = candidates.reduce((best, current) =>
current.fromEpisode > best.fromEpisode ? current : best
);
logger.debug(
{
seasonEpisode: `S${season}E${episode}`,
match:
bestMatch.source === 'kitsu'
? `kitsuId:${bestMatch.mapping.kitsuId}`
: `anidbId:${bestMatch.mapping.anidbId}`,
fromEpisode: bestMatch.fromEpisode,
},
'found best match'
);
return {
mappings: bestMatch.mapping,
details: this.findManamiDetailsFromMapping(bestMatch.mapping),
animeListEntry: bestMatch.animeListEntry,
};
}
// Try synonym matching
const seasonRegex = new RegExp(`season[\\s_-]*${season}`, 'i');
for (const mappingEntry of mappingsList) {
for (const [type, id] of Object.entries(mappingEntry)) {
if (!id) continue;
const potentialDetails = getFromMap(
this.dataStore.manamiById.get(type as IdType),
id
);
if (!potentialDetails) continue;
if (potentialDetails?.synonyms.some((syn) => seasonRegex.test(syn))) {
logger.debug(
{ id: `${type}:${id}` },
'matched season regex on synonym'
);
return { mappings: mappingEntry, details: potentialDetails };
}
break;
}
}
return {};
}
/**
* Convert IMDB ID to TVDB ID using Fribb mappings
*/
private getTvdbIdFromImdbId(imdbId: string): number | null {
const imdbMappings = this.dataStore.fribbMappingsById
.get('imdbId')
?.get(imdbId);
if (!imdbMappings || imdbMappings.length === 0) return null;
// Return the first TVDB ID found in mappings
for (const mapping of imdbMappings) {
if (mapping.thetvdbId) {
return mapping.thetvdbId;
}
}
return null;
}
/**
* Find the best AnimeList entry for a TVDB ID + season/episode combination
* Uses TVDB/TMDB season mappings and offsets from the AnimeList XML
*/
private findAnimeListMatchForTvdbSeason(
tvdbId: number,
season: number,
episode: number
): AnimeListEntry | null {
const candidates = this.dataStore.animeListByTvdbId.get(tvdbId);
if (!candidates || candidates.length === 0) return null;
if (candidates.length === 1) {
return candidates[0];
}
let bestMatch: AnimeListEntry | null = null;
let highestOffset = -1;
for (const candidate of candidates) {
// Check TVDB season match
if (
candidate.defaultTvdbSeason !== null &&
(candidate.defaultTvdbSeason === season ||
candidate.defaultTvdbSeason === 'a')
) {
const offset = candidate.episodeOffset ?? 0;
if (episode >= 1 + offset && offset > highestOffset) {
bestMatch = candidate;
highestOffset = offset;
}
}
// Check TMDB season match as fallback (only if no TVDB match for this season)
if (
candidate.tmdbSeason !== null &&
candidate.tmdbSeason === season &&
candidate.defaultTvdbSeason !== season
) {
const offset = candidate.tmdbOffset ?? 0;
if (episode >= 1 + offset && offset > highestOffset) {
bestMatch = candidate;
highestOffset = offset;
}
}
}
if (bestMatch) {
return bestMatch;
}
return null;
}
private buildAnimeEntry(
mappings: MappingEntry | undefined,
details: MinimisedManamiEntry | undefined,
kitsuEntry: KitsuEntry | null,
tvAnitraktEntry: ExtendedAnitraktTvEntry | null,
movieAnitraktEntry: ExtendedAnitraktMovieEntry | null,
animeListEntry: AnimeListEntry | null
): AnimeEntry {
const combinedMappings: MappingEntry = {
...mappings,
type: mappings?.type ?? AnimeType.UNKNOWN,
imdbId:
mappings?.imdbId ??
animeListEntry?.imdbId ??
kitsuEntry?.imdbId ??
movieAnitraktEntry?.externals?.imdb ??
tvAnitraktEntry?.externals?.imdb,
kitsuId: mappings?.kitsuId,
malId: mappings?.malId,
themoviedbId:
mappings?.themoviedbId ??
animeListEntry?.tmdbId ??
animeListEntry?.tmdbTv ??
movieAnitraktEntry?.externals?.tmdb ??
tvAnitraktEntry?.externals?.tmdb ??
undefined,
thetvdbId:
animeListEntry?.tvdbId ??
kitsuEntry?.tvdbId ??
mappings?.thetvdbId ??
tvAnitraktEntry?.externals?.tvdb,
traktId:
mappings?.traktId ??
tvAnitraktEntry?.trakt?.id ??
movieAnitraktEntry?.trakt?.id,
};
const {
type,
season: mappingSeasonInfo,
...finalMappings
} = combinedMappings;
const traktExternalSeasonInfo = tvAnitraktEntry?.trakt?.season?.externals;
// Determine TVDB season and episode offset
const tvdbSeasonNumber =
mappingSeasonInfo?.tvdb ??
(animeListEntry?.defaultTvdbSeason === 'a'
? null
: (animeListEntry?.defaultTvdbSeason ?? null));
const tvdbFromEpisode =
animeListEntry?.episodeOffset != null
? animeListEntry.episodeOffset + 1
: null;
const tmdbSeasonNumber =
mappingSeasonInfo?.tmdb ?? animeListEntry?.tmdbSeason ?? null;
const tmdbFromEpisode =
animeListEntry?.tmdbOffset != null ? animeListEntry.tmdbOffset + 1 : null;
return {
mappings: finalMappings,
tmdb: {
seasonNumber: tmdbSeasonNumber,
seasonId: traktExternalSeasonInfo?.tmdb ?? null,
fromEpisode: tmdbFromEpisode,
},
tvdb: {
seasonNumber: tvdbSeasonNumber,
seasonId: traktExternalSeasonInfo?.tvdb ?? null,
fromEpisode: tvdbFromEpisode,
},
imdb: kitsuEntry
? {
seasonNumber: kitsuEntry?.fromSeason,
fromEpisode: kitsuEntry?.fromEpisode,
nonImdbEpisodes: kitsuEntry?.nonImdbEpisodes,
title: kitsuEntry?.title,
}
: null,
fanart: kitsuEntry?.fanartLogoId
? { logoId: kitsuEntry.fanartLogoId }
: null,
trakt: tvAnitraktEntry?.trakt
? {
title: tvAnitraktEntry.trakt.title,
slug: tvAnitraktEntry.trakt.slug,
isSplitCour: tvAnitraktEntry.trakt.isSplitCour,
seasonId: tvAnitraktEntry.trakt.season?.id ?? null,
seasonNumber: tvAnitraktEntry.trakt.season?.number ?? null,
}
: movieAnitraktEntry?.trakt
? {
title: movieAnitraktEntry.trakt.title,
slug: movieAnitraktEntry.trakt.slug,
}
: null,
type: type,
...details,
episodeMappings: animeListEntry?.mappings,
};
}
// --- Refresh Interval Configuration ---
private setupAllRefreshIntervals(): void {
this.refreshTimers.forEach(clearInterval);
this.refreshTimers = [];
for (const [key, source] of Object.entries(DATA_SOURCES)) {
TaskManager.register({
id: `anime-db-refresh-${key}`,
label: `Refresh ${source.name}`,
description: `Refresh the ${source.name} anime database source.`,
category: 'data-sync',
kind: 'scheduled',
intervalMs: source.refreshInterval,
enabled: true,
destructive: false,
multiReplica: 'single',
run: async () => {
await this.refreshDataSource(source);
return { ok: true, message: `${source.name} refreshed` };
},
});
logger.info(
{ source: source.name, intervalMs: source.refreshInterval },
'registered auto-refresh task'
);
}
}
// --- Private Refresh and Load Methods ---
private async refreshDataSource(
source: (typeof DATA_SOURCES)[keyof typeof DATA_SOURCES]
): Promise<void> {
return withRetry(
async () => {
const remoteEtag = await this.fetchRemoteEtag(source.url);
const localEtag = await this.readLocalFile(source.etagPath);
const isDbMissing = !(await this.fileExists(source.filePath));
const isOutOfDate =
!remoteEtag || !localEtag || remoteEtag !== localEtag;
const fetchFromRemote = isDbMissing || isOutOfDate;
if (fetchFromRemote) {
logger.info(
{
source: source.name,
reason: isDbMissing
? 'missing'
: !remoteEtag
? 'no remote etag'
: !localEtag
? 'no local etag'
: 'etag mismatch',
},
'triggering download'
);
await this.downloadFile(
source.url,
source.filePath,
source.etagPath,
remoteEtag
);
} else {
logger.info({ source: source.name }, 'source up to date');
}
try {
await this[source.loader]();
} catch (error) {
// if we didnt fetch from remote and loading it failed, force a refresh next time by deleting the local file and etag
if (!fetchFromRemote) {
logger.debug(
{ source: source.name },
'deleting local file and etag due to error'
);
await fs.unlink(source.etagPath);
await fs.unlink(source.filePath);
}
throw error;
}
},
{
getContext: () => source.name,
}
);
}
private async loadFribbMappings(): Promise<void> {
const start = Date.now();
const fileContents = await this.readLocalFile(
DATA_SOURCES.fribbMappings.filePath
);
if (!fileContents)
throw new Error(DATA_SOURCES.fribbMappings.name + ' file not found');
const data = JSON.parse(fileContents);
if (!Array.isArray(data))
throw new Error(
DATA_SOURCES.fribbMappings.name + ' data must be an array'
);
const validEntries = this.validateEntries(data, validateMappingEntry);
const newMappingsById: MappingIdMap = new Map();
for (const idType of ID_TYPES) {
newMappingsById.set(idType, new Map());
}
for (const entry of validEntries) {
for (const idType of ID_TYPES) {
const idValue = entry[idType];
if (idValue !== undefined && idValue !== null) {
const existingEntry = newMappingsById.get(idType)?.get(idValue);
if (!existingEntry) {
newMappingsById.get(idType)?.set(idValue, [entry]);
} else {
existingEntry.push(entry);
}
}
}
}
this.dataStore.fribbMappingsById = newMappingsById;
logger.info(
{
source: DATA_SOURCES.fribbMappings.name,
entries: validEntries.length,
timeTaken: getTimeTakenSincePoint(start),
},
'loaded and indexed valid entries'
);
}
private async loadManamiDb(): Promise<void> {
const start = Date.now();
const fileContents = await this.readLocalFile(DATA_SOURCES.manami.filePath);
if (!fileContents)
throw new Error(DATA_SOURCES.manami.name + ' file not found');
const data = JSON.parse(fileContents);
if (!Array.isArray(data.data))
throw new Error(DATA_SOURCES.manami.name + ' data must be an array');
const validEntries = this.validateEntries(data.data, validateManamiEntry);
const newManamiById: ManamiIdMap = new Map();
const idTypes = Object.keys(extractIdFromUrl) as Exclude<
IdType,
'traktId'
>[];
for (const idType of idTypes) {
newManamiById.set(idType, new Map());
}
for (const entry of validEntries) {
for (const sourceUrl of entry.sources) {
for (const idType of idTypes) {
const idExtractor = extractIdFromUrl[idType];
if (idExtractor) {
const idValue = idExtractor(sourceUrl);
if (idValue) {
const existingEntry = newManamiById.get(idType)?.get(idValue);
if (!existingEntry) {
newManamiById
.get(idType)
?.set(
idValue,
appConfig.metadata.animeDb.levelOfDetail === 'required'
? this.minimiseManamiEntry(entry)
: entry
);
}
}
}
}
}
}
this.dataStore.manamiById = newManamiById;
logger.info(
{
source: DATA_SOURCES.manami.name,
entries: validEntries.length,
timeTaken: getTimeTakenSincePoint(start),
},
'loaded and indexed valid entries'
);
}
private minimiseManamiEntry(entry: ManamiEntry): MinimisedManamiEntry {
return {
title: entry.title,
animeSeason: entry.animeSeason,
synonyms: entry.synonyms,
};
}
private async loadKitsuImdbMapping(): Promise<void> {
const start = Date.now();
const fileContents = await this.readLocalFile(
DATA_SOURCES.kitsuImdb.filePath
);
if (!fileContents)
throw new Error(DATA_SOURCES.kitsuImdb.name + ' file not found');
const data = JSON.parse(fileContents);
// Validate and store kitsu entries
this.dataStore.kitsuById = new Map();
let enrichedCount = 0;
const entries: Array<[number, any]> = Array.isArray(data)
? data.map((entry) => [entry.kitsu_id, entry])
: Object.entries(data).map(([id, entry]) => [Number(id), entry]);
for (const [kitsuId, kitsuEntry] of entries) {
const validated = validateKitsuEntry(kitsuEntry);
if (validated !== null) {
this.dataStore.kitsuById.set(Number(kitsuId), validated);
// Enrich Fribb mappings with IMDB ID if available and not already present
if (validated.imdbId) {
const kitsuMappings = this.dataStore.fribbMappingsById
.get('kitsuId')
?.get(Number(kitsuId));
if (kitsuMappings && kitsuMappings.length > 0) {
for (const mapping of kitsuMappings) {
if (!mapping.imdbId) {
mapping.imdbId = validated.imdbId;
const imdbMap = this.dataStore.fribbMappingsById.get('imdbId');
if (!imdbMap) continue;
const existingImdbMappings =
imdbMap.get(validated.imdbId) || [];
if (
!existingImdbMappings.some(
(m) => m.kitsuId === Number(kitsuId)
)
) {
existingImdbMappings.push(mapping);
imdbMap.set(validated.imdbId, existingImdbMappings);
this.dataStore.fribbMappingsById.set('imdbId', imdbMap);
enrichedCount++;
}
}
}
}
}
} else {
logger.warn(
{ source: DATA_SOURCES.kitsuImdb.name, kitsuId },
'skipping invalid entry'
);
}
}
logger.info(
{
source: DATA_SOURCES.kitsuImdb.name,
kitsuEntries: this.dataStore.kitsuById.size,
enrichedMappings: enrichedCount,
timeTaken: getTimeTakenSincePoint(start),
},
'loaded kitsu entries and enriched Fribb mappings with IMDB IDs'
);
}
private async loadExtendedAnitraktMovies(): Promise<void> {
const start = Date.now();
const fileContents = await this.readLocalFile(
DATA_SOURCES.anitraktMovies.filePath
);
if (!fileContents)
throw new Error(DATA_SOURCES.anitraktMovies.name + ' file not found');
const data = JSON.parse(fileContents);
if (!Array.isArray(data))
throw new Error(
DATA_SOURCES.anitraktMovies.name + ' data must be an array'
);
const validEntries = this.validateEntries(
data,
validateExtendedAnitraktMovieEntry
);
const newExtendedAnitraktMoviesById: ExtendedAnitraktMoviesIdMap =
new Map();
for (const entry of validEntries) {
newExtendedAnitraktMoviesById.set(entry.myanimelist.id, entry);
}
this.dataStore.extendedAnitraktMoviesById = newExtendedAnitraktMoviesById;
logger.info(
{
source: DATA_SOURCES.anitraktMovies.name,
entries: validEntries.length,
timeTaken: getTimeTakenSincePoint(start),
},
'loaded and indexed valid entries'
);
}
private async loadExtendedAnitraktTv(): Promise<void> {
const start = Date.now();
const fileContents = await this.readLocalFile(
DATA_SOURCES.anitraktTv.filePath
);
if (!fileContents)
throw new Error(DATA_SOURCES.anitraktTv.name + ' file not found');
const data = JSON.parse(fileContents);
if (!Array.isArray(data))
throw new Error(DATA_SOURCES.anitraktTv.name + ' data must be an array');
const validEntries = this.validateEntries(
data,
validateExtendedAnitraktTvEntry
);
const newExtendedAnitraktTvById: ExtendedAnitraktTvIdMap = new Map();
for (const entry of validEntries) {
newExtendedAnitraktTvById.set(entry.myanimelist.id, entry);
}
this.dataStore.extendedAnitraktTvById = newExtendedAnitraktTvById;
logger.info(
{
source: DATA_SOURCES.anitraktTv.name,
entries: validEntries.length,
timeTaken: getTimeTakenSincePoint(start),
},
'loaded and indexed valid entries'
);
}
private async loadAnimeList(): Promise<void> {
const start = Date.now();
const fileContents = await this.readLocalFile(
DATA_SOURCES.animeList.filePath
);
if (!fileContents)
throw new Error(DATA_SOURCES.animeList.name + ' file not found');
const parser = new Parser({
explicitArray: true,
async: true,
});
const rawParseStart = Date.now();
const parsed = await parser.parseStringPromise(fileContents);
logger.info(
{
source: DATA_SOURCES.animeList.name,
timeTaken: getTimeTakenSincePoint(rawParseStart),
},
'parsed XML'
);
if (!parsed?.['anime-list']?.anime) {
throw new Error(DATA_SOURCES.animeList.name + ' invalid XML structure');
}
const validEntries = this.validateEntries(
parsed['anime-list'].anime,
validateAnimeListEntry
);
const newAnimeListById: AnimeListIdMap = new Map();
const newAnimeListByTvdbId: AnimeListByTvdbIdMap = new Map();
let entriesInTvdbMap = 0;
for (const entry of validEntries) {
newAnimeListById.set(entry.anidbId, entry);
// Index by TVDB ID for reverse lookups
if (entry.tvdbId) {
const existing = newAnimeListByTvdbId.get(entry.tvdbId) ?? [];
existing.push(entry);
newAnimeListByTvdbId.set(entry.tvdbId, existing);
entriesInTvdbMap++;
}
}
this.dataStore.animeListById = newAnimeListById;
this.dataStore.animeListByTvdbId = newAnimeListByTvdbId;
logger.info(
{
source: DATA_SOURCES.animeList.name,
entries: validEntries.length,
entriesWithTvdbIds: entriesInTvdbMap,
timeTaken: getTimeTakenSincePoint(start),
},
'loaded entries'
);
}
// --- Generic File and Network Helpers ---
private validateEntries<T>(
entries: unknown[],
validator: (data: any) => T | null
): T[] {
const validEntries: T[] = [];
for (const entry of entries) {
const validated = validator(entry);
if (validated !== null) {
validEntries.push(validated);
} else {
logger.warn(
{ validator: validator.name, entry },
'skipping invalid entry'
);
}
}
return validEntries;
}
private async fetchRemoteEtag(url: string): Promise<string | null> {
try {
const response = await makeRequest(url, {
method: 'HEAD',
timeout: 15000,
});
return response.headers.get('etag');
} catch (error) {
logger.warn({ url, error }, 'failed to fetch remote etag');
return null;
}
}
private async readLocalFile(filePath: string): Promise<string | null> {
try {
return await fs.readFile(filePath, 'utf8');
} catch (error) {
return null; // Gracefully handle file not existing
}
}
private async downloadFile(
url: string,
filePath: string,
etagPath: string,
remoteEtag: string | null
): Promise<void> {
const startTime = Date.now();
const response = await makeRequest(url, { method: 'GET', timeout: 90000 });
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
// Stream the response directly to file for large files
await fs.mkdir(ANIME_DATABASE_PATH, { recursive: true });
// Create a write stream for the file
const fileStream = createWriteStream(filePath);
// Pipe the response body to the file using Node.js streams
await new Promise<void>((resolve, reject) => {
if (!response.body) {
reject(new Error('No response body to stream'));
return;
}
const reader = response.body.getReader();
const stream = new ReadableStream({
async start(controller) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
controller.enqueue(value);
}
controller.close();
} catch (error) {
controller.error(error);
}
},
});
// Pipe the stream to the file
stream
.pipeTo(
new WritableStream({
write(chunk) {
return new Promise((resolve, reject) => {
fileStream.write(chunk, (error) => {
if (error) reject(error);
else resolve();
});
});
},
close() {
fileStream.end();
},
})
)
.then(resolve)
.catch(reject);
// Handle stream errors
fileStream.on('error', reject);
});
// Write the etag if present
const etag = remoteEtag ?? response.headers.get('etag');
if (etag) {
await fs.writeFile(etagPath, etag);
}
logger.info(
{
file: path.basename(filePath),
timeTaken: getTimeTakenSincePoint(startTime),
},
'downloaded file'
);
}
private async fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
}
/**
* Extract season number from anime synonyms
* @param synonyms - Array of anime title synonyms
* @returns Season number as string, or undefined if not found
*/
export function getSeasonFromSynonyms(synonyms: string[]): string | undefined {
const seasonRegex = /(?:season|s)\s(\d+)/i;
for (const synonym of synonyms) {
const match = synonym.match(seasonRegex);
if (match) {
return match[1].toString().trim();
}
}
return undefined;
}
/**
* Enrich parsed ID with anime database entry information
* Updates season and episode numbers based on anime database mappings
* @param parsedId - Parsed ID object to enrich (modified in place)
* @param animeEntry - Anime database entry
*/
export function enrichParsedIdWithAnimeEntry(
parsedId: ParsedId,
animeEntry: AnimeEntry
): void {
let enriched: boolean = false;
let original = {
season: parsedId.season,
episode: parsedId.episode,
};
const imdbId = animeEntry.mappings?.imdbId;
let episodeOffsetApplied: boolean = false;
// Handle episode mappings for anime with split seasons (e.g., one AniDB season maps to multiple TVDB seasons)
if (
parsedId.episode &&
['malId', 'kitsuId', 'anilistId'].includes(parsedId.type) &&
animeEntry.episodeMappings &&
animeEntry.episodeMappings.length > 0
) {
const episodeNum = Number(parsedId.episode);
// Find the mapping that contains this episode
const mapping = animeEntry.episodeMappings.find(
(m) =>
m.start !== undefined &&
m.end !== undefined &&
episodeNum >= m.start &&
episodeNum <= m.end
);
if (mapping) {
const mappedSeason = mapping.tvdbSeason;
const shouldApplyEpisodeOffset = imdbId && ['tt1528406'].includes(imdbId);
if (
mappedSeason &&
shouldApplyEpisodeOffset &&
mapping.offset !== undefined
) {
// Apply both season and episode offset for whitelisted IDs
parsedId.season = mappedSeason.toString();
parsedId.episode = (episodeNum + mapping.offset).toString();
enriched = true;
episodeOffsetApplied = true;
logger.debug(
{
id: `${parsedId.type}:${parsedId.value}`,
originalEpisode: episodeNum,
mappedSeason: parsedId.season,
mappedEpisode: parsedId.episode,
...mapping,
},
'applied episode mapping'
);
}
}
}
if (!parsedId.season) {
parsedId.season =
animeEntry.imdb?.seasonNumber?.toString() ??
animeEntry.tvdb?.seasonNumber?.toString() ??
animeEntry.trakt?.seasonNumber?.toString() ??
getSeasonFromSynonyms(animeEntry.synonyms ?? []) ??
animeEntry.tmdb?.seasonNumber?.toString();
if (parsedId.season) enriched = true;
}
// Only apply fromEpisode offset if episode mappings didn't already handle it
if (
parsedId.episode &&
['malId', 'kitsuId'].includes(parsedId.type) &&
!episodeOffsetApplied
) {
const fromEpisode =
animeEntry.imdb?.fromEpisode ?? animeEntry.tvdb?.fromEpisode;
if (fromEpisode && fromEpisode !== 1) {
parsedId.episode = (
fromEpisode +
Number(parsedId.episode) -
1
).toString();
enriched = true;
}
}
if (enriched) {
logger.debug(
{
original: `${parsedId.type}:${parsedId.value}${original.season ? `:${original.season}` : ''}${original.episode ? `:${original.episode}` : ''}`,
enriched: `${parsedId.type}:${parsedId.value}${parsedId.season ? `:${parsedId.season}` : ''}${parsedId.episode ? `:${parsedId.episode}` : ''}`,
},
'enriched anime ID'
);
}
}