feat(tmdb): opt-in TMDB metadata enrichment for Xtream and Stalker portals (#1123)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 authored and GitHub committed 2026-07-04 17:07:46 +02:00
1 parent 342175fe76
commit dc05a2566e
113 files changed
+7855 -712

No files matched your search

+7
View File
@@ -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
+19 -3
View File
@@ -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
@@ -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[] = [
@@ -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,
@@ -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');
});
});
@@ -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<CatalogTitleMatch[]> {
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;
}
@@ -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 })
);
});
});
@@ -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<TmdbCacheEntry | null> {
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 };
}
@@ -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 {
@@ -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 }));
@@ -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'] },
},
];
@@ -44,6 +44,7 @@ async function importDatabaseEventModules(): Promise<void> {
await import('./playback-position.events');
await import('./playlist.events');
await import('./recently-viewed.events');
await import('./tmdb.events');
await import('./xtream.events');
}
@@ -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];
@@ -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<typeof setTmdbMetadata>[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 };
@@ -128,6 +128,24 @@
</div>
}
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_CREDITS' | translate }}</h4>
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
<a
href="https://www.themoviedb.org"
target="_blank"
rel="noreferrer noopener"
>
<img
src="assets/images/tmdb-logo.svg"
alt="TMDB"
style="height: 20px; margin-top: 8px"
/>
</a>
</div>
</div>
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.COMMUNITY' | translate }}</h4>
@@ -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() ?? '',
},
};
}
@@ -127,6 +127,7 @@ describe('buildSettingsSectionNavItems', () => {
'epg',
'dashboard',
'remote-control',
'tmdb',
'backup',
'reset',
'about',
@@ -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',
@@ -0,0 +1,113 @@
<section
class="settings-group"
[class.settings-group--active]="activeSection() === 'tmdb'"
id="tmdb"
[formGroup]="form()"
>
<header class="settings-group__header">
<div class="settings-group__header-icon">
<mat-icon>movie</mat-icon>
</div>
<div class="settings-group__header-text">
<h3>{{ 'SETTINGS.TMDB' | translate }}</h3>
<p>{{ 'SETTINGS.TMDB_DESCRIPTION' | translate }}</p>
</div>
</header>
<div formGroupName="tmdb">
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_ENABLE' | translate }}</h4>
<p>{{ 'SETTINGS.TMDB_ENABLE_DESCRIPTION' | translate }}</p>
</div>
<div class="setting-item__control setting-item__toggle">
<mat-checkbox
formControlName="enabled"
data-test-id="tmdb-enabled"
></mat-checkbox>
</div>
</div>
@if (form().value.tmdb?.enabled === true) {
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_API_KEY' | translate }}</h4>
<p>{{ 'SETTINGS.TMDB_API_KEY_DESCRIPTION' | translate }}</p>
<p>
<a
href="https://www.themoviedb.org/settings/api"
target="_blank"
rel="noreferrer noopener"
data-test-id="tmdb-get-key-link"
>
{{ 'SETTINGS.TMDB_GET_KEY_HINT' | translate }}
</a>
</p>
</div>
<div class="setting-item__control">
<mat-form-field
appearance="outline"
subscriptSizing="dynamic"
>
<mat-label>{{
'SETTINGS.TMDB_API_KEY' | translate
}}</mat-label>
<input
matInput
type="text"
autocomplete="off"
formControlName="apiKey"
data-test-id="tmdb-api-key"
/>
</mat-form-field>
<div class="tmdb-key-test">
<button
mat-stroked-button
type="button"
[disabled]="
!enteredApiKey || keyTestState() === 'testing'
"
(click)="testApiKey()"
data-test-id="tmdb-test-key"
>
@if (keyTestState() === 'testing') {
<mat-spinner diameter="16" />
} @else {
<mat-icon>network_check</mat-icon>
}
{{ 'SETTINGS.TMDB_TEST_BUTTON' | translate }}
</button>
@if (keyTestState() === 'success') {
<span
class="tmdb-key-test__result tmdb-key-test__result--success"
data-test-id="tmdb-test-success"
>
<mat-icon>check_circle</mat-icon>
{{ 'SETTINGS.TMDB_TEST_SUCCESS' | translate }}
</span>
} @else if (keyTestState() === 'error') {
<span
class="tmdb-key-test__result tmdb-key-test__result--error"
data-test-id="tmdb-test-error"
>
<mat-icon>error</mat-icon>
{{ 'SETTINGS.TMDB_TEST_FAILED' | translate }}
</span>
}
</div>
</div>
</div>
}
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<img
src="assets/images/tmdb-logo.svg"
alt="TMDB"
style="height: 20px; margin-bottom: 8px"
/>
<p>{{ 'SETTINGS.TMDB_ATTRIBUTION' | translate }}</p>
</div>
</div>
</div>
</section>
@@ -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<FormGroup>();
readonly activeSection = input.required<string>();
readonly keyTestState = signal<TmdbKeyTestState>('idle');
get enteredApiKey(): string {
return (this.form().value.tmdb?.apiKey ?? '').trim();
}
async testApiKey(): Promise<void> {
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');
}
}
@@ -84,6 +84,11 @@
/>
}
<app-settings-tmdb-section
[form]="settingsForm"
[activeSection]="activeSection()"
/>
<app-settings-backup-section
[activeSection]="activeSection()"
[isPwa]="isPwa"
@@ -105,6 +105,7 @@ const DEFAULT_SETTINGS = {
dashboardRails: DEFAULT_DASHBOARD_RAILS,
preferUploadedEpgOverXtream: false,
epgViewMode: 'timeline',
tmdb: { enabled: false, apiKey: '' },
};
const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
@@ -79,6 +79,7 @@ import { SettingsPlaybackSectionComponent } from './settings-playback-section.co
import { SettingsRemoteControlSectionComponent } from './settings-remote-control-section.component';
import { SettingsResetSectionComponent } from './settings-reset-section.component';
import { SettingsSectionScrollDirective } from './settings-section-scroll.directive';
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
import { SettingsBackupFacade } from './settings-backup.facade';
import { SettingsPlaylistResetFacade } from './settings-playlist-reset.facade';
import {
@@ -114,6 +115,7 @@ const APP_UPDATE_STATUS_LOAD_RETRY_DELAY_MS = 250;
SettingsRemoteControlSectionComponent,
SettingsResetSectionComponent,
SettingsSectionScrollDirective,
SettingsTmdbSectionComponent,
],
providers: [
SettingsBackupFacade,
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "حول",
"NAV_TMDB": "البيانات الوصفية",
"TMDB": "البيانات الوصفية (TMDB)",
"TMDB_DESCRIPTION": "إثراء تفاصيل الأفلام والمسلسلات ببيانات من The Movie Database",
"TMDB_ENABLE": "تفعيل بيانات TMDB الوصفية",
"TMDB_ENABLE_DESCRIPTION": "يحسّن ملخصات القصة وطاقم العمل والتقييمات والصور في صفحات التفاصيل. عند التفعيل، تُرسل عناوين الأفلام والمسلسلات إلى TMDB.",
"TMDB_API_KEY": "مفتاح TMDB API (اختياري)",
"TMDB_API_KEY_DESCRIPTION": "استخدم مفتاح TMDB API الخاص بك بدلاً من المفتاح المدمج",
"TMDB_GET_KEY_HINT": "احصل على مفتاح API مجاني على themoviedb.org",
"TMDB_TEST_BUTTON": "التحقق من المفتاح",
"TMDB_TEST_SUCCESS": "المفتاح يعمل — استجاب TMDB بنجاح",
"TMDB_TEST_FAILED": "فشل التحقق — رفض TMDB المفتاح أو تعذر الوصول إليه",
"TMDB_CREDITS": "البيانات الوصفية",
"TMDB_ATTRIBUTION": "يستخدم هذا المنتج TMDB API ولكنه غير معتمد أو مصدّق من قِبل TMDB.",
"APP_PREFERENCES": "تفضيلات التطبيق",
"HERO_EYEBROW": "إعدادات مساحة العمل",
"HERO_TITLE": "خصّص تجربة IPTVnator",
@@ -738,6 +751,15 @@
"COUNTRY": "البلد",
"ACTORS": "الممثلون",
"DIRECTOR": "المخرج",
"SIMILAR": "مشابه",
"ACTOR_FILMOGRAPHY": "الأعمال الفنية",
"ACTOR_FILTER_ALL": "الكل",
"ACTOR_SCOPE_THIS_PORTAL": "هذه البوابة",
"ACTOR_SCOPE_ALL_PORTALS": "كل البوابات",
"ACTOR_NO_RESULTS": "لا توجد نتائج لهذا الفلتر",
"ACTOR_IN_LIBRARY": "متوفر في مكتبتك",
"ACTOR_SEARCH_IN_PORTAL": "البحث في البوابة",
"ACTOR_BORN": "تاريخ الميلاد:",
"DURATION": "المدة",
"CAST": "طاقم العمل",
"EPISODE_RUN_TIME": "مدة الحلقة",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "حول",
"NAV_TMDB": "الميتاداتا",
"TMDB": "الميتاداتا (TMDB)",
"TMDB_DESCRIPTION": "غنّي تفاصيل الأفلام والسلاسل بمعطيات من The Movie Database",
"TMDB_ENABLE": "فعّل ميتاداتا TMDB",
"TMDB_ENABLE_DESCRIPTION": "كيحسّن القصص، طاقم التمثيل، التقييمات والصور فصفحات التفاصيل. ملي يكون مفعّل، عناوين الأفلام والسلاسل كيتصيفطو ل TMDB.",
"TMDB_API_KEY": "مفتاح TMDB API (اختياري)",
"TMDB_API_KEY_DESCRIPTION": "استعمل مفتاح TMDB API ديالك بلاصة المفتاح المدمج",
"TMDB_GET_KEY_HINT": "جيب مفتاح API مجاني من themoviedb.org",
"TMDB_TEST_BUTTON": "تأكد من المفتاح",
"TMDB_TEST_SUCCESS": "المفتاح خدام — TMDB جاوب مزيان",
"TMDB_TEST_FAILED": "التحقق ما نجحش — TMDB رفض المفتاح ولا ما يمكنش الوصول ليه",
"TMDB_CREDITS": "الميتاداتا",
"TMDB_ATTRIBUTION": "هاد المنتج كيستعمل TMDB API ولكن ماشي معتمد ولا مصادق عليه من طرف TMDB.",
"APP_PREFERENCES": "تفضيلات التطبيق",
"HERO_EYEBROW": "إعدادات مساحة العمل",
"HERO_TITLE": "شكّل كيفاش كيتحس IPTVnator",
@@ -738,6 +751,15 @@
"COUNTRY": "البلد",
"ACTORS": "الممثلون",
"DIRECTOR": "المخرج",
"SIMILAR": "بحال هادو",
"ACTOR_FILMOGRAPHY": "الأفلام ديالو",
"ACTOR_FILTER_ALL": "كلشي",
"ACTOR_SCOPE_THIS_PORTAL": "هاد البوابة",
"ACTOR_SCOPE_ALL_PORTALS": "گاع البوابات",
"ACTOR_NO_RESULTS": "ما لقينا والو بهاد الفيلتر",
"ACTOR_IN_LIBRARY": "كاين فالمكتبة ديالك",
"ACTOR_SEARCH_IN_PORTAL": "قلب فالبوابة",
"ACTOR_BORN": "مزداد:",
"DURATION": "المدة",
"CAST": "طاقم التمثيل",
"EPISODE_RUN_TIME": "مدة تشغيل الحلقة",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Аб праграме",
"NAV_TMDB": "Метаданыя",
"TMDB": "Метаданыя (TMDB)",
"TMDB_DESCRIPTION": "Узбагачайце дэталі фільмаў і серыялаў дадзенымі з The Movie Database",
"TMDB_ENABLE": "Уключыць метаданыя TMDB",
"TMDB_ENABLE_DESCRIPTION": "Паляпшае апісанні, акцёрскі склад, рэйтынгі і ілюстрацыі на старонках з дэталямі. Калі ўключана, назвы фільмаў і серыялаў адпраўляюцца ў TMDB.",
"TMDB_API_KEY": "Ключ TMDB API (неабавязкова)",
"TMDB_API_KEY_DESCRIPTION": "Выкарыстоўвайце ўласны ключ TMDB API замест убудаванага",
"TMDB_GET_KEY_HINT": "Атрымайце бясплатны API-ключ на themoviedb.org",
"TMDB_TEST_BUTTON": "Праверыць ключ",
"TMDB_TEST_SUCCESS": "Ключ працуе — TMDB паспяхова адказаў",
"TMDB_TEST_FAILED": "Праверка не ўдалася — TMDB адхіліў ключ або недаступны",
"TMDB_CREDITS": "Метаданыя",
"TMDB_ATTRIBUTION": "Гэты прадукт выкарыстоўвае TMDB API, але не ўхвалены і не сертыфікаваны TMDB.",
"APP_PREFERENCES": "Параметры праграмы",
"HERO_EYEBROW": "Налады працоўнай прасторы",
"HERO_TITLE": "Наладзьце IPTVnator пад сябе",
@@ -738,6 +751,15 @@
"COUNTRY": "Краіна",
"ACTORS": "Акцёры",
"DIRECTOR": "Рэжысёр",
"SIMILAR": "Падобнае",
"ACTOR_FILMOGRAPHY": "Фільмаграфія",
"ACTOR_FILTER_ALL": "Усе",
"ACTOR_SCOPE_THIS_PORTAL": "Гэты партал",
"ACTOR_SCOPE_ALL_PORTALS": "Усе парталы",
"ACTOR_NO_RESULTS": "Нічога не знойдзена для гэтага фільтра",
"ACTOR_IN_LIBRARY": "Ёсць у вашай бібліятэцы",
"ACTOR_SEARCH_IN_PORTAL": "Пошук на партале",
"ACTOR_BORN": "Нарадзіўся:",
"DURATION": "Працягласць",
"CAST": "Акцёрскі склад",
"EPISODE_RUN_TIME": "Працягласць эпізоду",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Info",
"NAV_TMDB": "Metadaten",
"TMDB": "Metadaten (TMDB)",
"TMDB_DESCRIPTION": "Film- und Seriendetails mit Daten von The Movie Database anreichern",
"TMDB_ENABLE": "TMDB-Metadaten aktivieren",
"TMDB_ENABLE_DESCRIPTION": "Verbessert Handlung, Besetzung, Bewertungen und Artwork in Detailansichten. Wenn aktiviert, werden Film- und Serientitel an TMDB gesendet.",
"TMDB_API_KEY": "TMDB-API-Schlüssel (optional)",
"TMDB_API_KEY_DESCRIPTION": "Eigenen TMDB-API-Schlüssel anstelle des integrierten verwenden",
"TMDB_GET_KEY_HINT": "Kostenlosen API-Schlüssel auf themoviedb.org erhalten",
"TMDB_TEST_BUTTON": "Schlüssel prüfen",
"TMDB_TEST_SUCCESS": "Der Schlüssel funktioniert — TMDB hat erfolgreich geantwortet",
"TMDB_TEST_FAILED": "Prüfung fehlgeschlagen — TMDB hat den Schlüssel abgelehnt oder ist nicht erreichbar",
"TMDB_CREDITS": "Metadaten",
"TMDB_ATTRIBUTION": "Dieses Produkt verwendet die TMDB API, wird jedoch nicht von TMDB unterstützt oder zertifiziert.",
"APP_PREFERENCES": "App-Einstellungen",
"HERO_EYEBROW": "Arbeitsbereich",
"HERO_TITLE": "IPTVnator anpassen",
@@ -738,6 +751,15 @@
"COUNTRY": "Land",
"ACTORS": "Schauspieler",
"DIRECTOR": "Regisseur",
"SIMILAR": "Ähnliche Titel",
"ACTOR_FILMOGRAPHY": "Filmografie",
"ACTOR_FILTER_ALL": "Alle",
"ACTOR_SCOPE_THIS_PORTAL": "Dieses Portal",
"ACTOR_SCOPE_ALL_PORTALS": "Alle Portale",
"ACTOR_NO_RESULTS": "Für diesen Filter wurde nichts gefunden",
"ACTOR_IN_LIBRARY": "In Ihrer Bibliothek",
"ACTOR_SEARCH_IN_PORTAL": "Im Portal suchen",
"ACTOR_BORN": "Geboren:",
"DURATION": "Dauer",
"CAST": "Gießen",
"EPISODE_RUN_TIME": "Laufzeit der Episode",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Πληροφορίες",
"NAV_TMDB": "Μεταδεδομένα",
"TMDB": "Μεταδεδομένα (TMDB)",
"TMDB_DESCRIPTION": "Εμπλουτίστε τις λεπτομέρειες ταινιών και σειρών με δεδομένα από το The Movie Database",
"TMDB_ENABLE": "Ενεργοποίηση μεταδεδομένων TMDB",
"TMDB_ENABLE_DESCRIPTION": "Βελτιώνει τις υποθέσεις, τους συντελεστές, τις βαθμολογίες και το εικαστικό υλικό στις προβολές λεπτομερειών. Όταν είναι ενεργοποιημένο, οι τίτλοι ταινιών και σειρών αποστέλλονται στο TMDB.",
"TMDB_API_KEY": "Κλειδί API TMDB (προαιρετικό)",
"TMDB_API_KEY_DESCRIPTION": "Χρησιμοποιήστε το δικό σας κλειδί API TMDB αντί για το ενσωματωμένο",
"TMDB_GET_KEY_HINT": "Αποκτήστε δωρεάν κλειδί API στο themoviedb.org",
"TMDB_TEST_BUTTON": "Έλεγχος κλειδιού",
"TMDB_TEST_SUCCESS": "Το κλειδί λειτουργεί — το TMDB απάντησε με επιτυχία",
"TMDB_TEST_FAILED": "Ο έλεγχος απέτυχε — το TMDB απέρριψε το κλειδί ή δεν είναι προσβάσιμο",
"TMDB_CREDITS": "Μεταδεδομένα",
"TMDB_ATTRIBUTION": "Αυτό το προϊόν χρησιμοποιεί το TMDB API, αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB.",
"APP_PREFERENCES": "Προτιμήσεις εφαρμογής",
"HERO_EYEBROW": "Ρυθμίσεις χώρου εργασίας",
"HERO_TITLE": "Διαμορφώστε την εμπειρία του IPTVnator",
@@ -738,6 +751,15 @@
"COUNTRY": "Χώρα",
"ACTORS": "Ηθοποιοί",
"DIRECTOR": "Σκηνοθέτης",
"SIMILAR": "Παρόμοια",
"ACTOR_FILMOGRAPHY": "Φιλμογραφία",
"ACTOR_FILTER_ALL": "Όλα",
"ACTOR_SCOPE_THIS_PORTAL": "Αυτή η πύλη",
"ACTOR_SCOPE_ALL_PORTALS": "Όλες οι πύλες",
"ACTOR_NO_RESULTS": "Δεν βρέθηκε τίποτα για αυτό το φίλτρο",
"ACTOR_IN_LIBRARY": "Στη βιβλιοθήκη σας",
"ACTOR_SEARCH_IN_PORTAL": "Αναζήτηση στην πύλη",
"ACTOR_BORN": "Γεννήθηκε:",
"DURATION": "Διάρκεια",
"CAST": "Πρόσωπα",
"EPISODE_RUN_TIME": "Διάρκεια επεισοδίου",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "About",
"NAV_TMDB": "Metadata",
"TMDB": "Metadata (TMDB)",
"TMDB_DESCRIPTION": "Enrich movie and series details with data from The Movie Database",
"TMDB_ENABLE": "Enable TMDB metadata",
"TMDB_ENABLE_DESCRIPTION": "Improves plots, cast, ratings and artwork in detail views. When enabled, movie and series titles are sent to TMDB.",
"TMDB_API_KEY": "TMDB API key (optional)",
"TMDB_API_KEY_DESCRIPTION": "Use your own TMDB API key instead of the built-in one",
"TMDB_GET_KEY_HINT": "Get a free API key on themoviedb.org",
"TMDB_TEST_BUTTON": "Check key",
"TMDB_TEST_SUCCESS": "The key works — TMDB responded successfully",
"TMDB_TEST_FAILED": "Check failed — TMDB rejected the key or is unreachable",
"TMDB_CREDITS": "Metadata",
"TMDB_ATTRIBUTION": "This product uses the TMDB API but is not endorsed or certified by TMDB.",
"APP_PREFERENCES": "App preferences",
"HERO_EYEBROW": "Workspace settings",
"HERO_TITLE": "Shape how IPTVnator feels",
@@ -738,6 +751,15 @@
"COUNTRY": "Country",
"ACTORS": "Actors",
"DIRECTOR": "Director",
"SIMILAR": "Similar",
"ACTOR_FILMOGRAPHY": "Filmography",
"ACTOR_FILTER_ALL": "All",
"ACTOR_SCOPE_THIS_PORTAL": "This portal",
"ACTOR_SCOPE_ALL_PORTALS": "All portals",
"ACTOR_NO_RESULTS": "Nothing found for this filter",
"ACTOR_IN_LIBRARY": "In your library",
"ACTOR_SEARCH_IN_PORTAL": "Search in portal",
"ACTOR_BORN": "Born",
"DURATION": "Duration",
"CAST": "Cast",
"EPISODE_RUN_TIME": "Episode run time",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Acerca de",
"NAV_TMDB": "Metadatos",
"TMDB": "Metadatos (TMDB)",
"TMDB_DESCRIPTION": "Enriquece los detalles de películas y series con datos de The Movie Database",
"TMDB_ENABLE": "Activar metadatos de TMDB",
"TMDB_ENABLE_DESCRIPTION": "Mejora las sinopsis, el elenco, las calificaciones y las imágenes en las vistas de detalles. Cuando esta opción está activada, los títulos de películas y series se envían a TMDB.",
"TMDB_API_KEY": "Clave de API de TMDB (opcional)",
"TMDB_API_KEY_DESCRIPTION": "Usa tu propia clave de API de TMDB en lugar de la integrada",
"TMDB_GET_KEY_HINT": "Obtén una clave de API gratuita en themoviedb.org",
"TMDB_TEST_BUTTON": "Comprobar clave",
"TMDB_TEST_SUCCESS": "La clave funciona — TMDB respondió correctamente",
"TMDB_TEST_FAILED": "La comprobación falló — TMDB rechazó la clave o no está disponible",
"TMDB_CREDITS": "Metadatos",
"TMDB_ATTRIBUTION": "Este producto utiliza la API de TMDB, pero no está avalado ni certificado por TMDB.",
"APP_PREFERENCES": "Preferencias de la app",
"HERO_EYEBROW": "Ajustes del espacio de trabajo",
"HERO_TITLE": "Configura como se siente IPTVnator",
@@ -738,6 +751,15 @@
"COUNTRY": "País",
"ACTORS": "Actores",
"DIRECTOR": "Director",
"SIMILAR": "Similares",
"ACTOR_FILMOGRAPHY": "Filmografía",
"ACTOR_FILTER_ALL": "Todas",
"ACTOR_SCOPE_THIS_PORTAL": "Este portal",
"ACTOR_SCOPE_ALL_PORTALS": "Todos los portales",
"ACTOR_NO_RESULTS": "No se encontró nada con este filtro",
"ACTOR_IN_LIBRARY": "En tu biblioteca",
"ACTOR_SEARCH_IN_PORTAL": "Buscar en el portal",
"ACTOR_BORN": "Nacimiento:",
"DURATION": "Duración",
"CAST": "Elenco",
"EPISODE_RUN_TIME": "Tiempo de ejecución del episodio",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "À propos",
"NAV_TMDB": "Métadonnées",
"TMDB": "Métadonnées (TMDB)",
"TMDB_DESCRIPTION": "Enrichir les détails des films et séries avec les données de The Movie Database",
"TMDB_ENABLE": "Activer les métadonnées TMDB",
"TMDB_ENABLE_DESCRIPTION": "Améliore les synopsis, la distribution, les notes et les visuels dans les vues détaillées. Lorsque cette option est activée, les titres des films et séries sont envoyés à TMDB.",
"TMDB_API_KEY": "Clé API TMDB (optionnelle)",
"TMDB_API_KEY_DESCRIPTION": "Utiliser votre propre clé API TMDB au lieu de celle intégrée",
"TMDB_GET_KEY_HINT": "Obtenez une clé API gratuite sur themoviedb.org",
"TMDB_TEST_BUTTON": "Vérifier la clé",
"TMDB_TEST_SUCCESS": "La clé fonctionne — TMDB a répondu avec succès",
"TMDB_TEST_FAILED": "Échec de la vérification — TMDB a rejeté la clé ou est injoignable",
"TMDB_CREDITS": "Métadonnées",
"TMDB_ATTRIBUTION": "Ce produit utilise l'API TMDB mais n'est ni approuvé ni certifié par TMDB.",
"APP_PREFERENCES": "Préférences de l'application",
"HERO_EYEBROW": "Paramètres de l'espace de travail",
"HERO_TITLE": "Façonnez l'expérience IPTVnator",
@@ -738,6 +751,15 @@
"COUNTRY": "Pays",
"ACTORS": "Acteurs",
"DIRECTOR": "Réalisateur",
"SIMILAR": "Similaires",
"ACTOR_FILMOGRAPHY": "Filmographie",
"ACTOR_FILTER_ALL": "Tout",
"ACTOR_SCOPE_THIS_PORTAL": "Ce portail",
"ACTOR_SCOPE_ALL_PORTALS": "Tous les portails",
"ACTOR_NO_RESULTS": "Aucun résultat pour ce filtre",
"ACTOR_IN_LIBRARY": "Dans votre bibliothèque",
"ACTOR_SEARCH_IN_PORTAL": "Rechercher dans le portail",
"ACTOR_BORN": "Naissance :",
"DURATION": "Durée",
"CAST": "Distribution",
"EPISODE_RUN_TIME": "Durée de l'épisode",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Info",
"NAV_TMDB": "Metadati",
"TMDB": "Metadati (TMDB)",
"TMDB_DESCRIPTION": "Arricchisci i dettagli di film e serie con dati da The Movie Database",
"TMDB_ENABLE": "Abilita metadati TMDB",
"TMDB_ENABLE_DESCRIPTION": "Migliora trame, cast, valutazioni e immagini nelle viste di dettaglio. Quando attivo, i titoli di film e serie vengono inviati a TMDB.",
"TMDB_API_KEY": "Chiave API TMDB (opzionale)",
"TMDB_API_KEY_DESCRIPTION": "Usa la tua chiave API TMDB al posto di quella integrata",
"TMDB_GET_KEY_HINT": "Ottieni una chiave API gratuita su themoviedb.org",
"TMDB_TEST_BUTTON": "Verifica chiave",
"TMDB_TEST_SUCCESS": "La chiave funziona — TMDB ha risposto correttamente",
"TMDB_TEST_FAILED": "Verifica non riuscita — TMDB ha rifiutato la chiave o non è raggiungibile",
"TMDB_CREDITS": "Metadati",
"TMDB_ATTRIBUTION": "Questo prodotto utilizza l'API di TMDB ma non è approvato né certificato da TMDB.",
"APP_PREFERENCES": "Preferenze app",
"HERO_EYEBROW": "Impostazioni area di lavoro",
"HERO_TITLE": "Modella IPTVnator come preferisci",
@@ -738,6 +751,15 @@
"COUNTRY": "Nazione",
"ACTORS": "Attori",
"DIRECTOR": "Regista",
"SIMILAR": "Simili",
"ACTOR_FILMOGRAPHY": "Filmografia",
"ACTOR_FILTER_ALL": "Tutti",
"ACTOR_SCOPE_THIS_PORTAL": "Questo portale",
"ACTOR_SCOPE_ALL_PORTALS": "Tutti i portali",
"ACTOR_NO_RESULTS": "Nessun risultato per questo filtro",
"ACTOR_IN_LIBRARY": "Nella tua libreria",
"ACTOR_SEARCH_IN_PORTAL": "Cerca nel portale",
"ACTOR_BORN": "Nato:",
"DURATION": "Durata",
"CAST": "Cast",
"EPISODE_RUN_TIME": "Tempo esecuzione episodio",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "アプリについて",
"NAV_TMDB": "メタデータ",
"TMDB": "メタデータ(TMDB)",
"TMDB_DESCRIPTION": "The Movie Database のデータで映画やシリーズの詳細情報を充実させます",
"TMDB_ENABLE": "TMDB メタデータを有効にする",
"TMDB_ENABLE_DESCRIPTION": "詳細画面のあらすじ、キャスト、評価、アートワークを改善します。有効にすると、映画やシリーズのタイトルが TMDB に送信されます。",
"TMDB_API_KEY": "TMDB API キー(任意)",
"TMDB_API_KEY_DESCRIPTION": "内蔵のキーの代わりに独自の TMDB API キーを使用します",
"TMDB_GET_KEY_HINT": "themoviedb.org で無料の API キーを取得",
"TMDB_TEST_BUTTON": "キーを確認",
"TMDB_TEST_SUCCESS": "キーは有効です — TMDB が正常に応答しました",
"TMDB_TEST_FAILED": "確認に失敗しました — TMDB がキーを拒否したか、接続できません",
"TMDB_CREDITS": "メタデータ",
"TMDB_ATTRIBUTION": "本製品は TMDB API を使用していますが、TMDB による承認または認証を受けたものではありません。",
"APP_PREFERENCES": "アプリ設定",
"HERO_EYEBROW": "ワークスペース設定",
"HERO_TITLE": "IPTVnatorをカスタマイズ",
@@ -738,6 +751,15 @@
"COUNTRY": "国",
"ACTORS": "出演者",
"DIRECTOR": "監督",
"SIMILAR": "類似作品",
"ACTOR_FILMOGRAPHY": "出演作品",
"ACTOR_FILTER_ALL": "すべて",
"ACTOR_SCOPE_THIS_PORTAL": "このポータル",
"ACTOR_SCOPE_ALL_PORTALS": "すべてのポータル",
"ACTOR_NO_RESULTS": "このフィルターに一致するものはありません",
"ACTOR_IN_LIBRARY": "ライブラリにあり",
"ACTOR_SEARCH_IN_PORTAL": "ポータルで検索",
"ACTOR_BORN": "生年月日:",
"DURATION": "時間",
"CAST": "キャスト",
"EPISODE_RUN_TIME": "エピソード再生時間",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "정보",
"NAV_TMDB": "메타데이터",
"TMDB": "메타데이터 (TMDB)",
"TMDB_DESCRIPTION": "The Movie Database의 데이터로 영화 및 시리즈 상세 정보를 보강합니다",
"TMDB_ENABLE": "TMDB 메타데이터 사용",
"TMDB_ENABLE_DESCRIPTION": "상세 보기의 줄거리, 출연진, 평점 및 아트워크를 개선합니다. 활성화하면 영화 및 시리즈 제목이 TMDB로 전송됩니다.",
"TMDB_API_KEY": "TMDB API 키 (선택 사항)",
"TMDB_API_KEY_DESCRIPTION": "기본 제공 키 대신 자신의 TMDB API 키를 사용합니다",
"TMDB_GET_KEY_HINT": "themoviedb.org에서 무료 API 키 받기",
"TMDB_TEST_BUTTON": "키 확인",
"TMDB_TEST_SUCCESS": "키가 작동합니다 — TMDB가 정상적으로 응답했습니다",
"TMDB_TEST_FAILED": "확인에 실패했습니다 — TMDB가 키를 거부했거나 연결할 수 없습니다",
"TMDB_CREDITS": "메타데이터",
"TMDB_ATTRIBUTION": "이 제품은 TMDB API를 사용하지만 TMDB의 보증이나 인증을 받지 않았습니다.",
"APP_PREFERENCES": "앱 환경설정",
"HERO_EYEBROW": "작업 공간 설정",
"HERO_TITLE": "IPTVnator의 동작 방식을 설정하세요",
@@ -738,6 +751,15 @@
"COUNTRY": "국가",
"ACTORS": "배우",
"DIRECTOR": "감독",
"SIMILAR": "비슷한 콘텐츠",
"ACTOR_FILMOGRAPHY": "출연 작품",
"ACTOR_FILTER_ALL": "전체",
"ACTOR_SCOPE_THIS_PORTAL": "현재 포털",
"ACTOR_SCOPE_ALL_PORTALS": "모든 포털",
"ACTOR_NO_RESULTS": "이 필터에 해당하는 결과가 없습니다",
"ACTOR_IN_LIBRARY": "내 라이브러리에 있음",
"ACTOR_SEARCH_IN_PORTAL": "포털에서 검색",
"ACTOR_BORN": "출생:",
"DURATION": "지속",
"CAST": "깁스",
"EPISODE_RUN_TIME": "에피소드 실행 시간",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Over",
"NAV_TMDB": "Metadata",
"TMDB": "Metadata (TMDB)",
"TMDB_DESCRIPTION": "Verrijk film- en seriedetails met gegevens van The Movie Database",
"TMDB_ENABLE": "TMDB-metadata inschakelen",
"TMDB_ENABLE_DESCRIPTION": "Verbetert plots, cast, beoordelingen en artwork in detailweergaven. Wanneer ingeschakeld, worden film- en serietitels naar TMDB verzonden.",
"TMDB_API_KEY": "TMDB API-sleutel (optioneel)",
"TMDB_API_KEY_DESCRIPTION": "Gebruik je eigen TMDB API-sleutel in plaats van de ingebouwde",
"TMDB_GET_KEY_HINT": "Vraag een gratis API-sleutel aan op themoviedb.org",
"TMDB_TEST_BUTTON": "Sleutel controleren",
"TMDB_TEST_SUCCESS": "De sleutel werkt — TMDB heeft succesvol gereageerd",
"TMDB_TEST_FAILED": "Controle mislukt — TMDB heeft de sleutel geweigerd of is niet bereikbaar",
"TMDB_CREDITS": "Metadata",
"TMDB_ATTRIBUTION": "Dit product maakt gebruik van de TMDB API, maar wordt niet onderschreven of gecertificeerd door TMDB.",
"APP_PREFERENCES": "App-voorkeuren",
"HERO_EYEBROW": "Werkruimte-instellingen",
"HERO_TITLE": "Bepaal hoe IPTVnator aanvoelt",
@@ -738,6 +751,15 @@
"COUNTRY": "Land",
"ACTORS": "Acteurs",
"DIRECTOR": "Regisseur",
"SIMILAR": "Vergelijkbaar",
"ACTOR_FILMOGRAPHY": "Filmografie",
"ACTOR_FILTER_ALL": "Alles",
"ACTOR_SCOPE_THIS_PORTAL": "Dit portaal",
"ACTOR_SCOPE_ALL_PORTALS": "Alle portalen",
"ACTOR_NO_RESULTS": "Niets gevonden voor dit filter",
"ACTOR_IN_LIBRARY": "In je bibliotheek",
"ACTOR_SEARCH_IN_PORTAL": "Zoeken in portal",
"ACTOR_BORN": "Geboren:",
"DURATION": "Lengte",
"CAST": "Cast",
"EPISODE_RUN_TIME": "Aflevering lengte",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "O aplikacji",
"NAV_TMDB": "Metadane",
"TMDB": "Metadane (TMDB)",
"TMDB_DESCRIPTION": "Wzbogacaj szczegóły filmów i seriali o dane z The Movie Database",
"TMDB_ENABLE": "Włącz metadane TMDB",
"TMDB_ENABLE_DESCRIPTION": "Poprawia opisy fabuły, obsadę, oceny i grafiki w widokach szczegółów. Po włączeniu tytuły filmów i seriali są wysyłane do TMDB.",
"TMDB_API_KEY": "Klucz API TMDB (opcjonalnie)",
"TMDB_API_KEY_DESCRIPTION": "Użyj własnego klucza API TMDB zamiast wbudowanego",
"TMDB_GET_KEY_HINT": "Uzyskaj bezpłatny klucz API na themoviedb.org",
"TMDB_TEST_BUTTON": "Sprawdź klucz",
"TMDB_TEST_SUCCESS": "Klucz działa — TMDB odpowiedziało poprawnie",
"TMDB_TEST_FAILED": "Sprawdzenie nie powiodło się — TMDB odrzuciło klucz lub jest niedostępne",
"TMDB_CREDITS": "Metadane",
"TMDB_ATTRIBUTION": "Ten produkt korzysta z API TMDB, ale nie jest wspierany ani certyfikowany przez TMDB.",
"APP_PREFERENCES": "Preferencje aplikacji",
"HERO_EYEBROW": "Ustawienia obszaru roboczego",
"HERO_TITLE": "Dostosuj sposób działania IPTVnator",
@@ -738,6 +751,15 @@
"COUNTRY": "Kraj",
"ACTORS": "Aktorzy",
"DIRECTOR": "Reżyser",
"SIMILAR": "Podobne",
"ACTOR_FILMOGRAPHY": "Filmografia",
"ACTOR_FILTER_ALL": "Wszystkie",
"ACTOR_SCOPE_THIS_PORTAL": "Ten portal",
"ACTOR_SCOPE_ALL_PORTALS": "Wszystkie portale",
"ACTOR_NO_RESULTS": "Nic nie znaleziono dla tego filtra",
"ACTOR_IN_LIBRARY": "W twojej bibliotece",
"ACTOR_SEARCH_IN_PORTAL": "Szukaj w portalu",
"ACTOR_BORN": "Data urodzenia:",
"DURATION": "Czas trwania",
"CAST": "Obsada",
"EPISODE_RUN_TIME": "Czas trwania odcinka",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Sobre",
"NAV_TMDB": "Metadados",
"TMDB": "Metadados (TMDB)",
"TMDB_DESCRIPTION": "Enriqueça os detalhes de filmes e séries com dados do The Movie Database",
"TMDB_ENABLE": "Ativar metadados do TMDB",
"TMDB_ENABLE_DESCRIPTION": "Melhora sinopses, elenco, avaliações e imagens nas telas de detalhes. Quando ativado, os títulos de filmes e séries são enviados ao TMDB.",
"TMDB_API_KEY": "Chave de API do TMDB (opcional)",
"TMDB_API_KEY_DESCRIPTION": "Use sua própria chave de API do TMDB em vez da chave integrada",
"TMDB_GET_KEY_HINT": "Obtenha uma chave de API gratuita em themoviedb.org",
"TMDB_TEST_BUTTON": "Verificar chave",
"TMDB_TEST_SUCCESS": "A chave funciona — o TMDB respondeu com sucesso",
"TMDB_TEST_FAILED": "A verificação falhou — o TMDB rejeitou a chave ou está inacessível",
"TMDB_CREDITS": "Metadados",
"TMDB_ATTRIBUTION": "Este produto usa a API do TMDB, mas não é endossado nem certificado pelo TMDB.",
"APP_PREFERENCES": "Preferências do aplicativo",
"HERO_EYEBROW": "Configurações do espaço de trabalho",
"HERO_TITLE": "Personalize a experiência do IPTVnator",
@@ -738,6 +751,15 @@
"COUNTRY": "País",
"ACTORS": "Atores",
"DIRECTOR": "Diretor",
"SIMILAR": "Semelhantes",
"ACTOR_FILMOGRAPHY": "Filmografia",
"ACTOR_FILTER_ALL": "Todos",
"ACTOR_SCOPE_THIS_PORTAL": "Este portal",
"ACTOR_SCOPE_ALL_PORTALS": "Todos os portais",
"ACTOR_NO_RESULTS": "Nada encontrado para este filtro",
"ACTOR_IN_LIBRARY": "Na sua biblioteca",
"ACTOR_SEARCH_IN_PORTAL": "Buscar no portal",
"ACTOR_BORN": "Nascimento:",
"DURATION": "Duração",
"CAST": "Elenco",
"EPISODE_RUN_TIME": "Tempo de execução do episódio",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Инфо",
"NAV_TMDB": "Метаданные",
"TMDB": "Метаданные (TMDB)",
"TMDB_DESCRIPTION": "Обогащение информации о фильмах и сериалах данными из The Movie Database",
"TMDB_ENABLE": "Включить метаданные TMDB",
"TMDB_ENABLE_DESCRIPTION": "Улучшает описания, актёрский состав, рейтинги и обложки на страницах деталей. Когда включено, названия фильмов и сериалов отправляются в TMDB.",
"TMDB_API_KEY": "API-ключ TMDB (необязательно)",
"TMDB_API_KEY_DESCRIPTION": "Использовать собственный API-ключ TMDB вместо встроенного",
"TMDB_GET_KEY_HINT": "Получите бесплатный API-ключ на themoviedb.org",
"TMDB_TEST_BUTTON": "Проверить ключ",
"TMDB_TEST_SUCCESS": "Ключ работает — TMDB успешно ответил",
"TMDB_TEST_FAILED": "Проверка не удалась — TMDB отклонил ключ или недоступен",
"TMDB_CREDITS": "Метаданные",
"TMDB_ATTRIBUTION": "Этот продукт использует TMDB API, но не одобрен и не сертифицирован TMDB.",
"APP_PREFERENCES": "Параметры приложения",
"HERO_EYEBROW": "Настройки рабочей области",
"HERO_TITLE": "Настройте IPTVnator под себя",
@@ -738,6 +751,15 @@
"COUNTRY": "Страна",
"ACTORS": "Актеры",
"DIRECTOR": "Режиссёр",
"SIMILAR": "Похожее",
"ACTOR_FILMOGRAPHY": "Фильмография",
"ACTOR_FILTER_ALL": "Все",
"ACTOR_SCOPE_THIS_PORTAL": "Этот портал",
"ACTOR_SCOPE_ALL_PORTALS": "Все порталы",
"ACTOR_NO_RESULTS": "По этому фильтру ничего не найдено",
"ACTOR_IN_LIBRARY": "Есть в вашей библиотеке",
"ACTOR_SEARCH_IN_PORTAL": "Искать на портале",
"ACTOR_BORN": "Родился:",
"DURATION": "Продолжительность",
"CAST": "Бросать",
"EPISODE_RUN_TIME": "Длительность эпизода",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "Hakkında",
"NAV_TMDB": "Meta veri",
"TMDB": "Meta veri (TMDB)",
"TMDB_DESCRIPTION": "Film ve dizi ayrıntılarını The Movie Database verileriyle zenginleştirin",
"TMDB_ENABLE": "TMDB meta verisini etkinleştir",
"TMDB_ENABLE_DESCRIPTION": "Ayrıntı görünümlerinde konu özetlerini, oyuncu kadrosunu, puanları ve görselleri iyileştirir. Etkinleştirildiğinde film ve dizi adları TMDB'ye gönderilir.",
"TMDB_API_KEY": "TMDB API anahtarı (isteğe bağlı)",
"TMDB_API_KEY_DESCRIPTION": "Yerleşik anahtar yerine kendi TMDB API anahtarınızı kullanın",
"TMDB_GET_KEY_HINT": "themoviedb.org üzerinden ücretsiz API anahtarı alın",
"TMDB_TEST_BUTTON": "Anahtarı kontrol et",
"TMDB_TEST_SUCCESS": "Anahtar çalışıyor — TMDB başarıyla yanıt verdi",
"TMDB_TEST_FAILED": "Kontrol başarısız — TMDB anahtarı reddetti veya erişilemiyor",
"TMDB_CREDITS": "Meta veri",
"TMDB_ATTRIBUTION": "Bu ürün TMDB API'sini kullanır ancak TMDB tarafından onaylanmamış veya sertifikalandırılmamıştır.",
"APP_PREFERENCES": "Uygulama tercihleri",
"HERO_EYEBROW": "Çalışma alanı ayarları",
"HERO_TITLE": "IPTVnator'un nasıl hissettirdiğini şekillendirin",
@@ -738,6 +751,15 @@
"COUNTRY": "Ülke",
"ACTORS": "Oyuncular",
"DIRECTOR": "Yönetmen",
"SIMILAR": "Benzerler",
"ACTOR_FILMOGRAPHY": "Filmografi",
"ACTOR_FILTER_ALL": "Tümü",
"ACTOR_SCOPE_THIS_PORTAL": "Bu portal",
"ACTOR_SCOPE_ALL_PORTALS": "Tüm portallar",
"ACTOR_NO_RESULTS": "Bu filtre için sonuç bulunamadı",
"ACTOR_IN_LIBRARY": "Kitaplığınızda mevcut",
"ACTOR_SEARCH_IN_PORTAL": "Portalda ara",
"ACTOR_BORN": "Doğum:",
"DURATION": "Süre",
"CAST": "Oyuncular",
"EPISODE_RUN_TIME": "Bölüm süresi",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "关于",
"NAV_TMDB": "元数据",
"TMDB": "元数据(TMDB)",
"TMDB_DESCRIPTION": "使用来自 The Movie Database 的数据丰富电影和剧集详情",
"TMDB_ENABLE": "启用 TMDB 元数据",
"TMDB_ENABLE_DESCRIPTION": "改进详情视图中的剧情简介、演职人员、评分和海报图片。启用后,电影和剧集标题将发送至 TMDB。",
"TMDB_API_KEY": "TMDB API 密钥(可选)",
"TMDB_API_KEY_DESCRIPTION": "使用您自己的 TMDB API 密钥替代内置密钥",
"TMDB_GET_KEY_HINT": "在 themoviedb.org 获取免费的 API 密钥",
"TMDB_TEST_BUTTON": "检查密钥",
"TMDB_TEST_SUCCESS": "密钥有效 — TMDB 响应成功",
"TMDB_TEST_FAILED": "检查失败 — TMDB 拒绝了该密钥或无法访问",
"TMDB_CREDITS": "元数据",
"TMDB_ATTRIBUTION": "本产品使用 TMDB API,但未获得 TMDB 的认可或认证。",
"APP_PREFERENCES": "应用偏好",
"HERO_EYEBROW": "工作区设置",
"HERO_TITLE": "定制 IPTVnator 的使用体验",
@@ -738,6 +751,15 @@
"COUNTRY": "国家",
"ACTORS": "演员",
"DIRECTOR": "导演",
"SIMILAR": "类似影片",
"ACTOR_FILMOGRAPHY": "参演作品",
"ACTOR_FILTER_ALL": "全部",
"ACTOR_SCOPE_THIS_PORTAL": "当前门户",
"ACTOR_SCOPE_ALL_PORTALS": "所有门户",
"ACTOR_NO_RESULTS": "此筛选条件下没有结果",
"ACTOR_IN_LIBRARY": "已在您的媒体库中",
"ACTOR_SEARCH_IN_PORTAL": "在门户中搜索",
"ACTOR_BORN": "出生:",
"DURATION": "期间",
"CAST": "投掷",
"EPISODE_RUN_TIME": "剧集运行时间",
+22
View File
@@ -235,6 +235,19 @@
"NAV_BACKUP": "Backup",
"NAV_RESET": "Reset",
"NAV_ABOUT": "關於",
"NAV_TMDB": "中繼資料",
"TMDB": "中繼資料(TMDB)",
"TMDB_DESCRIPTION": "使用 The Movie Database 的資料豐富電影與影集的詳細資訊",
"TMDB_ENABLE": "啟用 TMDB 中繼資料",
"TMDB_ENABLE_DESCRIPTION": "改善詳細資訊頁面中的劇情簡介、演員陣容、評分與圖片。啟用後,電影與影集標題將傳送至 TMDB。",
"TMDB_API_KEY": "TMDB API 金鑰(選填)",
"TMDB_API_KEY_DESCRIPTION": "使用您自己的 TMDB API 金鑰,而非內建金鑰",
"TMDB_GET_KEY_HINT": "在 themoviedb.org 取得免費的 API 金鑰",
"TMDB_TEST_BUTTON": "檢查金鑰",
"TMDB_TEST_SUCCESS": "金鑰有效 — TMDB 回應成功",
"TMDB_TEST_FAILED": "檢查失敗 — TMDB 拒絕該金鑰或無法連線",
"TMDB_CREDITS": "中繼資料",
"TMDB_ATTRIBUTION": "本產品使用 TMDB API,但未經 TMDB 認可或認證。",
"APP_PREFERENCES": "應用程式偏好設定",
"HERO_EYEBROW": "工作區設定",
"HERO_TITLE": "打造您的 IPTVnator 體驗",
@@ -738,6 +751,15 @@
"COUNTRY": "國家",
"ACTORS": "演員",
"DIRECTOR": "導演",
"SIMILAR": "類似作品",
"ACTOR_FILMOGRAPHY": "參演作品",
"ACTOR_FILTER_ALL": "全部",
"ACTOR_SCOPE_THIS_PORTAL": "目前入口",
"ACTOR_SCOPE_ALL_PORTALS": "所有入口",
"ACTOR_NO_RESULTS": "此篩選條件下沒有結果",
"ACTOR_IN_LIBRARY": "已在您的媒體庫中",
"ACTOR_SEARCH_IN_PORTAL": "在入口網站搜尋",
"ACTOR_BORN": "出生:",
"DURATION": "片長",
"CAST": "演員陣容",
"EPISODE_RUN_TIME": "集數時長",
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 273.42 35.52" role="img" aria-label="The Movie Database (TMDB)">
<defs>
<linearGradient id="tmdbGradient" x1="0" y1="0" x2="273.42" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#90cea1"/>
<stop offset="0.56" stop-color="#3cbec9"/>
<stop offset="1" stop-color="#00b3e5"/>
</linearGradient>
</defs>
<rect width="273.42" height="35.52" rx="17.76" fill="url(#tmdbGradient)"/>
<text x="136.71" y="24.4" text-anchor="middle" font-family="'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif" font-size="19" font-weight="700" letter-spacing="1.5" fill="#0d253f">THE MOVIE DB</text>
</svg>

After

Width:  |  Height:  |  Size: 694 B

+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; base-uri 'self'; object-src 'none'; frame-src 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http: https: file: data: blob:; font-src 'self' data:; media-src 'self' http: https: file: data: blob:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:"
content="default-src 'self'; base-uri 'self'; object-src 'none'; frame-src https://www.youtube-nocookie.com; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http: https: file: data: blob:; font-src 'self' data:; media-src 'self' http: https: file: data: blob:; connect-src 'self' http: https: ws: wss: blob: data:; worker-src 'self' blob:"
/>
<meta property="og:type" content="website" />
<meta property="og:title" content="IPTVnator" />
@@ -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:<tmdbId>' -- details payload row
'title:<normalized>|year:<y>' -- search resolution row
'person:<personId>' -- 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).
@@ -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);
});
});
@@ -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 }
: {}),
};
}
@@ -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<StalkerSelectionState>(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,
});
},
}))
};
})
);
}
@@ -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<void> {
if (!enrichment.isEnabled()) {
return;
}
const info = item.info as Partial<StalkerVodInfo> | 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);
}
}
@@ -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: `<app-actor-view
[profile]="profile()"
[items]="items()"
[isLoading]="isLoading()"
[isMatching]="isMatchingGlobal()"
[showAvailabilityFilter]="scope() === 'global'"
[showScopeToggle]="showScopeToggle"
[scope]="scope()"
(scopeChanged)="onScopeChanged($event)"
(itemClicked)="openItem($event)"
(backClicked)="goBack()"
/>`,
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<ActorProfile | null>(null);
private readonly filmography = signal<ActorFilmographyCredit[]>([]);
readonly isLoading = signal(true);
readonly showScopeToggle = this.titleMatch.isAvailable;
readonly scope = signal<ActorViewScope>('portal');
readonly isMatchingGlobal = signal(false);
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(null);
private readonly globalIndex = computed(() =>
buildTitleMatchIndex(this.globalMatches() ?? [])
);
readonly items = computed<ActorViewItem[]>(() => {
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<void> {
// 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<void> {
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();
}
}
}
@@ -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 {
@@ -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,
@@ -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<StalkerSearchContentType>('vod');
private readonly favoritesRefresh = createRefreshTrigger();
itemDetails: StalkerSelectedVodItem | null = null;
vodDetailsItem: VodDetailsItem | null = null;
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly selectedVodPosition = signal<PlaybackPositionData | null>(null);
readonly selectedVodPlaybackPosition = computed<number | null>(
@@ -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<string, string | number> = {
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<StalkerSearchResponse>(
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<StalkerSearchResponse>(
{
...(playlist as Playlist),
isFullStalkerPortal: true,
},
requestParams
)
: await this.dataService.sendIpcEvent<StalkerSearchResponse>(
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'
);
}
@@ -3,6 +3,7 @@
[title]="serial.info.name"
[description]="serial.info.description"
[posterUrl]="serial.info.movie_image"
[backdropUrl]="serial.info.tmdb_backdrop"
(backClicked)="goBack()"
>
<!-- Tags: Year, Genre, Rating -->
@@ -27,10 +28,52 @@
<!-- Metadata Grid -->
<ng-container hero-meta>
@if (serial.info.actors) {
@if (serial.info.actors || serial.info.tmdb_cast?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ serial.info.actors }}</span>
@if (serial.info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (
member of serial.info.tmdb_cast;
track member.name
) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="
member.character
? member.name +
' — ' +
member.character
: member.name
"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ serial.info.actors }}</span>
}
</div>
}
@if (serial.info.director) {
@@ -112,5 +155,21 @@
#seasonContainer
/>
}
<!-- YouTube Trailer (TMDB enrichment; Stalker provides none) -->
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="trailerUrl | safe"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerpolicy="strict-origin-when-cross-origin"
allowfullscreen
></iframe>
</div>
</div>
}
</app-content-hero>
}
@@ -31,6 +31,7 @@ class StubContentHeroComponent {
readonly title = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
readonly posterUrl = input<string | undefined>(undefined);
readonly backdropUrl = input<string | undefined>(undefined);
readonly backClicked = output<void>();
}
@@ -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<string, TmdbEpisode[]>
>(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<StalkerSelectedVodItem | null>(() => {
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<string, XtreamSerieEpisode[]> where string is season number/name.
*/
readonly mappedSeasons = computed<Record<string, XtreamSerieEpisode[]>>(
() => {
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<string, XtreamSerieEpisode[]> = {};
for (const [seasonKey, episodes] of Object.entries(base)) {
const forSeason = tmdbEpisodes.get(`${tmdbId}|${seasonKey}`);
merged[seasonKey] = forSeason?.length
? mergeEpisodesWithTmdb(episodes, forSeason)
: episodes;
}
return merged;
}
);
@@ -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<void> {
const tmdbId = this.displayItem()?.info?.tmdb_id;
if (!tmdbId) {
return;
}
const mapKey = `${tmdbId}|${seasonKey}`;
if (this.tmdbSeasonEpisodes().has(mapKey)) {
return;
}
const episodes = this.mappedSeasons()[seasonKey];
const seasonNumber = Number(episodes?.[0]?.season ?? seasonKey);
if (!Number.isFinite(seasonNumber)) {
return;
}
const tmdbEpisodes = await this.tmdbEnrichment.getSeasonEpisodes(
tmdbId,
seasonNumber
);
if (!tmdbEpisodes?.length) {
return;
}
const next = new Map(this.tmdbSeasonEpisodes());
next.set(mapKey, tmdbEpisodes);
this.tmdbSeasonEpisodes.set(next);
}
/**
* Loads episodes for a specific VOD season
*/
@@ -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();
@@ -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<TmdbEnrichmentService> = {}) {
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<TmdbEnrichmentService>);
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<TmdbEnrichmentService>);
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<TmdbEnrichmentService>);
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<TmdbEnrichmentService>);
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();
});
});
@@ -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<TItem> {
selectedItem: () => TItem | null;
setSelectedItem: (item: TItem | null) => void;
}
type SelectionRecord = { readonly [key: string]: unknown };
export async function enrichVodSelectionWithTmdb<TItem extends SelectionRecord>(
store: EnrichableSelectionStore<TItem>,
enrichment: TmdbEnrichmentService,
vodId: string | number
): Promise<void> {
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<TItem>,
enrichment: TmdbEnrichmentService,
serialId: string | number
): Promise<void> {
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<TItem>,
enrichment: TmdbEnrichmentService,
seasonKey: string
): Promise<void> {
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);
}
}
@@ -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
);
},
};
})
);
@@ -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: `<app-actor-view
[profile]="profile()"
[items]="items()"
[isLoading]="isLoading()"
[isMatching]="isMatchingGlobal()"
[showAvailabilityFilter]="true"
[showScopeToggle]="showScopeToggle"
[scope]="scope()"
(scopeChanged)="onScopeChanged($event)"
(itemClicked)="openItem($event)"
(backClicked)="goBack()"
/>`,
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<ActorProfile | null>(null);
private readonly filmography = signal<ActorFilmographyCredit[]>([]);
readonly isLoading = signal(true);
readonly showScopeToggle = this.titleMatch.isAvailable;
readonly scope = signal<ActorViewScope>('portal');
readonly isMatchingGlobal = signal(false);
private readonly globalMatches = signal<CatalogTitleMatch[] | null>(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<ActorViewItem[]>(() => {
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<void> {
// 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<void> {
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();
}
}
}
@@ -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<XtreamSerieDetailsView | null>;
}
/**
* 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<SerialDetailsPlaybackBindings | null>(
null
);
private readonly currentPlaylistId = computed(
() => this.xtreamStore.currentPlaylist()?.id ?? ''
);
private lastSaveTime = 0;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(null);
readonly quickStartAction = computed(() => {
const item = this.selectedItem();
if (!item) {
return null;
}
return getSeriesQuickStartAction({
seasons: item.episodes ?? {},
playbackPositions: this.episodePlaybackPositions(),
});
});
readonly inlineEpisodeState =
computed<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | 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<void> {
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<void> {
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
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<XtreamSerieEpisode> | 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);
}
}
@@ -30,10 +30,49 @@
</ng-container>
<ng-container hero-meta>
@if (info.cast) {
@if (info.cast || info.tmdb_cast?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.CAST' | translate }}</span>
<span class="value">{{ info.cast }}</span>
@if (info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_cast; track member.name) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="
member.character
? member.name +
' — ' +
member.character
: member.name
"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{ info.cast }}</span>
}
</div>
}
@if (info.director) {
@@ -113,7 +152,42 @@
[activeEpisodeId]="activeEpisodeId()"
(episodeClicked)="playEpisode($event)"
(playbackToggleRequested)="handlePlaybackToggleRequested($event)"
(seasonSelected)="onSeasonSelected($event)"
/>
@if (similarItems().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (similar of similarItems(); track similar.id) {
<button
type="button"
class="details__similar-card"
[title]="similar.title"
(click)="openSimilar(similar)"
>
@if (similar.posterUrl) {
<img
class="details__similar-poster"
[src]="similar.posterUrl"
[alt]="similar.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ similar.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ similar.title }}
</span>
</button>
}
</div>
</section>
}
</app-content-hero>
} @else if (detailsError()) {
<app-content-hero
@@ -158,6 +158,10 @@ describe('SerialDetailsComponent', () => {
{
provide: ActivatedRoute,
useValue: {
params: of({
categoryId: '3',
serialId: '103',
}),
snapshot: {
params: {
categoryId: '3',
@@ -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<ResolvedPortalPlayback | null>(null);
readonly episodePlaybackPositions = signal<
Map<number, PlaybackPositionData>
>(new Map());
readonly currentPlaylistId = signal('');
readonly xtreamDownloadContext =
signal<SeasonContainerXtreamDownloadContext | null>(null);
private readonly detailsInitDone = signal(false);
/** `playlistId:categoryId:serialId` of the last initialized view */
private readonly lastInitKey = signal<string | null>(null);
private readonly backdropBackfillKey = signal<string | null>(null);
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly openingEpisodeId = signal<number | null>(null);
readonly activeEpisodeId = signal<number | null>(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<SeriesPlaybackEpisodeState<XtreamSerieEpisode> | 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<SimilarCatalogItem[]>(() => {
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<void> {
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<XtreamSerieEpisode> | 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<void> {
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<void> {
const positions =
await this.playbackPositions.getSeriesPlaybackPositions(
playlistId,
seriesXtreamId
);
const positionsMap = new Map<number, PlaybackPositionData>();
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
);
}
}
@@ -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);
});
});
@@ -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<string, unknown>)[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<string, IndexedCatalogEntry>;
base: Map<string, IndexedCatalogEntry>;
}
export function buildCatalogTitleIndex(
streams: readonly CatalogStream[]
): CatalogTitleIndex {
const exact = new Map<string, IndexedCatalogEntry>();
const base = new Map<string, IndexedCatalogEntry>();
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<string, TmdbRecommendation>();
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<string, CatalogStream>();
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<number>();
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;
}
@@ -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<number>;
/** Usable metadata of the selected VOD, if any */
vodInfo: Signal<XtreamVodInfo | null>;
}
/**
* 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<VodDetailsPlaybackBindings | null>(
null
);
private lastSaveTime = 0;
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(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<void> {
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<void> {
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);
}
}
@@ -30,10 +30,51 @@
</ng-container>
<ng-container hero-meta>
@if (info.actors || info.cast) {
@if (info.actors || info.cast || info.tmdb_cast?.length) {
<div class="details__meta-item">
<span class="label">{{ 'XTREAM.ACTORS' | translate }}</span>
<span class="value">{{ info.actors || info.cast }}</span>
@if (info.tmdb_cast?.length) {
<div class="details__cast-strip">
@for (member of info.tmdb_cast; track member.name) {
<span
class="details__cast-chip"
[class.details__cast-chip--clickable]="
!!member.tmdbPersonId
"
(click)="openActor(member)"
[title]="
member.character
? member.name +
' — ' +
member.character
: member.name
"
>
@if (member.profileUrl) {
<img
class="details__cast-avatar"
[src]="member.profileUrl"
[alt]="member.name"
loading="lazy"
/>
} @else {
<span
class="details__cast-avatar details__cast-avatar--fallback"
>
{{ member.name.charAt(0) }}
</span>
}
<span class="details__cast-name">{{
member.name
}}</span>
</span>
}
</div>
} @else {
<span class="value">{{
info.actors || info.cast
}}</span>
}
</div>
}
@if (info.director) {
@@ -168,15 +209,12 @@
/>
}
@if (info.youtube_trailer) {
@if (trailerEmbedUrl(); as trailerUrl) {
<div class="youtube-trailer">
<h3>{{ 'XTREAM.YOUTUBE_TRAILER' | translate }}</h3>
<div class="trailer-wrapper">
<iframe
[src]="
'https://www.youtube.com/embed/' +
info.youtube_trailer | safe
"
[src]="trailerUrl | safe"
title="YouTube video player"
allow="
accelerometer;
@@ -193,6 +231,40 @@
</div>
</div>
}
@if (similarItems().length) {
<section class="details__similar" data-test-id="tmdb-similar">
<h3>{{ 'XTREAM.SIMILAR' | translate }}</h3>
<div class="details__similar-strip">
@for (item of similarItems(); track item.id) {
<button
type="button"
class="details__similar-card"
[title]="item.title"
(click)="openSimilar(item)"
>
@if (item.posterUrl) {
<img
class="details__similar-poster"
[src]="item.posterUrl"
[alt]="item.title"
loading="lazy"
/>
} @else {
<span
class="details__similar-poster details__similar-poster--fallback"
>
{{ item.title.charAt(0) }}
</span>
}
<span class="details__similar-title">
{{ item.title }}
</span>
</button>
}
</div>
</section>
}
</app-content-hero>
} @else if (fallbackView(); as fallback) {
<app-content-hero
@@ -54,7 +54,10 @@ describe('VodDetailsRouteComponent', () => {
consoleDebugSpy = jest
.spyOn(console, 'debug')
.mockImplementation((...args: unknown[]) => {
if (args[0] === '[VodDetailsRoute]') {
if (
args[0] === '[VodDetailsRoute]' ||
args[0] === '[VodDetailsPlayback]'
) {
return;
}
@@ -94,6 +97,10 @@ describe('VodDetailsRouteComponent', () => {
{
provide: ActivatedRoute,
useValue: {
params: of({
vodId: '650020',
categoryId: '235',
}),
snapshot: {
params: {
vodId: '650020',
@@ -9,43 +9,38 @@ import {
inject,
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 { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from '@iptvnator/ui/components';
import { SafePipe } from '@iptvnator/pipes';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
createLogger,
getPortalPlaybackProgressPercent,
} from '@iptvnator/portal/shared/util';
import { createLogger } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
type PlaybackFallbackRequest,
PortalInlinePlayerComponent,
} from '@iptvnator/ui/playback';
import { DownloadsService, SettingsStore } from '@iptvnator/services';
import {
DownloadsService,
PlaybackPositionRuntimeBridgeService,
SettingsStore,
} from '@iptvnator/services';
import {
PlaybackPositionData,
PlayerContentInfo,
ResolvedPortalPlayback,
XtreamCategory,
TmdbEnrichedCastMember,
XtreamVodDetails,
XtreamVodInfo,
XtreamVodStream,
getXtreamVodInfo,
youtubeEmbedUrl,
} from '@iptvnator/shared/interfaces';
import {
SimilarCatalogItem,
matchRecommendationsToCatalog,
} from '../tmdb-similar.util';
import {
buildXtreamVodFallbackViewModel,
hasUsableXtreamVodMetadata,
} from './vod-details-fallback.util';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
@Component({
templateUrl: './vod-details-route.component.html',
@@ -54,6 +49,7 @@ import {
'./vod-details-route.component.scss',
],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [VodDetailsPlaybackService],
imports: [
ContentHeroComponent,
MatIcon,
@@ -67,21 +63,27 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly location = inject(Location);
private readonly settingsStore = inject(SettingsStore);
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 downloadsService = inject(DownloadsService);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translateService = inject(TranslateService);
private readonly playback = inject(VodDetailsPlaybackService);
private readonly logger = createLogger('VodDetailsRoute');
private readonly detailsInitDone = signal(false);
/** `playlistId:vodId` of the last initialized detail view */
private readonly lastInitKey = signal<string | null>(null);
private readonly backdropBackfillKey = signal<string | null>(null);
readonly inlinePlayback = signal<ResolvedPortalPlayback | null>(null);
readonly vodPlaybackPosition = signal<PlaybackPositionData | null>(null);
readonly inlinePlayback = this.playback.inlinePlayback;
readonly vodPlaybackPosition = this.playback.vodPlaybackPosition;
/**
* Reactive route params: the component is reused when navigating
* between two VOD details (e.g. via the Similar rail), so computeds
* must not read the one-shot snapshot.
*/
private readonly routeParams = toSignal(this.route.params, {
initialValue: this.route.snapshot.params,
});
readonly theme = this.settingsStore.theme;
readonly isElectron = this.downloadsService.isAvailable;
@@ -91,11 +93,9 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
() =>
this.xtreamStore.selectedItem() as unknown as XtreamVodDetails | null
);
readonly selectedVodId = computed(() =>
Number(this.route.snapshot.params.vodId)
);
readonly selectedVodId = computed(() => Number(this.routeParams().vodId));
readonly selectedCategory = computed<Partial<XtreamCategory> | null>(() => {
const categoryId = this.route.snapshot.params.categoryId;
const categoryId = this.routeParams().categoryId;
if (!categoryId) {
return null;
}
@@ -177,92 +177,18 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
});
readonly isLoadingDetails = this.xtreamStore.isLoadingDetails;
readonly detailsError = this.xtreamStore.detailsError;
private lastSaveTime = 0;
private unsubscribePositionUpdates: (() => void) | null = null;
readonly matchedExternalPlayback = computed(() => {
const session = this.externalPlayback.activeSession();
const vodId = Number(this.route.snapshot.params.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.route.snapshot.params.vodId,
inProgress,
});
return inProgress;
});
readonly matchedExternalPlayback = this.playback.matchedExternalPlayback;
readonly externalPrimaryLabel = this.playback.externalPrimaryLabel;
readonly externalPrimaryIcon = this.playback.externalPrimaryIcon;
readonly isExternalLaunchPending = this.playback.isExternalLaunchPending;
readonly isExternalStopAction = this.playback.isExternalStopAction;
readonly externalPrimaryButtonState =
this.playback.externalPrimaryButtonState;
readonly vodPlaybackProgress = this.playback.vodPlaybackProgress;
readonly hasPlaybackPosition = this.playback.hasPlaybackPosition;
readonly isDownloaded = computed(() => {
const vodId = Number(this.route.snapshot.params.vodId);
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
@@ -270,25 +196,55 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
});
readonly isDownloading = computed(() => {
const vodId = Number(this.route.snapshot.params.vodId);
const vodId = this.selectedVodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (!playlistId) return false;
this.downloadsService.downloads();
return this.downloadsService.isDownloading(vodId, playlistId, 'vod');
});
readonly trailerEmbedUrl = computed(() =>
youtubeEmbedUrl(this.selectedVodInfo()?.youtube_trailer)
);
/** TMDB recommendations matched against the loaded VOD catalog */
readonly similarItems = computed<SimilarCatalogItem[]>(() => {
const info = this.selectedVodInfo();
if (!info?.tmdb_recommendations?.length) {
return [];
}
return matchRecommendationsToCatalog(
info.tmdb_recommendations,
this.xtreamStore.vodStreams(),
{ excludeId: this.selectedVodId() }
);
});
constructor() {
this.playback.bind({
vodId: this.selectedVodId,
vodInfo: this.selectedVodInfo,
});
// 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 vodId = Number(this.route.snapshot.params.vodId);
if (!playlistId || this.detailsInitDone()) return;
const vodId = this.selectedVodId();
if (!playlistId || !Number.isFinite(vodId) || vodId <= 0) return;
const initKey = `${playlistId}:${vodId}`;
if (this.lastInitKey() === initKey) return;
this.lastInitKey.set(initKey);
this.inlinePlayback.set(null);
this.vodPlaybackPosition.set(null);
this.initializeVodDetails(playlistId, vodId);
this.detailsInitDone.set(true);
});
effect(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const vodId = Number(this.route.snapshot.params.vodId);
const vodId = this.selectedVodId();
const backdropUrl =
this.selectedVodInfo()?.backdrop_path?.[0]?.trim();
@@ -314,147 +270,58 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
backdropUrl,
});
});
this.unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const vodId = Number(this.route.snapshot.params.vodId);
if (
data.contentType !== 'vod' ||
data.playlistId !== playlistId ||
data.contentXtreamId !== vodId
) {
return;
}
this.vodPlaybackPosition.set(data);
}
) ?? null;
}
ngOnInit(): void {
const currentPlaylist = this.xtreamStore.currentPlaylist();
if (!currentPlaylist?.id) {
// Initialization is handled by the params-driven effect in the
// constructor; the hook remains for interface compatibility.
if (!this.xtreamStore.currentPlaylist()?.id) {
this.logger.warn('Deferring VOD details init: playlist not ready');
}
}
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;
}
this.initializeVodDetails(
currentPlaylist.id,
Number(this.route.snapshot.params.vodId)
);
this.detailsInitDone.set(true);
void this.router.navigate([
'/workspace/xtreams',
playlistId,
'actor',
member.tmdbPersonId,
]);
}
ngOnDestroy(): void {
this.inlinePlayback.set(null);
this.unsubscribePositionUpdates?.();
this.playback.closeInlinePlayer();
this.xtreamStore.setSelectedItem(null);
}
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.route.snapshot.params.vodId;
const id = routeVodId
? Number(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);
this.playback.playVod(vodItem);
}
resumeVod(vodItem: XtreamVodDetails | null): void {
if (!vodItem) {
return;
}
const playlist = this.xtreamStore.currentPlaylist();
if (!playlist) {
return;
}
const info = getXtreamVodInfo(vodItem);
this.addToRecentlyViewed();
const vodId = Number(this.route.snapshot.params.vodId);
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);
this.playback.resumeVod(vodItem);
}
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);
this.playback.onPrimaryAction(vodItem);
}
async stopExternalPlayback(): Promise<void> {
await this.externalPlayback.closeSession(
this.matchedExternalPlayback()
);
stopExternalPlayback(): Promise<void> {
return this.playback.stopExternalPlayback();
}
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;
return this.playback.formatPosition();
}
toggleFavorite(): void {
@@ -476,36 +343,19 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
goBack(): void {
this.closeInlinePlayer();
this.playback.closeInlinePlayer();
this.location.back();
}
closeInlinePlayer(): void {
this.inlinePlayback.set(null);
this.lastSaveTime = 0;
this.playback.closeInlinePlayer();
}
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);
this.playback.handleInlineTimeUpdate(event);
}
showCopyNotification(): void {
@@ -519,10 +369,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
this.playback.handleExternalFallbackRequest(request);
}
async downloadVod(vodItem: XtreamVodDetails | null): Promise<void> {
@@ -576,15 +423,6 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
}
}
private addToRecentlyViewed(): void {
this.xtreamStore.addRecentItem({
xtreamId: Number(this.route.snapshot.params.vodId),
contentType: 'movie',
playlist: this.xtreamStore.currentPlaylist,
backdropUrl: this.selectedVodInfo()?.backdrop_path?.[0],
});
}
private initializeVodDetails(playlistId: string, vodId: number): void {
const { categoryId } = this.route.snapshot.params;
this.xtreamStore.fetchVodDetailsWithMetadata({
@@ -592,29 +430,6 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
categoryId,
});
this.xtreamStore.checkFavoriteStatus(vodId, playlistId, 'movie');
void this.loadVodPlaybackPosition(playlistId, vodId);
}
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 async loadVodPlaybackPosition(
playlistId: string,
vodId: number
): Promise<void> {
const position = await this.playbackPositions.getPlaybackPosition(
playlistId,
vodId,
'vod'
);
this.vodPlaybackPosition.set(position);
void this.playback.loadPosition(playlistId, vodId);
}
}
@@ -8,7 +8,7 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import { PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { of } from 'rxjs';
import { firstValueFrom, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details/serial-details.component';
import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component';
@@ -129,12 +129,18 @@ describe('XtreamCollectionDetailComponent', () => {
expect(fixture.componentInstance.detailComponent()).toBe(
SerialDetailsComponent
);
expect(
fixture.componentInstance
.detailInjector()
?.get(ActivatedRoute)
.snapshot.params
).toEqual({
const route = fixture.componentInstance
.detailInjector()
?.get(ActivatedRoute);
expect(route?.snapshot.params).toEqual({
categoryId: '3',
serialId: '103',
});
// Regression: the detail components consume route.params via
// toSignal(), so the fake route must expose the observable too —
// otherwise the inline detail crashes on construction.
expect(route?.params).toBeDefined();
await expect(firstValueFrom(route!.params)).resolves.toEqual({
categoryId: '3',
serialId: '103',
});
@@ -19,7 +19,7 @@ import {
} from '@iptvnator/portal/xtream/data-access';
import { PlaylistsService } from '@iptvnator/services';
import { Playlist } from '@iptvnator/shared/interfaces';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details/serial-details.component';
import { VodDetailsRouteComponent } from './vod-details/vod-details-route.component';
@@ -132,28 +132,26 @@ export class XtreamCollectionDetailComponent {
? VodDetailsRouteComponent
: SerialDetailsComponent
);
const routeParams =
item.contentType === 'movie'
? {
categoryId: this.toPathSegment(item.categoryId),
vodId: xtreamId,
}
: {
categoryId: this.toPathSegment(item.categoryId),
serialId: xtreamId,
};
this.detailInjector.set(
Injector.create({
providers: [
{
provide: ActivatedRoute,
// The detail components read both snapshot.params and
// the params observable (via toSignal) — provide both.
useValue: {
snapshot: {
params:
item.contentType === 'movie'
? {
categoryId: this.toPathSegment(
item.categoryId
),
vodId: xtreamId,
}
: {
categoryId: this.toPathSegment(
item.categoryId
),
serialId: xtreamId,
},
},
snapshot: { params: routeParams },
params: of(routeParams),
},
},
],
@@ -49,6 +49,11 @@ const loadSerialDetailsComponent: ComponentLoader = () =>
(c) => c.SerialDetailsComponent
);
const loadXtreamActorRouteComponent: ComponentLoader = () =>
import('./actor/xtream-actor-route.component').then(
(c) => c.XtreamActorRouteComponent
);
export function createXtreamRoutes(): Route[] {
return [
{
@@ -112,6 +117,10 @@ export function createXtreamRoutes(): Route[] {
path: 'search',
loadComponent: loadSearchResultsComponent,
},
{
path: 'actor/:personId',
loadComponent: loadXtreamActorRouteComponent,
},
{
path: 'recently-added',
loadComponent: loadRecentlyAddedComponent,
+2
View File
@@ -1,3 +1,4 @@
export * from './lib/catalog-title-match.service';
export * from './lib/data.service';
export * from './lib/database-electron.service';
export * from './lib/downloads.service';
@@ -12,4 +13,5 @@ export * from './lib/portal-status.service';
export * from './lib/runtime-capabilities.service';
export * from './lib/settings-store.service';
export * from './lib/sort.service';
export * from './lib/tmdb';
export * from './lib/xtream-pending-restore.service';
@@ -0,0 +1,56 @@
import { Injectable } from '@angular/core';
import {
CatalogTitleMatch,
normalizeTitleKeys,
} from '@iptvnator/shared/interfaces';
/**
* Index of matches keyed by `type:exactNormalizedTitle` for O(1) lookups
* when mapping a filmography onto the match list. Exact-title matches
* (trailingYear === null) win over year-stripped ones for the same key.
*/
export function buildTitleMatchIndex(
matches: readonly CatalogTitleMatch[]
): Map<string, CatalogTitleMatch> {
const index = new Map<string, CatalogTitleMatch>();
for (const match of matches) {
const key = `${match.type}:${normalizeTitleKeys(match.queryTitle).exact}`;
const existing = index.get(key);
if (
!existing ||
(existing.trailingYear !== null && match.trailingYear === null)
) {
index.set(key, match);
}
}
return index;
}
/**
* Cross-playlist title matching via the Electron DB worker
* (`DB_MATCH_TITLES`, trigram FTS over all imported Xtream playlists).
* Unavailable in the PWA — `isAvailable` gates the actor page's
* "All portals" scope.
*/
@Injectable({ providedIn: 'root' })
export class CatalogTitleMatchService {
get isAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.electron?.dbMatchTitles === 'function'
);
}
async matchTitles(titles: string[]): Promise<CatalogTitleMatch[]> {
if (!this.isAvailable || titles.length === 0) {
return [];
}
try {
return await window.electron.dbMatchTitles(titles);
} catch (error) {
console.warn('Cross-playlist title matching failed:', error);
return [];
}
}
}
@@ -11,6 +11,7 @@ import { StorageMap } from '@ngx-pwa/local-storage';
import { firstValueFrom } from 'rxjs';
import {
DEFAULT_DASHBOARD_RAILS_SETTINGS,
DEFAULT_TMDB_SETTINGS,
ElectronBridgeTrustOptions,
EpgViewMode,
Language,
@@ -50,6 +51,7 @@ const DEFAULT_SETTINGS: Settings = {
preferUploadedEpgOverXtream: false,
trustedPrivateNetworkEpgUrls: [],
trustedInsecureTlsHosts: [],
tmdb: DEFAULT_TMDB_SETTINGS,
};
let embeddedMpvPrepareScheduled = false;
@@ -200,6 +202,7 @@ export const SettingsStore = signalStore(
trustedInsecureTlsHosts:
store.trustedInsecureTlsHosts?.() ??
DEFAULT_SETTINGS.trustedInsecureTlsHosts,
tmdb: store.tmdb?.() ?? DEFAULT_SETTINGS.tmdb,
};
},
+9
View File
@@ -0,0 +1,9 @@
export * from './tmdb-api.service';
export * from './tmdb-cache.service';
export * from './tmdb-config';
export * from './tmdb-enrichment.service';
export * from './tmdb-episode-merge';
export * from './tmdb-matcher';
export * from './tmdb-merge';
export * from './tmdb-person';
export * from './tmdb.types';
@@ -0,0 +1,151 @@
import { Injectable } from '@angular/core';
import { TMDB_API_BASE_URL } from './tmdb-config';
import {
TmdbMovieDetails,
TmdbPersonDetails,
TmdbSearchResponse,
TmdbSearchResult,
TmdbSeasonDetails,
TmdbTvDetails,
} from './tmdb.types';
/**
* Thin TMDB v3 API client. Uses `fetch` directly — TMDB supports CORS, so
* both the Electron renderer and the PWA can call it without a proxy.
* Supports classic v3 keys (query param) and v4 read tokens (Bearer).
*/
@Injectable({ providedIn: 'root' })
export class TmdbApiService {
async searchMovie(
query: string,
year: number | null,
language: string,
apiKey: string
): Promise<TmdbSearchResult[]> {
const response = await this.request<TmdbSearchResponse>(
'/search/movie',
{
query,
language,
...(year !== null ? { year: String(year) } : {}),
},
apiKey
);
return response.results ?? [];
}
async searchTv(
query: string,
year: number | null,
language: string,
apiKey: string
): Promise<TmdbSearchResult[]> {
const response = await this.request<TmdbSearchResponse>(
'/search/tv',
{
query,
language,
...(year !== null ? { first_air_date_year: String(year) } : {}),
},
apiKey
);
return response.results ?? [];
}
async getMovieDetails(
tmdbId: number,
language: string,
apiKey: string
): Promise<TmdbMovieDetails> {
return this.request<TmdbMovieDetails>(
`/movie/${tmdbId}`,
{ language, append_to_response: 'credits,videos,recommendations' },
apiKey
);
}
async getTvDetails(
tmdbId: number,
language: string,
apiKey: string
): Promise<TmdbTvDetails> {
return this.request<TmdbTvDetails>(
`/tv/${tmdbId}`,
{ language, append_to_response: 'credits,videos,recommendations' },
apiKey
);
}
async getPersonDetails(
personId: number,
language: string,
apiKey: string
): Promise<TmdbPersonDetails> {
return this.request<TmdbPersonDetails>(
`/person/${personId}`,
{ language, append_to_response: 'combined_credits' },
apiKey
);
}
async getSeasonDetails(
tmdbId: number,
seasonNumber: number,
language: string,
apiKey: string
): Promise<TmdbSeasonDetails> {
return this.request<TmdbSeasonDetails>(
`/tv/${tmdbId}/season/${seasonNumber}`,
{ language },
apiKey
);
}
/**
* Cheap authenticated call to verify an API key. Used by the settings
* "check key" button.
*/
async validateApiKey(apiKey: string): Promise<boolean> {
try {
await this.request('/configuration', {}, apiKey);
return true;
} catch {
return false;
}
}
private async request<T>(
path: string,
params: Record<string, string>,
apiKey: string
): Promise<T> {
const url = new URL(`${TMDB_API_BASE_URL}${path}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, value);
}
// v4 read access tokens are JWTs sent as a Bearer header; v3 keys
// travel as the api_key query param
const isBearerToken = apiKey.startsWith('eyJ');
if (!isBearerToken) {
url.searchParams.set('api_key', apiKey);
}
const response = await fetch(url.toString(), {
headers: {
accept: 'application/json',
...(isBearerToken
? { authorization: `Bearer ${apiKey}` }
: {}),
},
});
if (!response.ok) {
throw new Error(
`TMDB request failed: ${response.status} ${response.statusText}`
);
}
return (await response.json()) as T;
}
}
@@ -0,0 +1,91 @@
import { Injectable } from '@angular/core';
import { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces';
/**
* Environment-aware cache for TMDB lookups.
*
* - Electron: persists via the `tmdb_metadata` SQLite table (IPC bridge)
* - PWA: session-scoped in-memory map (phase 1 baseline)
*
* Freshness (TTL) is decided by the caller via {@link isFresh} so different
* row kinds (details vs. negative match) can use different TTLs.
*/
@Injectable({ providedIn: 'root' })
export class TmdbCacheService {
private readonly memoryCache = new Map<string, TmdbCacheEntry>();
private get bridge() {
// typeof checks guard against version skew: an older Electron shell
// may not expose the TMDB cache methods yet
return typeof window !== 'undefined' &&
typeof window.electron?.dbGetTmdbMetadata === 'function' &&
typeof window.electron?.dbSetTmdbMetadata === 'function'
? window.electron
: null;
}
async get(
mediaType: TmdbCacheMediaType,
lookupKey: string,
language: string
): Promise<TmdbCacheEntry | null> {
const bridge = this.bridge;
if (bridge) {
try {
return await bridge.dbGetTmdbMetadata(
mediaType,
lookupKey,
language
);
} catch (error) {
console.warn('TMDB cache read failed:', error);
return null;
}
}
return (
this.memoryCache.get(
this.memoryKey(mediaType, lookupKey, language)
) ?? null
);
}
async set(entry: TmdbCacheEntry): Promise<void> {
const stamped: TmdbCacheEntry = {
...entry,
fetchedAt: new Date().toISOString(),
};
const bridge = this.bridge;
if (bridge) {
try {
await bridge.dbSetTmdbMetadata(stamped);
} catch (error) {
console.warn('TMDB cache write failed:', error);
}
return;
}
this.memoryCache.set(
this.memoryKey(entry.mediaType, entry.lookupKey, entry.language),
stamped
);
}
isFresh(entry: TmdbCacheEntry | null, ttlMs: number): boolean {
if (!entry?.fetchedAt) {
return false;
}
const fetchedAt = Date.parse(entry.fetchedAt);
return Number.isFinite(fetchedAt) && Date.now() - fetchedAt < ttlMs;
}
private memoryKey(
mediaType: TmdbCacheMediaType,
lookupKey: string,
language: string
): string {
return `${mediaType}:${language}:${lookupKey}`;
}
}
@@ -0,0 +1,40 @@
import { Language } from '@iptvnator/shared/interfaces';
import { tmdbSearchLanguageForTitle, toTmdbLanguage } from './tmdb-config';
describe('toTmdbLanguage', () => {
it('maps app languages to TMDB codes', () => {
expect(toTmdbLanguage(Language.GERMAN)).toBe('de-DE');
expect(toTmdbLanguage(Language.RUSSIAN)).toBe('ru-RU');
});
it('falls back to en-US for unknown values', () => {
expect(toTmdbLanguage(undefined)).toBe('en-US');
expect(toTmdbLanguage('xx')).toBe('en-US');
});
});
describe('tmdbSearchLanguageForTitle', () => {
it('uses the app language for Latin titles', () => {
expect(tmdbSearchLanguageForTitle('The Matrix', Language.GERMAN)).toBe(
'de-DE'
);
});
it('switches to ru-RU for Cyrillic titles when the app language is not Cyrillic-based', () => {
expect(
tmdbSearchLanguageForTitle('Ирония судьбы', Language.ENGLISH)
).toBe('ru-RU');
expect(
tmdbSearchLanguageForTitle('Ирония судьбы', Language.GERMAN)
).toBe('ru-RU');
});
it('keeps Cyrillic-based app languages for Cyrillic titles', () => {
expect(
tmdbSearchLanguageForTitle('Ирония судьбы', Language.RUSSIAN)
).toBe('ru-RU');
expect(
tmdbSearchLanguageForTitle('Ірония лёсу', Language.BELARUSIAN)
).toBe('be-BY');
});
});
+105
View File
@@ -0,0 +1,105 @@
import { Language } from '@iptvnator/shared/interfaces';
export const TMDB_API_BASE_URL = 'https://api.themoviedb.org/3';
export const TMDB_IMAGE_BASE_URL = 'https://image.tmdb.org/t/p';
export const TMDB_POSTER_SIZE = 'w500';
export const TMDB_BACKDROP_SIZE = 'w1280';
export const TMDB_PROFILE_SIZE = 'w185';
export const TMDB_STILL_SIZE = 'w300';
/**
* Embedded application API key used when the user has not configured their
* own key in settings. Empty by design: distributed builds ship without a
* key (each user brings their own — that matches TMDB's personal-use
* terms). CI can optionally inject a key via the TMDB_API_KEY secret and
* tools/tmdb/inject-tmdb-key.mjs. With no key available, enrichment stays
* inactive even when enabled in settings.
*/
export const DEFAULT_TMDB_API_KEY = '';
const DAY_MS = 24 * 60 * 60 * 1000;
/** How long cached TMDB details payloads stay fresh */
export const TMDB_DETAILS_CACHE_TTL_MS = 30 * DAY_MS;
/** How long a resolved title→id match stays fresh */
export const TMDB_MATCH_CACHE_TTL_MS = 30 * DAY_MS;
/** Negative matches retry sooner — providers fix titles, TMDB adds entries */
export const TMDB_NEGATIVE_MATCH_CACHE_TTL_MS = 7 * DAY_MS;
/** App language → TMDB language-region code for localized metadata */
const TMDB_LANGUAGE_MAP: Record<Language, string> = {
[Language.ARABIC]: 'ar-SA',
[Language.MOROCCAN_ARABIC]: 'ar-MA',
[Language.ENGLISH]: 'en-US',
[Language.KOREAN]: 'ko-KR',
[Language.RUSSIAN]: 'ru-RU',
[Language.GERMAN]: 'de-DE',
[Language.SPANISH]: 'es-ES',
[Language.CHINESE]: 'zh-CN',
[Language.TRADITIONAL_CHINESE]: 'zh-TW',
[Language.FRENCH]: 'fr-FR',
[Language.ITALIAN]: 'it-IT',
[Language.TURKISH]: 'tr-TR',
[Language.JAPANESE]: 'ja-JP',
[Language.DUTCH]: 'nl-NL',
[Language.BELARUSIAN]: 'be-BY',
[Language.POLISH]: 'pl-PL',
[Language.PORTUGUESE]: 'pt-PT',
[Language.GREEK]: 'el-GR',
};
export function toTmdbLanguage(
language: Language | string | null | undefined
): string {
return (
TMDB_LANGUAGE_MAP[language as Language] ??
TMDB_LANGUAGE_MAP[Language.ENGLISH]
);
}
const CYRILLIC_PATTERN = /[\u0400-\u04ff]/;
const CYRILLIC_TMDB_LANGUAGES = new Set(['ru-RU', 'be-BY']);
/**
* Language to use for the /search request. TMDB matches translated titles
* but returns `title` in the REQUEST language — so a Cyrillic query issued
* with an `en-US` request comes back with an English title and fails the
* exact-match confidence gate. Detect the query script and switch the
* search language accordingly; details are still fetched in the app
* language afterwards.
*/
export function tmdbSearchLanguageForTitle(
title: string,
appLanguage: Language | string | null | undefined
): string {
const appTmdbLanguage = toTmdbLanguage(appLanguage);
if (
CYRILLIC_PATTERN.test(title) &&
!CYRILLIC_TMDB_LANGUAGES.has(appTmdbLanguage)
) {
return 'ru-RU';
}
return appTmdbLanguage;
}
export function tmdbPosterUrl(path: string | null | undefined): string | null {
return path ? `${TMDB_IMAGE_BASE_URL}/${TMDB_POSTER_SIZE}${path}` : null;
}
export function tmdbBackdropUrl(
path: string | null | undefined
): string | null {
return path ? `${TMDB_IMAGE_BASE_URL}/${TMDB_BACKDROP_SIZE}${path}` : null;
}
export function tmdbProfileUrl(
path: string | null | undefined
): string | null {
return path ? `${TMDB_IMAGE_BASE_URL}/${TMDB_PROFILE_SIZE}${path}` : null;
}
export function tmdbStillUrl(path: string | null | undefined): string | null {
return path ? `${TMDB_IMAGE_BASE_URL}/${TMDB_STILL_SIZE}${path}` : null;
}
@@ -0,0 +1,319 @@
import { Injectable, inject } from '@angular/core';
import { TmdbMediaType } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '../settings-store.service';
import { TmdbApiService } from './tmdb-api.service';
import { TmdbCacheService } from './tmdb-cache.service';
import {
DEFAULT_TMDB_API_KEY,
TMDB_DETAILS_CACHE_TTL_MS,
TMDB_MATCH_CACHE_TTL_MS,
TMDB_NEGATIVE_MATCH_CACHE_TTL_MS,
tmdbSearchLanguageForTitle,
toTmdbLanguage,
} from './tmdb-config';
import {
buildDetailsLookupKey,
buildSearchLookupKey,
buildSearchTitleVariants,
extractYear,
parseProviderTmdbId,
pickConfidentMatch,
} from './tmdb-matcher';
import {
TmdbDetails,
TmdbEnrichmentQuery,
TmdbEpisode,
TmdbMovieDetails,
TmdbPersonDetails,
TmdbSeasonDetails,
TmdbTvDetails,
} from './tmdb.types';
/**
* Best-effort TMDB enrichment orchestrator. Resolves a provider item to a
* TMDB id (trusting the provider's tmdb_id, else a confidence-gated title
* search), fetches localized details with credits, and caches every step.
* Any failure returns `null` — detail views always render provider data.
*/
@Injectable({ providedIn: 'root' })
export class TmdbEnrichmentService {
private readonly settingsStore = inject(SettingsStore);
private readonly api = inject(TmdbApiService);
private readonly cache = inject(TmdbCacheService);
isEnabled(): boolean {
return Boolean(this.settingsStore.tmdb?.()?.enabled && this.apiKey());
}
async enrichMovie(
query: TmdbEnrichmentQuery
): Promise<TmdbMovieDetails | null> {
return (await this.enrich('movie', query)) as TmdbMovieDetails | null;
}
async enrichTv(query: TmdbEnrichmentQuery): Promise<TmdbTvDetails | null> {
return (await this.enrich('tv', query)) as TmdbTvDetails | null;
}
/**
* Episode list of one season (names, overviews, stills, air dates) in
* the app language. Fetched lazily when a season is opened and cached
* like details payloads. Returns `null` when enrichment is off or the
* request fails — episode lists then stay provider-only.
*/
async getSeasonEpisodes(
tmdbId: number,
seasonNumber: number
): Promise<TmdbEpisode[] | null> {
if (!this.isEnabled()) {
return null;
}
try {
const language = this.language();
const lookupKey = `id:${tmdbId}|season:${seasonNumber}`;
const cached = await this.cache.get('tv', lookupKey, language);
if (
this.cache.isFresh(cached, TMDB_DETAILS_CACHE_TTL_MS) &&
cached?.payload
) {
try {
const season = JSON.parse(
cached.payload
) as TmdbSeasonDetails;
return season.episodes ?? [];
} catch {
// Corrupt cache row — fall through to a fresh fetch
}
}
const season = await this.api.getSeasonDetails(
tmdbId,
seasonNumber,
language,
this.apiKey()
);
await this.cache.set({
mediaType: 'tv',
lookupKey,
language,
tmdbId,
payload: JSON.stringify(season),
});
return season.episodes ?? [];
} catch (error) {
console.warn('TMDB season enrichment failed:', error);
return null;
}
}
/**
* Person details with the full combined filmography, in the app
* language. Cached like details payloads under the 'person' media
* type.
*/
async getPersonDetails(
personId: number
): Promise<TmdbPersonDetails | null> {
if (!this.isEnabled() || !Number.isInteger(personId) || personId <= 0) {
return null;
}
try {
const language = this.language();
const lookupKey = `person:${personId}`;
const cached = await this.cache.get('person', lookupKey, language);
if (
this.cache.isFresh(cached, TMDB_DETAILS_CACHE_TTL_MS) &&
cached?.payload
) {
try {
return JSON.parse(cached.payload) as TmdbPersonDetails;
} catch {
// Corrupt cache row — fall through to a fresh fetch
}
}
const person = await this.api.getPersonDetails(
personId,
language,
this.apiKey()
);
await this.cache.set({
mediaType: 'person',
lookupKey,
language,
tmdbId: personId,
payload: JSON.stringify(person),
});
return person;
} catch (error) {
console.warn('TMDB person enrichment failed:', error);
return null;
}
}
private async enrich(
mediaType: TmdbMediaType,
query: TmdbEnrichmentQuery
): Promise<TmdbDetails | null> {
if (!this.isEnabled()) {
return null;
}
try {
const tmdbId =
parseProviderTmdbId(query.tmdbId) ??
(await this.resolveIdBySearch(mediaType, query));
if (tmdbId === null) {
return null;
}
return await this.getDetails(mediaType, tmdbId);
} catch (error) {
console.warn(`TMDB ${mediaType} enrichment failed:`, error);
return null;
}
}
/**
* Resolve a title/year to a TMDB id via /search with the confidence
* gate. Both hits and misses are cached; misses use a shorter TTL.
*/
private async resolveIdBySearch(
mediaType: TmdbMediaType,
query: TmdbEnrichmentQuery
): Promise<number | null> {
// Try the original title, the display title, then language-prefix-
// stripped fallbacks; the first confident match wins.
const variants = buildSearchTitleVariants(
query.title,
query.originalTitle
);
if (variants.length === 0) {
return null;
}
const year = query.year ?? extractYear(null, query.title);
const cacheLanguage = tmdbSearchLanguageForTitle(
variants[0],
this.settingsStore.language()
);
const lookupKey = buildSearchLookupKey(variants[0], year);
const cached = await this.cache.get(
mediaType,
lookupKey,
cacheLanguage
);
const ttl =
cached?.tmdbId !== null && cached?.tmdbId !== undefined
? TMDB_MATCH_CACHE_TTL_MS
: TMDB_NEGATIVE_MATCH_CACHE_TTL_MS;
if (this.cache.isFresh(cached, ttl)) {
return cached?.tmdbId ?? null;
}
let match = null;
for (const variant of variants) {
// Cyrillic (and other non-app-script) titles search in their
// own language so TMDB returns comparable titles — see
// tmdbSearchLanguageForTitle. Search by title only: TMDB's
// year params filter strictly; the ±1/season tolerance lives
// in pickConfidentMatch instead.
const language = tmdbSearchLanguageForTitle(
variant,
this.settingsStore.language()
);
const results =
mediaType === 'movie'
? await this.api.searchMovie(
variant,
null,
language,
this.apiKey()
)
: await this.api.searchTv(
variant,
null,
language,
this.apiKey()
);
match = pickConfidentMatch(
results,
{ title: variant, year },
mediaType
);
if (match) {
break;
}
}
await this.cache.set({
mediaType,
lookupKey,
language: cacheLanguage,
tmdbId: match?.id ?? null,
payload: null,
});
return match?.id ?? null;
}
private async getDetails(
mediaType: TmdbMediaType,
tmdbId: number
): Promise<TmdbDetails | null> {
const language = this.language();
const lookupKey = buildDetailsLookupKey(tmdbId);
const cached = await this.cache.get(mediaType, lookupKey, language);
if (
this.cache.isFresh(cached, TMDB_DETAILS_CACHE_TTL_MS) &&
cached?.payload
) {
try {
return JSON.parse(cached.payload) as TmdbDetails;
} catch {
// Corrupt cache row — fall through to a fresh fetch
}
}
const details =
mediaType === 'movie'
? await this.api.getMovieDetails(
tmdbId,
language,
this.apiKey()
)
: await this.api.getTvDetails(tmdbId, language, this.apiKey());
await this.cache.set({
mediaType,
lookupKey,
language,
tmdbId,
payload: JSON.stringify(details),
});
return details;
}
private apiKey(): string {
return (
this.settingsStore.tmdb?.()?.apiKey?.trim() || DEFAULT_TMDB_API_KEY
);
}
private language(): string {
return toTmdbLanguage(this.settingsStore.language());
}
}
@@ -0,0 +1,109 @@
import { XtreamSerieEpisode } from '@iptvnator/shared/interfaces';
import { mergeEpisodesWithTmdb } from './tmdb-episode-merge';
import { TmdbEpisode } from './tmdb.types';
function providerEpisode(
overrides: Partial<XtreamSerieEpisode> = {}
): XtreamSerieEpisode {
return {
id: '1001',
episode_num: 4,
title: 'Episode 4',
container_extension: 'mp4',
info: [],
custom_sid: '',
added: '',
season: 1,
direct_source: '',
...overrides,
};
}
const tmdbEpisode: TmdbEpisode = {
episode_number: 4,
season_number: 1,
name: 'The Female of the Species',
overview: 'TMDB episode overview',
still_path: '/still.jpg',
air_date: '2019-08-16',
vote_average: 8.1,
vote_count: 500,
};
describe('mergeEpisodesWithTmdb', () => {
it('replaces generic titles and fills editorial fields', () => {
const [merged] = mergeEpisodesWithTmdb(
[providerEpisode()],
[tmdbEpisode]
);
expect(merged.title).toBe('The Female of the Species');
const info = merged.info as Exclude<
XtreamSerieEpisode['info'],
unknown[]
>;
expect(info.plot).toBe('TMDB episode overview');
expect(info.movie_image).toBe(
'https://image.tmdb.org/t/p/w300/still.jpg'
);
expect(info.releasedate).toBe('2019-08-16');
expect(info.rating).toBe(8.1);
});
it('recognizes localized and SxxEyy generic titles', () => {
const cases = ['Серия 4', 'S01E04', '4', ''];
for (const title of cases) {
const [merged] = mergeEpisodesWithTmdb(
[providerEpisode({ title })],
[tmdbEpisode]
);
expect(merged.title).toBe('The Female of the Species');
}
});
it('keeps a meaningful provider title', () => {
const [merged] = mergeEpisodesWithTmdb(
[providerEpisode({ title: 'Особый эпизод — финал' })],
[tmdbEpisode]
);
expect(merged.title).toBe('Особый эпизод — финал');
});
it('keeps provider info fields that TMDB should not overwrite', () => {
const [merged] = mergeEpisodesWithTmdb(
[
providerEpisode({
info: {
releasedate: '2019-01-01',
rating: 6,
duration: '00:55:00',
duration_secs: 3300,
},
}),
],
[tmdbEpisode]
);
const info = merged.info as Exclude<
XtreamSerieEpisode['info'],
unknown[]
>;
expect(info.releasedate).toBe('2019-01-01');
expect(info.rating).toBe(6);
expect(info.duration).toBe('00:55:00');
expect(info.duration_secs).toBe(3300);
});
it('passes through episodes without a TMDB counterpart', () => {
const episode = providerEpisode({ episode_num: 99 });
const [merged] = mergeEpisodesWithTmdb([episode], [tmdbEpisode]);
expect(merged).toBe(episode);
});
it('returns a copy when TMDB has no episodes', () => {
const episodes = [providerEpisode()];
const merged = mergeEpisodesWithTmdb(episodes, []);
expect(merged).toEqual(episodes);
expect(merged).not.toBe(episodes);
});
});
@@ -0,0 +1,78 @@
import {
XtreamSerieEpisode,
XtreamSerieEpisodeInfo,
} from '@iptvnator/shared/interfaces';
import { tmdbStillUrl } from './tmdb-config';
import { TmdbEpisode } from './tmdb.types';
/**
* Field-level merge of TMDB season data into an episode list (shared by
* Xtream serial details and the Stalker series view, which map episodes to
* the same `XtreamSerieEpisode` shape). Matching is by episode number.
*
* TMDB wins for editorial fields (real episode names, overviews, stills),
* the provider keeps everything stream-related. Episodes without a TMDB
* counterpart pass through untouched, so partial/missing TMDB data can
* never degrade the list.
*/
/** Provider titles like "Episode 4", "4", "S01E04" carry no information */
const GENERIC_EPISODE_TITLE =
/^(?:episode\s*\d+|серия\s*\d+|\d+|s\d{1,2}\s*e\d{1,3})$/i;
function isGenericEpisodeTitle(title: string | undefined | null): boolean {
const trimmed = title?.trim() ?? '';
return trimmed === '' || GENERIC_EPISODE_TITLE.test(trimmed);
}
function episodeInfoOf(
episode: XtreamSerieEpisode
): Partial<XtreamSerieEpisodeInfo> {
return !episode.info || Array.isArray(episode.info) ? {} : episode.info;
}
export function mergeEpisodesWithTmdb(
episodes: readonly XtreamSerieEpisode[],
tmdbEpisodes: readonly TmdbEpisode[]
): XtreamSerieEpisode[] {
if (!tmdbEpisodes.length) {
return [...episodes];
}
const byNumber = new Map(
tmdbEpisodes.map((episode) => [episode.episode_number, episode])
);
return episodes.map((episode) => {
const tmdb = byNumber.get(Number(episode.episode_num));
if (!tmdb) {
return episode;
}
const info = episodeInfoOf(episode);
const still = tmdbStillUrl(tmdb.still_path);
const rating =
tmdb.vote_average && tmdb.vote_average > 0
? Math.round(tmdb.vote_average * 10) / 10
: null;
return {
...episode,
// Real episode names beat generic "Episode 4" placeholders,
// but a meaningful provider title is kept
title:
tmdb.name && isGenericEpisodeTitle(episode.title)
? tmdb.name
: episode.title,
info: {
...info,
...(tmdb.overview?.trim() ? { plot: tmdb.overview } : {}),
...(still ? { movie_image: still } : {}),
releasedate: info.releasedate || (tmdb.air_date ?? ''),
...(info.rating === undefined && rating !== null
? { rating }
: {}),
},
};
});
}
@@ -0,0 +1,297 @@
import {
buildDetailsLookupKey,
buildSearchLookupKey,
buildSearchTitleVariants,
extractYear,
normalizeTitle,
parseProviderTmdbId,
pickConfidentMatch,
} from './tmdb-matcher';
import { TmdbSearchResult } from './tmdb.types';
describe('normalizeTitle', () => {
it('lowercases and strips punctuation', () => {
expect(normalizeTitle('The Matrix: Reloaded!')).toBe(
'the matrix reloaded'
);
});
it('removes bracketed tags and quality markers', () => {
expect(normalizeTitle('The Matrix (1999) [4K] MULTI')).toBe(
'the matrix'
);
});
it('removes leading language prefixes', () => {
expect(normalizeTitle('EN - The Matrix')).toBe('the matrix');
expect(normalizeTitle('DE| Der Untergang')).toBe('der untergang');
});
it('strips diacritics', () => {
expect(normalizeTitle('Amélie')).toBe('amelie');
});
it('removes trailing release-year tags', () => {
expect(normalizeTitle('The Matrix 1999')).toBe('the matrix');
});
it('keeps year-only titles intact', () => {
expect(normalizeTitle('2012')).toBe('2012');
});
it('removes trailing season markers from series titles', () => {
expect(normalizeTitle('The Boys s05')).toBe('the boys');
expect(normalizeTitle('The Boys S01')).toBe('the boys');
expect(normalizeTitle('Breaking Bad Season 2')).toBe('breaking bad');
expect(normalizeTitle('Пацаны сезон 3')).toBe('пацаны');
expect(normalizeTitle('Dark Staffel 2')).toBe('dark');
});
it('does not touch season-like tokens mid-title', () => {
expect(normalizeTitle('Season of the Witch')).toBe(
'season of the witch'
);
});
it('returns empty string for nullish input', () => {
expect(normalizeTitle(null)).toBe('');
expect(normalizeTitle(undefined)).toBe('');
});
});
describe('extractYear', () => {
it('prefers the release date', () => {
expect(extractYear('1999-03-31', 'Movie 2005')).toBe(1999);
});
it('falls back to a year tag in the title', () => {
expect(extractYear(null, 'The Matrix (1999)')).toBe(1999);
expect(extractYear('', 'The Matrix 2021 4K')).toBe(2021);
});
it('returns null when no year is present', () => {
expect(extractYear(null, 'The Matrix')).toBeNull();
expect(extractYear('invalid', null)).toBeNull();
});
});
describe('lookup keys', () => {
it('builds stable search and details keys', () => {
expect(buildSearchLookupKey('the matrix', 1999)).toBe(
'title:the matrix|year:1999'
);
expect(buildSearchLookupKey('the matrix', null)).toBe(
'title:the matrix|year:'
);
expect(buildDetailsLookupKey(603)).toBe('id:603|v2');
});
});
describe('buildSearchTitleVariants', () => {
it('orders original title before display title', () => {
expect(buildSearchTitleVariants('Пацаны', 'The Boys')).toEqual([
'the boys',
'пацаны',
]);
});
it('adds a language-prefix-stripped fallback variant', () => {
expect(buildSearchTitleVariants('DE Batman', null)).toEqual([
'de batman',
'batman',
]);
expect(
buildSearchTitleVariants('English The Godfather', null)
).toEqual(['english the godfather', 'the godfather']);
});
it('keeps titles that merely look like prefixed ones as the primary variant', () => {
// "It Follows" must be searched as-is first; the stripped variant
// is only a fallback
expect(buildSearchTitleVariants('It Follows', null)[0]).toBe(
'it follows'
);
});
it('deduplicates and drops empty values', () => {
expect(buildSearchTitleVariants('The Boys', 'The Boys')).toEqual([
'the boys',
]);
expect(buildSearchTitleVariants('', null)).toEqual([]);
});
});
describe('parseProviderTmdbId', () => {
it('accepts positive numbers and numeric strings', () => {
expect(parseProviderTmdbId(603)).toBe(603);
expect(parseProviderTmdbId('603')).toBe(603);
});
it('rejects zero, negatives, garbage and nullish values', () => {
expect(parseProviderTmdbId(0)).toBeNull();
expect(parseProviderTmdbId(-5)).toBeNull();
expect(parseProviderTmdbId('abc')).toBeNull();
expect(parseProviderTmdbId('')).toBeNull();
expect(parseProviderTmdbId(null)).toBeNull();
expect(parseProviderTmdbId(undefined)).toBeNull();
});
});
describe('pickConfidentMatch', () => {
const matrix1999: TmdbSearchResult = {
id: 603,
title: 'The Matrix',
release_date: '1999-03-31',
vote_count: 20000,
};
const matrix2021: TmdbSearchResult = {
id: 624860,
title: 'The Matrix Resurrections',
release_date: '2021-12-16',
vote_count: 5000,
};
it('matches exact normalized title with matching year', () => {
expect(
pickConfidentMatch(
[matrix2021, matrix1999],
{ title: 'The Matrix (1999)', year: 1999 },
'movie'
)
).toBe(matrix1999);
});
it('tolerates a year off by one', () => {
expect(
pickConfidentMatch(
[matrix1999],
{ title: 'The Matrix', year: 2000 },
'movie'
)
).toBe(matrix1999);
});
it('rejects when the year differs by more than one', () => {
expect(
pickConfidentMatch(
[matrix1999],
{ title: 'The Matrix', year: 2005 },
'movie'
)
).toBeNull();
});
it('rejects fuzzy/partial title matches', () => {
expect(
pickConfidentMatch(
[matrix2021],
{ title: 'The Matrix', year: 2021 },
'movie'
)
).toBeNull();
});
it('without a year, requires the exact title to be unambiguous', () => {
expect(
pickConfidentMatch(
[matrix1999],
{ title: 'The Matrix', year: null },
'movie'
)
).toBe(matrix1999);
const remake: TmdbSearchResult = {
id: 999,
title: 'The Matrix',
release_date: '2030-01-01',
};
expect(
pickConfidentMatch(
[matrix1999, remake],
{ title: 'The Matrix', year: null },
'movie'
)
).toBeNull();
});
it('disambiguates same-title results by vote count', () => {
const obscure: TmdbSearchResult = {
id: 111,
title: 'The Matrix',
release_date: '1998-05-01',
vote_count: 3,
};
expect(
pickConfidentMatch(
[obscure, matrix1999],
{ title: 'The Matrix', year: 1999 },
'movie'
)
).toBe(matrix1999);
});
it('matches original_title too', () => {
const amelie: TmdbSearchResult = {
id: 194,
title: 'Amélie',
original_title: 'Le Fabuleux Destin d’Amélie Poulain',
release_date: '2001-04-25',
vote_count: 11000,
};
expect(
pickConfidentMatch(
[amelie],
{ title: 'Le fabuleux destin d’Amelie Poulain', year: 2001 },
'movie'
)
).toBe(amelie);
});
it('uses name fields for tv results', () => {
const dark: TmdbSearchResult = {
id: 70523,
name: 'Dark',
first_air_date: '2017-12-01',
vote_count: 3000,
};
expect(
pickConfidentMatch([dark], { title: 'Dark', year: 2017 }, 'tv')
).toBe(dark);
});
it('accepts tv shows that premiered before the provider year (season year)', () => {
const theBoys: TmdbSearchResult = {
id: 76479,
name: 'The Boys',
first_air_date: '2019-07-26',
vote_count: 12000,
};
// Provider sends the running season's year ("The Boys s05" → 2026)
expect(
pickConfidentMatch(
[theBoys],
{ title: 'The Boys s05', year: 2026 },
'tv'
)
).toBe(theBoys);
});
it('still rejects movies with a year that differs by more than one', () => {
expect(
pickConfidentMatch(
[matrix1999],
{ title: 'The Matrix', year: 2026 },
'movie'
)
).toBeNull();
});
it('returns null for empty inputs', () => {
expect(
pickConfidentMatch([], { title: 'The Matrix', year: 1999 }, 'movie')
).toBeNull();
expect(
pickConfidentMatch([matrix1999], { title: '', year: null }, 'movie')
).toBeNull();
});
});
+163
View File
@@ -0,0 +1,163 @@
import {
TmdbMediaType,
extractYear,
normalizeTitle,
} from '@iptvnator/shared/interfaces';
import { TmdbSearchResult } from './tmdb.types';
// Re-exported for existing consumers of the tmdb barrel
export { extractYear, normalizeTitle } from '@iptvnator/shared/interfaces';
/**
* Title matching for TMDB search results. Provider titles are noisy
* ("EN - The Matrix (1999) 4K"), so titles are normalized before comparison
* and a result is only accepted when the match is high-confidence:
* normalized title equality plus a compatible release year (±1). Without a
* year, the normalized title must match exactly one search result.
*/
/**
* Leading language tag without a separator ("DE Batman", "English The
* Godfather"). Only used to build FALLBACK search variants — stripping it
* up front would break real titles like "It Follows" or "Us". Short codes
* must be ALL-CAPS so articles ("The", "De Lift") are left alone.
*/
const LEADING_LANGUAGE_CODE = /^\s*[A-Z]{2,3}\s+(?=\S)/;
const LEADING_LANGUAGE_WORD =
/^\s*(?:multi|english|german|french|arabic|turkish|russian|spanish|italian|deutsch)\s+(?=\S)/i;
function stripLeadingLanguageToken(raw: string): string | null {
if (LEADING_LANGUAGE_CODE.test(raw)) {
return raw.replace(LEADING_LANGUAGE_CODE, '');
}
if (LEADING_LANGUAGE_WORD.test(raw)) {
return raw.replace(LEADING_LANGUAGE_WORD, '');
}
return null;
}
/**
* Ordered search-title candidates for one provider item: the original
* title, the display title, then the same values with a leading
* language-looking token dropped. The confidence gate still applies to
* every variant, so extra candidates cannot produce wrong matches — only
* extra searches on misses.
*/
export function buildSearchTitleVariants(
title: string | null | undefined,
originalTitle?: string | null
): string[] {
const variants: string[] = [];
const push = (raw: string | null | undefined) => {
const normalized = normalizeTitle(raw);
if (normalized && !variants.includes(normalized)) {
variants.push(normalized);
}
};
push(originalTitle);
push(title);
for (const raw of [originalTitle, title]) {
if (raw) {
push(stripLeadingLanguageToken(raw));
}
}
return variants;
}
export function buildSearchLookupKey(
normalizedTitle: string,
year: number | null
): string {
return `title:${normalizedTitle}|year:${year ?? ''}`;
}
export function buildDetailsLookupKey(tmdbId: number): string {
// v2: details payloads now include videos in append_to_response;
// the version suffix invalidates pre-videos cache rows
return `id:${tmdbId}|v2`;
}
/** Provider tmdb_id fields arrive as number, numeric string, or garbage */
export function parseProviderTmdbId(
tmdbId: number | string | null | undefined
): number | null {
const parsed = Number(tmdbId);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
}
function resultTitles(result: TmdbSearchResult, mediaType: TmdbMediaType) {
return mediaType === 'movie'
? [result.title, result.original_title]
: [result.name, result.original_name];
}
function resultYear(
result: TmdbSearchResult,
mediaType: TmdbMediaType
): number | null {
return extractYear(
mediaType === 'movie' ? result.release_date : result.first_air_date
);
}
/**
* Pick the search result that confidently matches the queried title/year.
* Returns `null` when confidence is insufficient — enrichment must never
* attach a wrong movie's metadata.
*/
export function pickConfidentMatch(
results: TmdbSearchResult[] | null | undefined,
query: { title: string; year: number | null },
mediaType: TmdbMediaType
): TmdbSearchResult | null {
const wantedTitle = normalizeTitle(query.title);
if (!wantedTitle || !results?.length) {
return null;
}
const exactTitleMatches = results.filter((result) =>
resultTitles(result, mediaType).some(
(title) => normalizeTitle(title) === wantedTitle
)
);
if (exactTitleMatches.length === 0) {
return null;
}
const wantedYear = query.year;
if (wantedYear !== null) {
const yearMatches = exactTitleMatches.filter((result) => {
const year = resultYear(result, mediaType);
if (year === null) {
return false;
}
if (Math.abs(year - wantedYear) <= 1) {
return true;
}
// Series: providers often report the CURRENT season's year
// ("The Boys s05" → 2026) while TMDB's first_air_date is the
// show's premiere (2019) — accept shows that started earlier.
return mediaType === 'tv' && year < wantedYear;
});
if (yearMatches.length === 0) {
return null;
}
return pickMostPopular(yearMatches);
}
// Without a year the title must be unambiguous
return exactTitleMatches.length === 1 ? exactTitleMatches[0] : null;
}
function pickMostPopular(results: TmdbSearchResult[]): TmdbSearchResult {
return [...results].sort(
(a, b) =>
(b.vote_count ?? 0) - (a.vote_count ?? 0) ||
(b.popularity ?? 0) - (a.popularity ?? 0)
)[0];
}
@@ -0,0 +1,386 @@
import {
StalkerVodInfo,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import {
mergeSerieInfoWithTmdb,
mergeStalkerInfoWithTmdb,
mergeVodInfoWithTmdb,
} from './tmdb-merge';
import { TmdbMovieDetails, TmdbTvDetails } from './tmdb.types';
function providerVodInfo(overrides: Partial<XtreamVodInfo> = {}): XtreamVodInfo {
return {
kinopoisk_url: '',
tmdb_id: '',
name: 'The Matrix',
o_name: 'The Matrix',
cover_big: 'http://provider/poster-big.jpg',
movie_image: 'http://provider/poster.jpg',
releasedate: '1999-03-31',
episode_run_time: 0,
youtube_trailer: 'provider-trailer',
director: 'Provider Director',
actors: 'Provider Actor',
cast: 'Provider Actor',
description: 'Provider description',
plot: 'Provider plot',
age: '',
mpaa_rating: 'R',
rating_count_kinopoisk: 0,
country: 'USA',
genre: 'Action',
backdrop_path: ['http://provider/backdrop.jpg'],
duration_secs: 8160,
duration: '02:16:00',
video: [],
audio: [],
bitrate: 0,
rating: 7,
...overrides,
};
}
const tmdbMovie: TmdbMovieDetails = {
id: 603,
title: 'The Matrix',
overview: 'TMDB overview',
genres: [
{ id: 28, name: 'Action' },
{ id: 878, name: 'Science Fiction' },
],
release_date: '1999-03-31',
runtime: 136,
vote_average: 8.22,
vote_count: 26000,
poster_path: '/matrix-poster.jpg',
backdrop_path: '/matrix-backdrop.jpg',
production_countries: [{ iso_3166_1: 'US', name: 'United States' }],
credits: {
cast: [
{
name: 'Keanu Reeves',
order: 0,
character: 'Neo',
profile_path: '/keanu.jpg',
},
{ name: 'Laurence Fishburne', order: 1, profile_path: null },
],
crew: [
{ name: 'Lana Wachowski', job: 'Director' },
{ name: 'Lilly Wachowski', job: 'Director' },
{ name: 'Someone Else', job: 'Producer' },
],
},
videos: {
results: [
{ key: 'fan-clip', site: 'YouTube', type: 'Clip' },
{
key: 'official-trailer',
site: 'YouTube',
type: 'Trailer',
official: true,
},
{ key: 'vimeo-trailer', site: 'Vimeo', type: 'Trailer' },
],
},
recommendations: {
results: [
{
id: 604,
title: 'The Matrix Reloaded',
release_date: '2003-05-15',
poster_path: '/reloaded.jpg',
},
{ id: 605, title: 'The Matrix Revolutions' },
],
},
};
describe('mergeVodInfoWithTmdb', () => {
it('prefers TMDB editorial fields when present', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), tmdbMovie);
expect(merged.plot).toBe('TMDB overview');
expect(merged.description).toBe('TMDB overview');
expect(merged.cast).toBe('Keanu Reeves, Laurence Fishburne');
expect(merged.actors).toBe('Keanu Reeves, Laurence Fishburne');
expect(merged.director).toBe('Lana Wachowski, Lilly Wachowski');
expect(merged.genre).toBe('Action, Science Fiction');
expect(merged.rating).toBe(8.2);
expect(merged.tmdb_id).toBe(603);
expect(merged.movie_image).toBe(
'https://image.tmdb.org/t/p/w500/matrix-poster.jpg'
);
expect(merged.backdrop_path[0]).toBe(
'https://image.tmdb.org/t/p/w1280/matrix-backdrop.jpg'
);
expect(merged.backdrop_path).toContain('http://provider/backdrop.jpg');
expect(merged.tmdb_cast).toEqual([
{
name: 'Keanu Reeves',
character: 'Neo',
profileUrl: 'https://image.tmdb.org/t/p/w185/keanu.jpg',
},
{ name: 'Laurence Fishburne', profileUrl: null },
]);
});
it('prefers the official YouTube trailer', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), tmdbMovie);
expect(merged.youtube_trailer).toBe('official-trailer');
});
it('keeps the provider trailer when TMDB has no usable video', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), {
...tmdbMovie,
videos: { results: [{ key: 'x', site: 'Vimeo', type: 'Trailer' }] },
});
expect(merged.youtube_trailer).toBe('provider-trailer');
});
it('attaches recommendations with year and poster', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), tmdbMovie);
expect(merged.tmdb_recommendations).toEqual([
{
tmdbId: 604,
title: 'The Matrix Reloaded',
year: 2003,
posterUrl: 'https://image.tmdb.org/t/p/w500/reloaded.jpg',
},
{
tmdbId: 605,
title: 'The Matrix Revolutions',
year: null,
posterUrl: null,
},
]);
});
it('keeps provider values when TMDB fields are missing', () => {
const sparse: TmdbMovieDetails = { id: 603 };
const info = providerVodInfo();
const merged = mergeVodInfoWithTmdb(info, sparse);
expect(merged.plot).toBe('Provider plot');
expect(merged.cast).toBe('Provider Actor');
expect(merged.director).toBe('Provider Director');
expect(merged.genre).toBe('Action');
expect(merged.rating).toBe(7);
expect(merged.movie_image).toBe('http://provider/poster.jpg');
expect(merged.tmdb_cast).toBeUndefined();
expect(merged.backdrop_path).toEqual([
'http://provider/backdrop.jpg',
]);
});
it('ignores TMDB rating without votes', () => {
const unrated: TmdbMovieDetails = {
id: 603,
vote_average: 5,
vote_count: 0,
};
const merged = mergeVodInfoWithTmdb(providerVodInfo(), unrated);
expect(merged.rating).toBe(7);
});
it('fills the displayed rating_imdb field only when the provider left it empty', () => {
const withoutImdb = mergeVodInfoWithTmdb(
providerVodInfo({ rating_imdb: '' }),
tmdbMovie
);
expect(withoutImdb.rating_imdb).toBe('8.2');
const withImdb = mergeVodInfoWithTmdb(
providerVodInfo({ rating_imdb: '7.9' }),
tmdbMovie
);
expect(withImdb.rating_imdb).toBe('7.9');
});
it('fills missing provider release date and country', () => {
const info = providerVodInfo({ releasedate: '', country: '' });
const merged = mergeVodInfoWithTmdb(info, tmdbMovie);
expect(merged.releasedate).toBe('1999-03-31');
expect(merged.country).toBe('United States');
});
it('does not mutate the provider object', () => {
const info = providerVodInfo();
const snapshot = JSON.parse(JSON.stringify(info));
mergeVodInfoWithTmdb(info, tmdbMovie);
expect(info).toEqual(snapshot);
});
});
describe('mergeSerieInfoWithTmdb', () => {
const providerSerieInfo: XtreamSerieInfo = {
name: 'Dark',
cover: 'http://provider/cover.jpg',
plot: 'Provider plot',
cast: 'Provider Cast',
director: '',
genre: 'Drama',
releaseDate: '',
last_modified: '',
rating: '7',
rating_5based: 3.5,
backdrop_path: ['http://provider/backdrop.jpg'],
youtube_trailer: '',
episode_run_time: '60',
category_id: '1',
};
const tmdbTv: TmdbTvDetails = {
id: 70523,
name: 'Dark',
overview: 'TMDB tv overview',
genres: [{ id: 9648, name: 'Mystery' }],
first_air_date: '2017-12-01',
vote_average: 8.4,
vote_count: 3200,
poster_path: '/dark-poster.jpg',
backdrop_path: '/dark-backdrop.jpg',
created_by: [{ name: 'Baran bo Odar' }, { name: 'Jantje Friese' }],
credits: {
cast: [{ name: 'Louis Hofmann', order: 0 }],
},
};
it('prefers TMDB fields and fills gaps', () => {
const merged = mergeSerieInfoWithTmdb(providerSerieInfo, tmdbTv);
expect(merged.plot).toBe('TMDB tv overview');
expect(merged.cast).toBe('Louis Hofmann');
expect(merged.director).toBe('Baran bo Odar, Jantje Friese');
expect(merged.genre).toBe('Mystery');
expect(merged.rating).toBe('8.4');
expect(merged.rating_5based).toBe(4.2);
expect(merged.releaseDate).toBe('2017-12-01');
expect(merged.cover).toBe(
'https://image.tmdb.org/t/p/w500/dark-poster.jpg'
);
expect(merged.backdrop_path[0]).toBe(
'https://image.tmdb.org/t/p/w1280/dark-backdrop.jpg'
);
});
it('keeps provider values for missing TMDB fields', () => {
const merged = mergeSerieInfoWithTmdb(providerSerieInfo, {
id: 70523,
});
expect(merged.plot).toBe('Provider plot');
expect(merged.cast).toBe('Provider Cast');
expect(merged.genre).toBe('Drama');
expect(merged.rating).toBe('7');
expect(merged.rating_5based).toBe(3.5);
expect(merged.cover).toBe('http://provider/cover.jpg');
});
});
describe('mergeStalkerInfoWithTmdb', () => {
const providerStalkerInfo: StalkerVodInfo = {
movie_image: '',
description: '',
name: 'Ирония судьбы, или С лёгким паром!',
o_name: undefined,
actors: 'Провайдерский актёр',
director: '',
releasedate: '1976',
genre: '',
rating_imdb: '',
rating_kinopoisk: '8.1',
};
const tmdbMovieRu: TmdbMovieDetails = {
id: 20992,
title: 'Ирония судьбы, или С лёгким паром!',
overview: 'Описание из TMDB',
genres: [{ id: 35, name: 'комедия' }],
release_date: '1976-01-01',
vote_average: 7.9,
vote_count: 250,
poster_path: '/irony-poster.jpg',
backdrop_path: '/irony-backdrop.jpg',
credits: {
cast: [
{
name: 'Андрей Мягков',
order: 0,
profile_path: '/myagkov.jpg',
},
],
crew: [{ name: 'Эльдар Рязанов', job: 'Director' }],
},
};
it('prefers TMDB editorial fields and attaches cast/backdrop', () => {
const merged = mergeStalkerInfoWithTmdb(
providerStalkerInfo,
tmdbMovieRu,
'movie'
);
expect(merged.description).toBe('Описание из TMDB');
expect(merged.actors).toBe('Андрей Мягков');
expect(merged.director).toBe('Эльдар Рязанов');
expect(merged.genre).toBe('комедия');
expect(merged.movie_image).toBe(
'https://image.tmdb.org/t/p/w500/irony-poster.jpg'
);
expect(merged.tmdb_backdrop).toBe(
'https://image.tmdb.org/t/p/w1280/irony-backdrop.jpg'
);
expect(merged.tmdb_cast).toEqual([
{
name: 'Андрей Мягков',
profileUrl: 'https://image.tmdb.org/t/p/w185/myagkov.jpg',
},
]);
// Provider keeps its own fields where TMDB should not win
expect(merged.name).toBe('Ирония судьбы, или С лёгким паром!');
expect(merged.rating_kinopoisk).toBe('8.1');
expect(merged.releasedate).toBe('1976');
});
it('only fills rating_imdb when the provider left it empty', () => {
const merged = mergeStalkerInfoWithTmdb(
providerStalkerInfo,
tmdbMovieRu,
'movie'
);
expect(merged.rating_imdb).toBe('7.9');
const withProviderRating = mergeStalkerInfoWithTmdb(
{ ...providerStalkerInfo, rating_imdb: '8.4' },
tmdbMovieRu,
'movie'
);
expect(withProviderRating.rating_imdb).toBe('8.4');
});
it('uses series creators as director for tv items', () => {
const tv: TmdbTvDetails = {
id: 1,
overview: 'tv overview',
created_by: [{ name: 'Создатель Сериала' }],
};
const merged = mergeStalkerInfoWithTmdb(providerStalkerInfo, tv, 'tv');
expect(merged.director).toBe('Создатель Сериала');
});
it('keeps provider values for missing TMDB fields', () => {
const merged = mergeStalkerInfoWithTmdb(
{ ...providerStalkerInfo, description: 'Провайдерский сюжет' },
{ id: 20992 },
'movie'
);
expect(merged.description).toBe('Провайдерский сюжет');
expect(merged.actors).toBe('Провайдерский актёр');
expect(merged.tmdb_cast).toBeUndefined();
expect(merged.tmdb_backdrop).toBeUndefined();
});
});
+253
View File
@@ -0,0 +1,253 @@
import {
StalkerVodInfo,
TmdbEnrichedCastMember,
TmdbMediaType,
TmdbRecommendation,
XtreamSerieInfo,
XtreamVodInfo,
} from '@iptvnator/shared/interfaces';
import { tmdbBackdropUrl, tmdbPosterUrl, tmdbProfileUrl } from './tmdb-config';
import { extractYear } from './tmdb-matcher';
import {
TmdbCredits,
TmdbDetails,
TmdbMovieDetails,
TmdbTvDetails,
} from './tmdb.types';
/**
* Field-level merge of TMDB metadata into provider detail objects.
* The provider stays authoritative for stream-related data; TMDB wins for
* editorial fields (plot, cast, director, genres, rating, artwork) when it
* has a value, otherwise the provider value is kept. Nothing is mutated.
*/
const MAX_CAST_NAMES = 10;
function topCast(credits: TmdbCredits | undefined) {
return [...(credits?.cast ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.slice(0, MAX_CAST_NAMES)
.filter((member) => Boolean(member.name));
}
function castNames(credits: TmdbCredits | undefined): string {
return topCast(credits)
.map((member) => member.name)
.join(', ');
}
/** Cast with profile photos for the avatar chips in detail views */
function enrichedCast(
credits: TmdbCredits | undefined
): TmdbEnrichedCastMember[] {
return topCast(credits).map((member) => ({
name: member.name,
...(member.character ? { character: member.character } : {}),
profileUrl: tmdbProfileUrl(member.profile_path),
...(member.id ? { tmdbPersonId: member.id } : {}),
}));
}
function directorNames(credits: TmdbCredits | undefined): string {
return (credits?.crew ?? [])
.filter((member) => member.job === 'Director')
.map((member) => member.name)
.filter(Boolean)
.join(', ');
}
function creatorNames(details: TmdbTvDetails): string {
return (details.created_by ?? [])
.map((creator) => creator.name)
.filter(Boolean)
.join(', ');
}
const MAX_RECOMMENDATIONS = 12;
/** Best YouTube trailer key: official trailer > any trailer > teaser */
function pickTrailerKey(details: TmdbDetails): string {
const videos = (details.videos?.results ?? []).filter(
(video) => video.site === 'YouTube' && Boolean(video.key)
);
const best =
videos.find((video) => video.type === 'Trailer' && video.official) ??
videos.find((video) => video.type === 'Trailer') ??
videos.find((video) => video.type === 'Teaser');
return best?.key ?? '';
}
function recommendationList(details: TmdbDetails): TmdbRecommendation[] {
return (details.recommendations?.results ?? [])
.slice(0, MAX_RECOMMENDATIONS)
.map((result) => ({
tmdbId: result.id,
title: (result.title ?? result.name ?? '').trim(),
year: extractYear(result.release_date ?? result.first_air_date),
posterUrl: tmdbPosterUrl(result.poster_path),
}))
.filter((recommendation) => recommendation.title !== '');
}
function genreNames(details: TmdbDetails): string {
return (details.genres ?? [])
.map((genre) => genre.name)
.filter(Boolean)
.join(', ');
}
function tmdbRating(details: TmdbDetails): number | null {
const { vote_average: average, vote_count: count } = details;
return average && average > 0 && count && count > 0
? Math.round(average * 10) / 10
: null;
}
/** TMDB backdrop first, then the provider's own entries (deduplicated) */
function mergedBackdrops(
details: TmdbDetails,
// Some panels send backdrop_path as a plain string despite the typing
providerBackdrops: string[] | string | undefined
): string[] {
const tmdbUrl = tmdbBackdropUrl(details.backdrop_path);
const provider = (
Array.isArray(providerBackdrops)
? providerBackdrops
: [providerBackdrops]
).filter((value): value is string => typeof value === 'string' && !!value);
return tmdbUrl
? [tmdbUrl, ...provider.filter((url) => url !== tmdbUrl)]
: provider;
}
function prefer(tmdbValue: string | null | undefined, providerValue: string) {
return tmdbValue?.trim() ? tmdbValue : providerValue;
}
export function mergeVodInfoWithTmdb(
info: XtreamVodInfo,
details: TmdbMovieDetails
): XtreamVodInfo {
const tmdbCast = enrichedCast(details.credits);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
const director = directorNames(details.credits);
const genre = genreNames(details);
const rating = tmdbRating(details);
const poster = tmdbPosterUrl(details.poster_path);
const country = (details.production_countries ?? [])
.map((entry) => entry.name)
.filter(Boolean)
.join(', ');
return {
...info,
tmdb_id: details.id,
plot: prefer(details.overview, info.plot),
description: prefer(details.overview, info.description),
cast: prefer(cast, info.cast),
actors: prefer(cast, info.actors),
director: prefer(director, info.director),
genre: prefer(genre, info.genre),
rating: rating ?? info.rating,
// The VOD detail badge renders rating_imdb — fill it when the
// provider left it empty so a TMDB-only score is actually shown
rating_imdb:
info.rating_imdb || (rating !== null ? String(rating) : ''),
releasedate: info.releasedate || (details.release_date ?? ''),
country: info.country || country,
movie_image: prefer(poster, info.movie_image),
cover_big: prefer(poster, info.cover_big),
backdrop_path: mergedBackdrops(details, info.backdrop_path),
episode_run_time: info.episode_run_time || (details.runtime ?? 0),
youtube_trailer: prefer(trailer, info.youtube_trailer),
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
: {}),
};
}
export function mergeSerieInfoWithTmdb(
info: XtreamSerieInfo,
details: TmdbTvDetails
): XtreamSerieInfo {
const tmdbCast = enrichedCast(details.credits);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
const creators = creatorNames(details);
const genre = genreNames(details);
const rating = tmdbRating(details);
const poster = tmdbPosterUrl(details.poster_path);
return {
...info,
plot: prefer(details.overview, info.plot),
cast: prefer(cast, info.cast),
director: prefer(creators, info.director),
genre: prefer(genre, info.genre),
rating: rating !== null ? String(rating) : info.rating,
rating_5based:
rating !== null ? Math.round(rating * 5) / 10 : info.rating_5based,
releaseDate: info.releaseDate || (details.first_air_date ?? ''),
cover: prefer(poster, info.cover),
backdrop_path: mergedBackdrops(details, info.backdrop_path),
youtube_trailer: prefer(trailer, info.youtube_trailer),
tmdb_id: details.id,
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
: {}),
};
}
/**
* Merge for Stalker portal detail objects. One function covers movies and
* series — both render from the same `StalkerVodInfo` shape. The TMDB
* rating only FILLS `rating_imdb` (the UI labels it as IMDb, so a present
* provider value must not be overwritten with a TMDB score).
*/
export function mergeStalkerInfoWithTmdb(
info: StalkerVodInfo,
details: TmdbMovieDetails | TmdbTvDetails,
mediaType: TmdbMediaType
): StalkerVodInfo {
const tmdbCast = enrichedCast(details.credits);
const trailer = pickTrailerKey(details);
const recommendations = recommendationList(details);
const cast = castNames(details.credits);
const director =
mediaType === 'movie'
? directorNames(details.credits)
: creatorNames(details as TmdbTvDetails);
const genre = genreNames(details);
const rating = tmdbRating(details);
const poster = tmdbPosterUrl(details.poster_path);
const backdrop = tmdbBackdropUrl(details.backdrop_path);
const releaseDate =
mediaType === 'movie'
? (details as TmdbMovieDetails).release_date
: (details as TmdbTvDetails).first_air_date;
return {
...info,
description: prefer(details.overview, info.description),
actors: prefer(cast, info.actors),
director: prefer(director, info.director),
genre: prefer(genre, info.genre),
releasedate: info.releasedate || (releaseDate ?? ''),
movie_image: prefer(poster, info.movie_image),
rating_imdb:
info.rating_imdb || (rating !== null ? String(rating) : ''),
tmdb_id: details.id,
...(backdrop ? { tmdb_backdrop: backdrop } : {}),
...(trailer ? { tmdb_trailer: trailer } : {}),
...(tmdbCast.length > 0 ? { tmdb_cast: tmdbCast } : {}),
...(recommendations.length > 0
? { tmdb_recommendations: recommendations }
: {}),
};
}
@@ -0,0 +1,78 @@
import { mapPersonFilmography, mapPersonProfile } from './tmdb-person';
import { TmdbPersonDetails } from './tmdb.types';
const person: TmdbPersonDetails = {
id: 287,
name: 'Brad Pitt',
biography: 'Bio text',
birthday: '1963-12-18',
place_of_birth: 'Shawnee, Oklahoma, USA',
profile_path: '/pitt.jpg',
combined_credits: {
cast: [
{
id: 550,
media_type: 'movie',
title: 'Fight Club',
release_date: '1999-10-15',
poster_path: '/fc.jpg',
character: 'Tyler Durden',
},
{
id: 550,
media_type: 'movie',
title: 'Fight Club',
release_date: '1999-10-15',
},
{
id: 1104,
media_type: 'tv',
name: 'Friends',
first_air_date: '1994-09-22',
character: 'Will Colbert',
},
{ id: 999, media_type: 'movie', title: 'Undated Movie' },
{ id: 42, title: 'No media type' },
{ id: 43, media_type: 'movie', title: ' ' },
],
},
};
describe('mapPersonProfile', () => {
it('maps the person with a full profile photo URL', () => {
expect(mapPersonProfile(person)).toEqual({
tmdbId: 287,
name: 'Brad Pitt',
biography: 'Bio text',
birthday: '1963-12-18',
deathday: null,
placeOfBirth: 'Shawnee, Oklahoma, USA',
photoUrl: 'https://image.tmdb.org/t/p/w185/pitt.jpg',
});
});
});
describe('mapPersonFilmography', () => {
it('deduplicates, drops unusable credits and sorts newest first', () => {
const credits = mapPersonFilmography(person);
expect(credits.map((credit) => credit.tmdbId)).toEqual([
550, 1104, 999,
]);
expect(credits[0]).toEqual({
tmdbId: 550,
mediaType: 'movie',
title: 'Fight Club',
year: 1999,
posterUrl: 'https://image.tmdb.org/t/p/w500/fc.jpg',
character: 'Tyler Durden',
});
expect(credits[1].mediaType).toBe('tv');
// Undated entries sort last
expect(credits[2].year).toBeNull();
});
it('handles a person without credits', () => {
expect(mapPersonFilmography({ id: 1 })).toEqual([]);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { tmdbPosterUrl, tmdbProfileUrl } from './tmdb-config';
import { extractYear } from './tmdb-matcher';
import { TmdbPersonDetails } from './tmdb.types';
/**
* View-friendly projection of a TMDB person + combined filmography, shared
* by the portal actor pages.
*/
export interface ActorProfile {
tmdbId: number;
name: string;
biography: string;
birthday: string | null;
deathday: string | null;
placeOfBirth: string | null;
photoUrl: string | null;
}
export interface ActorFilmographyCredit {
tmdbId: number;
mediaType: 'movie' | 'tv';
title: string;
year: number | null;
posterUrl: string | null;
character: string | null;
}
const MAX_FILMOGRAPHY_CREDITS = 80;
export function mapPersonProfile(person: TmdbPersonDetails): ActorProfile {
return {
tmdbId: person.id,
name: person.name ?? '',
biography: person.biography ?? '',
birthday: person.birthday ?? null,
deathday: person.deathday ?? null,
placeOfBirth: person.place_of_birth ?? null,
photoUrl: tmdbProfileUrl(person.profile_path),
};
}
/**
* Deduplicated acting credits, newest first (undated entries last),
* capped at {@link MAX_FILMOGRAPHY_CREDITS}.
*/
export function mapPersonFilmography(
person: TmdbPersonDetails
): ActorFilmographyCredit[] {
const seen = new Set<string>();
const credits: ActorFilmographyCredit[] = [];
for (const credit of person.combined_credits?.cast ?? []) {
const mediaType =
credit.media_type === 'movie' || credit.media_type === 'tv'
? credit.media_type
: null;
const title = (credit.title ?? credit.name ?? '').trim();
const key = `${mediaType}:${credit.id}`;
if (!mediaType || !title || seen.has(key)) {
continue;
}
seen.add(key);
credits.push({
tmdbId: credit.id,
mediaType,
title,
year: extractYear(credit.release_date ?? credit.first_air_date),
posterUrl: tmdbPosterUrl(credit.poster_path),
character: credit.character?.trim() || null,
});
}
return credits
.sort((a, b) => (b.year ?? -1) - (a.year ?? -1))
.slice(0, MAX_FILMOGRAPHY_CREDITS);
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Response shapes of the TMDB v3 API endpoints used for enrichment.
* Only fields the app consumes are typed; payloads are cached verbatim so
* later phases can pick up additional fields without refetching.
*/
export interface TmdbSearchResult {
id: number;
/** Movie results */
title?: string;
original_title?: string;
release_date?: string;
/** TV results */
name?: string;
original_name?: string;
first_air_date?: string;
popularity?: number;
vote_count?: number;
poster_path?: string | null;
}
export interface TmdbSearchResponse {
results?: TmdbSearchResult[];
}
export interface TmdbCastMember {
id?: number;
name: string;
character?: string;
order?: number;
profile_path?: string | null;
}
export interface TmdbCrewMember {
name: string;
job?: string;
department?: string;
}
export interface TmdbCredits {
cast?: TmdbCastMember[];
crew?: TmdbCrewMember[];
}
export interface TmdbGenre {
id: number;
name: string;
}
export interface TmdbVideo {
key: string;
site?: string;
type?: string;
official?: boolean;
name?: string;
iso_639_1?: string;
}
interface TmdbDetailsBase {
id: number;
overview?: string;
genres?: TmdbGenre[];
vote_average?: number;
vote_count?: number;
poster_path?: string | null;
backdrop_path?: string | null;
credits?: TmdbCredits;
videos?: { results?: TmdbVideo[] };
recommendations?: { results?: TmdbSearchResult[] };
}
export interface TmdbMovieDetails extends TmdbDetailsBase {
title?: string;
original_title?: string;
release_date?: string;
runtime?: number;
production_countries?: { iso_3166_1?: string; name?: string }[];
}
export interface TmdbTvDetails extends TmdbDetailsBase {
name?: string;
original_name?: string;
first_air_date?: string;
episode_run_time?: number[];
created_by?: { name: string }[];
}
export interface TmdbEpisode {
episode_number: number;
season_number?: number;
name?: string;
overview?: string;
still_path?: string | null;
air_date?: string;
vote_average?: number;
vote_count?: number;
runtime?: number | null;
}
export interface TmdbSeasonDetails {
season_number?: number;
episodes?: TmdbEpisode[];
}
/** One acting credit from /person/{id} combined_credits */
export interface TmdbPersonCredit {
id: number;
media_type?: string;
/** Movie credits */
title?: string;
release_date?: string;
/** TV credits */
name?: string;
first_air_date?: string;
character?: string;
poster_path?: string | null;
vote_count?: number;
popularity?: number;
}
export interface TmdbPersonDetails {
id: number;
name?: string;
biography?: string;
birthday?: string | null;
deathday?: string | null;
place_of_birth?: string | null;
profile_path?: string | null;
combined_credits?: { cast?: TmdbPersonCredit[] };
}
export type TmdbDetails = TmdbMovieDetails | TmdbTvDetails;
/** Input for the enrichment orchestrator */
export interface TmdbEnrichmentQuery {
/** Provider-supplied TMDB id — trusted fully when valid */
tmdbId?: number | string | null;
title: string;
/** Original title, often cleaner than the display title */
originalTitle?: string | null;
/** Release year used to disambiguate fuzzy matches */
year?: number | null;
}
@@ -73,6 +73,17 @@ export function getDatabasePath(): string {
/**
* SQL statements for creating all tables
*/
const TMDB_METADATA_TABLE_SQL = `CREATE TABLE IF NOT EXISTS tmdb_metadata (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv', 'person')),
lookup_key TEXT NOT NULL,
language TEXT NOT NULL,
tmdb_id INTEGER,
payload TEXT,
fetched_at TEXT DEFAULT (datetime('now'))
)`;
const TMDB_METADATA_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`;
const CREATE_TABLE_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
@@ -294,6 +305,9 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
// TMDB metadata cache (details payloads + search match resolutions)
TMDB_METADATA_TABLE_SQL,
TMDB_METADATA_INDEX_SQL,
];
/**
@@ -714,10 +728,41 @@ function runMigrationStatements(
}
}
/**
* Pre-release installs created tmdb_metadata with a CHECK that only
* allowed 'movie'/'tv'; person rows need 'person'. The table is a pure
* cache, so the cheapest "migration" is a drop-and-recreate with the
* widened constraint. Self-healing via sqlite_master — no app_state key.
*/
function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
try {
const row = sqliteDb
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tmdb_metadata'`
)
.get() as { sql?: string } | undefined;
if (!row?.sql || row.sql.includes(`'person'`)) {
return;
}
const rebuild = sqliteDb.transaction(() => {
sqliteDb.prepare(`DROP TABLE IF EXISTS tmdb_metadata`).run();
sqliteDb.prepare(TMDB_METADATA_TABLE_SQL).run();
sqliteDb.prepare(TMDB_METADATA_INDEX_SQL).run();
});
rebuild();
console.log(
'[DB] Rebuilt tmdb_metadata cache with widened media_type CHECK'
);
} catch (error) {
console.warn('[DB] tmdb_metadata CHECK widening failed:', error);
}
}
/**
* Run migrations that may fail if already applied
*/
function runMigrations(sqliteDb: Database.Database): void {
widenTmdbMetadataMediaTypeCheck(sqliteDb);
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureContentTitleFts(sqliteDb);
deduplicateXtreamCache(sqliteDb);
+28
View File
@@ -349,3 +349,31 @@ export const downloads = sqliteTable(
export type Download = typeof downloads.$inferSelect;
export type NewDownload = typeof downloads.$inferInsert;
// TMDB metadata cache table.
// Two row kinds share the table, discriminated by the lookup_key prefix:
// - 'id:<tmdbId>' → full TMDB details payload (JSON)
// - 'title:<normalized>|year:<y>' → search resolution; tmdb_id NULL means
// "no confident match" (negative cache)
export const tmdbMetadata = sqliteTable(
'tmdb_metadata',
{
id: integer('id').primaryKey({ autoIncrement: true }),
mediaType: text('media_type', { enum: ['movie', 'tv', 'person'] }).notNull(),
lookupKey: text('lookup_key').notNull(),
language: text('language').notNull(),
tmdbId: integer('tmdb_id'),
payload: text('payload'),
fetchedAt: text('fetched_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
lookupUnique: uniqueIndex('tmdb_metadata_lookup_unique').on(
table.mediaType,
table.lookupKey,
table.language
),
})
);
export type TmdbMetadata = typeof tmdbMetadata.$inferSelect;
export type NewTmdbMetadata = typeof tmdbMetadata.$inferInsert;
+3
View File
@@ -32,7 +32,10 @@ export * from './lib/settings.interface';
export * from './lib/stalker-portal-actions.enum';
export * from './lib/store-keys.enum';
export * from './lib/stream-format.enum';
export * from './lib/catalog-title-match.interface';
export * from './lib/theme.enum';
export * from './lib/title-normalization.util';
export * from './lib/tmdb.interface';
export * from './lib/xtream-account-info-dialog-data.interface';
export * from './lib/xtream-category.interface';
export * from './lib/xtream-code-actions';
@@ -0,0 +1,24 @@
/**
* One confident cross-playlist title match returned by the Electron DB
* worker (`DB_MATCH_TITLES`). Used by the actor page's "All portals"
* scope: matching runs over the trigram FTS index of the `content` table
* (Xtream playlists only — Stalker content is never imported).
*/
export interface CatalogTitleMatch {
/** The title from the request this row matched (verbatim) */
queryTitle: string;
playlistId: string;
playlistName: string;
/** Xtream category id (route param for detail navigation) */
categoryId: number;
xtreamId: number;
type: 'movie' | 'series';
/**
* Release-year tag stripped from the matched catalog title when the
* match happened on the year-stripped tier ("The Matrix 1999" matching
* the query "The Matrix"), null for exact-title matches. Consumers
* with a known query year must reject incompatible years — this keeps
* "Blade Runner" from claiming a catalog "Blade Runner 2049".
*/
trailingYear: number | null;
}
@@ -29,7 +29,9 @@ import {
ResolvedPortalPlayback,
} from './portal-playback.interface';
import { PortalDebugEvent } from './portal-debug.interface';
import { CatalogTitleMatch } from './catalog-title-match.interface';
import { Settings } from './settings.interface';
import { TmdbCacheEntry, TmdbCacheMediaType } from './tmdb.interface';
import { XtreamCategory } from './xtream-category.interface';
export const ELECTRON_BRIDGE_CONTENT_TYPES = {
@@ -776,6 +778,15 @@ export interface ElectronBridgeApi {
key: string,
value: string
) => Promise<ElectronBridgeResult>;
// TMDB metadata cache
dbGetTmdbMetadata: (
mediaType: TmdbCacheMediaType,
lookupKey: string,
language: string
) => Promise<TmdbCacheEntry | null>;
dbSetTmdbMetadata: (entry: TmdbCacheEntry) => Promise<ElectronBridgeResult>;
/** Cross-playlist title matching (actor page "All portals" scope) */
dbMatchTitles: (titles: string[]) => Promise<CatalogTitleMatch[]>;
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
) => () => void;
@@ -1,6 +1,7 @@
import { Language } from './language.enum';
import { StreamFormat } from './stream-format.enum';
import { Theme } from './theme.enum';
import { TmdbSettings } from './tmdb.interface';
/**
* Contains all types of supported video players
@@ -130,4 +131,9 @@ export interface Settings {
* unrelated playlist or EPG hosts.
*/
trustedInsecureTlsHosts?: string[];
/**
* Opt-in TMDB metadata enrichment for VOD/series detail views.
* Disabled by default because enrichment sends content titles to TMDB.
*/
tmdb?: TmdbSettings;
}
@@ -1,3 +1,8 @@
import {
TmdbEnrichedCastMember,
TmdbRecommendation,
} from './tmdb.interface';
/**
* Stalker portal VOD details response structure.
* Contains movie metadata from Stalker/Ministra portals.
@@ -32,4 +37,14 @@ export interface StalkerVodInfo {
rating_imdb: string;
/** Kinopoisk rating as string */
rating_kinopoisk: string;
/** Populated by TMDB enrichment; absent in raw portal responses */
tmdb_cast?: TmdbEnrichedCastMember[];
/** TMDB backdrop URL — Stalker portals never provide one themselves */
tmdb_backdrop?: string;
/** YouTube trailer key from TMDB — Stalker portals provide no trailers */
tmdb_trailer?: string;
/** Populated by TMDB enrichment */
tmdb_recommendations?: TmdbRecommendation[];
/** Matched TMDB id — enables lazy season/episode enrichment */
tmdb_id?: number;
}
@@ -0,0 +1,69 @@
import {
normalizeTitle,
normalizeTitleKeys,
titleYearsCompatible,
} from './title-normalization.util';
describe('normalizeTitleKeys', () => {
it('keeps a trailing year in the exact form and strips it in base', () => {
expect(normalizeTitleKeys('Blade Runner 2049')).toEqual({
exact: 'blade runner 2049',
base: 'blade runner',
trailingYear: 2049,
});
});
it('returns identical tiers when there is no trailing year', () => {
expect(normalizeTitleKeys('Blade Runner')).toEqual({
exact: 'blade runner',
base: 'blade runner',
trailingYear: null,
});
});
it('strips quality tags and bracket groups on both tiers', () => {
expect(normalizeTitleKeys('The Matrix 1999 [4K] (Remastered)')).toEqual(
{
exact: 'the matrix 1999',
base: 'the matrix',
trailingYear: 1999,
}
);
});
it('never strips a year that IS the whole title', () => {
expect(normalizeTitleKeys('2012')).toEqual({
exact: '2012',
base: '2012',
trailingYear: null,
});
});
it('keeps leading/mid-title years (only trailing years are tags)', () => {
expect(normalizeTitle('2001: A Space Odyssey')).toBe(
'2001 a space odyssey'
);
});
it('only strips UPPERCASE language prefixes', () => {
expect(normalizeTitle('EN - The Boys s05')).toBe('the boys');
expect(normalizeTitle('It: Chapter Two')).toBe('it chapter two');
});
it('strips season suffixes on both tiers', () => {
expect(normalizeTitleKeys('The Boys s05').exact).toBe('the boys');
expect(normalizeTitleKeys('Пацаны сезон 2').base).toBe('пацаны');
});
});
describe('titleYearsCompatible', () => {
it('accepts unknown years and ±1 tolerance', () => {
expect(titleYearsCompatible(null, 2049)).toBe(true);
expect(titleYearsCompatible(1999, undefined)).toBe(true);
expect(titleYearsCompatible(1999, 2000)).toBe(true);
});
it('rejects contradicting years', () => {
expect(titleYearsCompatible(1982, 2049)).toBe(false);
});
});
@@ -0,0 +1,149 @@
/**
* Provider-title normalization shared by the renderer (TMDB matching,
* catalog indexes) and the Electron DB worker (cross-playlist title
* matching). Pure functions — no Angular/Node dependencies.
*/
const QUALITY_TAGS = new Set([
'4k',
'uhd',
'fhd',
'hd',
'sd',
'hdr',
'hevc',
'h264',
'h265',
'x264',
'x265',
'480p',
'720p',
'1080p',
'2160p',
'multi',
'multisub',
'vostfr',
'vf',
'dubbed',
]);
/**
* Leading channel/language prefix like "EN - ", "DE| ", "FR: ".
* UPPERCASE-only on purpose: a case-insensitive match would amputate real
* title words ("It: Chapter Two" → "Chapter Two").
*/
const LANGUAGE_PREFIX = /^[A-Z]{2,3}\s*[-|:]\s+/;
const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/;
/**
* Release-year tag at the very end of a title ("The Matrix 1999"). Only
* trailing years are stripped — an unanchored pattern would eat years that
* are part of the title ("2001: A Space Odyssey" → "a space odyssey").
*/
const TRAILING_YEAR_PATTERN = /(?:^|\s)(19\d{2}|20\d{2})$/;
/**
* Trailing season markers on series titles: "The Boys s05", "сезон 2".
* Uses (?:^|\s) instead of \b — JS word boundaries are ASCII-only and
* never fire next to Cyrillic letters.
*/
const SEASON_SUFFIX_PATTERN =
/(?:^|\s)(?:s\d{1,2}|season\s*\d{1,2}|сезон\s*\d{1,2}|staffel\s*\d{1,2}|temporada\s*\d{1,2})$/i;
/**
* A provider title normalized on two tiers. Trailing years on provider
* titles are ambiguous — usually a release tag ("The Matrix 1999") but
* sometimes part of the title itself ("Blade Runner 2049") — so matching
* must try the exact form first and only fall back to the year-stripped
* form when the stripped year does not contradict the other side's year.
*/
export interface NormalizedTitleKeys {
/** Fully normalized, trailing year KEPT ("blade runner 2049") */
exact: string;
/** Trailing year stripped ("blade runner"); equals `exact` if none */
base: string;
/** The trailing year removed in `base`, when there was one */
trailingYear: number | null;
}
export function normalizeTitleKeys(
raw: string | null | undefined
): NormalizedTitleKeys {
if (!raw) {
return { exact: '', base: '', trailingYear: null };
}
const cleaned = raw
// Inner classes exclude the opening delimiter too, so runaway
// inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos)
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ')
.replace(LANGUAGE_PREFIX, '')
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.split(' ')
.filter((token) => token !== '' && !QUALITY_TAGS.has(token))
.join(' ')
.trim();
// Portal series list titles carry season suffixes ("The Boys s05");
// TMDB knows only the show title.
const stripSeason = (value: string) =>
value.replace(SEASON_SUFFIX_PATTERN, '').trim() || value;
const exact = stripSeason(cleaned);
// Trailing years are release tags ("The Matrix 1999"), but a year can
// also BE the title ("2012") — never normalize down to an empty string.
const yearMatch = cleaned.match(TRAILING_YEAR_PATTERN);
const withoutYear = cleaned
.replace(TRAILING_YEAR_PATTERN, '')
.replace(/\s+/g, ' ')
.trim();
if (!yearMatch || !withoutYear) {
return { exact, base: exact, trailingYear: null };
}
return {
exact,
base: stripSeason(withoutYear),
trailingYear: Number(yearMatch[1]),
};
}
export function normalizeTitle(raw: string | null | undefined): string {
return normalizeTitleKeys(raw).base;
}
/**
* True when a base-tier (year-stripped) title match is not contradicted by
* the known years of both sides. Unknown years never block a match.
*/
export function titleYearsCompatible(
a: number | null | undefined,
b: number | null | undefined
): boolean {
return (
a === null ||
a === undefined ||
b === null ||
b === undefined ||
Math.abs(a - b) <= 1
);
}
/** Extract a release year from a date string or from tags in a raw title */
export function extractYear(
releaseDate: string | null | undefined,
rawTitle?: string | null
): number | null {
const fromDate = releaseDate?.match(YEAR_PATTERN)?.[0];
if (fromDate) {
return Number(fromDate);
}
const fromTitle = rawTitle?.match(YEAR_PATTERN)?.[0];
return fromTitle ? Number(fromTitle) : null;
}
@@ -0,0 +1,74 @@
/**
* Shared contracts for the TMDB metadata enrichment subsystem.
*
* The renderer talks to the TMDB API directly (it supports CORS); these
* types describe the user-facing settings and the cache rows persisted in
* SQLite (Electron) or kept in memory (PWA).
*/
export type TmdbMediaType = 'movie' | 'tv';
/**
* Row namespace in the shared metadata cache table. Besides movie/tv
* details it also stores person payloads (`person:<id>` lookup keys).
*/
export type TmdbCacheMediaType = TmdbMediaType | 'person';
/**
* Opt-in TMDB enrichment settings. Enrichment sends movie/series titles to
* TMDB, so it is disabled by default for privacy.
*/
export interface TmdbSettings {
enabled: boolean;
/** Optional user-provided API key overriding the embedded default */
apiKey?: string;
}
export const DEFAULT_TMDB_SETTINGS: TmdbSettings = {
enabled: false,
apiKey: '',
};
/**
* Cast member attached to provider detail objects after TMDB enrichment.
* Rendered as avatar chips in the VOD/series detail views.
*/
export interface TmdbEnrichedCastMember {
name: string;
character?: string;
/** Full TMDB profile image URL, `null` when the actor has no photo */
profileUrl: string | null;
/** TMDB person id — makes the cast chip clickable (actor page) */
tmdbPersonId?: number;
}
/**
* A recommended/similar title attached to detail objects after TMDB
* enrichment. Detail views match these against the provider catalog and
* render a "Similar" rail from the hits.
*/
export interface TmdbRecommendation {
tmdbId: number;
title: string;
year: number | null;
posterUrl: string | null;
}
/**
* One cached TMDB lookup. Two kinds of rows share the table, discriminated
* by the `lookupKey` prefix:
* - `id:<tmdbId>` — full details payload for a TMDB id
* - `title:<normalized>|year:<year>` — search resolution result; a `null`
* `tmdbId` is a cached "no confident match" verdict (negative cache)
*/
export interface TmdbCacheEntry {
mediaType: TmdbCacheMediaType;
lookupKey: string;
/** TMDB language code the payload was fetched with, e.g. `en-US` */
language: string;
tmdbId: number | null;
/** Raw JSON of the TMDB response, `null` for negative match rows */
payload: string | null;
/** ISO timestamp set by the persistence layer */
fetchedAt?: string;
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more