import { ParsedStream, UserData } from '../db/schemas.js'; import { MetadataService } from '../metadata/service.js'; import { Metadata } from '../metadata/utils.js'; import { ReleaseDate, TMDBMetadata } from '../metadata/tmdb.js'; import { AnimeDatabase, AnimeEntry, IdParser, ParsedId, createLogger, getSeaDexInfoHashes, enrichParsedIdWithAnimeEntry, } from '../utils/index.js'; import { SeaDexResult } from '../utils/seadex.js'; import { calculateAbsoluteEpisode } from '../builtins/utils/general.js'; import { iso6391ToLanguage } from '../utils/languages.js'; const logger = createLogger('stream-context'); /** * Extended metadata that includes additional fields computed during context build */ export interface ExtendedMetadata extends Metadata { absoluteEpisode?: number; relativeAbsoluteEpisode?: number; // Episode number within current AniDB entry (for split entries) seasonYear?: number; // For anime, the year of the season (e.g., 2021 for "Winter 2021") } export interface ExpressionContext { type?: string; id?: string; isAnime?: boolean; queryType?: string; season?: number; episode?: number; // Metadata fields title?: string; titles?: string[]; year?: number; yearEnd?: number; genres?: string[]; runtime?: number; absoluteEpisode?: number; relativeAbsoluteEpisode?: number; // Episode number within current AniDB entry (for split entries) originalLanguage?: string; daysSinceRelease?: number; // age in days of the movie / **episode** hasNextEpisode?: boolean; daysUntilNextEpisode?: number; daysSinceFirstAired?: number; daysSinceLastAired?: number; latestSeason?: number; // Anime entry data anilistId?: number; malId?: number; // SeaDex availability hasSeaDex?: boolean; } /** * StreamContext encapsulates all request-specific data that can be shared * across filtering, sorting, precomputing, and expression evaluation. * */ export class StreamContext { // Core request info public readonly type: string; public readonly id: string; public readonly parsedId: ParsedId | null; // Anime-related data (cached from AnimeDatabase) public readonly isAnime: boolean; public readonly animeEntry: AnimeEntry | null; public readonly queryType: string; // 'anime.movie', 'anime.series', 'movie', 'series' // Metadata (fetched from TMDB/TVDB/IMDB) private _metadata: ExtendedMetadata | undefined; private _metadataPromise: Promise | undefined; private _metadataFetched: boolean = false; // Release dates for digital release filter (movie only) private _releaseDates: ReleaseDate[] | undefined; private _releaseDatesPromise: Promise | undefined; // Episode details for series digital release filter and bitrate calculation private _episodeDetails: { airDate?: string; runtime?: number } | undefined; private _episodeDetailsPromise: | Promise<{ airDate?: string; runtime?: number } | undefined> | undefined; // SeaDex data (for anime) private _seadex: SeaDexResult | undefined; private _seadexPromise: Promise | undefined; private _seadexFetched: boolean = false; // Year within title (for year matching) // public readonly yearWithinTitle: string | undefined; // public readonly yearWithinTitleRegex: RegExp | undefined; // User data reference private readonly userData: UserData; private constructor( type: string, id: string, userData: UserData, options: { parsedId: ParsedId | null; isAnime: boolean; animeEntry: AnimeEntry | null; queryType: string; } ) { this.type = type; this.id = id; this.userData = userData; this.parsedId = options.parsedId; this.isAnime = options.isAnime; this.animeEntry = options.animeEntry; this.queryType = options.queryType; } /** * Create a StreamContext for a request. * This performs initial synchronous lookups from the AnimeDatabase. */ public static create( type: string, id: string, userData: UserData ): StreamContext { const start = Date.now(); const parsedId = IdParser.parse(id, type); let isAnime = id.startsWith('kitsu'); const animeDb = AnimeDatabase.getInstance(); if (animeDb.isAnime(id)) { isAnime = true; } let animeEntry: AnimeEntry | null = null; if (parsedId) { animeEntry = animeDb.getEntryById( parsedId.type, parsedId.value, parsedId.season ? Number(parsedId.season) : undefined, parsedId.episode ? Number(parsedId.episode) : undefined ); // Enrich parsedId with anime entry data if available and no season specified if (animeEntry && !parsedId.season) { enrichParsedIdWithAnimeEntry(parsedId, animeEntry); } } const queryType = isAnime ? `anime.${type}` : type; logger.debug( { id, type, isAnime, hasAnimeEntry: !!animeEntry, queryType, took: Date.now() - start, }, 'stream context created' ); return new StreamContext(type, id, userData, { parsedId, isAnime, animeEntry, queryType, }); } /** * Start fetching metadata asynchronously. * Call this early (e.g., when starting addon fetches) to parallelize. */ public startMetadataFetch(): void { if (this._metadataPromise || this._metadataFetched) { return; } this._metadataPromise = (async () => { try { const service = new MetadataService({ tmdbAccessToken: this.userData.tmdbAccessToken, tmdbApiKey: this.userData.tmdbApiKey, tvdbApiKey: this.userData.tvdbApiKey, }); const metadata = await service.getMetadata( this.parsedId!, this.type as any ); // Calculate absolute episode for anime let absoluteEpisode: number | undefined; let relativeAbsoluteEpisode: number | undefined; if ( this.isAnime && this.parsedId!.season && this.parsedId!.episode && metadata.seasons ) { const seasons = metadata.seasons.map( ({ season_number, episode_count }) => ({ number: season_number.toString(), episodes: episode_count, }) ); absoluteEpisode = Number( calculateAbsoluteEpisode( this.parsedId!.season, this.parsedId!.episode, seasons ) ); // Calculate relative absolute episode (within current AniDB entry) const startingSeason = this.animeEntry?.imdb?.seasonNumber ?? this.animeEntry?.tvdb?.seasonNumber ?? this.animeEntry?.trakt?.seasonNumber ?? this.animeEntry?.tmdb?.seasonNumber; if (startingSeason) { // Calculate absolute episode from the starting season (AniDB episode number) const episodeNum = Number(this.parsedId!.episode); let totalEpisodesBeforeCurrentSeason = 0; for (const s of seasons.filter((s) => s.number !== '0')) { const seasonNum = Number(s.number); if (seasonNum < startingSeason) continue; // Skip seasons before this AniDB entry if (s.number === this.parsedId!.season) break; totalEpisodesBeforeCurrentSeason += s.episodes; } const calculated = totalEpisodesBeforeCurrentSeason + episodeNum; // Only set if different from regular episode number if (calculated !== episodeNum) { relativeAbsoluteEpisode = calculated; } } // Adjust for non-IMDB episodes if they exist. const parsedSeasonRecord = seasons.find( (s) => s.number === this.parsedId!.season ); const isAlreadyAbsoluteForNonImdb = parsedSeasonRecord !== undefined && Number(this.parsedId!.episode) > parsedSeasonRecord.episodes; if ( this.animeEntry?.imdb?.nonImdbEpisodes && absoluteEpisode && !isAlreadyAbsoluteForNonImdb ) { const nonImdbEpisodesBefore = this.animeEntry.imdb.nonImdbEpisodes.filter( (ep: number) => ep < absoluteEpisode! ).length; if (nonImdbEpisodesBefore > 0) { absoluteEpisode += nonImdbEpisodesBefore; } if (relativeAbsoluteEpisode) { const nonImdbEpisodesBeforeRelative = this.animeEntry.imdb.nonImdbEpisodes.filter( (ep: number) => ep < relativeAbsoluteEpisode! ).length; if (nonImdbEpisodesBeforeRelative > 0) { relativeAbsoluteEpisode += nonImdbEpisodesBeforeRelative; } } } } const extendedMetadata: ExtendedMetadata = { ...metadata, absoluteEpisode, relativeAbsoluteEpisode, seasonYear: this.animeEntry?.animeSeason?.year ?? undefined, }; return extendedMetadata; } catch (error) { logger.warn( { id: this.id, err: error instanceof Error ? error.message : String(error), }, 'failed to fetch metadata' ); return undefined; } finally { this._metadataFetched = true; } })(); } /** * Start fetching release dates asynchronously (for digital release filter). */ public startReleaseDatesFetch(): void { if ( this._releaseDatesPromise || !this.userData.digitalReleaseFilter?.enabled ) { return; } this._releaseDatesPromise = (async () => { const metadata = await this.getMetadata(); if (!metadata?.tmdbId) { return undefined; } if (this.type === 'movie') { try { return await new TMDBMetadata({ accessToken: this.userData.tmdbAccessToken, apiKey: this.userData.tmdbApiKey, }).getReleaseDates(metadata.tmdbId); } catch (error) { logger.warn( { id: this.id, err: error instanceof Error ? error.message : String(error), }, 'failed to fetch release dates' ); return undefined; } } return undefined; })(); } /** * Start fetching episode details asynchronously (for series digital release filter and bitrate). */ public startEpisodeDetailsFetch(): void { const useMetadataRuntime = this.userData.bitrate?.useMetadataRuntime !== false; const digitalReleaseFilterEnabled = this.userData.digitalReleaseFilter?.enabled; if ( this._episodeDetailsPromise || (!digitalReleaseFilterEnabled && !useMetadataRuntime) || (this.type !== 'series' && !this.isAnime) ) { return; } this._episodeDetailsPromise = (async () => { const metadata = await this.getMetadata(); if ( !metadata?.tmdbId || !this.parsedId?.season || !this.parsedId?.episode ) { return undefined; } try { const originalSeason = Number(this.parsedId.season); let seasonNumber = originalSeason; let episodeNumber = Number(this.parsedId.episode); if (this.isAnime && this.animeEntry) { seasonNumber = this.animeEntry.tmdb?.seasonNumber ?? seasonNumber; if (this.animeEntry.tmdb?.fromEpisode) { const fromEpisode = Number(this.animeEntry.tmdb.fromEpisode); if ( seasonNumber !== originalSeason || episodeNumber < fromEpisode ) { episodeNumber = fromEpisode + episodeNumber - 1; } } logger.debug( { originalSeason, originalEpisode: this.parsedId.episode, tmdbSeason: seasonNumber, tmdbEpisode: episodeNumber, fromEpisode: this.animeEntry.tmdb?.fromEpisode, }, 'resolved tmdb season/episode for episode details' ); } return await new TMDBMetadata({ accessToken: this.userData.tmdbAccessToken, apiKey: this.userData.tmdbApiKey, }).getEpisodeDetails(metadata.tmdbId, seasonNumber, episodeNumber); } catch (error) { logger.warn( { id: this.id, err: error instanceof Error ? error.message : String(error), }, 'failed to fetch episode details' ); return undefined; } })(); } /** * Start fetching SeaDex data asynchronously. * Call this early to parallelize with addon fetches. */ public startSeaDexFetch(): void { if ( this._seadexPromise || this._seadexFetched || !this.isAnime || this.userData.enableSeadex === false ) { return; } const anilistIdRaw = this.animeEntry?.mappings?.anilistId; if (!anilistIdRaw) { logger.debug( { id: this.id }, 'no anilist id found, skipping seadex lookup' ); this._seadexFetched = true; return; } const anilistId = typeof anilistIdRaw === 'string' ? parseInt(anilistIdRaw, 10) : anilistIdRaw; if (isNaN(anilistId)) { logger.debug( { id: this.id, anilistId: anilistIdRaw }, 'invalid anilist id, skipping seadex lookup' ); this._seadexFetched = true; return; } this._seadexPromise = (async () => { try { return await getSeaDexInfoHashes(anilistId); } catch (error) { logger.warn( { id: this.id, err: error instanceof Error ? error.message : String(error), }, 'failed to fetch seadex data' ); return undefined; } finally { this._seadexFetched = true; } })(); } /** * Start all async fetches in parallel. * Call this when starting addon fetches to maximize parallelism. */ public startAllFetches(): void { this.startMetadataFetch(); this.startSeaDexFetch(); this.startReleaseDatesFetch(); this.startEpisodeDetailsFetch(); } /** * Get metadata, waiting for fetch if needed. */ public async getMetadata(): Promise { if (this._metadata !== undefined) { return this._metadata; } if (!this._metadataPromise) { this.startMetadataFetch(); } if (this._metadataPromise) { this._metadata = await this._metadataPromise; } return this._metadata; } /** * Get release dates, waiting for fetch if needed. */ public async getReleaseDates(): Promise { if (this._releaseDates !== undefined) { return this._releaseDates; } if (!this._releaseDatesPromise) { this.startReleaseDatesFetch(); } if (this._releaseDatesPromise) { this._releaseDates = await this._releaseDatesPromise; } return this._releaseDates; } /** * Get episode air date, waiting for fetch if needed. */ public async getEpisodeAirDate(): Promise { if (this._episodeDetails !== undefined) { return this._episodeDetails.airDate; } if (!this._episodeDetailsPromise) { this.startEpisodeDetailsFetch(); } if (this._episodeDetailsPromise) { this._episodeDetails = await this._episodeDetailsPromise; } return this._episodeDetails?.airDate; } public async getEpisodeRuntime(): Promise { if (this._episodeDetails !== undefined) { return this._episodeDetails.runtime; } if (!this._episodeDetailsPromise) { this.startEpisodeDetailsFetch(); } if (this._episodeDetailsPromise) { this._episodeDetails = await this._episodeDetailsPromise; } return this._episodeDetails?.runtime; } /** * Get SeaDex data, waiting for fetch if needed. */ public async getSeaDex(): Promise { if (this._seadex !== undefined) { return this._seadex; } if (!this._seadexPromise) { this.startSeaDexFetch(); } if (this._seadexPromise) { this._seadex = await this._seadexPromise; } return this._seadex; } private getDaysSince(dateString: string): number { const date = new Date(dateString); const now = new Date(); date.setHours(0, 0, 0, 0); now.setHours(0, 0, 0, 0); const diffTime = now.getTime() - date.getTime(); return Math.floor(diffTime / (1000 * 60 * 60 * 24)); } private computeAgeInDays(): number | undefined { if (this.type === 'series' && this._episodeDetails?.airDate) { return this.getDaysSince(this._episodeDetails.airDate); } else if (this._metadata?.releaseDate) { return this.getDaysSince(this._metadata.releaseDate); } return undefined; } private computeDaysUntilNextEpisode(): number | undefined { if (!this._metadata?.nextAirDate) { return undefined; } return -this.getDaysSince(this._metadata.nextAirDate); } private computeDaysSinceFirstAired(): number | undefined { if (this._metadata?.firstAiredDate) { return this.getDaysSince(this._metadata.firstAiredDate); } return undefined; } private computeDaysSinceLastAired(): number | undefined { if (this._metadata?.lastAiredDate) { return this.getDaysSince(this._metadata.lastAiredDate); } return undefined; } /** * Convert context to FormatterContext for formatter initialization. * Requires streams to calculate maxRseScore and maxRegexScore. */ public toFormatterContext( streams?: ParsedStream[] ): import('../formatters/base.js').FormatterContext { let maxSeScore: number | undefined; let maxRegexScore: number | undefined; if (streams && streams.length > 0) { // Calculate max scores from streams const seScores = streams .map((s) => s.streamExpressionScore) .filter((score): score is number => typeof score === 'number'); const regexScores = streams .map((s) => s.regexScore) .filter((score): score is number => typeof score === 'number'); maxSeScore = seScores.length > 0 ? Math.max(...seScores) : undefined; maxRegexScore = regexScores.length > 0 ? Math.max(...regexScores) : undefined; } return { userData: this.userData, type: this.type, isAnime: this.isAnime, queryType: this.queryType, season: this.parsedId?.season ? Number(this.parsedId.season) : undefined, episode: this.parsedId?.episode ? Number(this.parsedId.episode) : undefined, title: this._metadata?.title, titles: this._metadata?.titles?.map((t) => t.title), year: this._metadata?.year, yearEnd: this._metadata?.yearEnd, genres: this._metadata?.genres, runtime: this._metadata?.runtime, episodeRuntime: this._episodeDetails?.runtime, absoluteEpisode: this._metadata?.absoluteEpisode, relativeAbsoluteEpisode: this._metadata?.relativeAbsoluteEpisode, originalLanguage: iso6391ToLanguage( this._metadata?.originalLanguage || '' ), daysSinceRelease: this.computeAgeInDays(), hasNextEpisode: !!this._metadata?.nextAirDate, daysUntilNextEpisode: this.computeDaysUntilNextEpisode(), daysSinceFirstAired: this.computeDaysSinceFirstAired(), daysSinceLastAired: this.computeDaysSinceLastAired(), latestSeason: this._metadata?.seasons ? Math.max(...this._metadata.seasons.map((s) => s.season_number)) : undefined, anilistId: this.animeEntry?.mappings?.anilistId, malId: this.animeEntry?.mappings?.malId, hasSeaDex: !!this._seadex?.allHashes?.size, maxSeScore, maxRegexScore, }; } /** * Convert context to a plain object for expression evaluation. */ public toExpressionContext(): Record { return { type: this.type, id: this.id, isAnime: this.isAnime, queryType: this.queryType, season: this.parsedId?.season ? Number(this.parsedId.season) : undefined, episode: this.parsedId?.episode ? Number(this.parsedId.episode) : undefined, // Metadata fields title: this._metadata?.title, titles: this._metadata?.titles?.map((t) => t.title), year: this._metadata?.year, yearEnd: this._metadata?.yearEnd, genres: this._metadata?.genres ?? [], runtime: this._metadata?.runtime, originalLanguage: iso6391ToLanguage( this._metadata?.originalLanguage || '' ), daysSinceRelease: this.computeAgeInDays(), absoluteEpisode: this._metadata?.absoluteEpisode, relativeAbsoluteEpisode: this._metadata?.relativeAbsoluteEpisode, hasNextEpisode: !!this._metadata?.nextAirDate, daysUntilNextEpisode: this.computeDaysUntilNextEpisode(), daysSinceFirstAired: this.computeDaysSinceFirstAired(), daysSinceLastAired: this.computeDaysSinceLastAired(), latestSeason: this._metadata?.seasons ? Math.max(...this._metadata.seasons.map((s) => s.season_number)) : undefined, // Anime entry data anilistId: this.animeEntry?.mappings?.anilistId, malId: this.animeEntry?.mappings?.malId, // SeaDex availability hasSeaDex: !!this._seadex?.allHashes?.size, }; } }