mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
342175fe76
commit
dc05a2566e
113 files changed
+7855
-712
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "مدة الحلقة",
|
||||
|
||||
@@ -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": "مدة تشغيل الحلقة",
|
||||
|
||||
@@ -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": "Працягласць эпізоду",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Διάρκεια επεισοδίου",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "エピソード再生時間",
|
||||
|
||||
@@ -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": "에피소드 실행 시간",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Длительность эпизода",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "剧集运行时间",
|
||||
|
||||
@@ -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": "集數時長",
|
||||
|
||||
@@ -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 |
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+30
-2
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
+61
-2
@@ -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>
|
||||
}
|
||||
+1
@@ -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>();
|
||||
}
|
||||
|
||||
|
||||
+107
-11
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
Reference in new issue
Block a user