aiostreams / packages /core /src /streams /context.ts
f4b404's picture
Upload folder using huggingface_hub
f55c62a verified
Raw
History Blame Contribute Delete
21.7 kB
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,
};
}
}