diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 959086578..f87fad7f9 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -40,9 +40,12 @@ store imports): | `tmdb.types.ts` | TMDB v3 response shapes (search, details with credits) | | `tmdb-api.service.ts` | Thin `fetch`-based client (TMDB supports CORS; works in Electron renderer and PWA). Accepts v3 keys (`api_key` param) and v4 tokens (Bearer) | | `tmdb-matcher.ts` | Title normalization, year extraction, and the match-confidence gate (pure functions) | -| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory) with caller-supplied TTLs | +| `tmdb-cache.service.ts` | Environment-aware cache (Electron IPC bridge vs in-memory LRU capped at 300 entries) with caller-supplied TTLs | | `tmdb-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) | -| `tmdb-enrichment.service.ts` | Orchestrator: settings gate → id resolution → details fetch → cache | +| `tmdb-runtime.service.ts` | Shared runtime context: opt-in gate, effective API key, language resolution | +| `tmdb-enrichment.service.ts` | Movie/TV orchestrator and facade: id resolution → details fetch → cache; delegates person/season lookups | +| `tmdb-person.service.ts` | Cached person details + combined filmography (`person:` rows) | +| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:\|season:` rows) | Integration glue per portal: diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts new file mode 100644 index 000000000..04a567b63 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-tmdb-seasons.service.ts @@ -0,0 +1,84 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { + TmdbEnrichmentService, + mergeEpisodesWithTmdb, + type TmdbEpisode, +} from '@iptvnator/services'; +import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces'; + +/** + * Component-scoped holder for lazily fetched TMDB episode data in the + * Stalker series view (provide it in the component's `providers`). + * + * Entries are keyed by `${tmdbId}|${seasonKey}` so data from a previously + * shown series can never leak into the current one. + */ +@Injectable() +export class StalkerSeriesTmdbSeasonsService { + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + + private readonly episodesByKey = signal< + ReadonlyMap + >(new Map()); + + /** + * Overlays fetched TMDB episode data (real names, overviews, stills) + * onto provider season maps — a no-op while nothing is fetched. + * Reads a signal, so callers can use it inside a `computed`. + */ + overlay( + seasons: Record, + tmdbId: number | null | undefined + ): Record { + const fetched = this.episodesByKey(); + if (!tmdbId || fetched.size === 0) { + return seasons; + } + + const merged: Record = {}; + for (const [seasonKey, episodes] of Object.entries(seasons)) { + const forSeason = fetched.get(`${tmdbId}|${seasonKey}`); + merged[seasonKey] = forSeason?.length + ? mergeEpisodesWithTmdb(episodes, forSeason) + : episodes; + } + return merged; + } + + /** + * Lazily pulls the TMDB episode list for an opened season; a no-op + * without a show-level TMDB match, with enrichment disabled, or when + * the season was already fetched. + */ + async fetchSeason( + tmdbId: number | null | undefined, + seasonKey: string, + episodes: XtreamSerieEpisode[] | undefined + ): Promise { + if (!tmdbId) { + return; + } + + const mapKey = `${tmdbId}|${seasonKey}`; + if (this.episodesByKey().has(mapKey)) { + return; + } + + const seasonNumber = Number(episodes?.[0]?.season ?? seasonKey); + if (!Number.isFinite(seasonNumber)) { + return; + } + + const tmdbEpisodes = await this.tmdbEnrichment.getSeasonEpisodes( + tmdbId, + seasonNumber + ); + if (!tmdbEpisodes?.length) { + return; + } + + const next = new Map(this.episodesByKey()); + next.set(mapKey, tmdbEpisodes); + this.episodesByKey.set(next); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts index 66c9be70e..96acf7c8a 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.ts @@ -60,10 +60,8 @@ import { import { DownloadsService, PlaybackPositionRuntimeBridgeService, - TmdbEnrichmentService, - mergeEpisodesWithTmdb, - type TmdbEpisode, } from '@iptvnator/services'; +import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service'; import { getStalkerSeriesQuickStartButton, type StalkerQuickStartButton, @@ -89,6 +87,7 @@ import { SeasonContainerComponent, MatIcon, ], + providers: [StalkerSeriesTmdbSeasonsService], }) export class StalkerSeriesViewComponent implements OnDestroy { readonly stalkerStore = inject(StalkerStore); @@ -121,15 +120,7 @@ export class StalkerSeriesViewComponent implements OnDestroy { readonly selectedItem = this.stalkerStore.selectedItem; - /** - * TMDB episode data per opened season, keyed by `${tmdbId}|${seasonKey}` - * so entries from a previously shown series can never leak into the - * current one. Filled lazily in onSeasonSelected. - */ - private readonly tmdbSeasonEpisodes = signal< - ReadonlyMap - >(new Map()); - private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + private readonly tmdbSeasons = inject(StalkerSeriesTmdbSeasonsService); /** * Track VOD series seasons with their loaded episodes @@ -298,20 +289,10 @@ export class StalkerSeriesViewComponent implements OnDestroy { // Overlay lazily fetched TMDB episode data (real names, // overviews, stills) — a no-op while nothing is fetched - const tmdbEpisodes = this.tmdbSeasonEpisodes(); - const tmdbId = this.displayItem()?.info?.tmdb_id; - if (!tmdbId || tmdbEpisodes.size === 0) { - return base; - } - - const merged: Record = {}; - for (const [seasonKey, episodes] of Object.entries(base)) { - const forSeason = tmdbEpisodes.get(`${tmdbId}|${seasonKey}`); - merged[seasonKey] = forSeason?.length - ? mergeEpisodesWithTmdb(episodes, forSeason) - : episodes; - } - return merged; + return this.tmdbSeasons.overlay( + base, + this.displayItem()?.info?.tmdb_id + ); } ); @@ -339,7 +320,11 @@ export class StalkerSeriesViewComponent implements OnDestroy { * For VOD Series, triggers lazy loading of episodes. */ onSeasonSelected(seasonKey: string) { - void this.fetchTmdbSeason(seasonKey); + void this.tmdbSeasons.fetchSeason( + this.displayItem()?.info?.tmdb_id, + seasonKey, + this.mappedSeasons()[seasonKey] + ); if (!this.isVodSeries()) return; @@ -353,40 +338,6 @@ export class StalkerSeriesViewComponent implements OnDestroy { } } - /** - * Lazily pulls the TMDB episode list for an opened season; a no-op - * without a show-level TMDB match or with enrichment disabled. - */ - private async fetchTmdbSeason(seasonKey: string): Promise { - const tmdbId = this.displayItem()?.info?.tmdb_id; - if (!tmdbId) { - return; - } - - const mapKey = `${tmdbId}|${seasonKey}`; - if (this.tmdbSeasonEpisodes().has(mapKey)) { - return; - } - - const episodes = this.mappedSeasons()[seasonKey]; - const seasonNumber = Number(episodes?.[0]?.season ?? seasonKey); - if (!Number.isFinite(seasonNumber)) { - return; - } - - const tmdbEpisodes = await this.tmdbEnrichment.getSeasonEpisodes( - tmdbId, - seasonNumber - ); - if (!tmdbEpisodes?.length) { - return; - } - - const next = new Map(this.tmdbSeasonEpisodes()); - next.set(mapKey, tmdbEpisodes); - this.tmdbSeasonEpisodes.set(next); - } - /** * Loads episodes for a specific VOD season */ diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts new file mode 100644 index 000000000..d7a4e40bd --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts @@ -0,0 +1,53 @@ +import { TmdbCacheEntry } from '@iptvnator/shared/interfaces'; +import { TmdbCacheService } from './tmdb-cache.service'; + +/** PWA path only — window.electron is undefined in the jsdom environment */ +describe('TmdbCacheService (in-memory LRU)', () => { + let service: TmdbCacheService; + + const entry = (lookupKey: string): TmdbCacheEntry => ({ + mediaType: 'movie', + lookupKey, + language: 'en-US', + tmdbId: 1, + payload: '{}', + }); + + beforeEach(() => { + // No Angular DI dependencies — instantiate directly (the services + // Jest target has no @angular/core/testing available) + service = new TmdbCacheService(); + }); + + it('stores and returns entries with a fetchedAt stamp', async () => { + await service.set(entry('id:1')); + const cached = await service.get('movie', 'id:1', 'en-US'); + expect(cached?.lookupKey).toBe('id:1'); + expect(cached?.fetchedAt).toBeDefined(); + }); + + it('evicts the least recently used entry beyond the ceiling', async () => { + for (let i = 0; i < 300; i++) { + await service.set(entry(`id:${i}`)); + } + // Touch the oldest entry so it becomes the most recently used + await service.get('movie', 'id:0', 'en-US'); + + await service.set(entry('id:300')); + + // id:0 was touched and survives; id:1 was the true LRU and is gone + await expect(service.get('movie', 'id:0', 'en-US')).resolves.not.toBeNull(); + await expect(service.get('movie', 'id:1', 'en-US')).resolves.toBeNull(); + await expect( + service.get('movie', 'id:300', 'en-US') + ).resolves.not.toBeNull(); + }); + + it('does not grow when overwriting the same key', async () => { + for (let i = 0; i < 5; i++) { + await service.set(entry('id:same')); + } + const cached = await service.get('movie', 'id:same', 'en-US'); + expect(cached?.lookupKey).toBe('id:same'); + }); +}); diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.ts index a1ea1394a..b2bb18d33 100644 --- a/libs/services/src/lib/tmdb/tmdb-cache.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.ts @@ -1,11 +1,15 @@ import { Injectable } from '@angular/core'; import { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +/** PWA in-memory cache ceiling — details payloads are a few KB each */ +const MEMORY_CACHE_MAX_ENTRIES = 300; + /** * Environment-aware cache for TMDB lookups. * * - Electron: persists via the `tmdb_metadata` SQLite table (IPC bridge) - * - PWA: session-scoped in-memory map (phase 1 baseline) + * - PWA: session-scoped in-memory LRU map capped at + * {@link MEMORY_CACHE_MAX_ENTRIES} entries * * Freshness (TTL) is decided by the caller via {@link isFresh} so different * row kinds (details vs. negative match) can use different TTLs. @@ -43,11 +47,15 @@ export class TmdbCacheService { } } - return ( - this.memoryCache.get( - this.memoryKey(mediaType, lookupKey, language) - ) ?? null - ); + const key = this.memoryKey(mediaType, lookupKey, language); + const entry = this.memoryCache.get(key); + if (entry) { + // LRU touch: Map preserves insertion order, so re-inserting + // moves the entry to the "most recently used" end + this.memoryCache.delete(key); + this.memoryCache.set(key, entry); + } + return entry ?? null; } async set(entry: TmdbCacheEntry): Promise { @@ -66,10 +74,20 @@ export class TmdbCacheService { return; } - this.memoryCache.set( - this.memoryKey(entry.mediaType, entry.lookupKey, entry.language), - stamped + const key = this.memoryKey( + entry.mediaType, + entry.lookupKey, + entry.language ); + this.memoryCache.delete(key); + this.memoryCache.set(key, stamped); + // delete-then-reinsert above means at most one entry over the cap + if (this.memoryCache.size > MEMORY_CACHE_MAX_ENTRIES) { + const oldest = this.memoryCache.keys().next().value; + if (oldest !== undefined) { + this.memoryCache.delete(oldest); + } + } } isFresh(entry: TmdbCacheEntry | null, ttlMs: number): boolean { diff --git a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts index a2b4ecd49..20783c9c4 100644 --- a/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-enrichment.service.ts @@ -1,15 +1,12 @@ import { Injectable, inject } from '@angular/core'; import { TmdbMediaType } from '@iptvnator/shared/interfaces'; -import { SettingsStore } from '../settings-store.service'; import { TmdbApiService } from './tmdb-api.service'; import { TmdbCacheService } from './tmdb-cache.service'; import { - DEFAULT_TMDB_API_KEY, TMDB_DETAILS_CACHE_TTL_MS, TMDB_MATCH_CACHE_TTL_MS, TMDB_NEGATIVE_MATCH_CACHE_TTL_MS, tmdbSearchLanguageForTitle, - toTmdbLanguage, } from './tmdb-config'; import { buildDetailsLookupKey, @@ -19,13 +16,15 @@ import { parseProviderTmdbId, pickConfidentMatch, } from './tmdb-matcher'; +import { TmdbPersonService } from './tmdb-person.service'; +import { TmdbRuntimeService } from './tmdb-runtime.service'; +import { TmdbSeasonService } from './tmdb-season.service'; import { TmdbDetails, TmdbEnrichmentQuery, TmdbEpisode, TmdbMovieDetails, TmdbPersonDetails, - TmdbSeasonDetails, TmdbTvDetails, } from './tmdb.types'; @@ -34,15 +33,21 @@ import { * TMDB id (trusting the provider's tmdb_id, else a confidence-gated title * search), fetches localized details with credits, and caches every step. * Any failure returns `null` — detail views always render provider data. + * + * Person and season lookups live in {@link TmdbPersonService} and + * {@link TmdbSeasonService}; the delegating methods here keep the store + * glue talking to a single facade. */ @Injectable({ providedIn: 'root' }) export class TmdbEnrichmentService { - private readonly settingsStore = inject(SettingsStore); + private readonly runtime = inject(TmdbRuntimeService); private readonly api = inject(TmdbApiService); private readonly cache = inject(TmdbCacheService); + private readonly person = inject(TmdbPersonService); + private readonly season = inject(TmdbSeasonService); isEnabled(): boolean { - return Boolean(this.settingsStore.tmdb?.()?.enabled && this.apiKey()); + return this.runtime.isEnabled(); } async enrichMovie( @@ -55,108 +60,17 @@ export class TmdbEnrichmentService { return (await this.enrich('tv', query)) as TmdbTvDetails | null; } - /** - * Episode list of one season (names, overviews, stills, air dates) in - * the app language. Fetched lazily when a season is opened and cached - * like details payloads. Returns `null` when enrichment is off or the - * request fails — episode lists then stay provider-only. - */ async getSeasonEpisodes( tmdbId: number, seasonNumber: number ): Promise { - if (!this.isEnabled()) { - return null; - } - - try { - const language = this.language(); - const lookupKey = `id:${tmdbId}|season:${seasonNumber}`; - - const cached = await this.cache.get('tv', lookupKey, language); - if ( - this.cache.isFresh(cached, TMDB_DETAILS_CACHE_TTL_MS) && - cached?.payload - ) { - try { - const season = JSON.parse( - cached.payload - ) as TmdbSeasonDetails; - return season.episodes ?? []; - } catch { - // Corrupt cache row — fall through to a fresh fetch - } - } - - const season = await this.api.getSeasonDetails( - tmdbId, - seasonNumber, - language, - this.apiKey() - ); - - await this.cache.set({ - mediaType: 'tv', - lookupKey, - language, - tmdbId, - payload: JSON.stringify(season), - }); - - return season.episodes ?? []; - } catch (error) { - console.warn('TMDB season enrichment failed:', error); - return null; - } + return this.season.getSeasonEpisodes(tmdbId, seasonNumber); } - /** - * Person details with the full combined filmography, in the app - * language. Cached like details payloads under the 'person' media - * type. - */ async getPersonDetails( personId: number ): Promise { - if (!this.isEnabled() || !Number.isInteger(personId) || personId <= 0) { - return null; - } - - try { - const language = this.language(); - const lookupKey = `person:${personId}`; - - const cached = await this.cache.get('person', lookupKey, language); - if ( - this.cache.isFresh(cached, TMDB_DETAILS_CACHE_TTL_MS) && - cached?.payload - ) { - try { - return JSON.parse(cached.payload) as TmdbPersonDetails; - } catch { - // Corrupt cache row — fall through to a fresh fetch - } - } - - const person = await this.api.getPersonDetails( - personId, - language, - this.apiKey() - ); - - await this.cache.set({ - mediaType: 'person', - lookupKey, - language, - tmdbId: personId, - payload: JSON.stringify(person), - }); - - return person; - } catch (error) { - console.warn('TMDB person enrichment failed:', error); - return null; - } + return this.person.getPersonDetails(personId); } private async enrich( @@ -204,7 +118,7 @@ export class TmdbEnrichmentService { const year = query.year ?? extractYear(null, query.title); const cacheLanguage = tmdbSearchLanguageForTitle( variants[0], - this.settingsStore.language() + this.runtime.appLanguage() ); const lookupKey = buildSearchLookupKey(variants[0], year); @@ -230,7 +144,7 @@ export class TmdbEnrichmentService { // in pickConfidentMatch instead. const language = tmdbSearchLanguageForTitle( variant, - this.settingsStore.language() + this.runtime.appLanguage() ); const results = mediaType === 'movie' @@ -238,13 +152,13 @@ export class TmdbEnrichmentService { variant, null, language, - this.apiKey() + this.runtime.apiKey() ) : await this.api.searchTv( variant, null, language, - this.apiKey() + this.runtime.apiKey() ); match = pickConfidentMatch( @@ -272,7 +186,7 @@ export class TmdbEnrichmentService { mediaType: TmdbMediaType, tmdbId: number ): Promise { - const language = this.language(); + const language = this.runtime.language(); const lookupKey = buildDetailsLookupKey(tmdbId); const cached = await this.cache.get(mediaType, lookupKey, language); @@ -292,9 +206,13 @@ export class TmdbEnrichmentService { ? await this.api.getMovieDetails( tmdbId, language, - this.apiKey() + this.runtime.apiKey() ) - : await this.api.getTvDetails(tmdbId, language, this.apiKey()); + : await this.api.getTvDetails( + tmdbId, + language, + this.runtime.apiKey() + ); await this.cache.set({ mediaType, @@ -306,14 +224,4 @@ export class TmdbEnrichmentService { return details; } - - private apiKey(): string { - return ( - this.settingsStore.tmdb?.()?.apiKey?.trim() || DEFAULT_TMDB_API_KEY - ); - } - - private language(): string { - return toTmdbLanguage(this.settingsStore.language()); - } } diff --git a/libs/services/src/lib/tmdb/tmdb-person.service.ts b/libs/services/src/lib/tmdb/tmdb-person.service.ts new file mode 100644 index 000000000..ea1114bd1 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-person.service.ts @@ -0,0 +1,66 @@ +import { Injectable, inject } from '@angular/core'; +import { TmdbApiService } from './tmdb-api.service'; +import { TmdbCacheService } from './tmdb-cache.service'; +import { TMDB_DETAILS_CACHE_TTL_MS } from './tmdb-config'; +import { TmdbRuntimeService } from './tmdb-runtime.service'; +import { TmdbPersonDetails } from './tmdb.types'; + +/** + * Person details with the full combined filmography, in the app language. + * Cached like details payloads under the 'person' media type. Any failure + * returns `null` — actor pages then show their empty state. + */ +@Injectable({ providedIn: 'root' }) +export class TmdbPersonService { + private readonly runtime = inject(TmdbRuntimeService); + private readonly api = inject(TmdbApiService); + private readonly cache = inject(TmdbCacheService); + + async getPersonDetails( + personId: number + ): Promise { + if ( + !this.runtime.isEnabled() || + !Number.isInteger(personId) || + personId <= 0 + ) { + return null; + } + + try { + const language = this.runtime.language(); + const lookupKey = `person:${personId}`; + + const cached = await this.cache.get('person', lookupKey, language); + if ( + this.cache.isFresh(cached, TMDB_DETAILS_CACHE_TTL_MS) && + cached?.payload + ) { + try { + return JSON.parse(cached.payload) as TmdbPersonDetails; + } catch { + // Corrupt cache row — fall through to a fresh fetch + } + } + + const person = await this.api.getPersonDetails( + personId, + language, + this.runtime.apiKey() + ); + + await this.cache.set({ + mediaType: 'person', + lookupKey, + language, + tmdbId: personId, + payload: JSON.stringify(person), + }); + + return person; + } catch (error) { + console.warn('TMDB person enrichment failed:', error); + return null; + } + } +} diff --git a/libs/services/src/lib/tmdb/tmdb-runtime.service.ts b/libs/services/src/lib/tmdb/tmdb-runtime.service.ts new file mode 100644 index 000000000..e5ac98b50 --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-runtime.service.ts @@ -0,0 +1,34 @@ +import { Injectable, inject } from '@angular/core'; +import { SettingsStore } from '../settings-store.service'; +import { DEFAULT_TMDB_API_KEY, toTmdbLanguage } from './tmdb-config'; + +/** + * Shared TMDB runtime context: opt-in gate, effective API key and language + * resolution. Injected by every TMDB service so the rules live in exactly + * one place. + */ +@Injectable({ providedIn: 'root' }) +export class TmdbRuntimeService { + private readonly settingsStore = inject(SettingsStore); + + isEnabled(): boolean { + return Boolean(this.settingsStore.tmdb?.()?.enabled && this.apiKey()); + } + + /** User-provided key from settings, else the embedded default */ + apiKey(): string { + return ( + this.settingsStore.tmdb?.()?.apiKey?.trim() || DEFAULT_TMDB_API_KEY + ); + } + + /** TMDB language code derived from the app language ("en-US") */ + language(): string { + return toTmdbLanguage(this.appLanguage()); + } + + /** Raw app language setting ("en") */ + appLanguage(): string { + return this.settingsStore.language(); + } +} diff --git a/libs/services/src/lib/tmdb/tmdb-season.service.ts b/libs/services/src/lib/tmdb/tmdb-season.service.ts new file mode 100644 index 000000000..fbcc4602d --- /dev/null +++ b/libs/services/src/lib/tmdb/tmdb-season.service.ts @@ -0,0 +1,68 @@ +import { Injectable, inject } from '@angular/core'; +import { TmdbApiService } from './tmdb-api.service'; +import { TmdbCacheService } from './tmdb-cache.service'; +import { TMDB_DETAILS_CACHE_TTL_MS } from './tmdb-config'; +import { TmdbRuntimeService } from './tmdb-runtime.service'; +import { TmdbEpisode, TmdbSeasonDetails } from './tmdb.types'; + +/** + * Episode list of one season (names, overviews, stills, air dates) in the + * app language. Fetched lazily when a season is opened and cached like + * details payloads. Returns `null` when enrichment is off or the request + * fails — episode lists then stay provider-only. + */ +@Injectable({ providedIn: 'root' }) +export class TmdbSeasonService { + private readonly runtime = inject(TmdbRuntimeService); + private readonly api = inject(TmdbApiService); + private readonly cache = inject(TmdbCacheService); + + async getSeasonEpisodes( + tmdbId: number, + seasonNumber: number + ): Promise { + if (!this.runtime.isEnabled()) { + return null; + } + + try { + const language = this.runtime.language(); + const lookupKey = `id:${tmdbId}|season:${seasonNumber}`; + + const cached = await this.cache.get('tv', lookupKey, language); + if ( + this.cache.isFresh(cached, TMDB_DETAILS_CACHE_TTL_MS) && + cached?.payload + ) { + try { + const season = JSON.parse( + cached.payload + ) as TmdbSeasonDetails; + return season.episodes ?? []; + } catch { + // Corrupt cache row — fall through to a fresh fetch + } + } + + const season = await this.api.getSeasonDetails( + tmdbId, + seasonNumber, + language, + this.runtime.apiKey() + ); + + await this.cache.set({ + mediaType: 'tv', + lookupKey, + language, + tmdbId, + payload: JSON.stringify(season), + }); + + return season.episodes ?? []; + } catch (error) { + console.warn('TMDB season enrichment failed:', error); + return null; + } + } +}