Merge origin/master: adapt season enrichment to the split TMDB services

The TMDB refactor (#1126) moved season fetching into TmdbSeasonService
and extracted StalkerSeriesTmdbSeasonsService. This branch's additions
land in those new homes: getSeason (full payload incl. overview) lives
in TmdbSeasonService with the facade delegating; the Stalker seasons
service now also stores per-season overviews and exposes descriptions()
for the season tabs; the series view keeps the (tmdb_id, selected
season) race-fix effect but delegates fetching to the service.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 20:48:35 +02:00
commit da8eb03ca7
9 files changed
+420 -215

No files matched your search

@@ -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:<id>` rows) |
| `tmdb-season.service.ts` | Cached lazy per-season episode lists (`id:<id>\|season:<n>` rows) |
Integration glue per portal:
@@ -0,0 +1,118 @@
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 season data (episode
* lists and season overviews) 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<string, TmdbEpisode[]>
>(new Map());
private readonly overviewsByKey = signal<ReadonlyMap<string, string>>(
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<string, XtreamSerieEpisode[]>,
tmdbId: number | null | undefined
): Record<string, XtreamSerieEpisode[]> {
const fetched = this.episodesByKey();
if (!tmdbId || fetched.size === 0) {
return seasons;
}
const merged: Record<string, XtreamSerieEpisode[]> = {};
for (const [seasonKey, episodes] of Object.entries(seasons)) {
const forSeason = fetched.get(`${tmdbId}|${seasonKey}`);
merged[seasonKey] = forSeason?.length
? mergeEpisodesWithTmdb(episodes, forSeason)
: episodes;
}
return merged;
}
/**
* Season overviews for the season tabs, keyed by season key of the
* given show. Reads a signal, so callers can use it in a `computed`.
*/
descriptions(tmdbId: number | null | undefined): Record<string, string> {
const overviews = this.overviewsByKey();
if (!tmdbId || overviews.size === 0) {
return {};
}
const prefix = `${tmdbId}|`;
const descriptions: Record<string, string> = {};
for (const [mapKey, overview] of overviews) {
if (mapKey.startsWith(prefix)) {
descriptions[mapKey.slice(prefix.length)] = overview;
}
}
return descriptions;
}
/**
* Lazily pulls the TMDB season (episode list + overview) 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<void> {
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 season = await this.tmdbEnrichment.getSeason(
tmdbId,
seasonNumber
);
if (!season) {
return;
}
if (season.overview) {
const overviews = new Map(this.overviewsByKey());
overviews.set(mapKey, season.overview);
this.overviewsByKey.set(overviews);
}
if (!season.episodes?.length) {
return;
}
const next = new Map(this.episodesByKey());
next.set(mapKey, season.episodes);
this.episodesByKey.set(next);
}
}
@@ -64,10 +64,8 @@ import {
import {
DownloadsService,
PlaybackPositionRuntimeBridgeService,
TmdbEnrichmentService,
mergeEpisodesWithTmdb,
type TmdbEpisode,
} from '@iptvnator/services';
import { StalkerSeriesTmdbSeasonsService } from './stalker-series-tmdb-seasons.service';
import {
getStalkerSeriesQuickStartButton,
type StalkerQuickStartButton,
@@ -96,6 +94,7 @@ import {
SeasonContainerComponent,
MatIcon,
],
providers: [StalkerSeriesTmdbSeasonsService],
})
export class StalkerSeriesViewComponent implements OnDestroy {
readonly stalkerStore = inject(StalkerStore);
@@ -128,37 +127,14 @@ 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<string, TmdbEpisode[]>
>(new Map());
/** TMDB season overviews, keyed like tmdbSeasonEpisodes. */
private readonly tmdbSeasonOverviews = signal<
ReadonlyMap<string, string>
>(new Map());
private readonly tmdbSeasons = inject(StalkerSeriesTmdbSeasonsService);
/** Season currently selected in the season container. */
private readonly selectedSeasonKey = signal<string | null>(null);
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
/** Season descriptions for the season tabs (TMDB overview per season). */
readonly seasonDescriptions = computed<Record<string, string>>(() => {
const tmdbId = this.displayItem()?.info?.tmdb_id;
if (!tmdbId) {
return {};
}
const prefix = `${tmdbId}|`;
const descriptions: Record<string, string> = {};
for (const [mapKey, overview] of this.tmdbSeasonOverviews()) {
if (mapKey.startsWith(prefix)) {
descriptions[mapKey.slice(prefix.length)] = overview;
}
}
return descriptions;
});
readonly seasonDescriptions = computed<Record<string, string>>(() =>
this.tmdbSeasons.descriptions(this.displayItem()?.info?.tmdb_id)
);
/**
* Track VOD series seasons with their loaded episodes
@@ -192,12 +168,18 @@ export class StalkerSeriesViewComponent implements OnDestroy {
// tabs the first seasonSelected fires immediately when seasons load —
// usually BEFORE the async show-level TMDB enrichment has written
// tmdb_id — so the fetch must re-run when the match arrives, not only
// on selection. fetchTmdbSeason is idempotent per (tmdbId, season).
// on selection. fetchSeason is idempotent per (tmdbId, season).
effect(() => {
const tmdbId = this.displayItem()?.info?.tmdb_id;
const seasonKey = this.selectedSeasonKey();
if (tmdbId && seasonKey) {
untracked(() => void this.fetchTmdbSeason(seasonKey));
untracked(() =>
void this.tmdbSeasons.fetchSeason(
tmdbId,
seasonKey,
this.mappedSeasons()[seasonKey]
)
);
}
});
@@ -340,20 +322,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<string, XtreamSerieEpisode[]> = {};
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
);
}
);
@@ -397,50 +369,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<void> {
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 season = await this.tmdbEnrichment.getSeason(
tmdbId,
seasonNumber
);
if (!season) {
return;
}
if (season.overview) {
const overviews = new Map(this.tmdbSeasonOverviews());
overviews.set(mapKey, season.overview);
this.tmdbSeasonOverviews.set(overviews);
}
if (!season.episodes?.length) {
return;
}
const next = new Map(this.tmdbSeasonEpisodes());
next.set(mapKey, season.episodes);
this.tmdbSeasonEpisodes.set(next);
}
/**
* Loads episodes for a specific VOD season
*/
@@ -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');
});
});
@@ -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<void> {
@@ -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 {
@@ -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,6 +16,9 @@ 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,
@@ -34,15 +34,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,18 +61,11 @@ 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<TmdbEpisode[] | null> {
const season = await this.getSeason(tmdbId, seasonNumber);
return season ? (season.episodes ?? []) : null;
return this.season.getSeasonEpisodes(tmdbId, seasonNumber);
}
/**
@@ -78,95 +77,13 @@ export class TmdbEnrichmentService {
tmdbId: number,
seasonNumber: number
): Promise<TmdbSeasonDetails | null> {
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 {
return JSON.parse(cached.payload) as TmdbSeasonDetails;
} 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;
} catch (error) {
console.warn('TMDB season enrichment failed:', error);
return null;
}
return this.season.getSeason(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<TmdbPersonDetails | null> {
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(
@@ -214,7 +131,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);
@@ -240,7 +157,7 @@ export class TmdbEnrichmentService {
// in pickConfidentMatch instead.
const language = tmdbSearchLanguageForTitle(
variant,
this.settingsStore.language()
this.runtime.appLanguage()
);
const results =
mediaType === 'movie'
@@ -248,13 +165,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(
@@ -282,7 +199,7 @@ export class TmdbEnrichmentService {
mediaType: TmdbMediaType,
tmdbId: number
): Promise<TmdbDetails | null> {
const language = this.language();
const language = this.runtime.language();
const lookupKey = buildDetailsLookupKey(tmdbId);
const cached = await this.cache.get(mediaType, lookupKey, language);
@@ -302,9 +219,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,
@@ -316,14 +237,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());
}
}
@@ -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<TmdbPersonDetails | null> {
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;
}
}
}
@@ -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();
}
}
@@ -0,0 +1,74 @@
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';
/**
* Season payloads (overview + episode list with 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 — season data then stays 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<TmdbEpisode[] | null> {
const season = await this.getSeason(tmdbId, seasonNumber);
return season ? (season.episodes ?? []) : null;
}
/** Full season payload (overview + episodes), same cache rows. */
async getSeason(
tmdbId: number,
seasonNumber: number
): Promise<TmdbSeasonDetails | null> {
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 {
return JSON.parse(cached.payload) as TmdbSeasonDetails;
} 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;
} catch (error) {
console.warn('TMDB season enrichment failed:', error);
return null;
}
}
}