refactor(tmdb): split enrichment services, extract Stalker season overlay, cap PWA cache (#1126)

This commit is contained in:
4gray authored and GitHub committed 2026-07-04 18:16:41 +02:00
1 parent e7536a4602
commit 865a44c874
9 files changed
+373 -188

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,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<string, TmdbEpisode[]>
>(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;
}
/**
* 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<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 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);
}
}
@@ -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<string, TmdbEpisode[]>
>(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<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
);
}
);
@@ -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<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 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
*/
@@ -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,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<TmdbEpisode[] | 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 {
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<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(
@@ -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<TmdbDetails | null> {
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());
}
}
@@ -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,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<TmdbEpisode[] | 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 {
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;
}
}
}