mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
refactor(tmdb): split enrichment services, extract Stalker season overlay, cap PWA cache (#1126)
This commit is contained in:
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:
|
||||
|
||||
|
||||
+84
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
-61
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user