From dc05a2566ee3c8ef6a568285c3b5499c9b832028 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:07:46 +0200 Subject: [PATCH] feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals (#1123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals Adds an opt-in TMDB integration (Settings > Metadata) that enriches detail views with a field-level merge — the provider stays authoritative for stream data, TMDB fills editorial fields when the match is confident. Enrichment: - Movie/series details: plot, cast (avatar chips), director, genres, rating, poster/backdrop, official YouTube trailers - Confidence-gated matching: provider tmdb_id trusted; otherwise normalized-title search with year gate (±1; series accept earlier premieres), season-suffix stripping, Cyrillic search-language override, and language-prefix fallback variants - Lazy season/episode enrichment: real episode names, overviews, stills - "Similar" rail (Xtream): TMDB recommendations matched to the catalog - Actor pages per portal with full filmography, availability filter and an Electron-only "All portals" scope backed by a batched DB_MATCH_TITLES worker op over the trigram FTS index Infrastructure: - SQLite cache table tmdb_metadata (details, search verdicts, seasons, persons; per-language, TTL-guarded), in-memory fallback for the PWA - Settings: enable toggle, own-API-key override with a live "check key" button; TMDB attribution in Settings and About - Embedded key stays an empty placeholder; CI injects TMDB_API_KEY via tools/tmdb/inject-tmdb-key.mjs when the secret is configured - normalizeTitle shared between renderer and DB worker - CSP: allow YouTube embeds (frame-src was 'none'; trailers never worked) Fixes and refactors along the way: - fix(stalker): Advanced Search sent bare get_ordered_list requests and skipped the auth handshake when isFullStalkerPortal was missing on the active-playlist meta — full portals answered "Authorization failed." and search looked empty; now mirrors the catalog request shape and routes through makeAuthenticatedRequest with URL-based detection - fix(stalker): TMDB fields survive info re-normalization; detail views prefer the store copy patched by async enrichment over stale snapshots - refactor(xtream): split oversized vod/serial detail components into component-scoped playback services; detail routes re-initialize on route param changes (router reuses them for detail-to-detail nav) - i18n: all new keys translated across the 18 locales Docs: docs/architecture/tmdb-metadata-enrichment.md + CLAUDE.md updates. Co-Authored-By: Claude Fable 5 * fix(tmdb): provide route params observable to inline collection details, linearize regexes The global-collection inline detail host builds a fake ActivatedRoute for VodDetailsRouteComponent/SerialDetailsComponent with only snapshot.params. Since the detail components now read route.params via toSignal() (detail-> detail re-init), the missing observable crashed component construction and the content hero never rendered — broke dashboard-activation, favorites and recent Electron E2E on all platforms. Provide the params observable alongside the snapshot and assert it in the component spec. Also resolves both CodeQL js/polynomial-redos alerts: bracket-stripping in normalizeTitle now excludes opening delimiters inside the classes, and youtubeEmbedUrl extracts watch?v= ids with a linear two-pass match instead of "watch\?.*v=". Combining-diacritics range rewritten as explicit \u escapes (greptile note). Co-Authored-By: Claude Fable 5 * fix(tmdb): surface TMDB-only VOD score in the rating badge, drop youtube.com from CSP Review follow-ups on PR #1123: the Xtream VOD detail badge renders rating_imdb, but the merge wrote the TMDB score only into `rating`, so a TMDB-only score was never displayed (Codex P2) — fill rating_imdb when the provider left it empty, mirroring the Stalker merge. All trailer iframes are normalized to youtube-nocookie.com, so the extra youtube.com frame-src allowance was dead surface (greptile) — removed. Co-Authored-By: Claude Fable 5 * fix(tmdb): resolve confirmed review findings — matching correctness, race guards, cache schema Fixes the confirmed findings from the PR #1123 code review: - Stalker search: setSelectedContentType now runs BEFORE setSelectedItem, so the TMDB enrichment gate in the selection hook no longer sees the content type of the previously open tab (wrong/no enrichment after ITV -> search -> movie). - Title normalization is now two-tier (normalizeTitleKeys): the exact normalized form keeps a trailing year, the base form strips it and remembers the tag. Year stripping is anchored to the end of the title ("2001: A Space Odyssey" keeps its year) and language-prefix stripping is UPPERCASE-only ("It: Chapter Two" is no longer amputated). - All catalog matching (similar rail, actor pages, DB worker DB_MATCH_TITLES) compares exact forms first and only accepts year-stripped matches when the stripped tag is year-compatible (+-1) with the TMDB year — "Blade Runner" (1982) can no longer claim a catalog "Blade Runner 2049". CatalogTitleMatch carries the stripped trailingYear so the renderer can apply the guard to worker matches. - mergedBackdrops tolerates a plain-string backdrop_path; enrichment merge+patch blocks are wrapped in try/catch so a malformed provider payload can no longer become an unhandled rejection. - loadGlobalMatches (both actor routes) guards against actor->actor navigation races — a slow match for the previous person no longer overwrites the current one's results. - tmdb_metadata media_type CHECK widened to ('movie','tv','person') and person rows now use the honest 'person' type (TmdbCacheMediaType). Pre-release dev DBs with the narrow CHECK are rebuilt in place — the table is a pure cache, so the migration is a self-healing drop-and-recreate keyed off sqlite_master. Docs updated (tmdb-metadata-enrichment.md, CLAUDE.md). New regression coverage: title-normalization.util.spec.ts, two-tier cases in tmdb-similar.util.spec.ts and title-match.operations.spec.ts. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/build-and-make.yaml | 7 + CLAUDE.md | 22 +- .../src/app/api/main.preload.spec-data.ts | 25 + .../src/app/api/main.preload.ts | 18 + .../operations/title-match.operations.spec.ts | 102 +++++ .../operations/title-match.operations.ts | 109 +++++ .../operations/tmdb.operations.spec.ts | 107 +++++ .../database/operations/tmdb.operations.ts | 69 +++ .../src/app/events/database.events.ts | 1 + .../src/app/events/database/tmdb.events.ts | 22 + .../database/worker-ipc-contract.spec-data.ts | 22 + .../database/worker-ipc-contract.spec.ts | 1 + .../src/app/workers/database-worker.types.ts | 3 + .../src/app/workers/database.worker.ts | 31 ++ .../settings-about-section.component.html | 18 + .../src/app/settings/settings-form.utils.ts | 9 + .../src/app/settings/settings-options.spec.ts | 1 + apps/web/src/app/settings/settings-options.ts | 6 + .../settings-tmdb-section.component.html | 113 +++++ .../settings-tmdb-section.component.ts | 87 ++++ .../src/app/settings/settings.component.html | 5 + .../app/settings/settings.component.spec.ts | 1 + .../src/app/settings/settings.component.ts | 2 + apps/web/src/assets/i18n/ar.json | 22 + apps/web/src/assets/i18n/ary.json | 22 + apps/web/src/assets/i18n/by.json | 22 + apps/web/src/assets/i18n/de.json | 22 + apps/web/src/assets/i18n/el.json | 22 + apps/web/src/assets/i18n/en.json | 22 + apps/web/src/assets/i18n/es.json | 22 + apps/web/src/assets/i18n/fr.json | 22 + apps/web/src/assets/i18n/it.json | 22 + apps/web/src/assets/i18n/ja.json | 22 + apps/web/src/assets/i18n/ko.json | 22 + apps/web/src/assets/i18n/nl.json | 22 + apps/web/src/assets/i18n/pl.json | 22 + apps/web/src/assets/i18n/pt.json | 22 + apps/web/src/assets/i18n/ru.json | 22 + apps/web/src/assets/i18n/tr.json | 22 + apps/web/src/assets/i18n/zh.json | 22 + apps/web/src/assets/i18n/zhtw.json | 22 + apps/web/src/assets/images/tmdb-logo.svg | 11 + apps/web/src/index.html | 2 +- docs/architecture/tmdb-metadata-enrichment.md | 244 ++++++++++ .../src/lib/stalker-vod.utils.spec.ts | 34 ++ .../data-access/src/lib/stalker-vod.utils.ts | 9 + .../with-stalker-selection.feature.ts | 32 +- .../src/lib/stores/stalker-tmdb-enrichment.ts | 96 ++++ .../src/lib/stalker-actor-route.component.ts | 199 ++++++++ .../stalker-collection-detail.component.ts | 32 ++ .../feature/src/lib/stalker-feature.routes.ts | 9 + .../stalker-search.component.ts | 142 ++++-- .../stalker-series-view.component.html | 63 ++- .../stalker-series-view.component.spec.ts | 1 + .../stalker-series-view.component.ts | 118 ++++- .../lib/stores/xtream-tmdb-enrichment.spec.ts | 175 +++++++ .../src/lib/stores/xtream-tmdb-enrichment.ts | 204 +++++++++ .../src/lib/stores/xtream.store.ts | 32 ++ .../lib/actor/xtream-actor-route.component.ts | 227 +++++++++ .../serial-details-playback.service.ts | 369 +++++++++++++++ .../serial-details.component.html | 78 +++- .../serial-details.component.spec.ts | 4 + .../serial-details.component.ts | 433 +++++------------- .../feature/src/lib/tmdb-similar.util.spec.ts | 156 +++++++ .../feature/src/lib/tmdb-similar.util.ts | 212 +++++++++ .../vod-details-playback.service.ts | 337 ++++++++++++++ .../vod-details-route.component.html | 86 +++- .../vod-details-route.component.spec.ts | 9 +- .../vod-details-route.component.ts | 399 +++++----------- ...xtream-collection-detail.component.spec.ts | 20 +- .../lib/xtream-collection-detail.component.ts | 32 +- .../feature/src/lib/xtream-feature.routes.ts | 9 + libs/services/src/index.ts | 2 + .../src/lib/catalog-title-match.service.ts | 56 +++ .../src/lib/settings-store.service.ts | 3 + libs/services/src/lib/tmdb/index.ts | 9 + .../services/src/lib/tmdb/tmdb-api.service.ts | 151 ++++++ .../src/lib/tmdb/tmdb-cache.service.ts | 91 ++++ .../services/src/lib/tmdb/tmdb-config.spec.ts | 40 ++ libs/services/src/lib/tmdb/tmdb-config.ts | 105 +++++ .../src/lib/tmdb/tmdb-enrichment.service.ts | 319 +++++++++++++ .../src/lib/tmdb/tmdb-episode-merge.spec.ts | 109 +++++ .../src/lib/tmdb/tmdb-episode-merge.ts | 78 ++++ .../src/lib/tmdb/tmdb-matcher.spec.ts | 297 ++++++++++++ libs/services/src/lib/tmdb/tmdb-matcher.ts | 163 +++++++ libs/services/src/lib/tmdb/tmdb-merge.spec.ts | 386 ++++++++++++++++ libs/services/src/lib/tmdb/tmdb-merge.ts | 253 ++++++++++ .../services/src/lib/tmdb/tmdb-person.spec.ts | 78 ++++ libs/services/src/lib/tmdb/tmdb-person.ts | 78 ++++ libs/services/src/lib/tmdb/tmdb.types.ts | 143 ++++++ libs/shared/database/src/lib/connection.ts | 45 ++ libs/shared/database/src/lib/schema.ts | 28 ++ libs/shared/interfaces/src/index.ts | 3 + .../src/lib/catalog-title-match.interface.ts | 24 + .../src/lib/electron-api.interface.ts | 11 + .../interfaces/src/lib/settings.interface.ts | 6 + .../src/lib/stalker-vod-details.interface.ts | 15 + .../src/lib/title-normalization.util.spec.ts | 69 +++ .../src/lib/title-normalization.util.ts | 149 ++++++ .../interfaces/src/lib/tmdb.interface.ts | 74 +++ .../src/lib/vod-details-adapters.spec.ts | 36 ++ .../src/lib/vod-details-adapters.ts | 42 +- .../src/lib/vod-details-item.interface.ts | 3 + .../src/lib/xtream-serie-details.interface.ts | 11 + .../src/lib/xtream-vod-details.interface.ts | 9 + .../vod-details/vod-details.component.html | 47 +- .../lib/vod-details/vod-details.component.ts | 23 + libs/ui/shared-portals/src/index.ts | 1 + .../lib/actor-view/actor-view.component.html | 175 +++++++ .../lib/actor-view/actor-view.component.scss | 258 +++++++++++ .../lib/actor-view/actor-view.component.ts | 60 +++ libs/ui/styles/_detail-view.scss | 116 +++++ tools/tmdb/inject-tmdb-key.mjs | 50 ++ 113 files changed, 7855 insertions(+), 712 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/title-match.operations.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/title-match.operations.ts create mode 100644 apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts create mode 100644 apps/electron-backend/src/app/database/operations/tmdb.operations.ts create mode 100644 apps/electron-backend/src/app/events/database/tmdb.events.ts create mode 100644 apps/web/src/app/settings/settings-tmdb-section.component.html create mode 100644 apps/web/src/app/settings/settings-tmdb-section.component.ts create mode 100644 apps/web/src/assets/images/tmdb-logo.svg create mode 100644 docs/architecture/tmdb-metadata-enrichment.md create mode 100644 libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts create mode 100644 libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts create mode 100644 libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts create mode 100644 libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts create mode 100644 libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts create mode 100644 libs/portal/xtream/feature/src/lib/tmdb-similar.util.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts create mode 100644 libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts create mode 100644 libs/services/src/lib/catalog-title-match.service.ts create mode 100644 libs/services/src/lib/tmdb/index.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-api.service.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-cache.service.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-config.spec.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-config.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-enrichment.service.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-episode-merge.spec.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-episode-merge.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-matcher.spec.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-matcher.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-merge.spec.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-merge.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-person.spec.ts create mode 100644 libs/services/src/lib/tmdb/tmdb-person.ts create mode 100644 libs/services/src/lib/tmdb/tmdb.types.ts create mode 100644 libs/shared/interfaces/src/lib/catalog-title-match.interface.ts create mode 100644 libs/shared/interfaces/src/lib/title-normalization.util.spec.ts create mode 100644 libs/shared/interfaces/src/lib/title-normalization.util.ts create mode 100644 libs/shared/interfaces/src/lib/tmdb.interface.ts create mode 100644 libs/shared/interfaces/src/lib/vod-details-adapters.spec.ts create mode 100644 libs/ui/shared-portals/src/lib/actor-view/actor-view.component.html create mode 100644 libs/ui/shared-portals/src/lib/actor-view/actor-view.component.scss create mode 100644 libs/ui/shared-portals/src/lib/actor-view/actor-view.component.ts create mode 100644 tools/tmdb/inject-tmdb-key.mjs diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 894a3bbca..41dd2a48e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -100,6 +100,13 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Inject TMDB API key + # No-op when the secret is unavailable (e.g. fork PRs) — the + # app then requires a user-provided key for TMDB enrichment. + env: + TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }} + run: node tools/tmdb/inject-tmdb-key.mjs + - name: Build frontend run: pnpm nx build web --skip-nx-cache diff --git a/CLAUDE.md b/CLAUDE.md index f086a6ac3..851eb439e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -236,7 +236,7 @@ This is an Nx monorepo with the following structure: - **portal/catalog/feature** - Portal catalog UI - **portal/downloads/feature** - Download manager UI - **portal/shared/{data-access,ui,util}** - Cross-portal shared code - - **services** - Abstract DataService contract and shared app services + - **services** - Abstract DataService contract and shared app services (incl. the TMDB metadata enrichment module in `lib/tmdb/`) - **shared/interfaces** - TypeScript interfaces and types (incl. `ElectronBridgeApi`) - **shared/database** - Canonical Drizzle schema and DB connection (used by the Electron backend) - **shared/m3u-utils** - M3U playlist utilities @@ -426,8 +426,8 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation. - Dashboard: `/workspace/dashboard`; sources overview: `/workspace/sources` - M3U player: `/workspace/playlists/:id` (children: `favorites`, `recent`, `:view`) — routes in `libs/playlist/m3u/feature-player` -- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts` -- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts` +- Xtream Codes: `/workspace/xtreams/:id` (children: `live`, `vod`, `series`, `search`, `actor/:personId`, `recently-added`, `favorites`, `recent`, `downloads`) — `libs/portal/xtream/feature/src/lib/xtream-feature.routes.ts` +- Stalker portal: `/workspace/stalker/:id` (children: `itv`, `vod`, `radio`, `series`, `favorites`, `recent`, `search`, `actor/:personId`, `downloads`) — `libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts` - Global collections: `/workspace/global-favorites`, `/workspace/global-recent` - Global search: `/workspace/search` (Electron-only; a guard redirects the PWA to `/workspace/sources`) - Downloads: `/workspace/downloads` @@ -576,6 +576,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - `playbackPositions` - Resume positions - `downloads` - Download manager state - `appState` - Key-value app state (also tracks one-off data migrations) + - `tmdbMetadata` - TMDB enrichment cache (details payloads + search match resolutions, keyed by media type/lookup key/language) - **Connection**: `libs/shared/database/src/lib/connection.ts` - `createTables()` auto-creates tables on init (`CREATE TABLE IF NOT EXISTS`) - Provides full read-write access for `electron-backend` and a read-only mode @@ -633,6 +634,21 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use - Background parsing in worker thread - Stored in database for quick lookup +**TMDB Metadata Enrichment** (opt-in): + +- Enriches Xtream and Stalker VOD/series detail views with TMDB data (plot, cast with avatar chips, director, genres, rating, artwork, YouTube trailers) via a field-level merge — the provider stays authoritative for stream data and any field TMDB can't fill; Cyrillic titles are searched with `ru-RU` so exact-title matching works +- "Similar" rail in Xtream detail views: TMDB recommendations matched against the provider catalog by normalized title, two-tier — exact form first, year-stripped fallback gated on year compatibility (`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`, `normalizeTitleKeys`); detail components re-initialize on route param changes since the router reuses them for detail→detail navigation +- Season/episode enrichment: opening a season lazily fetches `/tv/{id}/season/{n}` and overlays real episode names, overviews and stills via `mergeEpisodesWithTmdb` (Xtream: `XtreamStore.enrichSelectedSerialSeason`; Stalker: overlay in the series view's `mappedSeasons`) +- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); shared UI in `libs/ui/shared-portals` (`ActorViewComponent`) +- Actor page "All portals" scope (Electron only): batched `DB_MATCH_TITLES` worker op (trigram FTS over all imported Xtream playlists, `apps/electron-backend/src/app/database/operations/title-match.operations.ts`); `normalizeTitle` is shared renderer/worker via `libs/shared/interfaces/src/lib/title-normalization.util.ts` +- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) +- Match confidence: provider `tmdb_id` trusted fully; otherwise normalized-title + year (±1) search with a strict gate — no confident match means no enrichment +- Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) +- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting +- Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`) +- TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About +- See `docs/architecture/tmdb-metadata-enrichment.md` + **Favorites and Recently Viewed**: - Per-playlist favorites and global favorites diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index bd5857229..229524cf8 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -41,6 +41,13 @@ const playbackData = { contentType: 'vod', positionSeconds: 120, }; +const tmdbCacheEntry = { + mediaType: 'movie', + lookupKey: 'id:603', + language: 'en-US', + tmdbId: 603, + payload: '{"id":603}', +}; export const dbPreloadCases: PreloadInvokeCase[] = [ { @@ -349,6 +356,24 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_CLEAR_PLAYBACK_POSITION', forwardedArgs: [playlistId, 42, 'vod'], }, + { + method: 'dbGetTmdbMetadata', + args: ['movie', 'id:603', 'en-US'], + channel: 'DB_GET_TMDB_METADATA', + forwardedArgs: ['movie', 'id:603', 'en-US'], + }, + { + method: 'dbSetTmdbMetadata', + args: [tmdbCacheEntry], + channel: 'DB_SET_TMDB_METADATA', + forwardedArgs: [tmdbCacheEntry], + }, + { + method: 'dbMatchTitles', + args: [[['The Matrix', 'Inception']][0]], + channel: 'DB_MATCH_TITLES', + forwardedArgs: [['The Matrix', 'Inception']], + }, ]; export const epgPreloadCases: PreloadInvokeCase[] = [ diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 6e8b3415b..743220410 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -38,6 +38,8 @@ import type { PortalDebugEvent, ResolvedPortalPlayback, Settings, + TmdbCacheEntry, + TmdbCacheMediaType, XtreamCategory, } from '@iptvnator/shared/interfaces'; import { @@ -755,6 +757,22 @@ const electronApi: ElectronBridgeApi = { dbGetAppState: (key: string) => ipcRenderer.invoke('DB_GET_APP_STATE', key), dbSetAppState: (key: string, value: string) => ipcRenderer.invoke('DB_SET_APP_STATE', key, value), + // TMDB metadata cache + dbGetTmdbMetadata: ( + mediaType: TmdbCacheMediaType, + lookupKey: string, + language: string + ) => + ipcRenderer.invoke( + 'DB_GET_TMDB_METADATA', + mediaType, + lookupKey, + language + ), + dbSetTmdbMetadata: (entry: TmdbCacheEntry) => + ipcRenderer.invoke('DB_SET_TMDB_METADATA', entry), + dbMatchTitles: (titles: string[]) => + ipcRenderer.invoke('DB_MATCH_TITLES', titles), // Playback Positions dbSavePlaybackPosition: ( playlistId: string, diff --git a/apps/electron-backend/src/app/database/operations/title-match.operations.spec.ts b/apps/electron-backend/src/app/database/operations/title-match.operations.spec.ts new file mode 100644 index 000000000..dc7988664 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-match.operations.spec.ts @@ -0,0 +1,102 @@ +import type { AppDatabase } from '../database.types'; +import { matchTitles } from './title-match.operations'; + +function createDbMock(rowsPerCall: unknown[][]) { + let call = 0; + const all = jest.fn().mockImplementation(() => { + const rows = rowsPerCall[call] ?? []; + call += 1; + return Promise.resolve(rows); + }); + return { db: { all } as unknown as AppDatabase, all }; +} + +const matrixRow = { + title: 'The Matrix [4K]', + xtream_id: 42, + type: 'movie', + category_xtream_id: 7, + playlist_id: 'playlist-1', + playlist_name: 'My Portal', +}; + +describe('title-match.operations', () => { + it('returns confident matches with playlist context', async () => { + const { db } = createDbMock([[matrixRow]]); + + await expect(matchTitles(db, ['The Matrix'])).resolves.toEqual([ + { + queryTitle: 'The Matrix', + playlistId: 'playlist-1', + playlistName: 'My Portal', + categoryId: 7, + xtreamId: 42, + type: 'movie', + trailingYear: null, + }, + ]); + }); + + it('reports the stripped year tag for base-tier matches', async () => { + const { db } = createDbMock([ + [{ ...matrixRow, title: 'The Matrix 1999' }], + ]); + + const matches = await matchTitles(db, ['The Matrix']); + expect(matches).toHaveLength(1); + expect(matches[0].trailingYear).toBe(1999); + }); + + it('keeps title-years intact on the exact tier', async () => { + const { db } = createDbMock([ + [{ ...matrixRow, title: 'Blade Runner 2049' }], + ]); + + // Exact-tier: the query itself carries the year as part of the title + const exact = await matchTitles(db, ['Blade Runner 2049']); + expect(exact).toHaveLength(1); + expect(exact[0].trailingYear).toBeNull(); + }); + + it('flags "Blade Runner" matching "Blade Runner 2049" with the year tag', async () => { + const { db } = createDbMock([ + [{ ...matrixRow, title: 'Blade Runner 2049' }], + ]); + + // Base-tier hit: the renderer must reject it via the 2049/1982 + // year incompatibility (trailingYear is surfaced for exactly that) + const matches = await matchTitles(db, ['Blade Runner']); + expect(matches).toHaveLength(1); + expect(matches[0].trailingYear).toBe(2049); + }); + + it('drops FTS candidates whose normalized title differs', async () => { + const { db } = createDbMock([ + [{ ...matrixRow, title: 'The Matrix Reloaded' }], + ]); + + await expect(matchTitles(db, ['The Matrix'])).resolves.toEqual([]); + }); + + it('skips titles that normalize to nothing usable and deduplicates', async () => { + const { db, all } = createDbMock([[matrixRow]]); + + await matchTitles(db, ['', ' ', 'The Matrix', 'The Matrix']); + expect(all).toHaveBeenCalledTimes(1); + }); + + it('survives a failing FTS query for one title', async () => { + let call = 0; + const all = jest.fn().mockImplementation(() => { + call += 1; + return call === 1 + ? Promise.reject(new Error('fts syntax')) + : Promise.resolve([matrixRow]); + }); + const db = { all } as unknown as AppDatabase; + + const matches = await matchTitles(db, ['Weird "Title"', 'The Matrix']); + expect(matches).toHaveLength(1); + expect(matches[0].queryTitle).toBe('The Matrix'); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/title-match.operations.ts b/apps/electron-backend/src/app/database/operations/title-match.operations.ts new file mode 100644 index 000000000..f3f5e4c75 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/title-match.operations.ts @@ -0,0 +1,109 @@ +import { sql } from 'drizzle-orm'; +import { + CatalogTitleMatch, + normalizeTitleKeys, +} from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; + +/** + * Batched cross-playlist title matching for the actor page's "All portals" + * scope. For every requested title one trigram-FTS lookup runs against + * `content_title_fts`. Confirmation is two-tier, mirroring the renderer's + * portal-scoped matching: the query (a canonical TMDB title) must equal + * either the candidate's exact normalized title, or its year-stripped form + * — in which case the stripped year tag is returned so the renderer can + * reject year-incompatible matches. + */ + +const MAX_TITLES_PER_REQUEST = 200; +const PER_TITLE_CANDIDATE_LIMIT = 24; + +interface TitleMatchRow { + title: string; + xtream_id: number; + type: 'movie' | 'series'; + category_xtream_id: number; + playlist_id: string; + playlist_name: string; +} + +function buildFtsMatchQuery(normalizedTitle: string): string { + const tokens = normalizedTitle + .split(' ') + // The trigram tokenizer cannot match tokens shorter than 3 chars + .filter((token) => token.length >= 3) + .map((token) => `"${token.replace(/"/g, '""')}"`); + return tokens.join(' AND '); +} + +export async function matchTitles( + db: AppDatabase, + titles: string[] +): Promise { + const uniqueTitles = [ + ...new Set( + (titles ?? []).filter( + (title) => typeof title === 'string' && title.trim() !== '' + ) + ), + ].slice(0, MAX_TITLES_PER_REQUEST); + + const matches: CatalogTitleMatch[] = []; + + for (const queryTitle of uniqueTitles) { + // TMDB titles are canonical — a trailing year is part of the title + const wanted = normalizeTitleKeys(queryTitle).exact; + const matchQuery = buildFtsMatchQuery(wanted); + if (!wanted || !matchQuery) { + continue; + } + + let rows: TitleMatchRow[]; + try { + rows = (await db.all(sql` + SELECT + c.title AS title, + c.xtream_id AS xtream_id, + c.type AS type, + cat.xtream_id AS category_xtream_id, + cat.playlist_id AS playlist_id, + p.name AS playlist_name + FROM content_title_fts + INNER JOIN content AS c ON c.id = content_title_fts.rowid + INNER JOIN categories AS cat ON c.category_id = cat.id + INNER JOIN playlists AS p ON cat.playlist_id = p.id + WHERE content_title_fts MATCH ${matchQuery} + AND c.type IN ('movie', 'series') + AND cat.hidden = 0 + ORDER BY rank, c.title + LIMIT ${PER_TITLE_CANDIDATE_LIMIT} + `)) as TitleMatchRow[]; + } catch { + // Malformed FTS query for exotic titles — skip, don't fail the batch + continue; + } + + for (const row of rows) { + const rowKeys = normalizeTitleKeys(row.title); + const exactMatch = rowKeys.exact === wanted; + const baseMatch = + !exactMatch && + rowKeys.base !== rowKeys.exact && + rowKeys.base === wanted; + if (!exactMatch && !baseMatch) { + continue; + } + matches.push({ + queryTitle, + playlistId: row.playlist_id, + playlistName: row.playlist_name, + categoryId: row.category_xtream_id, + xtreamId: row.xtream_id, + type: row.type, + trailingYear: exactMatch ? null : rowKeys.trailingYear, + }); + } + } + + return matches; +} diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts new file mode 100644 index 000000000..8b1e6a3d2 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts @@ -0,0 +1,107 @@ +import type { TmdbCacheEntry } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { getTmdbMetadata, setTmdbMetadata } from './tmdb.operations'; + +function createSelectMock(rows: unknown[]) { + const limit = jest.fn().mockResolvedValue(rows); + const where = jest.fn().mockReturnValue({ limit }); + const from = jest.fn().mockReturnValue({ where }); + const select = jest.fn().mockReturnValue({ from }); + return { db: { select } as unknown as AppDatabase, select, where, limit }; +} + +function createInsertMock() { + const onConflictDoUpdate = jest.fn().mockResolvedValue(undefined); + const values = jest.fn().mockReturnValue({ onConflictDoUpdate }); + const insert = jest.fn().mockReturnValue({ values }); + return { + db: { insert } as unknown as AppDatabase, + insert, + values, + onConflictDoUpdate, + }; +} + +describe('tmdb.operations', () => { + it('returns null on cache miss', async () => { + const { db } = createSelectMock([]); + await expect( + getTmdbMetadata(db, 'movie', 'id:603', 'en-US') + ).resolves.toBeNull(); + }); + + it('maps a cached row to a TmdbCacheEntry', async () => { + const { db } = createSelectMock([ + { + id: 1, + mediaType: 'movie', + lookupKey: 'id:603', + language: 'en-US', + tmdbId: 603, + payload: '{"id":603}', + fetchedAt: '2026-07-01T00:00:00.000Z', + }, + ]); + + await expect( + getTmdbMetadata(db, 'movie', 'id:603', 'en-US') + ).resolves.toEqual({ + mediaType: 'movie', + lookupKey: 'id:603', + language: 'en-US', + tmdbId: 603, + payload: '{"id":603}', + fetchedAt: '2026-07-01T00:00:00.000Z', + }); + }); + + it('upserts entries and refreshes fetchedAt', async () => { + const { db, values, onConflictDoUpdate } = createInsertMock(); + const entry: TmdbCacheEntry = { + mediaType: 'tv', + lookupKey: 'title:dark|year:2017', + language: 'de-DE', + tmdbId: 70523, + payload: null, + }; + + await expect(setTmdbMetadata(db, entry)).resolves.toEqual({ + success: true, + }); + + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ + mediaType: 'tv', + lookupKey: 'title:dark|year:2017', + language: 'de-DE', + tmdbId: 70523, + payload: null, + fetchedAt: expect.any(String), + }) + ); + expect(onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ + tmdbId: 70523, + payload: null, + fetchedAt: expect.any(String), + }), + }) + ); + }); + + it('persists negative matches (tmdbId null)', async () => { + const { db, values } = createInsertMock(); + await setTmdbMetadata(db, { + mediaType: 'movie', + lookupKey: 'title:unknown movie|year:', + language: 'en-US', + tmdbId: null, + payload: null, + }); + + expect(values).toHaveBeenCalledWith( + expect.objectContaining({ tmdbId: null, payload: null }) + ); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts new file mode 100644 index 000000000..75fc67865 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts @@ -0,0 +1,69 @@ +import { and, eq } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; + +export async function getTmdbMetadata( + db: AppDatabase, + mediaType: TmdbCacheMediaType, + lookupKey: string, + language: string +): Promise { + const rows = await db + .select() + .from(schema.tmdbMetadata) + .where( + and( + eq(schema.tmdbMetadata.mediaType, mediaType), + eq(schema.tmdbMetadata.lookupKey, lookupKey), + eq(schema.tmdbMetadata.language, language) + ) + ) + .limit(1); + + const row = rows[0]; + if (!row) { + return null; + } + + return { + mediaType: row.mediaType, + lookupKey: row.lookupKey, + language: row.language, + tmdbId: row.tmdbId, + payload: row.payload, + fetchedAt: row.fetchedAt ?? undefined, + }; +} + +export async function setTmdbMetadata( + db: AppDatabase, + entry: TmdbCacheEntry +): Promise<{ success: boolean }> { + const fetchedAt = new Date().toISOString(); + + await db + .insert(schema.tmdbMetadata) + .values({ + mediaType: entry.mediaType, + lookupKey: entry.lookupKey, + language: entry.language, + tmdbId: entry.tmdbId, + payload: entry.payload, + fetchedAt, + }) + .onConflictDoUpdate({ + target: [ + schema.tmdbMetadata.mediaType, + schema.tmdbMetadata.lookupKey, + schema.tmdbMetadata.language, + ], + set: { + tmdbId: entry.tmdbId, + payload: entry.payload, + fetchedAt, + }, + }); + + return { success: true }; +} diff --git a/apps/electron-backend/src/app/events/database.events.ts b/apps/electron-backend/src/app/events/database.events.ts index 4147839cb..59c37e2b3 100644 --- a/apps/electron-backend/src/app/events/database.events.ts +++ b/apps/electron-backend/src/app/events/database.events.ts @@ -13,6 +13,7 @@ import './database/favorites.events'; import './database/playback-position.events'; import './database/playlist.events'; import './database/recently-viewed.events'; +import './database/tmdb.events'; import './database/xtream.events'; export default class DatabaseEvents { diff --git a/apps/electron-backend/src/app/events/database/tmdb.events.ts b/apps/electron-backend/src/app/events/database/tmdb.events.ts new file mode 100644 index 000000000..19b6b84da --- /dev/null +++ b/apps/electron-backend/src/app/events/database/tmdb.events.ts @@ -0,0 +1,22 @@ +/** + * TMDB metadata cache IPC event handlers + * Persists TMDB API responses so detail-view enrichment stays offline-fast + */ + +import type { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +import { handleWorkerRequest } from './worker-events.utils'; + +handleWorkerRequest( + 'DB_GET_TMDB_METADATA', + (mediaType: TmdbCacheMediaType, lookupKey: string, language: string) => ({ + mediaType, + lookupKey, + language, + }) +); + +handleWorkerRequest('DB_SET_TMDB_METADATA', (entry: TmdbCacheEntry) => ({ + entry, +})); + +handleWorkerRequest('DB_MATCH_TITLES', (titles: string[]) => ({ titles })); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 3002b8c36..6fb9e60f8 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -24,6 +24,13 @@ const playbackData = { contentType: 'vod', positionSeconds: 120, }; +const tmdbCacheEntry = { + mediaType: 'movie', + lookupKey: 'id:603', + language: 'en-US', + tmdbId: 603, + payload: '{"id":603}', +}; export const workerIpcContractCases: WorkerIpcContractCase[] = [ { @@ -306,4 +313,19 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [playlistId, 42, 'vod'], payload: { playlistId, contentXtreamId: 42, contentType: 'vod' }, }, + { + operation: 'DB_GET_TMDB_METADATA', + args: ['movie', 'id:603', 'en-US'], + payload: { mediaType: 'movie', lookupKey: 'id:603', language: 'en-US' }, + }, + { + operation: 'DB_SET_TMDB_METADATA', + args: [tmdbCacheEntry], + payload: { entry: tmdbCacheEntry }, + }, + { + operation: 'DB_MATCH_TITLES', + args: [['The Matrix', 'Inception']], + payload: { titles: ['The Matrix', 'Inception'] }, + }, ]; diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts index 0a60ac8f8..55e58730f 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts @@ -44,6 +44,7 @@ async function importDatabaseEventModules(): Promise { await import('./playback-position.events'); await import('./playlist.events'); await import('./recently-viewed.events'); + await import('./tmdb.events'); await import('./xtream.events'); } diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 1478846c4..7b1f21df6 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -49,6 +49,9 @@ export const DB_WORKER_OPERATIONS = [ 'DB_GET_ALL_PLAYBACK_POSITIONS', 'DB_CLEAR_ALL_PLAYBACK_POSITIONS', 'DB_CLEAR_PLAYBACK_POSITION', + 'DB_GET_TMDB_METADATA', + 'DB_SET_TMDB_METADATA', + 'DB_MATCH_TITLES', ] as const; export type DbWorkerOperation = (typeof DB_WORKER_OPERATIONS)[number]; diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 2e785f65d..868a856d5 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -77,6 +77,11 @@ import { removeRecentItem, removeRecentItemsBatch, } from '../database/operations/recently-viewed.operations'; +import { matchTitles } from '../database/operations/title-match.operations'; +import { + getTmdbMetadata, + setTmdbMetadata, +} from '../database/operations/tmdb.operations'; import { deleteXtreamContent, restoreXtreamUserData, @@ -588,6 +593,32 @@ async function executeRequest(message: DbWorkerRequestMessage) { return setAppState(db, payload.key, payload.value); } + case 'DB_GET_TMDB_METADATA': { + const payload = message.payload as { + mediaType: 'movie' | 'tv'; + lookupKey: string; + language: string; + }; + return getTmdbMetadata( + db, + payload.mediaType, + payload.lookupKey, + payload.language + ); + } + + case 'DB_SET_TMDB_METADATA': { + const payload = message.payload as { + entry: Parameters[1]; + }; + return setTmdbMetadata(db, payload.entry); + } + + case 'DB_MATCH_TITLES': { + const payload = message.payload as { titles: string[] }; + return matchTitles(db, payload.titles); + } + case 'DB_DELETE_ALL_PLAYLISTS': { const payload = message.payload as { operationId?: string }; diff --git a/apps/web/src/app/settings/settings-about-section.component.html b/apps/web/src/app/settings/settings-about-section.component.html index bd349491c..184672bcc 100644 --- a/apps/web/src/app/settings/settings-about-section.component.html +++ b/apps/web/src/app/settings/settings-about-section.component.html @@ -128,6 +128,24 @@ } +
+
+

{{ 'SETTINGS.TMDB_CREDITS' | translate }}

+

{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}

+ + TMDB + +
+
+

{{ 'SETTINGS.COMMUNITY' | translate }}

diff --git a/apps/web/src/app/settings/settings-form.utils.ts b/apps/web/src/app/settings/settings-form.utils.ts index 5a49d8abd..3ce21e9d8 100644 --- a/apps/web/src/app/settings/settings-form.utils.ts +++ b/apps/web/src/app/settings/settings-form.utils.ts @@ -7,6 +7,7 @@ import { import { CoverSize, DEFAULT_DASHBOARD_RAILS_SETTINGS, + DEFAULT_TMDB_SETTINGS, EpgViewMode, Language, normalizeDashboardRailsSettings, @@ -77,6 +78,10 @@ export function createSettingsForm( epgViewMode: 'timeline' as EpgViewMode, } : {}), + tmdb: formBuilder.group({ + enabled: DEFAULT_TMDB_SETTINGS.enabled, + apiKey: DEFAULT_TMDB_SETTINGS.apiKey ?? '', + }), }); } @@ -140,6 +145,10 @@ export function createSettingsFromFormValue( trustedPrivateNetworkEpgUrls: currentSettings.trustedPrivateNetworkEpgUrls ?? [], trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [], + tmdb: { + enabled: value.tmdb?.enabled ?? DEFAULT_TMDB_SETTINGS.enabled, + apiKey: value.tmdb?.apiKey?.trim() ?? '', + }, }; } diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts index ac7a8d6de..545c6b918 100644 --- a/apps/web/src/app/settings/settings-options.spec.ts +++ b/apps/web/src/app/settings/settings-options.spec.ts @@ -127,6 +127,7 @@ describe('buildSettingsSectionNavItems', () => { 'epg', 'dashboard', 'remote-control', + 'tmdb', 'backup', 'reset', 'about', diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts index bbeb6adbc..5add3d301 100644 --- a/apps/web/src/app/settings/settings-options.ts +++ b/apps/web/src/app/settings/settings-options.ts @@ -145,6 +145,12 @@ export function buildSettingsSectionNavItems({ icon: 'smartphone', visible: supportsRemoteControl, }, + { + id: 'tmdb', + label: 'SETTINGS.NAV_TMDB', + icon: 'movie', + visible: true, + }, { id: 'backup', label: 'SETTINGS.NAV_BACKUP', diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.html b/apps/web/src/app/settings/settings-tmdb-section.component.html new file mode 100644 index 000000000..09e3a73e6 --- /dev/null +++ b/apps/web/src/app/settings/settings-tmdb-section.component.html @@ -0,0 +1,113 @@ +
+
+
+ movie +
+
+

{{ 'SETTINGS.TMDB' | translate }}

+

{{ 'SETTINGS.TMDB_DESCRIPTION' | translate }}

+
+
+ +
+
+
+

{{ 'SETTINGS.TMDB_ENABLE' | translate }}

+

{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}

+
+
+ +
+
+ + @if (form().value.tmdb?.enabled === true) { +
+
+

{{ 'SETTINGS.TMDB_API_KEY' | translate }}

+

{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}

+

+ + {{ 'SETTINGS.TMDB_GET_KEY_HINT' | translate }} + +

+
+
+ + {{ + 'SETTINGS.TMDB_API_KEY' | translate + }} + + +
+ + @if (keyTestState() === 'success') { + + check_circle + {{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }} + + } @else if (keyTestState() === 'error') { + + error + {{ 'SETTINGS.TMDB_TEST_FAILED' | translate }} + + } +
+
+
+ } + +
+
+ TMDB +

{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}

+
+
+
+
diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts new file mode 100644 index 000000000..593364775 --- /dev/null +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -0,0 +1,87 @@ +import { Component, inject, input, signal, ViewEncapsulation } from '@angular/core'; +import { FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { TranslateModule } from '@ngx-translate/core'; +import { TmdbApiService } from '@iptvnator/services'; + +type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; + +@Component({ + selector: 'app-settings-tmdb-section', + imports: [ + MatButtonModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatProgressSpinnerModule, + ReactiveFormsModule, + TranslateModule, + ], + templateUrl: './settings-tmdb-section.component.html', + encapsulation: ViewEncapsulation.None, + styles: [ + ` + app-settings-tmdb-section { + display: contents; + } + + .tmdb-key-test { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 8px; + } + + .tmdb-key-test__result { + align-items: center; + display: inline-flex; + font-size: 0.875rem; + gap: 4px; + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + } + } + + .tmdb-key-test__result--success { + color: #4caf50; + } + + .tmdb-key-test__result--error { + color: #f44336; + } + `, + ], +}) +export class SettingsTmdbSectionComponent { + private readonly tmdbApi = inject(TmdbApiService); + + readonly form = input.required(); + readonly activeSection = input.required(); + + readonly keyTestState = signal('idle'); + + get enteredApiKey(): string { + return (this.form().value.tmdb?.apiKey ?? '').trim(); + } + + async testApiKey(): Promise { + const apiKey = this.enteredApiKey; + if (!apiKey || this.keyTestState() === 'testing') { + return; + } + + this.keyTestState.set('testing'); + const isValid = await this.tmdbApi.validateApiKey(apiKey); + this.keyTestState.set(isValid ? 'success' : 'error'); + } +} diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html index 0369da0ac..68aa05b07 100644 --- a/apps/web/src/app/settings/settings.component.html +++ b/apps/web/src/app/settings/settings.component.html @@ -84,6 +84,11 @@ /> } + + + + + + + + + + + THE MOVIE DB + diff --git a/apps/web/src/index.html b/apps/web/src/index.html index c8e2b90e2..dc036d8b7 100644 --- a/apps/web/src/index.html +++ b/apps/web/src/index.html @@ -8,7 +8,7 @@ diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md new file mode 100644 index 000000000..959086578 --- /dev/null +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -0,0 +1,244 @@ +# TMDB Metadata Enrichment + +This document describes the opt-in TMDB (The Movie Database) metadata +enrichment subsystem introduced in phase 1: settings opt-in, the TMDB +service layer, the SQLite cache, and the Xtream detail-view integration. + +Related: + +- [SQLite DB Worker](./sqlite-db-worker.md) +- [Nx Workspace Boundaries](./nx-workspace-boundaries.md) + +## Summary + +- Xtream VOD and series detail views can be enriched with TMDB data (plot, + cast, director, genres, rating, artwork) via a **field-level merge** — the + provider stays authoritative for stream-related data and for any field + TMDB cannot fill. +- Enrichment is **opt-in** via `Settings > Metadata (TMDB)` because it sends + movie/series titles to a third-party API. Default: disabled. +- The detail view renders provider data **immediately**; enrichment runs + asynchronously and patches the selected item once TMDB responds. A + staleness guard drops responses that arrive after the user navigated away. +- All TMDB lookups are cached: SQLite (`tmdb_metadata` table) in Electron, + session-scoped in-memory map in the PWA. +- Attribution (TMDB logo + "This product uses the TMDB API but is not + endorsed or certified by TMDB.") is shown in the settings TMDB section and + in Settings > About, as required by TMDB's terms. + +## Module Layout + +The service layer lives in `libs/services/src/lib/tmdb/` (scope:shared) so +both portal libs and — in later phases — the M3U player can consume it +without creating dependency cycles (`portal/shared/data-access` already +depends on `portal/xtream/data-access`, so it cannot host code the Xtream +store imports): + +| File | Responsibility | +| --- | --- | +| `tmdb-config.ts` | API/image base URLs, embedded default API key, cache TTLs, app-language → TMDB-language mapping | +| `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-merge.ts` | Field-level merge into `XtreamVodInfo` / `XtreamSerieInfo` (pure functions, no mutation) | +| `tmdb-enrichment.service.ts` | Orchestrator: settings gate → id resolution → details fetch → cache | + +Integration glue per portal: + +- **Xtream**: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts`; + `XtreamStore.fetchVodDetailsWithMetadata` / `fetchSerialDetailsWithMetadata` + fire it after `setSelectedItem(...)`. +- **Stalker**: `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts`; + hooked inside `withStalkerSelection().setSelectedItem` so every detail flow + (catalog, search, favorites/recent) is covered. The enriched item is + applied via direct `patchState` — never `setSelectedItem` — so the hook + cannot recurse. Live/radio selections are skipped. Movies and series share + the `StalkerVodInfo` shape, so one merge function + (`mergeStalkerInfoWithTmdb`) covers both; Stalker has no TMDB id, so + resolution always goes through the title search. TMDB also supplies a + backdrop (`tmdb_backdrop`) — Stalker portals never provide one. + +Components read the selection through signals and re-render when the merged +item lands. Enriched cast (`tmdb_cast` with profile photos) renders as +avatar chips in the detail views; a "check key" button in the settings +section validates the API key against `/configuration`. + +## Match Confidence + +Wrong metadata is worse than no metadata, so id resolution is conservative: + +1. If the provider returns a usable `tmdb_id` (Xtream VOD info often does), + it is trusted fully and no search runs. Series have no show-level + `tmdb_id`, so they always go through search. +2. Otherwise `/search/movie` (or `/search/tv`) runs with the normalized + title. Normalization strips bracketed tags, quality markers (`4K`, + `1080p`, `MULTI`, …), leading language prefixes (`EN - `), diacritics, + punctuation, trailing release years, and trailing season markers + (`The Boys s05`, `Season 2`, `сезон 3`, `Staffel 2`, `Temporada 2`). + `buildSearchTitleVariants` produces ordered candidates — original + title, display title, then fallbacks with a leading language token + stripped (`DE Batman`, `English The Godfather`; ALL-CAPS short codes + only, so articles like "The"/"De Lift" survive) — tried sequentially + until a confident match. +3. A result is accepted only when its normalized `title`/`original_title` + (or `name`/`original_name`) is **exactly equal** to the normalized query + AND the release year matches within ±1 (year comes from the provider's + release date, falling back to a year tag in the raw title). For series + the year gate additionally accepts shows that premiered **before** the + provider year — portals report the current season's year while TMDB's + `first_air_date` is the premiere. Without a year, the exact-title match + must be unambiguous (single hit). +4. No confident match → the provider data stays untouched, and the negative + verdict is cached (shorter TTL) so browsing back doesn't re-search. + +The year filter is applied client-side rather than via TMDB's strict +`year`/`first_air_date_year` search params, which would drop correct results +when the provider's year is off by one. + +**Non-Latin titles**: TMDB matches translated titles but returns `title` in +the *request* language, so a Cyrillic query issued with `en-US` would come +back with an English title and fail the exact-match gate. +`tmdbSearchLanguageForTitle` detects Cyrillic queries and issues the search +with `ru-RU` (unless the app language is already Cyrillic-based); details +are still fetched in the app language afterwards. + +## Details Fetch and Localization + +Details are fetched with +`/movie/{id}?append_to_response=credits,videos,recommendations` (`/tv/{id}` +for series). Credits provide cast/director; videos supply the best YouTube +trailer (official trailer > trailer > teaser, merged into +`youtube_trailer` / `tmdb_trailer`); recommendations power the "Similar" +rail. In Xtream detail views the rail shows only recommendations that +match the provider catalog by normalized title +(`libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts`). Matching is +two-tier (`normalizeTitleKeys`): exact normalized titles compare first +(a trailing year in a TMDB title is part of the title — "Blade Runner +2049"); the provider's year-stripped form only counts when its stripped +year tag is compatible (±1) with the TMDB year, so "Blade Runner" (1982) +never claims a catalog "Blade Runner 2049". The rail navigates +to the matched item — the detail components re-initialize on route param +changes (reactive `routeParams` signal) because the router reuses the +component for detail→detail navigation. Stalker gets trailers and the +`tmdb_recommendations` data, but no rail yet (its catalog is +server-paginated, so there is no local list to match against). +The `language` param derives from the app language setting +(`Language` enum → TMDB code, e.g. `de` → `de-DE`); cache rows are keyed per +language, so switching the app language re-fetches localized metadata. + +## Season/Episode Enrichment + +Show-level merges store the matched id as `tmdb_id` on the enriched info +(`XtreamSerieInfo` / `StalkerVodInfo`). When the user opens a season, the +detail views lazily fetch `/tv/{tmdbId}/season/{n}` via +`TmdbEnrichmentService.getSeasonEpisodes` (cached per language under +`id:{tmdbId}|season:{n}`) and overlay it with `mergeEpisodesWithTmdb`: + +- generic provider titles ("Episode 4", "Серия 4", "S01E04", bare numbers) + are replaced with real episode names; meaningful provider titles are kept +- overviews and stills are TMDB-preferred; air date and rating only fill + empty provider fields; durations stay provider-owned +- episodes without a TMDB counterpart (by episode number) pass through + untouched + +Wiring: Xtream — `XtreamStore.enrichSelectedSerialSeason(seasonKey)` fired +from the serial detail's `(seasonSelected)`; Stalker — the series view +keeps a `${tmdbId}|${seasonKey}`-keyed map and overlays it inside its +`mappedSeasons` computed. Without a show-level match or with enrichment +disabled everything is a no-op — the `SeasonContainer` UI already renders +every episode field conditionally. + +## Actor Pages + +Cast chips carry the TMDB person id (`tmdbPersonId` on +`TmdbEnrichedCastMember`) and navigate to `actor/:personId` inside the +current portal. The page loads `/person/{id}?append_to_response= +combined_credits` via `TmdbEnrichmentService.getPersonDetails` (cached +under `person:{id}` with media_type `person`) and renders the shared +`ActorViewComponent` (`libs/ui/shared-portals`). + +Filmography has two scopes: + +- **This portal** (default): the Xtream route component matches every + credit against the loaded catalog via `buildCatalogTitleIndex` + (movies → vodStreams, tv → serialStreams) — matched titles get an + "In your library" badge and navigate straight to their detail view; the + rest open the portal search prefilled with the title (`?q=`). Stalker + has no local catalog, so every title goes through the portal search. +- **All portals** (Electron only, toggle hidden in the PWA): one batched + `DB_MATCH_TITLES` worker request runs a trigram-FTS lookup per title + over ALL imported Xtream playlists + (`operations/title-match.operations.ts`), confirming candidates with + the same two-tier normalized-title matching the renderer uses + (`normalizeTitle` now lives in `@iptvnator/shared/interfaces` so the + worker and the renderer share it). Matches carry the playlist name + (shown in the badge) and navigate into that playlist's detail view. + This also works from Stalker actor pages — the one place the Stalker + catalog limitation is lifted. + +## Cache + +Single table with two row kinds discriminated by `lookup_key` prefix: + +``` +tmdb_metadata ( + media_type 'movie' | 'tv' | 'person', + lookup_key 'id:' -- details payload row + 'title:|year:' -- search resolution row + 'person:' -- person payload row + language TEXT, -- TMDB language code + tmdb_id INTEGER, -- NULL on a search row = negative cache + payload TEXT, -- raw JSON details, NULL for search rows + fetched_at TEXT, + UNIQUE(media_type, lookup_key, language) +) +``` + +TTLs (enforced at read time in `TmdbCacheService.isFresh`): details and +positive matches 30 days, negative matches 7 days. + +Electron IPC path (follows the standard DB worker contract, see +[SQLite DB Worker](./sqlite-db-worker.md)): + +- Worker ops: `DB_GET_TMDB_METADATA`, `DB_SET_TMDB_METADATA` + (`database-worker.types.ts`, `database.worker.ts`, + `operations/tmdb.operations.ts`) +- IPC registration: `events/database/tmdb.events.ts` +- Preload bridge: `dbGetTmdbMetadata` / `dbSetTmdbMetadata` on + `window.electron` (typed in `ElectronBridgeApi`) + +The PWA uses a session-scoped in-memory map (acceptable for phase 1; TMDB +supports CORS so the PWA calls the API directly). + +## Settings and API Key + +`Settings.tmdb?: { enabled: boolean; apiKey?: string }` +(`libs/shared/interfaces/src/lib/tmdb.interface.ts`). The settings page has +a "Metadata (TMDB)" section (enable toggle + optional API key override). + +The embedded default key lives in `DEFAULT_TMDB_API_KEY` +(`libs/services/src/lib/tmdb/tmdb-config.ts`) and is an **empty placeholder +in the repository by design**: the real key is stored in the `TMDB_API_KEY` +GitHub Actions secret and injected at CI build time by +`tools/tmdb/inject-tmdb-key.mjs` (step "Inject TMDB API key" in +`build-and-make.yaml`, before the frontend build). Rationale: TMDB keys are +free and extractable from any client binary regardless, but keeping the key +out of the public repo prevents trivial scraping and fork propagation. Never +commit a real key; never reuse keys found in other repositories. + +With no key available (empty default and no user override in settings), +enrichment stays inactive even when the toggle is on — fork PRs and local +dev builds fall into this mode automatically. + +## Failure Behavior + +Enrichment is strictly best-effort: any API/cache/parse failure logs a +warning and returns `null`, leaving the provider data untouched. Enrichment +never blocks or delays rendering of the detail view. + +## Out of Scope (later phases) + +Similar/recommendations rails, actor cross-catalog search, trending +dashboard rail, artwork upgrade for M3U VOD, persistent PWA cache +(IndexedDB). diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts index b6ccedd9c..9b2cc4c99 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.spec.ts @@ -2,6 +2,7 @@ import { VodDetailsItem } from '@iptvnator/shared/interfaces'; import { StalkerFavoriteItem } from './models'; import { buildStalkerFavoritePayload, + createStalkerInfo, createStalkerInlineDetailState, createStalkerDetailViewState, normalizeStalkerFavoriteItem, @@ -224,4 +225,37 @@ describe('stalker-vod.utils regressions', () => { expect(detailState.categoryId).toBe('series'); expect(detailState.seriesItem?.id).toBe('9'); }); + + it('preserves TMDB enrichment fields through info re-normalization', () => { + const tmdbCast = [{ name: 'Karl Urban', profileUrl: null }]; + const tmdbRecommendations = [ + { tmdbId: 1, title: 'Invincible', year: 2021, posterUrl: null }, + ]; + + const info = createStalkerInfo({ + id: '7', + info: { + name: 'The Boys s05', + movie_image: 'http://portal/poster.jpg', + description: 'Plot', + actors: 'Karl Urban', + director: '', + releasedate: '2026', + genre: 'Action', + rating_imdb: '', + rating_kinopoisk: '8.1', + tmdb_cast: tmdbCast, + tmdb_backdrop: 'https://image.tmdb.org/t/p/w1280/boys.jpg', + tmdb_trailer: 'abc123def', + tmdb_recommendations: tmdbRecommendations, + }, + }); + + expect(info.tmdb_cast).toEqual(tmdbCast); + expect(info.tmdb_backdrop).toBe( + 'https://image.tmdb.org/t/p/w1280/boys.jpg' + ); + expect(info.tmdb_trailer).toBe('abc123def'); + expect(info.tmdb_recommendations).toEqual(tmdbRecommendations); + }); }); diff --git a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts index 6fefe9253..987cf755c 100644 --- a/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts +++ b/libs/portal/stalker/data-access/src/lib/stalker-vod.utils.ts @@ -154,6 +154,15 @@ export function createStalkerInfo(item: StalkerVodSource): StalkerVodInfo { rating_kinopoisk: toStringOrFallback( info.rating_kinopoisk ?? item.rating_kinopoisk ), + // TMDB enrichment fields must survive re-normalization — detail + // views normalize the selected item on every render + ...(info.tmdb_id ? { tmdb_id: info.tmdb_id } : {}), + ...(info.tmdb_cast ? { tmdb_cast: info.tmdb_cast } : {}), + ...(info.tmdb_backdrop ? { tmdb_backdrop: info.tmdb_backdrop } : {}), + ...(info.tmdb_trailer ? { tmdb_trailer: info.tmdb_trailer } : {}), + ...(info.tmdb_recommendations + ? { tmdb_recommendations: info.tmdb_recommendations } + : {}), }; } diff --git a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts index 3657023e2..064aa2d56 100644 --- a/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts +++ b/libs/portal/stalker/data-access/src/lib/stores/features/with-stalker-selection.feature.ts @@ -1,11 +1,17 @@ +import { inject } from '@angular/core'; import { patchState, signalStoreFeature, withMethods, withState, } from '@ngrx/signals'; +import { TmdbEnrichmentService } from '@iptvnator/services'; import { StalkerVodSource } from '../../models'; import { normalizeStalkerEntityId } from '../../stalker-vod.utils'; +import { + enrichStalkerSelectionWithTmdb, + stalkerSelectionMediaType, +} from '../stalker-tmdb-enrichment'; /** * Selection/pagination/search feature state. @@ -37,7 +43,10 @@ const initialSelectionState: StalkerSelectionState = { export function withStalkerSelection() { return signalStoreFeature( withState(initialSelectionState), - withMethods((store) => ({ + withMethods((store) => { + const tmdbEnrichment = inject(TmdbEnrichmentService); + + return { setSelectedContentType( type: 'vod' | 'itv' | 'series' | 'radio' ) { @@ -99,6 +108,24 @@ export function withStalkerSelection() { selectedItvId: selectedId, selectedItem, }); + + // Async, best-effort TMDB enrichment for VOD/series detail + // selections. Applies via patchState (not setSelectedItem) + // so the hook cannot recurse; live/radio items are skipped. + const contentType = store.selectedContentType(); + if ( + selectedItem && + (contentType === 'vod' || contentType === 'series') + ) { + void enrichStalkerSelectionWithTmdb( + store, + tmdbEnrichment, + selectedItem, + stalkerSelectionMediaType(selectedItem, contentType), + (enriched) => + patchState(store, { selectedItem: enriched }) + ); + } }, clearSelectedItem() { patchState(store, { @@ -108,6 +135,7 @@ export function withStalkerSelection() { selectedItem: undefined, }); }, - })) + }; + }) ); } diff --git a/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts b/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts new file mode 100644 index 000000000..fb6c5c4fe --- /dev/null +++ b/libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts @@ -0,0 +1,96 @@ +import { + TmdbEnrichmentService, + extractYear, + mergeStalkerInfoWithTmdb, +} from '@iptvnator/services'; +import { StalkerVodInfo, TmdbMediaType } from '@iptvnator/shared/interfaces'; +import { StalkerVodSource } from '../models'; +import { + isStalkerSeriesItem, + normalizeStalkerEntityId, +} from '../stalker-vod.utils'; + +/** + * Async TMDB enrichment of the currently selected Stalker item. Runs after + * `setSelectedItem`; the enriched item is applied through a dedicated + * callback (direct `patchState`) instead of `setSelectedItem` so the hook + * cannot recurse. Movies and series share the same `StalkerVodInfo` shape, + * so one code path covers both — only the TMDB media type differs. + * + * Stalker portals never expose a TMDB id, so resolution always goes + * through the title search with the confidence gate. Cyrillic titles are + * handled by the search-language override in TmdbEnrichmentService. + */ + +interface StalkerEnrichableStore { + selectedItem: () => StalkerVodSource | null | undefined; +} + +export function stalkerSelectionMediaType( + item: StalkerVodSource, + contentType: string +): TmdbMediaType { + return contentType === 'series' || isStalkerSeriesItem(item) + ? 'tv' + : 'movie'; +} + +export async function enrichStalkerSelectionWithTmdb( + store: StalkerEnrichableStore, + enrichment: TmdbEnrichmentService, + item: StalkerVodSource, + mediaType: TmdbMediaType, + applyEnrichedItem: (enriched: StalkerVodSource) => void +): Promise { + if (!enrichment.isEnabled()) { + return; + } + + const info = item.info as Partial | null | undefined; + const title = info?.name?.trim(); + if (!title || title === 'Unknown') { + return; + } + + const itemId = normalizeStalkerEntityId(item.id ?? item.stream_id); + const isCurrent = (): StalkerVodSource | null => { + const current = store.selectedItem(); + if (!current) { + return null; + } + const matches = itemId + ? normalizeStalkerEntityId(current.id ?? current.stream_id) === + itemId + : current === item; + return matches ? current : null; + }; + + const query = { + title, + originalTitle: info?.o_name, + year: extractYear(info?.releasedate, title), + }; + const details = + mediaType === 'tv' + ? await enrichment.enrichTv(query) + : await enrichment.enrichMovie(query); + if (!details) { + return; + } + + const current = isCurrent(); + const currentInfo = current?.info as StalkerVodInfo | null | undefined; + if (!current || !currentInfo?.name) { + return; + } + + try { + applyEnrichedItem({ + ...current, + info: mergeStalkerInfoWithTmdb(currentInfo, details, mediaType), + }); + } catch (error) { + // Never degrade provider data over a malformed payload shape + console.warn('[TMDB] Stalker merge failed:', error); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts new file mode 100644 index 000000000..0ccf45eeb --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-actor-route.component.ts @@ -0,0 +1,199 @@ +import { Location } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + ActorFilmographyCredit, + ActorProfile, + CatalogTitleMatchService, + TmdbEnrichmentService, + buildTitleMatchIndex, + mapPersonFilmography, + mapPersonProfile, +} from '@iptvnator/services'; +import { + CatalogTitleMatch, + normalizeTitleKeys, + titleYearsCompatible, +} from '@iptvnator/shared/interfaces'; +import { + ActorViewComponent, + ActorViewItem, + ActorViewScope, +} from '@iptvnator/ui/shared-portals'; + +/** + * Actor page inside a Stalker portal. The portal catalog is + * server-paginated, so "This portal" has no availability matching — every + * title opens the portal search prefilled. "All portals" (Electron only) + * matches against the imported Xtream playlists and navigates there. + */ +@Component({ + template: ``, + styles: [':host { display: block; height: 100%; min-height: 0; }'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ActorViewComponent], +}) +export class StalkerActorRouteComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly location = inject(Location); + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + private readonly titleMatch = inject(CatalogTitleMatchService); + + private readonly routeParams = toSignal(this.route.params, { + initialValue: this.route.snapshot.params, + }); + private readonly personId = computed(() => + Number(this.routeParams().personId) + ); + + readonly profile = signal(null); + private readonly filmography = signal([]); + readonly isLoading = signal(true); + + readonly showScopeToggle = this.titleMatch.isAvailable; + readonly scope = signal('portal'); + readonly isMatchingGlobal = signal(false); + private readonly globalMatches = signal(null); + private readonly globalIndex = computed(() => + buildTitleMatchIndex(this.globalMatches() ?? []) + ); + + readonly items = computed(() => { + if (this.scope() === 'global') { + return this.filmography().map((credit) => { + const match = this.globalMatchFor(credit); + return { + ...credit, + available: match !== null, + ...(match ? { availableIn: match.playlistName } : {}), + }; + }); + } + + return this.filmography().map((credit) => ({ + ...credit, + available: false, + })); + }); + + constructor() { + effect(() => { + const personId = this.personId(); + if (Number.isInteger(personId) && personId > 0) { + void this.loadPerson(personId); + } + }); + } + + onScopeChanged(scope: ActorViewScope): void { + this.scope.set(scope); + if (scope === 'global' && this.globalMatches() === null) { + void this.loadGlobalMatches(); + } + } + + openItem(item: ActorViewItem): void { + if (this.scope() === 'global') { + const match = this.globalMatchFor(item); + if (match) { + void this.router.navigate([ + '/workspace/xtreams', + match.playlistId, + match.type === 'movie' ? 'vod' : 'series', + match.categoryId, + match.xtreamId, + ]); + return; + } + } + + const playlistId = this.playlistIdFromRoute(); + if (!playlistId) { + return; + } + void this.router.navigate( + ['/workspace/stalker', playlistId, 'search'], + { queryParams: { q: item.title } } + ); + } + + goBack(): void { + this.location.back(); + } + + /** The portal ':id' param lives on an ancestor route */ + private playlistIdFromRoute(): string | null { + for (const snapshot of this.route.snapshot.pathFromRoot) { + const id = snapshot.params['id']; + if (typeof id === 'string' && id !== '') { + return id; + } + } + return null; + } + + private globalMatchFor( + credit: ActorFilmographyCredit + ): CatalogTitleMatch | null { + const type = credit.mediaType === 'movie' ? 'movie' : 'series'; + const key = `${type}:${normalizeTitleKeys(credit.title).exact}`; + const match = this.globalIndex().get(key) ?? null; + return match && + titleYearsCompatible(credit.year, match.trailingYear) + ? match + : null; + } + + private async loadGlobalMatches(): Promise { + // Guard against actor→actor navigation: a slow match for the + // previous person must not overwrite the current one's results + const requestedPersonId = this.personId(); + const titles = this.filmography().map((credit) => credit.title); + this.isMatchingGlobal.set(true); + try { + const matches = await this.titleMatch.matchTitles(titles); + if (this.personId() === requestedPersonId) { + this.globalMatches.set(matches); + } + } finally { + if (this.personId() === requestedPersonId) { + this.isMatchingGlobal.set(false); + } + } + } + + private async loadPerson(personId: number): Promise { + this.isLoading.set(true); + this.globalMatches.set(null); + const person = await this.tmdbEnrichment.getPersonDetails(personId); + if (personId !== this.personId()) { + return; + } + this.profile.set(person ? mapPersonProfile(person) : null); + this.filmography.set(person ? mapPersonFilmography(person) : []); + this.isLoading.set(false); + if (this.scope() === 'global') { + void this.loadGlobalMatches(); + } + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts index 5f8295d59..9d0e3911f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-collection-detail.component.ts @@ -30,6 +30,7 @@ import { createStalkerInlineDetailState, isSelectedStalkerVodFavorite, isStalkerSeriesFlag, + normalizeStalkerEntityId, StalkerContentType, StalkerSelectedVodItem, StalkerStore, @@ -171,6 +172,37 @@ export class StalkerCollectionDetailComponent { void this.prepareDetail(item); }); }); + + // TMDB enrichment patches the STORE's selected item asynchronously; + // this view renders local snapshots — pull the enriched copy back + // in when it belongs to the currently shown item. + effect(() => { + const selected = this.stalkerStore.selectedItem(); + const current = this.itemDetails(); + if (!selected || !current || selected === current) { + return; + } + const selectedId = normalizeStalkerEntityId( + selected.id ?? selected.stream_id + ); + if ( + !selectedId || + selectedId !== normalizeStalkerEntityId(current.id) + ) { + return; + } + + const enriched = selected as StalkerSelectedVodItem; + this.itemDetails.set(enriched); + if (this.vodDetailsItem()) { + const playlistId = + this.stalkerStore.currentPlaylist()?._id ?? ''; + this.vodDetailsItem.set( + createStalkerDetailViewState(enriched, playlistId) + .vodDetailsItem + ); + } + }); } ngOnDestroy(): void { diff --git a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts index b494d9c0a..c2722c818 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-feature.routes.ts @@ -31,6 +31,11 @@ const loadStalkerSearchComponent: ComponentLoader = () => (c) => c.StalkerSearchComponent ); +const loadStalkerActorRouteComponent: ComponentLoader = () => + import('./stalker-actor-route.component').then( + (c) => c.StalkerActorRouteComponent + ); + export function createStalkerRoutes(): Route[] { return [ { @@ -120,6 +125,10 @@ export function createStalkerRoutes(): Route[] { path: 'search', loadComponent: loadStalkerSearchComponent, }, + { + path: 'actor/:personId', + loadComponent: loadStalkerActorRouteComponent, + }, { path: 'downloads', loadComponent: loadDownloadsComponent, diff --git a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts index fadbe1ab0..5cbdf6b74 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-search/stalker-search.component.ts @@ -50,6 +50,7 @@ import { createStalkerDetailViewState, isSelectedStalkerVodFavorite, isStalkerSeriesFlag, + normalizeStalkerEntityId, toggleStalkerVodFavorite, } from '@iptvnator/portal/stalker/data-access'; import { StalkerVodPlaybackController } from '../stalker-vod-playback-controller'; @@ -132,8 +133,8 @@ export class StalkerSearchComponent { readonly selectedFilterType = signal('vod'); private readonly favoritesRefresh = createRefreshTrigger(); - itemDetails: StalkerSelectedVodItem | null = null; - vodDetailsItem: VodDetailsItem | null = null; + readonly itemDetails = signal(null); + readonly vodDetailsItem = signal(null); readonly inlinePlayback = signal(null); readonly selectedVodPosition = signal(null); readonly selectedVodPlaybackPosition = computed( @@ -174,37 +175,44 @@ export class StalkerSearchComponent { } const contentType = params.contentType; - let token: string | undefined; - let serialNumber: string | undefined; - if ((playlist as Playlist).isFullStalkerPortal) { - try { - const result = await this.stalkerSession.ensureToken( - playlist as Playlist - ); - token = result.token ?? undefined; - serialNumber = result.serialNumber; - } catch (error) { - this.logger.error('Failed to get stalker token', error); - } - } + // Mirror the catalog request shape: many Ministra portals + // return an empty list for get_ordered_list without the + // category/genre/sortby params the STB client always sends. + const requestParams: Record = { + action: StalkerContentTypes[contentType].getContentAction, + type: contentType, + sortby: 'added', + search: params.search, + p: 1, + max_page_items: 100, + category: '*', + ...(contentType === 'vod' ? { genre: '0' } : {}), + }; - const response = - await this.dataService.sendIpcEvent( - STALKER_REQUEST, - { - url: portalUrl, - macAddress, - params: { - action: StalkerContentTypes[contentType] - .getContentAction, - type: contentType, - search: params.search, - max_page_items: 100, - }, - token, - serialNumber, - } - ); + // Full portals need the handshake token. The persisted flag can + // be missing on the active-playlist meta object, so fall back + // to URL detection — otherwise the portal answers + // "Authorization failed." and the search looks empty. + const isFullPortal = + (playlist as Playlist).isFullStalkerPortal ?? + this.stalkerSession.isFullStalkerPortal(portalUrl); + + const response = isFullPortal + ? await this.stalkerSession.makeAuthenticatedRequest( + { + ...(playlist as Playlist), + isFullStalkerPortal: true, + }, + requestParams + ) + : await this.dataService.sendIpcEvent( + STALKER_REQUEST, + { + url: portalUrl, + macAddress, + params: requestParams, + } + ); const items = response.js?.data || []; return items.map((item: StalkerVodSource) => this.processItemUrls(item, portalUrl) @@ -227,6 +235,36 @@ export class StalkerSearchComponent { this.portalFavorites.value(); this.syncSelectedVodFavorite(); }); + + // TMDB enrichment patches the STORE's selected item asynchronously; + // pull the enriched copy back into the local detail snapshots. + effect(() => { + const selected = this.stalkerStore.selectedItem(); + const current = this.itemDetails(); + if (!selected || !current || selected === current) { + return; + } + const selectedId = normalizeStalkerEntityId( + selected.id ?? selected.stream_id + ); + if ( + !selectedId || + selectedId !== normalizeStalkerEntityId(current.id) + ) { + return; + } + + const enriched = selected as StalkerSelectedVodItem; + this.itemDetails.set(enriched); + if (this.vodDetailsItem()) { + this.vodDetailsItem.set( + createStalkerDetailViewState( + enriched, + this.currentPlaylist()?._id ?? '' + ).vodDetailsItem + ); + } + }); } /** Check if showing item details */ @@ -262,26 +300,35 @@ export class StalkerSearchComponent { selectItem(item: StalkerVodSource) { this.closeInlinePlayer(); + const filterType = this.selectedFilterType(); const hasEmbeddedSeries = (item.series?.length ?? 0) > 0; const needsSeriesFetch = - this.selectedFilterType() === 'vod' && + filterType === 'vod' && !hasEmbeddedSeries && isStalkerSeriesFlag(item.is_series); - this.itemDetails = buildStalkerSelectedVodItem(item, needsSeriesFetch); + // The setSelectedItem hook gates TMDB enrichment on the CURRENT + // content type — it must be up to date before the item is set, + // otherwise the type of the previously open tab leaks in. + if (filterType === 'vod' || filterType === 'series') { + this.stalkerStore.setSelectedContentType(filterType); + } - this.stalkerStore.setSelectedItem(this.itemDetails); + this.itemDetails.set( + buildStalkerSelectedVodItem(item, needsSeriesFetch) + ); - switch (this.selectedFilterType()) { + this.stalkerStore.setSelectedItem(this.itemDetails()); + + switch (filterType) { case 'vod': - this.stalkerStore.setSelectedContentType('vod'); if (!hasEmbeddedSeries && !needsSeriesFetch) { const detailViewState = createStalkerDetailViewState( - this.itemDetails, + this.itemDetails()!, this.currentPlaylist()?._id ?? '' ); - this.itemDetails = detailViewState.itemDetails; - this.vodDetailsItem = detailViewState.vodDetailsItem; + this.itemDetails.set(detailViewState.itemDetails); + this.vodDetailsItem.set(detailViewState.vodDetailsItem); this.syncSelectedVodFavorite(); void this.loadSelectedVodPosition( this.currentPlaylist()?._id ?? '', @@ -289,14 +336,11 @@ export class StalkerSearchComponent { ); } else { const cleared = clearStalkerDetailViewState(); - this.vodDetailsItem = cleared.vodDetailsItem; + this.vodDetailsItem.set(cleared.vodDetailsItem); this.isSelectedVodFavorite.set(false); this.selectedVodPosition.set(null); } break; - case 'series': - this.stalkerStore.setSelectedContentType('series'); - break; default: break; } @@ -343,8 +387,8 @@ export class StalkerSearchComponent { onVodBack(): void { const cleared = clearStalkerDetailViewState(); - this.itemDetails = cleared.itemDetails; - this.vodDetailsItem = cleared.vodDetailsItem; + this.itemDetails.set(cleared.itemDetails); + this.vodDetailsItem.set(cleared.vodDetailsItem); this.isSelectedVodFavorite.set(false); this.selectedVodPosition.set(null); this.closeInlinePlayer(); @@ -378,7 +422,7 @@ export class StalkerSearchComponent { } private syncSelectedVodFavorite(): void { - const item = this.vodDetailsItem; + const item = this.vodDetailsItem(); this.isSelectedVodFavorite.set( isSelectedStalkerVodFavorite( item, @@ -389,8 +433,8 @@ export class StalkerSearchComponent { inlineDetail() { return createStalkerInlineDetailState( - this.itemDetails, - this.vodDetailsItem, + this.itemDetails(), + this.vodDetailsItem(), this.selectedFilterType() === 'series' ? 'series' : 'vod' ); } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html index 7653fb690..350a37c8b 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.html @@ -3,6 +3,7 @@ [title]="serial.info.name" [description]="serial.info.description" [posterUrl]="serial.info.movie_image" + [backdropUrl]="serial.info.tmdb_backdrop" (backClicked)="goBack()" > @@ -27,10 +28,52 @@ - @if (serial.info.actors) { + @if (serial.info.actors || serial.info.tmdb_cast?.length) {
{{ 'XTREAM.ACTORS' | translate }} - {{ serial.info.actors }} + @if (serial.info.tmdb_cast?.length) { +
+ @for ( + member of serial.info.tmdb_cast; + track member.name + ) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ serial.info.actors }} + }
} @if (serial.info.director) { @@ -112,5 +155,21 @@ #seasonContainer /> } + + + @if (trailerEmbedUrl(); as trailerUrl) { +
+

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}

+
+ +
+
+ } } diff --git a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts index ef80d595e..0b340590c 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts +++ b/libs/portal/stalker/feature/src/lib/stalker-series-view/stalker-series-view.component.spec.ts @@ -31,6 +31,7 @@ class StubContentHeroComponent { readonly title = input(undefined); readonly description = input(undefined); readonly posterUrl = input(undefined); + readonly backdropUrl = input(undefined); readonly backClicked = output(); } 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 3e324f13a..66c9be70e 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 @@ -21,8 +21,11 @@ import { import { PlaybackPositionData, ResolvedPortalPlayback, + TmdbEnrichedCastMember, XtreamSerieEpisode, + youtubeEmbedUrl, } from '@iptvnator/shared/interfaces'; +import { SafePipe } from '@iptvnator/pipes'; import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYBACK_POSITIONS, @@ -40,6 +43,7 @@ import { StalkerMappedEpisode, StalkerSeriesSeasonVm, VodSeriesSeasonVm, + normalizeStalkerEntityId, normalizeStalkerVodDetailsItem, StalkerSelectedVodItem, StalkerStore, @@ -56,6 +60,9 @@ import { import { DownloadsService, PlaybackPositionRuntimeBridgeService, + TmdbEnrichmentService, + mergeEpisodesWithTmdb, + type TmdbEpisode, } from '@iptvnator/services'; import { getStalkerSeriesQuickStartButton, @@ -77,6 +84,7 @@ import { FavoritesButtonComponent, ContentHeroComponent, PortalInlinePlayerComponent, + SafePipe, TranslatePipe, SeasonContainerComponent, MatIcon, @@ -113,6 +121,16 @@ 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); + /** * Track VOD series seasons with their loaded episodes */ @@ -241,30 +259,59 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); /** - * Get the item to display details for (either vodWithSeries or selectedItem from store) + * Get the item to display details for (either vodWithSeries or + * selectedItem from store). When the input and the store hold the SAME + * entity, the store copy wins — TMDB enrichment patches the store + * asynchronously after selection, while the input is a snapshot. */ readonly displayItem = computed(() => { - const item = this.vodWithSeries() || this.selectedItem(); + const input = this.vodWithSeries(); + const fromStore = this.selectedItem(); + const sameEntity = + input && + fromStore && + normalizeStalkerEntityId(input.id ?? input.stream_id) === + normalizeStalkerEntityId(fromStore.id ?? fromStore.stream_id); + const item = sameEntity ? fromStore : input || fromStore; return item ? normalizeStalkerVodDetailsItem(item) : null; }); + readonly trailerEmbedUrl = computed(() => + youtubeEmbedUrl(this.displayItem()?.info?.tmdb_trailer) + ); + /** * Adapts both Regular and VOD series data into the format expected by SeasonContainerComponent. * Record where string is season number/name. */ readonly mappedSeasons = computed>( () => { - if (this.isVodSeries()) { - return mapVodSeriesEpisodes( - this.vodSeriesSeasons(), - this.displayItem()?.info?.movie_image - ); + const base = this.isVodSeries() + ? mapVodSeriesEpisodes( + this.vodSeriesSeasons(), + this.displayItem()?.info?.movie_image + ) + : mapRegularSeriesEpisodes( + this.regularSeasons(), + this.displayItem()?.info?.movie_image + ); + + // 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; } - return mapRegularSeriesEpisodes( - this.regularSeasons(), - this.displayItem()?.info?.movie_image - ); + 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; } ); @@ -292,6 +339,8 @@ export class StalkerSeriesViewComponent implements OnDestroy { * For VOD Series, triggers lazy loading of episodes. */ onSeasonSelected(seasonKey: string) { + void this.fetchTmdbSeason(seasonKey); + if (!this.isVodSeries()) return; const seasons = this.vodSeriesSeasons(); @@ -304,6 +353,40 @@ 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 */ @@ -466,6 +549,19 @@ export class StalkerSeriesViewComponent implements OnDestroy { ); } + openActor(member: TmdbEnrichedCastMember): void { + const playlistId = this.stalkerStore.currentPlaylist()?._id; + if (!playlistId || !member.tmdbPersonId) { + return; + } + void this.router.navigate([ + '/workspace/stalker', + playlistId, + 'actor', + member.tmdbPersonId, + ]); + } + goBack() { const returnTo = getStalkerReturnToState(window.history.state); this.closeInlinePlayer(); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts new file mode 100644 index 000000000..9c4690bbb --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.spec.ts @@ -0,0 +1,175 @@ +import type { TmdbEnrichmentService } from '@iptvnator/services'; +import { + enrichSerialSelectionWithTmdb, + enrichVodSelectionWithTmdb, +} from './xtream-tmdb-enrichment'; + +type SelectionRecord = { readonly [key: string]: unknown }; + +function createStore(initial: SelectionRecord | null) { + let item = initial; + return { + selectedItem: jest.fn(() => item), + setSelectedItem: jest.fn((next: SelectionRecord | null) => { + item = next; + }), + /** Simulate the user navigating elsewhere mid-flight */ + replaceItem(next: SelectionRecord | null) { + item = next; + }, + }; +} + +function createEnrichment(overrides: Partial = {}) { + return { + isEnabled: jest.fn(() => true), + enrichMovie: jest.fn().mockResolvedValue(null), + enrichTv: jest.fn().mockResolvedValue(null), + ...overrides, + } as unknown as TmdbEnrichmentService; +} + +const vodItem = { + stream_id: '42', + info: { + tmdb_id: 603, + name: 'The Matrix', + o_name: 'The Matrix', + releasedate: '1999-03-31', + plot: 'Provider plot', + cast: '', + actors: '', + director: '', + genre: '', + description: '', + movie_image: '', + cover_big: '', + backdrop_path: [], + rating: 0, + }, +}; + +const serialItem = { + series_id: '7', + info: { + name: 'Dark', + plot: 'Provider plot', + cast: '', + director: '', + genre: '', + releaseDate: '2017-12-01', + rating: '0', + rating_5based: 0, + cover: '', + backdrop_path: [], + }, +}; + +describe('enrichVodSelectionWithTmdb', () => { + it('does nothing when enrichment is disabled', async () => { + const store = createStore(vodItem); + const enrichment = createEnrichment({ + isEnabled: jest.fn(() => false), + } as Partial); + + await enrichVodSelectionWithTmdb(store, enrichment, '42'); + + expect(enrichment.enrichMovie).not.toHaveBeenCalled(); + expect(store.setSelectedItem).not.toHaveBeenCalled(); + }); + + it('skips when the selected item does not match the vod id', async () => { + const store = createStore(vodItem); + const enrichment = createEnrichment(); + + await enrichVodSelectionWithTmdb(store, enrichment, '99'); + + expect(enrichment.enrichMovie).not.toHaveBeenCalled(); + }); + + it('merges TMDB details into the selected item', async () => { + const store = createStore(vodItem); + const enrichment = createEnrichment({ + enrichMovie: jest.fn().mockResolvedValue({ + id: 603, + overview: 'TMDB overview', + vote_average: 8.2, + vote_count: 26000, + }), + } as Partial); + + await enrichVodSelectionWithTmdb(store, enrichment, '42'); + + expect(enrichment.enrichMovie).toHaveBeenCalledWith({ + tmdbId: 603, + title: 'The Matrix', + originalTitle: 'The Matrix', + year: 1999, + }); + expect(store.setSelectedItem).toHaveBeenCalledTimes(1); + const updated = store.setSelectedItem.mock.calls[0][0] as { + stream_id: string; + info: { plot: string; rating: number }; + }; + expect(updated.stream_id).toBe('42'); + expect(updated.info.plot).toBe('TMDB overview'); + expect(updated.info.rating).toBe(8.2); + }); + + it('drops the result when the user navigated away mid-flight', async () => { + const store = createStore(vodItem); + const enrichment = createEnrichment({ + enrichMovie: jest.fn().mockImplementation(async () => { + store.replaceItem({ stream_id: '99', info: {} }); + return { id: 603, overview: 'TMDB overview' }; + }), + } as Partial); + + await enrichVodSelectionWithTmdb(store, enrichment, '42'); + + expect(store.setSelectedItem).not.toHaveBeenCalled(); + }); + + it('keeps provider data when no confident match was found', async () => { + const store = createStore(vodItem); + const enrichment = createEnrichment(); + + await enrichVodSelectionWithTmdb(store, enrichment, '42'); + + expect(store.setSelectedItem).not.toHaveBeenCalled(); + }); +}); + +describe('enrichSerialSelectionWithTmdb', () => { + it('merges TMDB tv details into the selected series', async () => { + const store = createStore(serialItem); + const enrichment = createEnrichment({ + enrichTv: jest.fn().mockResolvedValue({ + id: 70523, + overview: 'TMDB tv overview', + }), + } as Partial); + + await enrichSerialSelectionWithTmdb(store, enrichment, '7'); + + expect(enrichment.enrichTv).toHaveBeenCalledWith({ + title: 'Dark', + year: 2017, + }); + const updated = store.setSelectedItem.mock.calls[0][0] as { + series_id: string; + info: { plot: string }; + }; + expect(updated.series_id).toBe('7'); + expect(updated.info.plot).toBe('TMDB tv overview'); + }); + + it('skips series items without provider info', async () => { + const store = createStore({ series_id: '7', info: [] }); + const enrichment = createEnrichment(); + + await enrichSerialSelectionWithTmdb(store, enrichment, '7'); + + expect(enrichment.enrichTv).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts new file mode 100644 index 000000000..79bc4de43 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts @@ -0,0 +1,204 @@ +import { + TmdbEnrichmentService, + extractYear, + mergeEpisodesWithTmdb, + mergeSerieInfoWithTmdb, + mergeVodInfoWithTmdb, +} from '@iptvnator/services'; +import { + XtreamSerieDetails, + XtreamVodDetails, + getXtreamVodInfo, +} from '@iptvnator/shared/interfaces'; + +/** + * Async TMDB enrichment of the currently selected Xtream detail item. + * + * The detail view renders provider data immediately; these helpers run + * afterwards and patch the selection with a field-level merge once TMDB + * responds. A staleness guard drops the result if the user has navigated + * to a different item in the meantime. All failures are swallowed by the + * enrichment service — provider data is never degraded. + */ + +interface EnrichableSelectionStore { + selectedItem: () => TItem | null; + setSelectedItem: (item: TItem | null) => void; +} + +type SelectionRecord = { readonly [key: string]: unknown }; + +export async function enrichVodSelectionWithTmdb( + store: EnrichableSelectionStore, + enrichment: TmdbEnrichmentService, + vodId: string | number +): Promise { + if (!enrichment.isEnabled()) { + return; + } + + const isCurrent = (): TItem | null => { + const item = store.selectedItem(); + return item && String(item['stream_id']) === String(vodId) + ? item + : null; + }; + + const selected = isCurrent(); + const info = getXtreamVodInfo( + selected as unknown as XtreamVodDetails | null + ); + if (!info) { + return; + } + + const details = await enrichment.enrichMovie({ + tmdbId: info.tmdb_id, + title: info.name, + originalTitle: info.o_name, + year: extractYear(info.releasedate, info.name), + }); + if (!details) { + return; + } + + const current = isCurrent(); + const currentInfo = getXtreamVodInfo( + current as unknown as XtreamVodDetails | null + ); + if (!current || !currentInfo) { + return; + } + + try { + store.setSelectedItem({ + ...current, + info: mergeVodInfoWithTmdb(currentInfo, details), + } as unknown as TItem); + } catch (error) { + // Never degrade provider data over a malformed payload shape + console.warn('[TMDB] VOD merge failed:', error); + } +} + +export async function enrichSerialSelectionWithTmdb< + TItem extends SelectionRecord, +>( + store: EnrichableSelectionStore, + enrichment: TmdbEnrichmentService, + serialId: string | number +): Promise { + if (!enrichment.isEnabled()) { + return; + } + + const isCurrent = (): TItem | null => { + const item = store.selectedItem(); + return item && String(item['series_id']) === String(serialId) + ? item + : null; + }; + + const selected = isCurrent() as unknown as XtreamSerieDetails | null; + const info = selected?.info; + if (!info || Array.isArray(info) || !info.name) { + return; + } + + const details = await enrichment.enrichTv({ + title: info.name, + year: extractYear(info.releaseDate, info.name), + }); + if (!details) { + return; + } + + const current = isCurrent(); + const currentInfo = (current as unknown as XtreamSerieDetails | null)?.info; + if (!current || !currentInfo || Array.isArray(currentInfo)) { + return; + } + + try { + store.setSelectedItem({ + ...current, + info: mergeSerieInfoWithTmdb(currentInfo, details), + } as unknown as TItem); + } catch (error) { + console.warn('[TMDB] series merge failed:', error); + } +} + +/** + * Lazy per-season episode enrichment, fired when the user opens a season. + * Requires a prior show-level match (`info.tmdb_id` set by + * enrichSerialSelectionWithTmdb). Merges real episode names, overviews and + * stills into `episodes[seasonKey]` by episode number. + */ +export async function enrichSerialSeasonWithTmdb< + TItem extends SelectionRecord, +>( + store: EnrichableSelectionStore, + enrichment: TmdbEnrichmentService, + seasonKey: string +): Promise { + if (!enrichment.isEnabled()) { + return; + } + + const selected = store.selectedItem() as unknown as + | (XtreamSerieDetails & { series_id?: string | number }) + | null; + const serialId = selected?.series_id; + const info = selected?.info; + const episodes = selected?.episodes?.[seasonKey]; + if ( + serialId === undefined || + !info || + Array.isArray(info) || + !info.tmdb_id || + !episodes?.length + ) { + return; + } + + const seasonNumber = Number(episodes[0]?.season ?? seasonKey); + if (!Number.isFinite(seasonNumber)) { + return; + } + + const tmdbEpisodes = await enrichment.getSeasonEpisodes( + info.tmdb_id, + seasonNumber + ); + if (!tmdbEpisodes?.length) { + return; + } + + const current = store.selectedItem() as unknown as + | (XtreamSerieDetails & { series_id?: string | number }) + | null; + const currentEpisodes = current?.episodes?.[seasonKey]; + if ( + !current || + String(current.series_id) !== String(serialId) || + !currentEpisodes?.length + ) { + return; + } + + try { + store.setSelectedItem({ + ...current, + episodes: { + ...current.episodes, + [seasonKey]: mergeEpisodesWithTmdb( + currentEpisodes, + tmdbEpisodes + ), + }, + } as unknown as TItem); + } catch (error) { + console.warn('[TMDB] season merge failed:', error); + } +} diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts index 9ab97fbd6..d16aa5160 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.ts @@ -11,6 +11,7 @@ import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface import { XtreamApiService } from '../services/xtream-api.service'; // Import new feature stores +import { TmdbEnrichmentService } from '@iptvnator/services'; import { createLogger } from '@iptvnator/portal/shared/util'; import { withContent, @@ -21,6 +22,11 @@ import { withSearch, withSelection, } from './features'; +import { + enrichSerialSeasonWithTmdb, + enrichSerialSelectionWithTmdb, + enrichVodSelectionWithTmdb, +} from './xtream-tmdb-enrichment'; /** * XtreamStore - Facade composing all feature stores. @@ -68,6 +74,7 @@ export const XtreamStore = signalStore( withMethods((store) => { const xtreamApiService = inject(XtreamApiService); const dataSource = inject(XTREAM_DATA_SOURCE); + const tmdbEnrichment = inject(TmdbEnrichmentService); const logger = createLogger('XtreamStore'); const findVodCatalogItem = (vodId: string | number) => store.vodStreams().find((item) => { @@ -164,6 +171,13 @@ export const XtreamStore = signalStore( xtream_id: catalogItem?.xtream_id ?? Number(params.vodId), }); + // Async, best-effort: patches the selection with a + // field-level TMDB merge once metadata arrives + void enrichVodSelectionWithTmdb( + store, + tmdbEnrichment, + params.vodId + ); }) .catch((error: unknown) => { logger.error('Error fetching VOD details', error); @@ -206,6 +220,11 @@ export const XtreamStore = signalStore( ...serialDetails, series_id: params.serialId, }); + void enrichSerialSelectionWithTmdb( + store, + tmdbEnrichment, + params.serialId + ); }) .catch((error: unknown) => { logger.error('Error fetching series details', error); @@ -219,6 +238,19 @@ export const XtreamStore = signalStore( store.setIsLoadingDetails(false); }); }, + + /** + * Lazy TMDB enrichment of one season's episodes (real names, + * overviews, stills). Fired by the detail view when the user + * opens a season; a no-op without a show-level TMDB match. + */ + enrichSelectedSerialSeason(seasonKey: string): void { + void enrichSerialSeasonWithTmdb( + store, + tmdbEnrichment, + seasonKey + ); + }, }; }) ); diff --git a/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts new file mode 100644 index 000000000..84c6fd211 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/actor/xtream-actor-route.component.ts @@ -0,0 +1,227 @@ +import { Location } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + signal, +} from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router } from '@angular/router'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { + ActorFilmographyCredit, + ActorProfile, + CatalogTitleMatchService, + TmdbEnrichmentService, + buildTitleMatchIndex, + mapPersonFilmography, + mapPersonProfile, +} from '@iptvnator/services'; +import { + CatalogTitleMatch, + normalizeTitleKeys, + titleYearsCompatible, +} from '@iptvnator/shared/interfaces'; +import { + ActorViewComponent, + ActorViewItem, + ActorViewScope, +} from '@iptvnator/ui/shared-portals'; +import { + buildCatalogTitleIndex, + lookupCatalogTitle, +} from '../tmdb-similar.util'; + +/** + * Actor page inside an Xtream portal: TMDB person + full filmography. + * Scope "This portal" matches against the loaded catalog; "All portals" + * (Electron only) matches against every imported Xtream playlist via the + * DB worker. Matched titles navigate straight to their detail view, the + * rest open the current portal's search prefilled. + */ +@Component({ + template: ``, + styles: [':host { display: block; height: 100%; min-height: 0; }'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ActorViewComponent], +}) +export class XtreamActorRouteComponent { + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly location = inject(Location); + private readonly xtreamStore = inject(XtreamStore); + private readonly tmdbEnrichment = inject(TmdbEnrichmentService); + private readonly titleMatch = inject(CatalogTitleMatchService); + + private readonly routeParams = toSignal(this.route.params, { + initialValue: this.route.snapshot.params, + }); + private readonly personId = computed(() => + Number(this.routeParams().personId) + ); + + readonly profile = signal(null); + private readonly filmography = signal([]); + readonly isLoading = signal(true); + + readonly showScopeToggle = this.titleMatch.isAvailable; + readonly scope = signal('portal'); + readonly isMatchingGlobal = signal(false); + private readonly globalMatches = signal(null); + + private readonly vodIndex = computed(() => + buildCatalogTitleIndex(this.xtreamStore.vodStreams()) + ); + private readonly serialIndex = computed(() => + buildCatalogTitleIndex(this.xtreamStore.serialStreams()) + ); + private readonly globalIndex = computed(() => + buildTitleMatchIndex(this.globalMatches() ?? []) + ); + + readonly items = computed(() => { + if (this.scope() === 'global') { + return this.filmography().map((credit) => { + const match = this.globalMatchFor(credit); + return { + ...credit, + available: match !== null, + ...(match ? { availableIn: match.playlistName } : {}), + }; + }); + } + + return this.filmography().map((credit) => ({ + ...credit, + available: this.portalMatchFor(credit) !== null, + })); + }); + + constructor() { + effect(() => { + const personId = this.personId(); + if (Number.isInteger(personId) && personId > 0) { + void this.loadPerson(personId); + } + }); + } + + onScopeChanged(scope: ActorViewScope): void { + this.scope.set(scope); + if (scope === 'global' && this.globalMatches() === null) { + void this.loadGlobalMatches(); + } + } + + openItem(item: ActorViewItem): void { + if (this.scope() === 'global') { + const match = this.globalMatchFor(item); + if (match) { + void this.router.navigate([ + '/workspace/xtreams', + match.playlistId, + match.type === 'movie' ? 'vod' : 'series', + match.categoryId, + match.xtreamId, + ]); + return; + } + this.openPortalSearch(item.title); + return; + } + + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const match = this.portalMatchFor(item); + if (playlistId && match) { + void this.router.navigate([ + '/workspace/xtreams', + playlistId, + item.mediaType === 'movie' ? 'vod' : 'series', + match.categoryId, + match.id, + ]); + return; + } + + this.openPortalSearch(item.title); + } + + goBack(): void { + this.location.back(); + } + + private openPortalSearch(title: string): void { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId) { + return; + } + void this.router.navigate( + ['/workspace/xtreams', playlistId, 'search'], + { queryParams: { q: title } } + ); + } + + private portalMatchFor(credit: ActorFilmographyCredit) { + const index = + credit.mediaType === 'movie' ? this.vodIndex() : this.serialIndex(); + return lookupCatalogTitle(index, credit.title, credit.year); + } + + private globalMatchFor( + credit: ActorFilmographyCredit + ): CatalogTitleMatch | null { + const type = credit.mediaType === 'movie' ? 'movie' : 'series'; + const key = `${type}:${normalizeTitleKeys(credit.title).exact}`; + const match = this.globalIndex().get(key) ?? null; + return match && + titleYearsCompatible(credit.year, match.trailingYear) + ? match + : null; + } + + private async loadGlobalMatches(): Promise { + // Guard against actor→actor navigation: a slow match for the + // previous person must not overwrite the current one's results + const requestedPersonId = this.personId(); + const titles = this.filmography().map((credit) => credit.title); + this.isMatchingGlobal.set(true); + try { + const matches = await this.titleMatch.matchTitles(titles); + if (this.personId() === requestedPersonId) { + this.globalMatches.set(matches); + } + } finally { + if (this.personId() === requestedPersonId) { + this.isMatchingGlobal.set(false); + } + } + } + + private async loadPerson(personId: number): Promise { + this.isLoading.set(true); + this.globalMatches.set(null); + const person = await this.tmdbEnrichment.getPersonDetails(personId); + if (personId !== this.personId()) { + return; + } + this.profile.set(person ? mapPersonProfile(person) : null); + this.filmography.set(person ? mapPersonFilmography(person) : []); + this.isLoading.set(false); + if (this.scope() === 'global') { + void this.loadGlobalMatches(); + } + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts new file mode 100644 index 000000000..47a9bf579 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.ts @@ -0,0 +1,369 @@ +import { + computed, + DestroyRef, + effect, + inject, + Injectable, + Signal, + signal, +} from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, + getSeriesQuickStartAction, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { + PlaybackPositionData, + PlayerContentInfo, + ResolvedPortalPlayback, + XtreamSerieDetails, + XtreamSerieEpisode, +} from '@iptvnator/shared/interfaces'; +import { SeasonContainerPlaybackToggleRequest } from '@iptvnator/ui/components'; +import { + getSeriesEpisodeMetadata, + getSeriesPlaybackNavigation, + type PlaybackFallbackRequest, + resolveSeriesPlaybackEpisodeState, + type SeriesPlaybackEpisodeState, +} from '@iptvnator/ui/playback'; + +export type XtreamSerieDetailsView = XtreamSerieDetails & { + readonly series_id: number; +}; + +interface SerialDetailsPlaybackBindings { + readonly selectedItem: Signal; +} + +/** + * Component-provided service that owns the episode playback concern of the + * serial details view: inline playback state, per-episode playback + * positions, external-player session tracking, and playback orchestration. + */ +@Injectable() +export class SerialDetailsPlaybackService { + private readonly route = inject(ActivatedRoute); + private readonly xtreamStore = inject(XtreamStore); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly playbackPositionBridge = inject( + PlaybackPositionRuntimeBridgeService + ); + private readonly portalPlayer = inject(PORTAL_PLAYER); + private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + + private readonly bindings = signal( + null + ); + private readonly currentPlaylistId = computed( + () => this.xtreamStore.currentPlaylist()?.id ?? '' + ); + private lastSaveTime = 0; + + readonly inlinePlayback = signal(null); + readonly episodePlaybackPositions = signal< + Map + >(new Map()); + readonly openingEpisodeId = signal(null); + readonly activeEpisodeId = signal(null); + + readonly quickStartAction = computed(() => { + const item = this.selectedItem(); + if (!item) { + return null; + } + + return getSeriesQuickStartAction({ + seasons: item.episodes ?? {}, + playbackPositions: this.episodePlaybackPositions(), + }); + }); + readonly inlineEpisodeState = + computed | null>(() => + this.getInlineEpisodeState() + ); + readonly inlineEpisodeMetadata = computed(() => + getSeriesEpisodeMetadata(this.inlineEpisodeState()) + ); + readonly inlineSeriesNavigation = computed(() => + getSeriesPlaybackNavigation(this.inlineEpisodeState()) + ); + + constructor() { + effect(() => { + const session = this.externalPlayback.activeSession(); + const selectedItem = this.selectedItem(); + const playlistId = this.currentPlaylistId(); + + if ( + !session?.contentInfo || + !selectedItem?.series_id || + !playlistId || + session.contentInfo.contentType !== 'episode' || + session.contentInfo.playlistId !== playlistId || + session.contentInfo.seriesXtreamId !== + Number(selectedItem.series_id) + ) { + this.openingEpisodeId.set(null); + this.activeEpisodeId.set(null); + return; + } + + if (session.status === 'launching') { + this.openingEpisodeId.set(session.contentInfo.contentXtreamId); + this.activeEpisodeId.set(null); + return; + } + + if (session.status === 'opened' || session.status === 'playing') { + this.openingEpisodeId.set(null); + this.activeEpisodeId.set(session.contentInfo.contentXtreamId); + return; + } + + this.openingEpisodeId.set(null); + this.activeEpisodeId.set(null); + }); + + const unsubscribePositionUpdates = + this.playbackPositionBridge.onPlaybackPositionUpdate( + (data: PlaybackPositionData) => { + const selectedItem = this.selectedItem(); + + if ( + data.contentType !== 'episode' || + data.playlistId !== this.currentPlaylistId() || + data.seriesXtreamId !== + Number(selectedItem?.series_id ?? 0) + ) { + return; + } + + this.updateEpisodePlaybackPosition(data); + } + ) ?? null; + + inject(DestroyRef).onDestroy(() => { + unsubscribePositionUpdates?.(); + }); + } + + /** Connects the service to the owning component's reactive state. */ + bind(bindings: SerialDetailsPlaybackBindings): void { + this.bindings.set(bindings); + } + + /** Clears all playback state when switching to another series. */ + resetForNewSeries(): void { + this.closeInlinePlayer(); + this.episodePlaybackPositions.set(new Map()); + this.openingEpisodeId.set(null); + this.activeEpisodeId.set(null); + } + + playEpisode(episode: XtreamSerieEpisode): void { + const playlist = this.xtreamStore.currentPlaylist(); + const selectedItem = this.selectedItem(); + if (!playlist || !selectedItem) { + return; + } + + this.addToRecentlyViewed(this.route.snapshot.params.serialId); + + const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode); + const contentInfo: PlayerContentInfo = { + playlistId: playlist.id, + contentXtreamId: Number(episode.id), + contentType: 'episode', + seriesXtreamId: Number(selectedItem.series_id), + seasonNumber: Number(episode.season), + episodeNumber: Number(episode.episode_num), + }; + + const position = this.episodePlaybackPositions().get( + Number(episode.id) + ); + + const playback: ResolvedPortalPlayback = { + streamUrl, + title: episode.title, + thumbnail: selectedItem.info.cover, + startTime: position?.positionSeconds, + contentInfo, + }; + + this.startPlayback(playback); + } + + playQuickStartEpisode(): void { + const action = this.quickStartAction(); + if (!action || action.disabled) { + return; + } + + this.playEpisode(action.episode); + } + + playPreviousEpisode(): void { + const previous = this.inlineEpisodeState()?.previous; + if (!previous) { + return; + } + this.playEpisode(previous); + } + + playNextEpisode(): void { + const next = this.inlineEpisodeState()?.next; + if (!next) { + return; + } + this.playEpisode(next); + } + + handleInlinePlaybackEnded(): void { + const navigation = this.inlineSeriesNavigation(); + if (!navigation?.autoplayEnabled || !navigation.canNext) { + return; + } + this.playNextEpisode(); + } + + closeInlinePlayer(): void { + this.inlinePlayback.set(null); + this.lastSaveTime = 0; + } + + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + const playback = this.inlinePlayback(); + if (!playback?.contentInfo) return; + + const now = Date.now(); + if (now - this.lastSaveTime <= 15000) return; + + this.lastSaveTime = now; + const position: PlaybackPositionData = { + ...playback.contentInfo, + positionSeconds: Math.floor(event.currentTime), + durationSeconds: Math.floor(event.duration), + }; + void this.playbackPositions.savePlaybackPosition( + playback.contentInfo.playlistId, + position + ); + this.updateEpisodePlaybackPosition(position); + } + + handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { + void this.portalPlayer.openExternalPlayback( + request.playback, + request.player + ); + } + + async handlePlaybackToggleRequested( + request: SeasonContainerPlaybackToggleRequest + ): Promise { + const playlistId = this.currentPlaylistId(); + if (!playlistId) { + return; + } + + if (request.nextPosition) { + await this.playbackPositions.savePlaybackPosition( + playlistId, + request.nextPosition + ); + this.updateEpisodePlaybackPosition(request.nextPosition); + return; + } + + await this.playbackPositions.clearPlaybackPosition( + playlistId, + request.contentXtreamId, + 'episode' + ); + this.removeEpisodePlaybackPosition(request.contentXtreamId); + } + + async loadSeriesPlaybackPositions( + playlistId: string, + seriesXtreamId: number + ): Promise { + const positions = + await this.playbackPositions.getSeriesPlaybackPositions( + playlistId, + seriesXtreamId + ); + const positionsMap = new Map(); + positions.forEach((position) => { + positionsMap.set(position.contentXtreamId, position); + }); + this.episodePlaybackPositions.set(positionsMap); + } + + private selectedItem(): XtreamSerieDetailsView | null { + return this.bindings()?.selectedItem() ?? null; + } + + private addToRecentlyViewed(xtreamId: number): void { + this.xtreamStore.addRecentItem({ + xtreamId, + contentType: 'series', + playlist: this.xtreamStore.currentPlaylist, + backdropUrl: this.selectedItem()?.info?.backdrop_path?.[0], + }); + } + + private startPlayback(playback: ResolvedPortalPlayback): void { + this.lastSaveTime = 0; + if (this.portalPlayer.isEmbeddedPlayer()) { + this.inlinePlayback.set(playback); + return; + } + + this.closeInlinePlayer(); + void this.portalPlayer.openResolvedPlayback(playback, true); + } + + private getInlineEpisodeState(): SeriesPlaybackEpisodeState | null { + const playback = this.inlinePlayback(); + const episodesBySeason = this.selectedItem()?.episodes; + const currentEpisodeId = playback?.contentInfo?.contentXtreamId; + + if ( + !episodesBySeason || + playback?.contentInfo?.contentType !== 'episode' || + currentEpisodeId === undefined + ) { + return null; + } + + return resolveSeriesPlaybackEpisodeState({ + episodesBySeason, + currentEpisodeId, + fallbackSeasonNumber: playback.contentInfo.seasonNumber, + fallbackEpisodeNumber: playback.contentInfo.episodeNumber, + }); + } + + private updateEpisodePlaybackPosition( + position: PlaybackPositionData + ): void { + const updated = new Map(this.episodePlaybackPositions()); + updated.set(position.contentXtreamId, position); + this.episodePlaybackPositions.set(updated); + } + + private removeEpisodePlaybackPosition(contentXtreamId: number): void { + const updated = new Map(this.episodePlaybackPositions()); + updated.delete(contentXtreamId); + this.episodePlaybackPositions.set(updated); + } +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html index 8fdd86066..9f56666bb 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.html @@ -30,10 +30,49 @@
- @if (info.cast) { + @if (info.cast || info.tmdb_cast?.length) {
{{ 'XTREAM.CAST' | translate }} - {{ info.cast }} + @if (info.tmdb_cast?.length) { +
+ @for (member of info.tmdb_cast; track member.name) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ info.cast }} + }
} @if (info.director) { @@ -113,7 +152,42 @@ [activeEpisodeId]="activeEpisodeId()" (episodeClicked)="playEpisode($event)" (playbackToggleRequested)="handlePlaybackToggleRequested($event)" + (seasonSelected)="onSeasonSelected($event)" /> + + @if (similarItems().length) { +
+

{{ 'XTREAM.SIMILAR' | translate }}

+
+ @for (similar of similarItems(); track similar.id) { + + } +
+
+ } } @else if (detailsError()) { { { provide: ActivatedRoute, useValue: { + params: of({ + categoryId: '3', + serialId: '103', + }), snapshot: { params: { categoryId: '3', diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index ae77bb116..c637bde0a 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -8,9 +8,10 @@ import { OnInit, signal, } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; import { MatIcon } from '@angular/material/icon'; import { MatSnackBar } from '@angular/material/snack-bar'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { ContentHeroComponent, @@ -18,34 +19,25 @@ import { SeasonContainerPlaybackToggleRequest, SeasonContainerXtreamDownloadContext, } from '@iptvnator/ui/components'; -import { - PORTAL_EXTERNAL_PLAYBACK, - PORTAL_PLAYBACK_POSITIONS, - PORTAL_PLAYER, - getSeriesQuickStartAction, -} from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { - getSeriesEpisodeMetadata, - getSeriesPlaybackNavigation, type PlaybackFallbackRequest, PortalInlinePlayerComponent, - resolveSeriesPlaybackEpisodeState, - type SeriesPlaybackEpisodeState, } from '@iptvnator/ui/playback'; -import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; import { - PlaybackPositionData, - PlayerContentInfo, - ResolvedPortalPlayback, + TmdbEnrichedCastMember, XtreamSerieInfo, XtreamSerieEpisode, XtreamSerieDetails, } from '@iptvnator/shared/interfaces'; - -type XtreamSerieDetailsView = XtreamSerieDetails & { - readonly series_id: number; -}; +import { + SerialDetailsPlaybackService, + type XtreamSerieDetailsView, +} from './serial-details-playback.service'; +import { + SimilarCatalogItem, + matchRecommendationsToCatalog, +} from '../tmdb-similar.util'; @Component({ selector: 'app-serial-details', @@ -63,6 +55,7 @@ type XtreamSerieDetailsView = XtreamSerieDetails & { } `, ], + providers: [SerialDetailsPlaybackService], imports: [ ContentHeroComponent, MatIcon, @@ -75,13 +68,9 @@ type XtreamSerieDetailsView = XtreamSerieDetails & { export class SerialDetailsComponent implements OnInit, OnDestroy { private readonly location = inject(Location); private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); private readonly xtreamStore = inject(XtreamStore); - private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); - private readonly playbackPositionBridge = inject( - PlaybackPositionRuntimeBridgeService - ); - private readonly portalPlayer = inject(PORTAL_PLAYER); - private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly playback = inject(SerialDetailsPlaybackService); private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); @@ -90,42 +79,47 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { readonly isFavorite = this.xtreamStore.isFavorite; readonly isLoadingDetails = this.xtreamStore.isLoadingDetails; readonly detailsError = this.xtreamStore.detailsError; - readonly inlinePlayback = signal(null); - readonly episodePlaybackPositions = signal< - Map - >(new Map()); readonly currentPlaylistId = signal(''); readonly xtreamDownloadContext = signal(null); - private readonly detailsInitDone = signal(false); + /** `playlistId:categoryId:serialId` of the last initialized view */ + private readonly lastInitKey = signal(null); private readonly backdropBackfillKey = signal(null); - private lastSaveTime = 0; - private unsubscribePositionUpdates: (() => void) | null = null; - readonly openingEpisodeId = signal(null); - readonly activeEpisodeId = signal(null); - readonly quickStartAction = computed(() => { - const item = this.selectedItem(); - if (!item) { - return null; - } - return getSeriesQuickStartAction({ - seasons: item.episodes ?? {}, - playbackPositions: this.episodePlaybackPositions(), - }); + /** + * Reactive route params: the component is reused when navigating + * between two series details (e.g. via the Similar rail). + */ + private readonly routeParams = toSignal(this.route.params, { + initialValue: this.route.snapshot.params, }); - readonly inlineEpisodeState = - computed | null>(() => - this.getInlineEpisodeState() + + // Episode playback state, re-exposed for the template. + readonly inlinePlayback = this.playback.inlinePlayback; + readonly episodePlaybackPositions = this.playback.episodePlaybackPositions; + readonly openingEpisodeId = this.playback.openingEpisodeId; + readonly activeEpisodeId = this.playback.activeEpisodeId; + readonly quickStartAction = this.playback.quickStartAction; + readonly inlineEpisodeMetadata = this.playback.inlineEpisodeMetadata; + readonly inlineSeriesNavigation = this.playback.inlineSeriesNavigation; + + /** TMDB recommendations matched against the loaded series catalog */ + readonly similarItems = computed(() => { + const item = this.selectedItem(); + const recommendations = item?.info?.tmdb_recommendations; + if (!recommendations?.length) { + return []; + } + return matchRecommendationsToCatalog( + recommendations, + this.xtreamStore.serialStreams(), + { excludeId: Number(item?.series_id) } ); - readonly inlineEpisodeMetadata = computed(() => - getSeriesEpisodeMetadata(this.inlineEpisodeState()) - ); - readonly inlineSeriesNavigation = computed(() => - getSeriesPlaybackNavigation(this.inlineEpisodeState()) - ); + }); constructor() { + this.playback.bind({ selectedItem: this.selectedItem }); + effect(() => { const item = this.xtreamStore.selectedItem() as unknown as | (XtreamSerieDetails & { @@ -156,15 +150,23 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { ); }); + // Initializes on first render and RE-initializes when the route + // params change while the component is reused (Similar rail). effect(() => { const playlistId = this.xtreamStore.currentPlaylist()?.id; - const { categoryId, serialId } = this.route.snapshot.params; - if (!playlistId || this.detailsInitDone()) { + const { categoryId, serialId } = this.routeParams(); + if (!playlistId || !serialId) { return; } + const initKey = `${playlistId}:${categoryId}:${serialId}`; + if (this.lastInitKey() === initKey) { + return; + } + this.lastInitKey.set(initKey); + + this.playback.resetForNewSeries(); this.initializeSerialDetails(playlistId, categoryId, serialId); - this.detailsInitDone.set(true); }); effect(() => { @@ -195,119 +197,80 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { backdropUrl, }); }); - - effect(() => { - const session = this.externalPlayback.activeSession(); - const selectedItem = this.selectedItem(); - const playlistId = this.currentPlaylistId(); - - if ( - !session?.contentInfo || - !selectedItem?.series_id || - !playlistId || - session.contentInfo.contentType !== 'episode' || - session.contentInfo.playlistId !== playlistId || - session.contentInfo.seriesXtreamId !== - Number(selectedItem.series_id) - ) { - this.openingEpisodeId.set(null); - this.activeEpisodeId.set(null); - return; - } - - if (session.status === 'launching') { - this.openingEpisodeId.set(session.contentInfo.contentXtreamId); - this.activeEpisodeId.set(null); - return; - } - - if (session.status === 'opened' || session.status === 'playing') { - this.openingEpisodeId.set(null); - this.activeEpisodeId.set(session.contentInfo.contentXtreamId); - return; - } - - this.openingEpisodeId.set(null); - this.activeEpisodeId.set(null); - }); - - this.unsubscribePositionUpdates = - this.playbackPositionBridge.onPlaybackPositionUpdate( - (data: PlaybackPositionData) => { - const selectedItem = this.selectedItem(); - - if ( - data.contentType !== 'episode' || - data.playlistId !== this.currentPlaylistId() || - data.seriesXtreamId !== - Number(selectedItem?.series_id ?? 0) - ) { - return; - } - - this.updateEpisodePlaybackPosition(data); - } - ) ?? null; } ngOnInit(): void { - const currentPlaylist = this.xtreamStore.currentPlaylist(); - if (!currentPlaylist?.id) { - return; - } - - const { categoryId, serialId } = this.route.snapshot.params; - this.initializeSerialDetails(currentPlaylist.id, categoryId, serialId); - this.detailsInitDone.set(true); + // Initialization is handled by the params-driven effect in the + // constructor; the hook remains for interface compatibility. } ngOnDestroy(): void { - this.closeInlinePlayer(); + this.playback.closeInlinePlayer(); this.xtreamStore.setSelectedItem(null); - this.unsubscribePositionUpdates?.(); + } + + openSimilar(item: SimilarCatalogItem): void { + void this.router.navigate(['../..', item.categoryId, item.id], { + relativeTo: this.route, + }); + } + + openActor(member: TmdbEnrichedCastMember): void { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + if (!playlistId || !member.tmdbPersonId) { + return; + } + void this.router.navigate([ + '/workspace/xtreams', + playlistId, + 'actor', + member.tmdbPersonId, + ]); + } + + onSeasonSelected(seasonKey: string): void { + this.xtreamStore.enrichSelectedSerialSeason(seasonKey); } playEpisode(episode: XtreamSerieEpisode): void { - const playlist = this.xtreamStore.currentPlaylist(); - const selectedItem = this.selectedItem(); - if (!playlist || !selectedItem) { - return; - } - - this.addToRecentlyViewed(this.route.snapshot.params.serialId); - - const streamUrl = this.xtreamStore.constructEpisodeStreamUrl(episode); - const contentInfo: PlayerContentInfo = { - playlistId: playlist.id, - contentXtreamId: Number(episode.id), - contentType: 'episode', - seriesXtreamId: Number(selectedItem.series_id), - seasonNumber: Number(episode.season), - episodeNumber: Number(episode.episode_num), - }; - - const position = this.episodePlaybackPositions().get( - Number(episode.id) - ); - - const playback: ResolvedPortalPlayback = { - streamUrl, - title: episode.title, - thumbnail: selectedItem.info.cover, - startTime: position?.positionSeconds, - contentInfo, - }; - - this.startPlayback(playback); + this.playback.playEpisode(episode); } playQuickStartEpisode(): void { - const action = this.quickStartAction(); - if (!action || action.disabled) { - return; - } + this.playback.playQuickStartEpisode(); + } - this.playEpisode(action.episode); + playPreviousEpisode(): void { + this.playback.playPreviousEpisode(); + } + + playNextEpisode(): void { + this.playback.playNextEpisode(); + } + + handleInlinePlaybackEnded(): void { + this.playback.handleInlinePlaybackEnded(); + } + + closeInlinePlayer(): void { + this.playback.closeInlinePlayer(); + } + + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + this.playback.handleInlineTimeUpdate(event); + } + + handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { + this.playback.handleExternalFallbackRequest(request); + } + + handlePlaybackToggleRequested( + request: SeasonContainerPlaybackToggleRequest + ): Promise { + return this.playback.handlePlaybackToggleRequested(request); } toggleFavorite(): void { @@ -329,38 +292,10 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { } goBack(): void { - this.closeInlinePlayer(); + this.playback.closeInlinePlayer(); this.location.back(); } - closeInlinePlayer(): void { - this.inlinePlayback.set(null); - this.lastSaveTime = 0; - } - - handleInlineTimeUpdate(event: { - currentTime: number; - duration: number; - }): void { - const playback = this.inlinePlayback(); - if (!playback?.contentInfo) return; - - const now = Date.now(); - if (now - this.lastSaveTime <= 15000) return; - - this.lastSaveTime = now; - const position: PlaybackPositionData = { - ...playback.contentInfo, - positionSeconds: Math.floor(event.currentTime), - durationSeconds: Math.floor(event.duration), - }; - void this.playbackPositions.savePlaybackPosition( - playback.contentInfo.playlistId, - position - ); - this.updateEpisodePlaybackPosition(position); - } - showCopyNotification(): void { this.snackBar.open( this.translateService.instant('PORTALS.STREAM_URL_COPIED'), @@ -371,133 +306,6 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { ); } - handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { - void this.portalPlayer.openExternalPlayback( - request.playback, - request.player - ); - } - - playPreviousEpisode(): void { - const previous = this.inlineEpisodeState()?.previous; - if (!previous) { - return; - } - this.playEpisode(previous); - } - - playNextEpisode(): void { - const next = this.inlineEpisodeState()?.next; - if (!next) { - return; - } - this.playEpisode(next); - } - - handleInlinePlaybackEnded(): void { - const navigation = this.inlineSeriesNavigation(); - if (!navigation?.autoplayEnabled || !navigation.canNext) { - return; - } - this.playNextEpisode(); - } - - private addToRecentlyViewed(xtreamId: number): void { - this.xtreamStore.addRecentItem({ - xtreamId, - contentType: 'series', - playlist: this.xtreamStore.currentPlaylist, - backdropUrl: this.selectedItem()?.info?.backdrop_path?.[0], - }); - } - - private startPlayback(playback: ResolvedPortalPlayback): void { - this.lastSaveTime = 0; - if (this.portalPlayer.isEmbeddedPlayer()) { - this.inlinePlayback.set(playback); - return; - } - - this.closeInlinePlayer(); - void this.portalPlayer.openResolvedPlayback(playback, true); - } - - private getInlineEpisodeState(): SeriesPlaybackEpisodeState | null { - const playback = this.inlinePlayback(); - const episodesBySeason = this.selectedItem()?.episodes; - const currentEpisodeId = playback?.contentInfo?.contentXtreamId; - - if ( - !episodesBySeason || - playback?.contentInfo?.contentType !== 'episode' || - currentEpisodeId === undefined - ) { - return null; - } - - return resolveSeriesPlaybackEpisodeState({ - episodesBySeason, - currentEpisodeId, - fallbackSeasonNumber: playback.contentInfo.seasonNumber, - fallbackEpisodeNumber: playback.contentInfo.episodeNumber, - }); - } - - async handlePlaybackToggleRequested( - request: SeasonContainerPlaybackToggleRequest - ): Promise { - const playlistId = this.currentPlaylistId(); - if (!playlistId) { - return; - } - - if (request.nextPosition) { - await this.playbackPositions.savePlaybackPosition( - playlistId, - request.nextPosition - ); - this.updateEpisodePlaybackPosition(request.nextPosition); - return; - } - - await this.playbackPositions.clearPlaybackPosition( - playlistId, - request.contentXtreamId, - 'episode' - ); - this.removeEpisodePlaybackPosition(request.contentXtreamId); - } - - private async loadSeriesPlaybackPositions( - playlistId: string, - seriesXtreamId: number - ): Promise { - const positions = - await this.playbackPositions.getSeriesPlaybackPositions( - playlistId, - seriesXtreamId - ); - const positionsMap = new Map(); - positions.forEach((position) => { - positionsMap.set(position.contentXtreamId, position); - }); - this.episodePlaybackPositions.set(positionsMap); - } - - private updateEpisodePlaybackPosition( - position: PlaybackPositionData - ): void { - const updated = new Map(this.episodePlaybackPositions()); - updated.set(position.contentXtreamId, position); - this.episodePlaybackPositions.set(updated); - } - - private removeEpisodePlaybackPosition(contentXtreamId: number): void { - const updated = new Map(this.episodePlaybackPositions()); - updated.delete(contentXtreamId); - this.episodePlaybackPositions.set(updated); - } - private initializeSerialDetails( playlistId: string, categoryId: string | number, @@ -513,6 +321,9 @@ export class SerialDetailsComponent implements OnInit, OnDestroy { playlistId, 'series' ); - void this.loadSeriesPlaybackPositions(playlistId, serialXtreamId); + void this.playback.loadSeriesPlaybackPositions( + playlistId, + serialXtreamId + ); } } diff --git a/libs/portal/xtream/feature/src/lib/tmdb-similar.util.spec.ts b/libs/portal/xtream/feature/src/lib/tmdb-similar.util.spec.ts new file mode 100644 index 000000000..8f1badc7c --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/tmdb-similar.util.spec.ts @@ -0,0 +1,156 @@ +import { TmdbRecommendation } from '@iptvnator/shared/interfaces'; +import { + buildCatalogTitleIndex, + lookupCatalogTitle, + matchRecommendationsToCatalog, +} from './tmdb-similar.util'; + +describe('matchRecommendationsToCatalog', () => { + const rec = ( + tmdbId: number, + title: string, + posterUrl: string | null = null + ): TmdbRecommendation => ({ tmdbId, title, year: null, posterUrl }); + + const catalog = [ + { + stream_id: 11, + name: 'The Matrix Reloaded', + category_id: '5', + stream_icon: 'http://provider/reloaded.jpg', + }, + { + xtream_id: 22, + title: 'EN - Inception (2010) 4K', + category_id: 7, + poster_url: 'http://provider/inception.jpg', + }, + { stream_id: 33, name: 'Unrelated Movie', category_id: '5' }, + ]; + + it('matches recommendations by normalized title', () => { + const matched = matchRecommendationsToCatalog( + [rec(603, 'The Matrix Reloaded'), rec(27205, 'Inception')], + catalog + ); + + expect(matched).toEqual([ + { + id: 11, + categoryId: '5', + title: 'The Matrix Reloaded', + posterUrl: 'http://provider/reloaded.jpg', + }, + { + id: 22, + categoryId: '7', + title: 'EN - Inception (2010) 4K', + posterUrl: 'http://provider/inception.jpg', + }, + ]); + }); + + it('prefers the TMDB poster when available', () => { + const matched = matchRecommendationsToCatalog( + [rec(603, 'The Matrix Reloaded', 'https://tmdb/poster.jpg')], + catalog + ); + expect(matched[0].posterUrl).toBe('https://tmdb/poster.jpg'); + }); + + it('drops recommendations without a catalog hit', () => { + const matched = matchRecommendationsToCatalog( + [rec(1, 'Movie Not In Catalog')], + catalog + ); + expect(matched).toEqual([]); + }); + + it('excludes the currently open item', () => { + const matched = matchRecommendationsToCatalog( + [rec(603, 'The Matrix Reloaded')], + catalog, + { excludeId: 11 } + ); + expect(matched).toEqual([]); + }); + + it('respects the limit', () => { + const matched = matchRecommendationsToCatalog( + [rec(603, 'The Matrix Reloaded'), rec(27205, 'Inception')], + catalog, + { limit: 1 } + ); + expect(matched).toHaveLength(1); + }); + + it('handles empty inputs', () => { + expect(matchRecommendationsToCatalog(undefined, catalog)).toEqual([]); + expect( + matchRecommendationsToCatalog([rec(1, 'Anything')], []) + ).toEqual([]); + }); +}); + +describe('two-tier year handling', () => { + const rec = ( + tmdbId: number, + title: string, + year: number | null = null + ): TmdbRecommendation => ({ tmdbId, title, year, posterUrl: null }); + + const catalog = [ + { stream_id: 1, name: 'Blade Runner 2049', category_id: '9' }, + { stream_id: 2, name: 'The Matrix 1999', category_id: '9' }, + ]; + + it('matches a title-year recommendation on the exact tier', () => { + const matched = matchRecommendationsToCatalog( + [rec(335984, 'Blade Runner 2049', 2017)], + catalog + ); + expect(matched.map((m) => m.id)).toEqual([1]); + }); + + it('rejects a year-incompatible base-tier match', () => { + // "Blade Runner" (1982) must NOT claim the catalog's "Blade Runner 2049" + const matched = matchRecommendationsToCatalog( + [rec(78, 'Blade Runner', 1982)], + catalog + ); + expect(matched).toEqual([]); + }); + + it('accepts a year-compatible base-tier match', () => { + const matched = matchRecommendationsToCatalog( + [rec(603, 'The Matrix', 1999)], + catalog + ); + expect(matched.map((m) => m.id)).toEqual([2]); + }); +}); + +describe('buildCatalogTitleIndex / lookupCatalogTitle', () => { + const index = buildCatalogTitleIndex([ + { stream_id: 1, name: 'Blade Runner 2049', category_id: '9' }, + { stream_id: 2, name: 'The Matrix 1999', category_id: '9' }, + { stream_id: 3, name: 'Heat', category_id: '9' }, + ]); + + it('resolves exact titles including title-years', () => { + expect(lookupCatalogTitle(index, 'Blade Runner 2049', 2017)?.id).toBe(1); + expect(lookupCatalogTitle(index, 'Heat', 1995)?.id).toBe(3); + }); + + it('resolves year-tagged provider titles when years agree', () => { + expect(lookupCatalogTitle(index, 'The Matrix', 1999)?.id).toBe(2); + }); + + it('rejects contradicting years on the stripped tier', () => { + expect(lookupCatalogTitle(index, 'Blade Runner', 1982)).toBeNull(); + }); + + it('is lenient when the credit year is unknown', () => { + expect(lookupCatalogTitle(index, 'The Matrix', null)?.id).toBe(2); + }); +}); diff --git a/libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts b/libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts new file mode 100644 index 000000000..c18f59e6b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/tmdb-similar.util.ts @@ -0,0 +1,212 @@ +import { + TmdbRecommendation, + normalizeTitleKeys, + titleYearsCompatible, +} from '@iptvnator/shared/interfaces'; + +/** + * Matches TMDB recommendations against the provider catalog so the + * "Similar" rail only shows titles the user can actually play. + * + * Matching is two-tier: TMDB titles are canonical (a trailing year is part + * of the title — "Blade Runner 2049"), provider titles are dirty (a + * trailing year is usually a release tag — "The Matrix 1999"). The exact + * normalized forms are compared first; the provider's year-stripped form + * only counts when the stripped year does not contradict the TMDB year. + */ + +export interface SimilarCatalogItem { + id: number; + categoryId: string; + title: string; + posterUrl: string | null; +} + +/** Catalog rows vary by source (API vs DB) — fields are read structurally */ +type CatalogStream = object; + +function field(stream: CatalogStream, key: string): unknown { + return (stream as Record)[key]; +} + +function streamId(stream: CatalogStream): number | null { + const candidate = + field(stream, 'xtream_id') ?? + field(stream, 'stream_id') ?? + field(stream, 'series_id') ?? + field(stream, 'id'); + const parsed = Number(candidate); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function streamTitle(stream: CatalogStream): string { + const title = field(stream, 'name') ?? field(stream, 'title'); + return typeof title === 'string' ? title : ''; +} + +function streamPoster(stream: CatalogStream): string | null { + const poster = + field(stream, 'stream_icon') ?? + field(stream, 'cover') ?? + field(stream, 'poster_url'); + return typeof poster === 'string' && poster !== '' ? poster : null; +} + +export interface CatalogMatch { + id: number; + categoryId: string; +} + +interface IndexedCatalogEntry extends CatalogMatch { + /** Year tag stripped from the provider title (base-tier entries only) */ + trailingYear: number | null; +} + +/** + * Two-tier catalog index for per-title availability checks (actor page + * filmography). First occurrence of a title wins per tier. + */ +export interface CatalogTitleIndex { + exact: Map; + base: Map; +} + +export function buildCatalogTitleIndex( + streams: readonly CatalogStream[] +): CatalogTitleIndex { + const exact = new Map(); + const base = new Map(); + for (const stream of streams) { + const keys = normalizeTitleKeys(streamTitle(stream)); + if (!keys.exact) { + continue; + } + const id = streamId(stream); + const categoryId = + field(stream, 'category_id') ?? field(stream, 'categoryId'); + if (id === null || categoryId === undefined || categoryId === null) { + continue; + } + const entry: IndexedCatalogEntry = { + id, + categoryId: String(categoryId), + trailingYear: keys.trailingYear, + }; + if (!exact.has(keys.exact)) { + exact.set(keys.exact, { ...entry, trailingYear: null }); + } + // The base tier only exists for titles that carried a year tag + if (keys.base !== keys.exact && !base.has(keys.base)) { + base.set(keys.base, entry); + } + } + return { exact, base }; +} + +/** + * Looks up a TMDB title (canonical — never year-stripped) in the catalog + * index. Base-tier hits require the provider's stripped year tag to be + * compatible with the TMDB year, so "Blade Runner" (1982) never claims the + * catalog's "Blade Runner 2049". + */ +export function lookupCatalogTitle( + index: CatalogTitleIndex, + title: string, + year?: number | null +): CatalogMatch | null { + const wanted = normalizeTitleKeys(title).exact; + if (!wanted) { + return null; + } + const exactHit = index.exact.get(wanted); + if (exactHit) { + return exactHit; + } + const baseHit = index.base.get(wanted); + return baseHit && titleYearsCompatible(year, baseHit.trailingYear) + ? baseHit + : null; +} + +export function matchRecommendationsToCatalog( + recommendations: readonly TmdbRecommendation[] | undefined, + streams: readonly CatalogStream[], + options: { excludeId?: number; limit?: number } = {} +): SimilarCatalogItem[] { + if (!recommendations?.length || !streams.length) { + return []; + } + + // Wanted keys are the EXACT normalized TMDB titles + const wanted = new Map(); + for (const recommendation of recommendations) { + const key = normalizeTitleKeys(recommendation.title).exact; + if (key && !wanted.has(key)) { + wanted.set(key, recommendation); + } + } + + // Single pass over the catalog; stop as soon as every wanted title has + // a candidate — catalogs can hold tens of thousands of rows. + const catalogHits = new Map(); + for (const stream of streams) { + if (catalogHits.size >= wanted.size) { + break; + } + const keys = normalizeTitleKeys(streamTitle(stream)); + if (!keys.exact) { + continue; + } + if (wanted.has(keys.exact) && !catalogHits.has(keys.exact)) { + catalogHits.set(keys.exact, stream); + continue; + } + // Base tier: provider title carried a year tag — only match when + // that year does not contradict the recommendation's year + if (keys.base === keys.exact || catalogHits.has(keys.base)) { + continue; + } + const recommendation = wanted.get(keys.base); + if ( + recommendation && + titleYearsCompatible(recommendation.year, keys.trailingYear) + ) { + catalogHits.set(keys.base, stream); + } + } + + const limit = options.limit ?? 12; + const seenIds = new Set(); + const matched: SimilarCatalogItem[] = []; + + for (const [key, recommendation] of wanted) { + if (matched.length >= limit) { + break; + } + const stream = catalogHits.get(key); + if (!stream) { + continue; + } + const id = streamId(stream); + const categoryId = + field(stream, 'category_id') ?? field(stream, 'categoryId'); + if ( + id === null || + id === options.excludeId || + seenIds.has(id) || + categoryId === undefined || + categoryId === null + ) { + continue; + } + seenIds.add(id); + matched.push({ + id, + categoryId: String(categoryId), + title: streamTitle(stream), + posterUrl: recommendation.posterUrl ?? streamPoster(stream), + }); + } + + return matched; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts new file mode 100644 index 000000000..c81f6b019 --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -0,0 +1,337 @@ +import { + DestroyRef, + Injectable, + Signal, + computed, + inject, + signal, +} from '@angular/core'; +import { + PORTAL_EXTERNAL_PLAYBACK, + PORTAL_PLAYBACK_POSITIONS, + PORTAL_PLAYER, + createLogger, + getPortalPlaybackProgressPercent, +} from '@iptvnator/portal/shared/util'; +import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; +import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services'; +import { + PlaybackPositionData, + PlayerContentInfo, + ResolvedPortalPlayback, + XtreamVodDetails, + XtreamVodInfo, + getXtreamVodInfo, +} from '@iptvnator/shared/interfaces'; +import type { PlaybackFallbackRequest } from '@iptvnator/ui/playback'; + +export interface VodDetailsPlaybackBindings { + /** Current vod id resolved from the route */ + vodId: Signal; + /** Usable metadata of the selected VOD, if any */ + vodInfo: Signal; +} + +/** + * Component-provided service that owns the playback concern of the VOD + * details route: inline playback state, playback positions, external + * player sessions, and play/resume actions. + */ +@Injectable() +export class VodDetailsPlaybackService { + private readonly xtreamStore = inject(XtreamStore); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); + private readonly portalPlayer = inject(PORTAL_PLAYER); + private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly playbackPositionBridge = inject( + PlaybackPositionRuntimeBridgeService + ); + private readonly logger = createLogger('VodDetailsPlayback'); + + /** Signals bound from the host component via `bind()` */ + private readonly bindings = signal( + null + ); + private lastSaveTime = 0; + + readonly inlinePlayback = signal(null); + readonly vodPlaybackPosition = signal(null); + + readonly matchedExternalPlayback = computed(() => { + const session = this.externalPlayback.activeSession(); + const vodId = this.bindings()?.vodId(); + const playlistId = this.xtreamStore.currentPlaylist()?.id; + + if ( + !session?.contentInfo || + !playlistId || + session.status === 'error' || + session.status === 'closed' + ) { + return null; + } + + const contentInfo = session.contentInfo; + if ( + contentInfo.playlistId !== playlistId || + contentInfo.contentType !== 'vod' || + contentInfo.contentXtreamId !== vodId + ) { + return null; + } + + return session; + }); + readonly externalPrimaryLabel = computed(() => { + const session = this.matchedExternalPlayback(); + if (!session) { + return null; + } + + const player = session.player.toUpperCase(); + switch (session.status) { + case 'launching': + return `Opening in ${player}...`; + case 'opened': + case 'playing': + return `Stop ${player}`; + default: + return null; + } + }); + readonly externalPrimaryIcon = computed(() => { + const session = this.matchedExternalPlayback(); + switch (session?.status) { + case 'launching': + return 'hourglass_top'; + case 'opened': + case 'playing': + return 'stop_circle'; + default: + return 'play_arrow'; + } + }); + readonly isExternalLaunchPending = computed( + () => this.matchedExternalPlayback()?.status === 'launching' + ); + readonly isExternalStopAction = computed(() => { + const status = this.matchedExternalPlayback()?.status; + return status === 'opened' || status === 'playing'; + }); + readonly externalPrimaryButtonState = computed(() => { + if (this.isExternalLaunchPending()) { + return 'launching'; + } + + return this.isExternalStopAction() ? 'stop' : 'idle'; + }); + readonly vodPlaybackProgress = computed(() => + getPortalPlaybackProgressPercent(this.vodPlaybackPosition()) + ); + + readonly hasPlaybackPosition = computed(() => { + const inProgress = + this.vodPlaybackProgress() > 0 && this.vodPlaybackProgress() < 90; + this.logger.debug('hasPlaybackPosition check', { + vodId: this.bindings()?.vodId(), + inProgress, + }); + return inProgress; + }); + + constructor() { + const unsubscribePositionUpdates = + this.playbackPositionBridge.onPlaybackPositionUpdate( + (data: PlaybackPositionData) => { + const playlistId = this.xtreamStore.currentPlaylist()?.id; + const vodId = this.bindings()?.vodId(); + + if ( + data.contentType !== 'vod' || + data.playlistId !== playlistId || + data.contentXtreamId !== vodId + ) { + return; + } + + this.vodPlaybackPosition.set(data); + } + ) ?? null; + + inject(DestroyRef).onDestroy(() => unsubscribePositionUpdates?.()); + } + + /** Wires the host component's context signals. Call once at construction. */ + bind(bindings: VodDetailsPlaybackBindings): void { + this.bindings.set(bindings); + } + + playVod(vodItem: XtreamVodDetails | null): void { + if (!vodItem) { + return; + } + + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } + + const info = getXtreamVodInfo(vodItem); + this.addToRecentlyViewed(); + const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); + const routeVodId = this.bindings()?.vodId(); + const id = + routeVodId != null && Number.isFinite(routeVodId) + ? routeVodId + : Number( + vodItem.movie_data?.stream_id || + (vodItem as { stream_id?: number }).stream_id + ); + + this.logger.debug('playVod resolved ID', { id, vodItem }); + + const contentInfo: PlayerContentInfo = { + playlistId: playlist.id, + contentXtreamId: id, + contentType: 'vod', + }; + const playback: ResolvedPortalPlayback = { + streamUrl, + title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', + thumbnail: info?.movie_image, + contentInfo, + }; + + this.startPlayback(playback); + } + + resumeVod(vodItem: XtreamVodDetails | null): void { + if (!vodItem) { + return; + } + + const playlist = this.xtreamStore.currentPlaylist(); + if (!playlist) { + return; + } + + const info = getXtreamVodInfo(vodItem); + this.addToRecentlyViewed(); + const vodId = this.bindings()?.vodId() ?? NaN; + const position = this.vodPlaybackPosition(); + const streamUrl = this.xtreamStore.constructVodStreamUrl(vodItem); + + const contentInfo: PlayerContentInfo = { + playlistId: playlist.id, + contentXtreamId: vodId, + contentType: 'vod', + }; + const playback: ResolvedPortalPlayback = { + streamUrl, + title: info?.name ?? vodItem.movie_data?.name ?? 'Unknown', + thumbnail: info?.movie_image, + startTime: position?.positionSeconds, + contentInfo, + }; + + this.startPlayback(playback); + } + + onPrimaryAction(vodItem: XtreamVodDetails | null): void { + if (!vodItem) { + return; + } + + if (this.isExternalStopAction()) { + void this.stopExternalPlayback(); + return; + } + + if (this.hasPlaybackPosition()) { + this.resumeVod(vodItem); + return; + } + + this.playVod(vodItem); + } + + async stopExternalPlayback(): Promise { + await this.externalPlayback.closeSession( + this.matchedExternalPlayback() + ); + } + + formatPosition(): string { + const position = this.vodPlaybackPosition(); + if (!position) return ''; + + const date = new Date(0); + date.setSeconds(position.positionSeconds); + const timeString = date.toISOString().substr(11, 8); + return timeString.startsWith('00:') ? timeString.substr(3) : timeString; + } + + closeInlinePlayer(): void { + this.inlinePlayback.set(null); + this.lastSaveTime = 0; + } + + handleInlineTimeUpdate(event: { + currentTime: number; + duration: number; + }): void { + const playback = this.inlinePlayback(); + if (!playback?.contentInfo) return; + + const now = Date.now(); + if (now - this.lastSaveTime <= 15000) return; + + this.lastSaveTime = now; + const position: PlaybackPositionData = { + ...playback.contentInfo, + positionSeconds: Math.floor(event.currentTime), + durationSeconds: Math.floor(event.duration), + }; + void this.playbackPositions.savePlaybackPosition( + playback.contentInfo.playlistId, + position + ); + this.vodPlaybackPosition.set(position); + } + + handleExternalFallbackRequest(request: PlaybackFallbackRequest): void { + void this.portalPlayer.openExternalPlayback( + request.playback, + request.player + ); + } + + async loadPosition(playlistId: string, vodId: number): Promise { + const position = await this.playbackPositions.getPlaybackPosition( + playlistId, + vodId, + 'vod' + ); + this.vodPlaybackPosition.set(position); + } + + private addToRecentlyViewed(): void { + this.xtreamStore.addRecentItem({ + xtreamId: this.bindings()?.vodId() ?? NaN, + contentType: 'movie', + playlist: this.xtreamStore.currentPlaylist, + backdropUrl: this.bindings()?.vodInfo()?.backdrop_path?.[0], + }); + } + + private startPlayback(playback: ResolvedPortalPlayback): void { + this.lastSaveTime = 0; + if (this.portalPlayer.isEmbeddedPlayer()) { + this.inlinePlayback.set(playback); + return; + } + + this.closeInlinePlayer(); + void this.portalPlayer.openResolvedPlayback(playback, true); + } +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html index 6d9708189..1de3b1ae8 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html @@ -30,10 +30,51 @@
- @if (info.actors || info.cast) { + @if (info.actors || info.cast || info.tmdb_cast?.length) {
{{ 'XTREAM.ACTORS' | translate }} - {{ info.actors || info.cast }} + @if (info.tmdb_cast?.length) { +
+ @for (member of info.tmdb_cast; track member.name) { + + @if (member.profileUrl) { + + } @else { + + {{ member.name.charAt(0) }} + + } + {{ + member.name + }} + + } +
+ } @else { + {{ + info.actors || info.cast + }} + }
} @if (info.director) { @@ -168,15 +209,12 @@ /> } - @if (info.youtube_trailer) { + @if (trailerEmbedUrl(); as trailerUrl) {

{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}