Spaces:
Paused
Paused
| 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<ExtendedMetadata | undefined> | undefined; | |
| private _metadataFetched: boolean = false; | |
| // Release dates for digital release filter (movie only) | |
| private _releaseDates: ReleaseDate[] | undefined; | |
| private _releaseDatesPromise: Promise<ReleaseDate[] | undefined> | 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<SeaDexResult | undefined> | 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<ExtendedMetadata | undefined> { | |
| 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<ReleaseDate[] | undefined> { | |
| 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<string | undefined> { | |
| 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<number | undefined> { | |
| 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<SeaDexResult | undefined> { | |
| 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<string, any> { | |
| 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, | |
| }; | |
| } | |
| } | |