fix(dashboard): reuse the detail view's TMDB identity for activity rows (#1423)

An Xtream activity row is built from its `content` row, and the catalog
endpoints that create those rows carry only a title and a poster. So the
dashboard hero and the recommendations rail rebuilt their TMDB query from the
display title alone, while the detail view had searched with the original
title, the release date and often a TMDB id. Without a year
`pickConfidentMatch` requires a globally unique exact title, which common
titles never satisfy — "Inside Out" matches several films and resolves to
nothing, every time.

Three `content` columns close that gap next to the existing `backdrop_url`:
`tmdb_id`, `release_year`, `original_title`. The detail views back-fill them
from what is on screen, the activity SELECTs project them, and
`buildDashboardTmdbAttempts` reads them back. Stalker keeps stating the same
facts through its stored entry, and rows with neither keep the title-only
fallback.

Measured against a real profile before building: of 58 distinct Xtream
movie/series activity rows, 16 (28%) produce a year-less key — the cohort
where a miss is guaranteed rather than likely.

Contracts worth preserving:

- Per-column, never overwrite. Enrichment supplies the pieces at different
  times, so a row-level guard would let the first arrival block every later
  one forever.
- `release_year` is the year the PROVIDER stated. The TMDB merge fills the
  date field when the provider left it empty, so it marks its own
  substitution with `tmdb_supplied_release_date` and the extractor skips
  those — making contamination structurally impossible rather than avoided.
- The id is stored unvetted: every consumer re-gates it through
  `assessProviderId`, which re-decides per lookup where a write-time verdict
  would be permanent.
- No media-type column — for Xtream the catalog files movies and series
  apart, so `content.type` already is the media type.

Worker requests now await `getDatabase()` before dispatching. The renderer
loads before `initDatabase()` and the worker opens the database file without
running migrations, so a query issued during startup on an upgraded install
could otherwise hit a schema whose new columns do not exist yet.

Not covered: the PWA, whose catalog cache is rebuilt from the API on every
load, so a stored id would never outlive the detail view that resolved it.
This commit is contained in:
4gray authored and GitHub committed 2026-08-13 18:51:26 +02:00
1 parent 78587f95ba
commit 61fca6f016
51 files changed
+1376 -281

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: fix
area: dashboard
---
Movies and series you have opened before now show their artwork, rating and
genres on the dashboard far more reliably, and seed the recommendations rail.
Previously the dashboard searched TMDB by title alone, so common titles like
"Inside Out" matched nothing — it now reuses what the detail page already
found.
+2 -2
View File
@@ -685,7 +685,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- **Schema** (`libs/shared/database/src/lib/schema.ts` — canonical; `apps/electron-backend/src/app/database/schema.ts` is a backwards-compat re-export shim):
- `playlists` - Playlist metadata (M3U, Xtream, Stalker)
- `categories` - Content categories (live, movies, series)
- `content` - Streams/VOD/series items
- `content` - Streams/VOD/series items. Besides the catalog fields it carries what a detail view learned and handed back: `backdrop_url`, plus the TMDB identity (`tmdb_id`, `release_year`, `original_title`) that lets an activity row repeat the detail view's lookup instead of rebuilding a weaker one from the display title
- `favorites` - User favorites
- `recentlyViewed` - Watch history
- `epgChannels`, `epgPrograms` - Persisted EPG data
@@ -1340,7 +1340,7 @@ stream_id`); it drops `series_id`/`movie_id`, so the builder pins the
- The M3U player consumes it too: entries recognized as movie files open in the VOD detail shell fed purely by `enrichMovie` (no provider payload to merge); the extra `Settings.m3uVodDetails` toggle (default on) sits in the TMDB settings section — see "M3U Movie Recognition" above
- "Similar" rail in ALL 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`); cross-portal matches from other imported Xtream playlists supplement the Xtream rail and fully power the Stalker rail (`CrossPortalSimilarService` in `libs/services`, batched `DB_MATCH_TITLES`, Electron only); 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`); for single-season provider slices whose title carries an explicit season marker ("The Mandalorian (2 season)", "s02", "2 сезон"), the marker overrides the provider's renumbered season (`resolveEnrichmentSeasonNumber` in `libs/shared/interfaces/src/lib/season-marker.util.ts`)
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url`
- Dashboard: opt-in "Trending this week" rail (weekly TMDB trending matched against imported Xtream playlists via one batched `DB_MATCH_TITLES` request; Electron-only, `dashboardRails.tmdbTrending` toggle), a "Because you watched" recommendations rail (`dashboardRails.tmdbRecommendations` toggle; TMDB has no account-free "for you" endpoint, so `DashboardRecommendationsService` seeds per-title `recommendations` — already riding in every cached details payload — from up to 3 recently watched movies/series via the shared `dashboard-tmdb-lookup.util.ts` attempt builder, interleaves them, dedupes by TMDB id (title collisions are resolved after matching, by the catalog row, so same-titled remakes both reach the matcher), drops watched/favorited titles through a year-gated exclusion index built by the same lookup-attempt builder (so a Stalker embedded-VOD series indexes under `series:` despite routing as `movie`, and its stored `o_name` alias counts too; only the PRIMARY attempt is indexed, or a watched film would swallow the same-named show) on two title tiers (exact normalized title plus a year-gated base tier so a stored "Inception 2010" excludes TMDB's "Inception" while "Blade Runner 2049" does not swallow the 1982 film), keeps only year-compatible `DB_MATCH_TITLES` matches — matching/exclusion run through both the localized title and the TMDB original-title alias, and a year-incompatible first alias falls through to the other — and hides the rail below 5 cards while resetting the latch; successful loads are keyed by TMDB language + seed set + watched/favorited exclusion set + imported-playlist ids, an emptied history clears the rail, a mid-flight load request is queued, and a no-seed-resolved load retries instead of latching) and hero TMDB extras (backdrop fallback, rating + genre badges, memoized per lookup identity; series heroes show the tracked S/E badge from playback positions) — `DashboardTrendingService` in `libs/workspace/dashboard/data-access`, `DashboardHeroTmdbService` in `libs/workspace/dashboard/feature`; both load async after first paint. The hero lookup must carry the same identity the detail view used, not just the display title — `extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads title/original title/year/tmdb id off a stored Stalker entry; an unconfirmed Stalker `movie` verdict retries as `tv` without the id (the default answer earns a retry, and an id is valid only for its own media type), while a `tv` verdict — reached only on positive series evidence — gets no retry back to `movie`; a confirmed `movie` gets none either, and is confirmed by an Xtream `source` (that catalog files movies and series apart) or by a stored Stalker `info.tmdb_id` (never a provider claim, only a match this app already gated). The lookup key is the WHOLE attempt sequence, since two rows can share title/year/id yet differ in whether a `tv` fallback follows, and callers memoize by it. Stalker items never reach the `content` table, so their backdrop rides in the stored entry (`info.tmdb_backdrop`) rather than `content.backdrop_url`, and the activity mappers surface it as `backdrop_url`. Xtream rows carry the same identity on the `content` row: the detail views back-fill `tmdb_id`/`release_year`/`original_title` next to `backdrop_url` (`xtreamDetailContentMetadata` → `XtreamStore.backfillContentMetadata` → `DB_SET_CONTENT_METADATA_IF_MISSING` → `persistContentMetadataIfMissing`), the activity SELECTs project them onto `PortalActivityItem`, and `buildDashboardTmdbAttempts` reads them back. Writes are per-column and never overwrite (enrichment supplies the pieces at different times, so a row-level guard would let the first arrival block every later one); `release_year` is the year the PROVIDER stated, never one read out of the title (readers still apply that fallback themselves, so an absent column means "no provider date" — and "2001: A Space Odyssey" can never be frozen in as a 2001 film), which holds only because the TMDB merge marks the dates it substitutes itself with `tmdb_supplied_release_date` and the extractor skips those — the merge's other `tmdb_*` fields are conditional on having content, so they cannot serve as an "enrichment ran" signal; the id is stored unvetted because every consumer re-gates it through `assessProviderId`; and there is no media-type column, since for Xtream `content.type` already is the media type. Both sides validate through `normalizeContentMetadataPatch` (`libs/shared/interfaces`), so legacy rows, never-opened rows and provider junk all collapse to the title-only fallback — as does the PWA, whose catalog cache is rebuilt from the API on every load
- Series detail views show a TMDB production-status chip (`tmdb_status`, e.g. Ended / Returning) — TMDB sends `status` in English regardless of request language, so it is normalized to a token by `normalizeSeriesStatus` and rendered via `seriesStatusLabelKey` translations; person pages show `deathday` alongside `birthday`
- Actor pages: cast avatar chips are clickable (TMDB person id) and open `actor/:personId` inside the current portal — TMDB person bio + full filmography (acting + directing credits merged; acting wins the per-title dedup); director/creator chips (`tmdb_directors` via `enrichedDirectors`/`enrichedCreators` in `tmdb-credits.ts`) are clickable the same way and open the same person page; Xtream matches titles against the loaded catalog (direct navigation), unmatched titles and all Stalker titles open the portal search prefilled (`?q=`); the in-portal search page shows a Back button (`SearchLayoutComponent.showBackButton` → `Location.back()`) so users can return to the actor page; 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`
@@ -299,10 +299,16 @@ export const dbPreloadCases: PreloadInvokeCase[] = [
forwardedArgs: [42, playlistId, 'movie'],
},
{
method: 'dbSetContentBackdropIfMissing',
args: [12, 'https://image.example/backdrop.jpg'],
channel: 'DB_SET_CONTENT_BACKDROP_IF_MISSING',
forwardedArgs: [12, 'https://image.example/backdrop.jpg'],
method: 'dbSetContentMetadataIfMissing',
args: [
12,
{ backdropUrl: 'https://image.example/backdrop.jpg', tmdbId: 603 },
],
channel: 'DB_SET_CONTENT_METADATA_IF_MISSING',
forwardedArgs: [
12,
{ backdropUrl: 'https://image.example/backdrop.jpg', tmdbId: 603 },
],
},
{
method: 'dbDeleteAllPlaylists',
@@ -24,6 +24,7 @@ import {
toXtreamPreloadPerformanceTargetMethod,
} from './xtream-preload-performance-capture';
import type {
ContentMetadataPatch,
DownloadMetadataSnapshot,
EmbeddedMpvBounds,
EmbeddedMpvRecordingStartOptions,
@@ -897,11 +898,14 @@ const electronApi: ElectronBridgeApi = {
playlistId,
contentType
),
dbSetContentBackdropIfMissing: (contentId: number, backdropUrl?: string) =>
dbSetContentMetadataIfMissing: (
contentId: number,
patch?: ContentMetadataPatch
) =>
ipcRenderer.invoke(
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
'DB_SET_CONTENT_METADATA_IF_MISSING',
contentId,
backdropUrl
patch
),
dbDeleteAllPlaylists: (operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS', operationId),
@@ -1,93 +0,0 @@
const andMock = jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
}));
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
const sqlMock = jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
kind: 'sql',
strings,
values,
}));
jest.mock('drizzle-orm', () => ({
and: (...conditions: unknown[]) => andMock(...conditions),
eq: (left: unknown, right: unknown) => eqMock(left, right),
sql: (strings: TemplateStringsArray, ...values: unknown[]) =>
sqlMock(strings, ...values),
}));
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { setContentBackdropIfMissing } from './content-backdrop.operations';
function createDbMock() {
const where = jest.fn().mockResolvedValue(undefined);
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
return {
db: {
update,
} as unknown as AppDatabase,
set,
update,
where,
};
}
describe('content-backdrop.operations', () => {
beforeEach(() => {
andMock.mockClear();
eqMock.mockClear();
sqlMock.mockClear();
});
it('populates the content backdrop when a non-empty url is provided', async () => {
const { db, set, update, where } = createDbMock();
await expect(
setContentBackdropIfMissing(
db,
42,
' https://example.com/backdrop.jpg '
)
).resolves.toEqual({ success: true });
expect(update).toHaveBeenCalledWith(schema.content);
expect(set).toHaveBeenCalledWith({
backdropUrl: 'https://example.com/backdrop.jpg',
});
expect(eqMock).toHaveBeenCalledWith(schema.content.id, 42);
expect(where.mock.calls[0][0].conditions).toHaveLength(2);
});
it('skips blank urls without touching content or recency tables', async () => {
const { db, update } = createDbMock();
await expect(
setContentBackdropIfMissing(db, 42, ' ')
).resolves.toEqual({ success: true });
expect(update).not.toHaveBeenCalled();
});
it('never updates recently viewed timestamps for backdrop-only backfill', async () => {
const { db, update } = createDbMock();
await setContentBackdropIfMissing(
db,
42,
'https://example.com/backdrop.jpg'
);
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(schema.content);
expect(update).not.toHaveBeenCalledWith(
schema.recentlyViewed as unknown as never
);
});
});
@@ -1,33 +0,0 @@
import { and, eq, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
export async function persistContentBackdropIfMissing(
db: AppDatabase,
contentId: number,
backdropUrl?: string
): Promise<void> {
const normalizedBackdropUrl = backdropUrl?.trim();
if (!normalizedBackdropUrl) {
return;
}
await db
.update(schema.content)
.set({ backdropUrl: normalizedBackdropUrl })
.where(
and(
eq(schema.content.id, contentId),
sql`(${schema.content.backdropUrl} IS NULL OR ${schema.content.backdropUrl} = '')`
)
);
}
export async function setContentBackdropIfMissing(
db: AppDatabase,
contentId: number,
backdropUrl?: string
): Promise<{ success: boolean }> {
await persistContentBackdropIfMissing(db, contentId, backdropUrl);
return { success: true };
}
@@ -0,0 +1,175 @@
const andMock = jest.fn((...conditions: unknown[]) => ({
kind: 'and',
conditions,
}));
const eqMock = jest.fn((left: unknown, right: unknown) => ({
kind: 'eq',
left,
right,
}));
const orMock = jest.fn((...conditions: unknown[]) => ({
kind: 'or',
conditions,
}));
const sqlMock = jest.fn(
(strings: TemplateStringsArray, ...values: unknown[]) => ({
kind: 'sql',
strings,
values,
})
);
jest.mock('drizzle-orm', () => ({
and: (...conditions: unknown[]) => andMock(...conditions),
eq: (left: unknown, right: unknown) => eqMock(left, right),
or: (...conditions: unknown[]) => orMock(...conditions),
sql: (strings: TemplateStringsArray, ...values: unknown[]) =>
sqlMock(strings, ...values),
}));
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { setContentMetadataIfMissing } from './content-metadata.operations';
function createDbMock() {
const where = jest.fn().mockResolvedValue(undefined);
const set = jest.fn().mockReturnValue({ where });
const update = jest.fn().mockReturnValue({ set });
return {
db: { update } as unknown as AppDatabase,
set,
update,
where,
};
}
/** The literal SQL text of a `sql` template, with its interpolations elided */
function sqlText(value: unknown): string {
const node = value as { strings?: TemplateStringsArray };
return node.strings ? node.strings.join('?') : '';
}
/** The interpolated values of a `sql` template, columns excluded */
function sqlValues(value: unknown): unknown[] {
return (value as { values: unknown[] }).values;
}
describe('content-metadata.operations', () => {
beforeEach(() => {
andMock.mockClear();
eqMock.mockClear();
orMock.mockClear();
sqlMock.mockClear();
});
it('writes the whole patch in one statement', async () => {
const { db, set, update } = createDbMock();
await expect(
setContentMetadataIfMissing(db, 42, {
backdropUrl: ' https://example.com/backdrop.jpg ',
tmdbId: 603,
releaseYear: 1999,
originalTitle: ' The Matrix ',
})
).resolves.toEqual({ success: true });
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(schema.content);
const written = set.mock.calls[0][0];
expect(Object.keys(written).sort()).toEqual([
'backdropUrl',
'originalTitle',
'releaseYear',
'tmdbId',
]);
// Values are trimmed by the shared normalizer before they get here
expect(sqlValues(written.backdropUrl)).toContain(
'https://example.com/backdrop.jpg'
);
expect(sqlValues(written.originalTitle)).toContain('The Matrix');
expect(sqlValues(written.tmdbId)).toContain(603);
expect(sqlValues(written.releaseYear)).toContain(1999);
});
it('preserves a column that already holds a value', async () => {
const { db, set } = createDbMock();
await setContentMetadataIfMissing(db, 42, {
backdropUrl: 'https://example.com/backdrop.jpg',
tmdbId: 603,
});
// One statement covers every requested column, so each assignment has
// to defend its own column: without COALESCE, filling an empty
// backdrop would also overwrite an id resolved earlier.
for (const assignment of Object.values(set.mock.calls[0][0])) {
expect(sqlText(assignment)).toContain('COALESCE');
expect(sqlText(assignment)).toContain('NULLIF');
}
});
it('only updates rows where at least one requested column is absent', async () => {
const { db, where } = createDbMock();
await setContentMetadataIfMissing(db, 42, {
backdropUrl: 'https://example.com/backdrop.jpg',
tmdbId: 603,
});
expect(eqMock).toHaveBeenCalledWith(schema.content.id, 42);
const conditions = where.mock.calls[0][0].conditions;
expect(conditions).toHaveLength(2);
// An OR, not an AND: a row missing only the id must still be updated,
// or the first field to arrive would block every later one forever.
expect(orMock).toHaveBeenCalledTimes(1);
expect(orMock.mock.calls[0]).toHaveLength(2);
});
it('treats an empty string as an absent text column but not an integer', async () => {
const { db } = createDbMock();
await setContentMetadataIfMissing(db, 42, {
backdropUrl: 'https://example.com/backdrop.jpg',
tmdbId: 603,
});
const guards = orMock.mock.calls[0].map(sqlText);
expect(guards.some((guard) => guard.includes("= ''"))).toBe(true);
expect(
guards.filter((guard) => guard.includes("= ''"))
).toHaveLength(1);
});
it.each([
['an undefined patch', undefined],
['an empty patch', {}],
['blank strings', { backdropUrl: ' ', originalTitle: ' ' }],
['a non-positive id', { tmdbId: 0 }],
['an implausible year', { releaseYear: 12 }],
])('skips the write entirely for %s', async (_label, patch) => {
const { db, update } = createDbMock();
await expect(
setContentMetadataIfMissing(db, 42, patch)
).resolves.toEqual({ success: true });
expect(update).not.toHaveBeenCalled();
});
it('never touches recently viewed while backfilling metadata', async () => {
const { db, update } = createDbMock();
await setContentMetadataIfMissing(db, 42, {
backdropUrl: 'https://example.com/backdrop.jpg',
});
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith(schema.content);
expect(update).not.toHaveBeenCalledWith(
schema.recentlyViewed as unknown as never
);
});
});
@@ -0,0 +1,116 @@
import { and, eq, or, sql, type SQL } from 'drizzle-orm';
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';
import * as schema from '@iptvnator/shared/database/schema';
import {
ContentMetadataPatch,
normalizeContentMetadataPatch,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
/**
* One patchable `content` column: the Drizzle field to write, how to
* recognise that it holds no value yet, and what to put there.
*
* Text columns count `''` as absent — provider payloads routinely carry empty
* strings where a field is unknown, and an empty backdrop is not a backdrop.
* Integer columns only count SQL NULL; `0` cannot reach here, since the patch
* normalizer rejects non-positive ids and out-of-range years.
*/
interface PatchableColumn {
key: 'backdropUrl' | 'tmdbId' | 'releaseYear' | 'originalTitle';
column: SQLiteColumn;
value: string | number;
isText: boolean;
}
function patchableColumns(patch: ContentMetadataPatch): PatchableColumn[] {
const candidates: PatchableColumn[] = [
{
key: 'backdropUrl',
column: schema.content.backdropUrl,
value: patch.backdropUrl as string,
isText: true,
},
{
key: 'tmdbId',
column: schema.content.tmdbId,
value: patch.tmdbId as number,
isText: false,
},
{
key: 'releaseYear',
column: schema.content.releaseYear,
value: patch.releaseYear as number,
isText: false,
},
{
key: 'originalTitle',
column: schema.content.originalTitle,
value: patch.originalTitle as string,
isText: true,
},
];
return candidates.filter(({ value }) => value !== undefined);
}
/** `col IS NULL` — plus `col = ''` for text columns */
function isAbsent({ column, isText }: PatchableColumn): SQL {
return isText
? sql`(${column} IS NULL OR ${column} = '')`
: sql`${column} IS NULL`;
}
/**
* Fill in the facts an Xtream detail view learned about a `content` row,
* one column at a time and only where the row has nothing yet.
*
* Per-column rather than per-row: enrichment supplies the pieces at different
* times — the release date and original title arrive with the provider's
* detail response, the TMDB id only once enrichment resolves one (and never,
* if the user leaves enrichment off). A row-level "already populated" guard
* would let whichever piece landed first block all the others forever.
*
* Existing values are never overwritten, so the first detail open wins and
* repeat opens are no-ops. That is what makes this safe to call
* unconditionally from the detail views' backfill effect.
*/
export async function persistContentMetadataIfMissing(
db: AppDatabase,
contentId: number,
patch?: ContentMetadataPatch
): Promise<void> {
const normalized = normalizeContentMetadataPatch(patch);
if (!normalized) {
return;
}
const columns = patchableColumns(normalized);
// `or()` only returns undefined for an empty list, which a non-null
// normalized patch rules out.
const anyAbsent = or(...columns.map(isAbsent)) as SQL;
await db
.update(schema.content)
.set(
Object.fromEntries(
columns.map(({ key, column, value }) => [
key,
// Every requested column is written in the one statement
// the `anyAbsent` guard admits, so a column that already
// holds a value must preserve it here — otherwise filling
// an empty neighbour would clobber it.
sql`COALESCE(NULLIF(${column}, ''), ${value})`,
])
)
)
.where(and(eq(schema.content.id, contentId), anyAbsent));
}
export async function setContentMetadataIfMissing(
db: AppDatabase,
contentId: number,
patch?: ContentMetadataPatch
): Promise<{ success: boolean }> {
await persistContentMetadataIfMissing(db, contentId, patch);
return { success: true };
}
@@ -7,7 +7,7 @@ import {
type OperationControl,
reportOperationProgress,
} from './operation-control';
import { persistContentBackdropIfMissing } from './content-backdrop.operations';
import { persistContentMetadataIfMissing } from './content-metadata.operations';
const DEFAULT_BATCH_SIZE = 100;
@@ -22,7 +22,9 @@ export async function addFavorite(
playlistId,
});
await persistContentBackdropIfMissing(db, contentId, options?.backdropUrl);
await persistContentMetadataIfMissing(db, contentId, {
backdropUrl: options?.backdropUrl,
});
return { success: true };
}
@@ -115,7 +117,12 @@ function selectGlobalFavoriteRows(
added: schema.content.added,
poster_url: schema.content.posterUrl,
...(options.includeBackdrop
? { backdrop_url: schema.content.backdropUrl }
? {
backdrop_url: schema.content.backdropUrl,
tmdb_id: schema.content.tmdbId,
release_year: schema.content.releaseYear,
original_title: schema.content.originalTitle,
}
: {}),
xtream_id: schema.content.xtreamId,
type: schema.content.type,
@@ -7,12 +7,12 @@ import {
jest.mock('drizzle-orm', () => mockDrizzleOrmModule());
jest.mock('./content-backdrop.operations', () => ({
persistContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
jest.mock('./content-metadata.operations', () => ({
persistContentMetadataIfMissing: jest.fn().mockResolvedValue(undefined),
}));
import * as schema from '@iptvnator/shared/database/schema';
import { persistContentBackdropIfMissing } from './content-backdrop.operations';
import { persistContentMetadataIfMissing } from './content-metadata.operations';
import {
addRecentItem,
clearPlaylistRecentItems,
@@ -26,7 +26,7 @@ import {
describe('recently-viewed.operations', () => {
beforeEach(() => {
resetDrizzleMocks();
(persistContentBackdropIfMissing as jest.Mock).mockClear();
(persistContentMetadataIfMissing as jest.Mock).mockClear();
});
describe('reading recent items', () => {
@@ -98,10 +98,10 @@ describe('recently-viewed.operations', () => {
playlistId: 'playlist-1',
});
expect(update).not.toHaveBeenCalled();
expect(persistContentBackdropIfMissing).toHaveBeenCalledWith(
expect(persistContentMetadataIfMissing).toHaveBeenCalledWith(
db,
42,
'https://example.com/backdrop.jpg'
{ backdropUrl: 'https://example.com/backdrop.jpg' }
);
});
@@ -128,10 +128,10 @@ describe('recently-viewed.operations', () => {
schema.recentlyViewed.playlistId,
'playlist-1'
);
expect(persistContentBackdropIfMissing).toHaveBeenCalledWith(
expect(persistContentMetadataIfMissing).toHaveBeenCalledWith(
db,
42,
undefined
{ backdropUrl: undefined }
);
});
});
@@ -1,7 +1,7 @@
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { persistContentBackdropIfMissing } from './content-backdrop.operations';
import { persistContentMetadataIfMissing } from './content-metadata.operations';
export async function getRecentlyViewed(db: AppDatabase) {
return db
@@ -13,6 +13,9 @@ export async function getRecentlyViewed(db: AppDatabase) {
added: schema.content.added,
poster_url: schema.content.posterUrl,
backdrop_url: schema.content.backdropUrl,
tmdb_id: schema.content.tmdbId,
release_year: schema.content.releaseYear,
original_title: schema.content.originalTitle,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
tv_archive: schema.content.tvArchive,
@@ -58,6 +61,9 @@ export async function getRecentItems(
added: schema.content.added,
poster_url: schema.content.posterUrl,
backdrop_url: schema.content.backdropUrl,
tmdb_id: schema.content.tmdbId,
release_year: schema.content.releaseYear,
original_title: schema.content.originalTitle,
xtream_id: schema.content.xtreamId,
type: schema.content.type,
tv_archive: schema.content.tvArchive,
@@ -108,7 +114,9 @@ export async function addRecentItem(
});
}
await persistContentBackdropIfMissing(db, contentId, options?.backdropUrl);
await persistContentMetadataIfMissing(db, contentId, {
backdropUrl: options?.backdropUrl,
});
return { success: true };
}
@@ -9,6 +9,7 @@ import {
requestWorkerWithEvents,
} from './worker-events.utils';
import type {
ContentMetadataPatch,
GlobalSearchPaginationOptions,
GlobalSearchResultSource,
} from '@iptvnator/shared/interfaces';
@@ -96,10 +97,10 @@ handleWorkerRequest(
);
handleWorkerRequest(
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
(contentId: number, backdropUrl?: string) => ({
'DB_SET_CONTENT_METADATA_IF_MISSING',
(contentId: number, patch?: ContentMetadataPatch) => ({
contentId,
backdropUrl,
patch,
})
);
@@ -1,10 +1,33 @@
import { ipcMain } from 'electron';
import { getDatabase } from '../../database/connection';
import { databaseWorkerClient } from '../../services/database-worker-client';
import type {
DbOperationEvent,
DbWorkerOperation,
} from '../../workers/database-worker.types';
/**
* Block until the main process has created and migrated the schema.
*
* The renderer is loaded BEFORE `initDatabase()` runs (deliberately — see
* `main.ts`), and the worker opens the database file directly: it runs no
* `CREATE TABLE` and no `ALTER TABLE`, and shares no promise with the main
* process. So without this, a worker query issued during startup can reach
* an upgraded install whose new columns do not exist yet, and SQLite rejects
* it with "no such column" — the dashboard then renders an empty activity
* list until the next reload.
*
* This is per-column-addition damage, not specific to any one migration, so
* the wait belongs at the single choke point every worker request passes
* through rather than in the queries that happen to read a new column.
* `getDatabase()` resolves the shared init promise, so this costs one await
* on the first request and nothing afterwards — the same lazy contract the
* main-process handlers already rely on.
*/
async function awaitSchemaReady(): Promise<void> {
await getDatabase();
}
export function forwardWorkerEvent(
event: Electron.IpcMainInvokeEvent,
workerEvent: DbOperationEvent
@@ -22,6 +45,7 @@ export function handleWorkerRequest<TArgs extends unknown[]>(
): void {
ipcMain.handle(channel, async (_event, ...args: TArgs) => {
try {
await awaitSchemaReady();
return await databaseWorkerClient.request(
channel,
buildPayload(...args)
@@ -38,6 +62,7 @@ export async function requestWorkerWithEvents<TResult, TPayload>(
channel: DbWorkerOperation,
payload: TPayload
): Promise<TResult> {
await awaitSchemaReady();
return databaseWorkerClient.request<TResult>(channel, payload, {
onEvent: (workerEvent) => forwardWorkerEvent(event, workerEvent),
});
@@ -19,6 +19,12 @@ const recentlyViewed = [{ contentId: 2, playlistId }];
const categoryIds = [10, 11];
const reorderUpdates = [{ content_id: 12, position: 1 }];
const recentItemsBatch = [{ contentId: 13, playlistId }];
const contentMetadataPatch = {
backdropUrl: 'https://image.example/backdrop.jpg',
tmdbId: 603,
releaseYear: 1999,
originalTitle: 'The Matrix',
};
const playbackData = {
contentXtreamId: 42,
contentType: 'vod',
@@ -162,12 +168,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
payload: { xtreamId: 42, playlistId, contentType: 'movie' },
},
{
operation: 'DB_SET_CONTENT_BACKDROP_IF_MISSING',
args: [12, 'https://image.example/backdrop.jpg'],
payload: {
contentId: 12,
backdropUrl: 'https://image.example/backdrop.jpg',
},
operation: 'DB_SET_CONTENT_METADATA_IF_MISSING',
// Same object on both sides: the patch must cross the boundary
// unreshaped, or the worker reads fields the caller never sent.
args: [12, contentMetadataPatch],
payload: { contentId: 12, patch: contentMetadataPatch },
},
{
operation: 'DB_SEARCH_CONTENT',
@@ -21,6 +21,7 @@ type MockIpcEvent = {
const mockRegisteredHandlers = new Map<string, IpcHandler>();
const mockWorkerRequest = jest.fn();
const mockWorkerCancel = jest.fn();
const mockGetDatabase = jest.fn();
jest.mock('electron', () => ({
ipcMain: {
@@ -37,6 +38,10 @@ jest.mock('../../services/database-worker-client', () => ({
},
}));
jest.mock('../../database/connection', () => ({
getDatabase: (...args: unknown[]) => mockGetDatabase(...args),
}));
async function importDatabaseEventModules(): Promise<void> {
await import('./category.events');
await import('./content.events');
@@ -80,6 +85,7 @@ describe('database worker IPC contract', () => {
mockRegisteredHandlers.clear();
mockWorkerRequest.mockReset().mockResolvedValue({ success: true });
mockWorkerCancel.mockReset().mockResolvedValue({ success: true });
mockGetDatabase.mockReset().mockResolvedValue({});
await importDatabaseEventModules();
});
@@ -116,6 +122,41 @@ describe('database worker IPC contract', () => {
}
);
it('waits for the migrated schema before dispatching to the worker', async () => {
// The renderer loads BEFORE `initDatabase()` in main.ts, and the
// worker opens the database file without running any migration. So a
// query issued during startup on an upgraded install could reach a
// schema whose newly added columns do not exist yet, and SQLite would
// reject it with "no such column" — emptying dashboard activity until
// the next reload. `getDatabase()` resolves the shared init promise.
let schemaReady = false;
mockGetDatabase.mockImplementation(async () => {
schemaReady = true;
return {};
});
mockWorkerRequest.mockImplementation(async () => {
expect(schemaReady).toBe(true);
return { success: true };
});
await getHandler('DB_GET_RECENTLY_VIEWED')(createIpcEvent());
expect(mockGetDatabase).toHaveBeenCalled();
expect(mockWorkerRequest).toHaveBeenCalled();
});
it('never reaches the worker when the schema cannot be prepared', async () => {
// Failing here rather than querying an unmigrated database: the query
// would fail anyway, but with an error naming a missing column instead
// of the real cause.
mockGetDatabase.mockRejectedValue(new Error('disk full'));
await expect(
getHandler('DB_GET_RECENTLY_VIEWED')(createIpcEvent())
).rejects.toThrow('disk full');
expect(mockWorkerRequest).not.toHaveBeenCalled();
});
it('covers every worker operation in the payload contract cases', () => {
expect(
new Set(workerIpcContractCases.map(({ operation }) => operation))
@@ -12,7 +12,7 @@ export const DB_WORKER_OPERATIONS = [
'DB_SAVE_CONTENT',
'DB_CLEAR_XTREAM_IMPORT_CACHE',
'DB_GET_CONTENT_BY_XTREAM_ID',
'DB_SET_CONTENT_BACKDROP_IF_MISSING',
'DB_SET_CONTENT_METADATA_IF_MISSING',
'DB_SEARCH_CONTENT',
'DB_GLOBAL_SEARCH',
'DB_CREATE_PLAYLIST',
@@ -4,6 +4,7 @@ import {
} from './database.worker-connection';
import { parentPort } from 'worker_threads';
import type {
ContentMetadataPatch,
VodSourcePin,
XtreamBackupFavoriteItem,
XtreamBackupRecentlyViewedItem,
@@ -44,7 +45,7 @@ import {
saveContent,
searchContent,
} from '../database/operations/content.operations';
import { setContentBackdropIfMissing } from '../database/operations/content-backdrop.operations';
import { setContentMetadataIfMissing } from '../database/operations/content-metadata.operations';
import {
clearAllPlaybackPositions,
clearPlaybackPosition,
@@ -557,15 +558,15 @@ async function executeRequest(
);
}
case 'DB_SET_CONTENT_BACKDROP_IF_MISSING': {
case 'DB_SET_CONTENT_METADATA_IF_MISSING': {
const payload = message.payload as {
contentId: number;
backdropUrl?: string;
patch?: ContentMetadataPatch;
};
return setContentBackdropIfMissing(
return setContentMetadataIfMissing(
db,
payload.contentId,
payload.backdropUrl
payload.patch
);
}
@@ -563,6 +563,8 @@ since shipped.)
`extractStalkerItemTmdbHints` (`libs/shared/interfaces`) reads
`info.name`, `info.o_name`, `info.releasedate` and `info.tmdb_id` off the
stored entry, mirroring `enrichStalkerSelectionWithTmdb` field for field.
Xtream rows carry them on the `content` row instead — see
[Identity on Xtream content rows](#identity-on-xtream-content-rows).
A `'movie'` verdict gets a second attempt under `'tv'` — `'movie'` is what
every row falls back to when nothing says otherwise, and an embedded-VOD
series is a `'movie'` activity row but a show on TMDB. That retry **drops
@@ -576,6 +578,71 @@ since shipped.)
portals never send one; its only source is a match this app already made
and gated.
### Identity on Xtream content rows
An Xtream activity row (recently viewed, favorites) is built from its
`content` row, and the catalog endpoints that create those rows carry only a
title and a poster. So a dashboard lookup used to be rebuilt from the display
title alone, while the detail view had searched with the original title, the
release date and often a TMDB id.
Three `content` columns close that gap — `tmdb_id`, `release_year`,
`original_title`, alongside the existing `backdrop_url`. The detail views
back-fill them from what is on screen
(`xtreamDetailContentMetadata` in `libs/portal/xtream/data-access`) through
`XtreamStore.backfillContentMetadata` →
`DB_SET_CONTENT_METADATA_IF_MISSING` →
`persistContentMetadataIfMissing`. The activity SELECTs project them,
`dashboard-mappers.ts` puts them on `PortalActivityItem`, and
`buildDashboardTmdbAttempts` reads them back.
Contracts worth keeping:
- **Per-column, never overwrite.** Enrichment supplies the pieces at
different times — the release date and original title arrive with the
provider's detail response, the id only once enrichment resolves one (and
never, with enrichment off). A row-level "already populated" guard would
let whichever piece landed first block all the others forever.
- **`release_year` is the year the PROVIDER stated**, never one read out of
the title. Readers already apply that fallback themselves, so an absent
column means "the provider gave no date" rather than "nobody looked" — and
a title year like "2001: A Space Odyssey" can never be frozen into the row
as that film's release year.
Keeping that true needs one thing from the merge. `xtreamDetailContentMetadata`
runs against the object the detail view is RENDERING, and
`mergeVodInfoWithTmdb`/`mergeSerieInfoWithTmdb` fill `releasedate`/
`releaseDate` from `details.release_date`/`first_air_date` whenever the
provider left them empty — silently, so afterwards the field alone cannot
say who stated the date. The merge therefore marks its own substitution
with `tmdb_supplied_release_date`, and the extractor skips the year when it
is set. Sniffing for enrichment instead would not work: the `tmdb_*` fields
the merge adds are all conditional on having content, so a film with no
credits and no recommendations carries none of them and reads as
un-enriched. Since the column is never overwritten, getting this wrong is
unfixable after the fact — a real provider date arriving later cannot
correct it. The same marker is what `trustedReleaseYear` (the
recommendations exclusion index) must consult if it ever reads this column.
- **The id is stored unvetted.** Every consumer reaches TMDB through
`TmdbEnrichmentService`, whose `detailsForProviderId` runs
`assessProviderId` and lets the title search take over when the years
contradict. Vetting on write would record one verdict permanently where
the shared gate re-decides per lookup.
- **No media-type column.** For Xtream the catalog files movies and series
apart, so `content.type` already is the media type. Stalker needs one
because its embedded-VOD series are stored as movies — hence the field on
`StalkerItemTmdbHints` and not here.
- **Both sides validate through `normalizeContentMetadataPatch`**
(`libs/shared/interfaces`), so a legacy row, a row whose detail page has
never been opened, and a provider's `"0"` all collapse to the same thing:
no identity, and the title-only fallback.
Not covered: rows whose detail page has never been opened, playlists
refreshed after this landed (the columns are re-learned on the next detail
open, same as `backdrop_url`), and the PWA — its catalog is a session-scoped
cache rebuilt from the API on every load, so a stored id would never outlive
the detail view that resolved it. All of them keep the title-only path.
### Stalker backdrops on activity rows
Xtream detail views back-fill `content.backdrop_url`, which the dashboard
@@ -4,4 +4,6 @@ export * from './lib/services';
export * from './lib/stores';
export * from './lib/with-favorites.feature';
export { withRecentItems } from './lib/with-recent-items';
export * from './lib/content-metadata-backfill';
export * from './lib/xtream-content-metadata.util';
export * from './lib/xtream-state';
@@ -0,0 +1,71 @@
import { Signal, effect, signal } from '@angular/core';
import { ContentMetadataPatch } from '@iptvnator/shared/interfaces';
import {
XtreamDetailMetadataSource,
xtreamContentMetadataKey,
xtreamDetailContentMetadata,
} from './xtream-content-metadata.util';
/** What the backfill needs off the store, kept structural to avoid a cycle */
interface ContentMetadataBackfillStore {
currentPlaylist: Signal<{ id: string } | null | undefined>;
backfillContentMetadata(input: {
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string } | null | undefined>;
patch: ContentMetadataPatch;
}): Promise<void>;
}
/**
* Hand back to the `content` row what this detail view learned about the
* item — its backdrop, and the identity a dashboard lookup needs to repeat
* this view's TMDB query instead of rebuilding a weaker one from the title.
*
* Registers an effect, so it must be called from an injection context.
*
* The VOD and series detail views differ only in where they read the id and
* the detail payload from, so they share this rather than each keeping a
* copy: both are near the file-size limit, and a duplicated effect is how the
* guard below drifts apart between them.
*/
export function registerContentMetadataBackfill(options: {
store: ContentMetadataBackfillStore;
contentType: 'movie' | 'series';
playlistId: () => string | null | undefined;
xtreamId: () => number;
info: () => XtreamDetailMetadataSource | null | undefined;
}): void {
const lastKey = signal<string | null>(null);
effect(() => {
const playlistId = options.playlistId();
const xtreamId = options.xtreamId();
const patch = xtreamDetailContentMetadata(options.info());
if (
!playlistId ||
!Number.isFinite(xtreamId) ||
xtreamId <= 0 ||
!patch
) {
return;
}
// Keyed on the WHOLE patch: this re-runs as enrichment fills the TMDB
// id in, and a key built from the backdrop alone would suppress the
// write carrying the id that arrives moments later.
const key = `${playlistId}:${xtreamId}:${xtreamContentMetadataKey(patch)}`;
if (lastKey() === key) {
return;
}
lastKey.set(key);
void options.store.backfillContentMetadata({
xtreamId,
contentType: options.contentType,
playlist: options.store.currentPlaylist,
patch,
});
});
}
@@ -47,7 +47,7 @@ export function createDbServiceMock() {
removeRecentItem: jest.fn().mockResolvedValue(undefined),
clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined),
getContentByXtreamId: jest.fn().mockResolvedValue(null),
setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
setContentMetadataIfMissing: jest.fn().mockResolvedValue(undefined),
deleteXtreamPlaylistContent: jest.fn().mockResolvedValue({
hiddenCategories: [],
favorites: [],
@@ -7,6 +7,7 @@ import {
VodSourcePinService,
} from '@iptvnator/services';
import {
ContentMetadataPatch,
PlaybackPositionData,
PlaylistMeta,
XtreamPendingRestoreState,
@@ -460,16 +461,13 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
);
}
async setContentBackdropIfMissing(
async setContentMetadataIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
patch: ContentMetadataPatch
): Promise<void> {
void playlistId;
await this.dbService.setContentBackdropIfMissing(
contentId,
backdropUrl
);
await this.dbService.setContentMetadataIfMissing(contentId, patch);
}
// =========================================================================
@@ -92,15 +92,16 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
'movie'
);
await harness.dataSource.setContentBackdropIfMissing(
const patch = { backdropUrl: 'https://example.com/backdrop.png' };
await harness.dataSource.setContentMetadataIfMissing(
1,
playlistId,
'https://example.com/backdrop.png'
patch
);
// playlistId is intentionally not forwarded for the Electron DB call
expect(
harness.dbService.setContentBackdropIfMissing
).toHaveBeenCalledWith(1, 'https://example.com/backdrop.png');
harness.dbService.setContentMetadataIfMissing
).toHaveBeenCalledWith(1, patch);
});
});
@@ -498,11 +498,9 @@ describe('PwaXtreamDataSource', () => {
localStorage.getItem('xtream-recent-items') || '{}'
)['playlist-1'][0].viewedAt;
await dataSource.setContentBackdropIfMissing(
202,
'playlist-1',
' https://example.com/backdrop.png '
);
await dataSource.setContentMetadataIfMissing(202, 'playlist-1', {
backdropUrl: ' https://example.com/backdrop.png ',
});
const stored = JSON.parse(
localStorage.getItem('xtream-recent-items') || '{}'
@@ -1,5 +1,6 @@
import { inject, Injectable, Injector } from '@angular/core';
import {
ContentMetadataPatch,
Playlist,
PlaybackPositionData,
XtreamPendingRestoreState,
@@ -1165,13 +1166,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource {
return null;
}
async setContentBackdropIfMissing(
/**
* Only the backdrop is retained here. The identity fields exist to spare
* a later reader from rebuilding a weaker TMDB query than the detail view
* used, and that reader is the dashboard reading a persisted `content`
* row — which the PWA has no equivalent of. Its catalog lives in a
* session-scoped cache that is rebuilt from the API on every load, so a
* stored id would never outlive the detail view that resolved it. PWA
* lookups stay on the documented title-only fallback.
*/
async setContentMetadataIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
patch: ContentMetadataPatch
): Promise<void> {
const normalizedContentId = this.normalizeStoredId(contentId);
const normalizedBackdropUrl = backdropUrl.trim();
const normalizedBackdropUrl = patch.backdropUrl?.trim();
if (normalizedContentId == null || !normalizedBackdropUrl) {
return;
}
@@ -1,5 +1,6 @@
import { InjectionToken } from '@angular/core';
import {
ContentMetadataPatch,
PlaybackPositionData,
XtreamPendingRestoreState,
XtreamCategory,
@@ -49,6 +50,9 @@ export interface XtreamContentItem {
added: string;
poster_url: string;
backdrop_url?: string | null;
tmdb_id?: number | null;
release_year?: number | null;
original_title?: string | null;
epg_channel_id?: string | null;
tv_archive?: number | null;
tv_archive_duration?: number | null;
@@ -361,13 +365,16 @@ export interface IXtreamDataSource {
): Promise<XtreamContentItem | null>;
/**
* Persist a backdrop URL for an already-known content item without changing
* favorites or recent ordering.
* Persist what a detail view learned about an already-known content item —
* its backdrop, and the identity (TMDB id, release year, original title)
* that lets the dashboard repeat this view's lookup instead of rebuilding
* a weaker one from the display title. Never changes favorites or recent
* ordering, and never overwrites a column that already has a value.
*/
setContentBackdropIfMissing(
setContentMetadataIfMissing(
contentId: number,
playlistId: string,
backdropUrl: string
patch: ContentMetadataPatch
): Promise<void>;
// =========================================================================
@@ -26,7 +26,7 @@ describe('withRecentItems', () => {
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
setContentBackdropIfMissing: jest.Mock;
setContentMetadataIfMissing: jest.Mock;
};
let dataSource: {
addRecentItem: jest.Mock;
@@ -34,7 +34,7 @@ describe('withRecentItems', () => {
getContentByXtreamId: jest.Mock;
getRecentItems: jest.Mock;
removeRecentItem: jest.Mock;
setContentBackdropIfMissing: jest.Mock;
setContentMetadataIfMissing: jest.Mock;
};
let playlistsService: {
clearPlaylistRecentlyViewed: jest.Mock;
@@ -64,7 +64,7 @@ describe('withRecentItems', () => {
},
]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
setContentMetadataIfMissing: jest.fn().mockResolvedValue(undefined),
};
dataSource = {
addRecentItem: jest.fn().mockResolvedValue(undefined),
@@ -83,7 +83,7 @@ describe('withRecentItems', () => {
},
]),
removeRecentItem: jest.fn().mockResolvedValue(undefined),
setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined),
setContentMetadataIfMissing: jest.fn().mockResolvedValue(undefined),
};
playlistsService = {
clearPlaylistRecentlyViewed: jest
@@ -236,11 +236,13 @@ describe('withRecentItems', () => {
xtream_id: 290,
});
await store.backfillContentBackdrop({
await store.backfillContentMetadata({
xtreamId: 290,
contentType: 'series',
playlist: signal({ id: 'playlist-1' }),
backdropUrl: ' https://example.com/krypton-backdrop.png ',
patch: {
backdropUrl: ' https://example.com/krypton-backdrop.png ',
},
});
expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith(
@@ -248,15 +250,15 @@ describe('withRecentItems', () => {
'playlist-1',
'series'
);
expect(dataSource.setContentBackdropIfMissing).toHaveBeenCalledWith(
expect(dataSource.setContentMetadataIfMissing).toHaveBeenCalledWith(
3941697,
'playlist-1',
'https://example.com/krypton-backdrop.png'
{ backdropUrl: 'https://example.com/krypton-backdrop.png' }
);
expect(dataSource.addRecentItem).not.toHaveBeenCalled();
expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled();
expect(
databaseService.setContentBackdropIfMissing
databaseService.setContentMetadataIfMissing
).not.toHaveBeenCalled();
});
@@ -10,6 +10,8 @@ import { firstValueFrom, pipe, switchMap, tap } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
buildPlaylistRecentItems,
ContentMetadataPatch,
normalizeContentMetadataPatch,
Playlist,
PortalRecentItem,
} from '@iptvnator/shared/interfaces';
@@ -137,25 +139,25 @@ export const withRecentItems = function () {
)
)
),
async backfillContentBackdrop({
async backfillContentMetadata({
xtreamId,
contentType,
playlist,
backdropUrl,
patch,
}: {
xtreamId: number | string;
contentType: 'live' | 'movie' | 'series';
playlist: Signal<{ id: string } | null | undefined>;
backdropUrl?: string;
patch: ContentMetadataPatch;
}): Promise<void> {
const playlistId = playlist()?.id;
const normalizedXtreamId = Number(xtreamId);
const normalizedBackdropUrl = backdropUrl?.trim();
const normalized = normalizeContentMetadataPatch(patch);
if (
!playlistId ||
!Number.isFinite(normalizedXtreamId) ||
normalizedXtreamId <= 0 ||
!normalizedBackdropUrl
!normalized
) {
return;
}
@@ -169,10 +171,10 @@ export const withRecentItems = function () {
return;
}
await dataSource.setContentBackdropIfMissing(
await dataSource.setContentMetadataIfMissing(
content.id,
playlistId,
normalizedBackdropUrl
normalized
);
},
clearRecentItems: rxMethod<{ id: string }>(
@@ -0,0 +1,125 @@
import {
xtreamContentMetadataKey,
xtreamDetailContentMetadata,
} from './xtream-content-metadata.util';
describe('xtreamDetailContentMetadata', () => {
it('reads a VOD detail response', () => {
expect(
xtreamDetailContentMetadata({
backdrop_path: ['https://example.com/b.jpg'],
tmdb_id: '603',
releasedate: '1999-03-31',
o_name: 'The Matrix',
})
).toEqual({
backdropUrl: 'https://example.com/b.jpg',
tmdbId: 603,
releaseYear: 1999,
originalTitle: 'The Matrix',
});
});
it('reads a series detail response, which spells the date differently', () => {
expect(
xtreamDetailContentMetadata({
releaseDate: '2019-11-12',
tmdb_id: 82856,
})
).toEqual({ tmdbId: 82856, releaseYear: 2019 });
});
it('does not store a date TMDB supplied when the provider had none', () => {
// `mergeVodInfoWithTmdb` fills `releasedate` from `details.release_date`
// whenever the provider left it empty, and this runs against the
// merged object. Recording that as a provider fact is unfixable
// afterwards: the column is never overwritten, so a real provider date
// arriving later cannot correct it.
expect(
xtreamDetailContentMetadata({
releasedate: '2015-06-19',
tmdb_supplied_release_date: true,
tmdb_id: 150540,
})
).toEqual({ tmdbId: 150540 });
});
it('stores the date when the provider stated it, enrichment or not', () => {
// The merge keeps the provider's own value and leaves the marker off,
// so an enriched item with a real provider date still records it.
expect(
xtreamDetailContentMetadata({
releasedate: '1999-03-31',
tmdb_id: 603,
})
).toEqual({ tmdbId: 603, releaseYear: 1999 });
});
it('applies the same rule to a series releaseDate', () => {
expect(
xtreamDetailContentMetadata({
releaseDate: '2019-11-12',
tmdb_supplied_release_date: true,
})
).toBeNull();
});
it('never stores a year found only in the title', () => {
// The provider stated no date. Readers apply their own title-derived
// fallback; recording one here would turn a guess into a fact, and
// "2001: A Space Odyssey" is not a 2001 film.
expect(
xtreamDetailContentMetadata({
tmdb_id: 62,
releasedate: '',
})
).toEqual({ tmdbId: 62 });
});
it.each([
['no info at all', null],
['an empty response', {}],
['only empty values', { backdrop_path: [], releasedate: '', o_name: '' }],
])('reports %s as nothing to persist', (_label, info) => {
expect(xtreamDetailContentMetadata(info)).toBeNull();
});
it('takes only the first backdrop', () => {
expect(
xtreamDetailContentMetadata({
backdrop_path: ['https://a.example/1.jpg', 'https://a.example/2.jpg'],
})
).toEqual({ backdropUrl: 'https://a.example/1.jpg' });
});
});
describe('xtreamContentMetadataKey', () => {
it('changes when enrichment adds the id to an already-backfilled patch', () => {
// The detail effect skips a repeat write by comparing this key. Keyed
// on the backdrop alone, it would suppress the write that carries the
// id arriving moments later.
const beforeEnrichment = xtreamDetailContentMetadata({
backdrop_path: ['https://example.com/b.jpg'],
});
const afterEnrichment = xtreamDetailContentMetadata({
backdrop_path: ['https://example.com/b.jpg'],
tmdb_id: 603,
});
expect(xtreamContentMetadataKey(beforeEnrichment)).not.toEqual(
xtreamContentMetadataKey(afterEnrichment)
);
});
it('is stable for an unchanged patch', () => {
const patch = { backdropUrl: 'https://example.com/b.jpg', tmdbId: 603 };
expect(xtreamContentMetadataKey(patch)).toEqual(
xtreamContentMetadataKey({ ...patch })
);
});
it('is empty for no patch', () => {
expect(xtreamContentMetadataKey(null)).toBe('');
});
});
@@ -0,0 +1,102 @@
import {
ContentMetadataPatch,
extractYear,
normalizeContentMetadataPatch,
} from '@iptvnator/shared/interfaces';
/**
* The subset of an Xtream detail response this reads. Movies and series
* disagree on the release-date field name (`releasedate` vs `releaseDate`)
* and only movies carry an original title, so both shapes are accepted and
* whichever fields exist are used.
*/
export interface XtreamDetailMetadataSource {
backdrop_path?: string[] | null;
tmdb_id?: number | string | null;
/** VOD (`get_vod_info`) */
releasedate?: string | null;
/** Series (`get_series_info`) */
releaseDate?: string | null;
/** Enrichment filled the date above because the provider sent none */
tmdb_supplied_release_date?: boolean;
/** VOD only — series responses carry no original title */
o_name?: string | null;
}
/**
* Build the patch persisted onto the item's `content` row from what the
* detail view has on screen.
*
* Called on every detail render, including before TMDB enrichment has run,
* so it must stay meaningful without it: `releasedate` and `o_name` come
* straight from the provider and are exactly the fields the enrichment query
* uses, which is what lets a later reader rebuild that same query. The
* `tmdb_id` is whichever the view currently holds — the provider's claim
* early on, the resolved id once enrichment lands. The write path never
* overwrites, so the first non-empty value for each column wins.
*
* The year deliberately does NOT fall back to a year found in the title:
* readers apply that fallback themselves, and storing it would turn a guess
* into a recorded fact. For the same reason it is skipped entirely once
* enrichment has filled the date itself — see {@link providerReleaseYear}.
*/
export function xtreamDetailContentMetadata(
info: XtreamDetailMetadataSource | null | undefined
): ContentMetadataPatch | null {
if (!info) {
return null;
}
return normalizeContentMetadataPatch({
backdropUrl: info.backdrop_path?.[0] ?? undefined,
tmdbId: info.tmdb_id != null ? Number(info.tmdb_id) : undefined,
releaseYear: providerReleaseYear(info),
originalTitle: info.o_name ?? undefined,
});
}
/**
* The release year the PROVIDER stated, or `undefined`.
*
* This runs against the object the detail view is rendering, which TMDB
* enrichment has already merged into. That merge fills `releasedate` from
* `details.release_date` whenever the provider left it empty — so reading
* the field alone would record a TMDB date as a provider fact, and since the
* column is never overwritten, a real provider date arriving later could not
* correct it.
*
* The merge marks its own substitution, which is the only reliable signal:
* the `tmdb_*` fields it adds are all conditional on having content, so a
* film with no credits and no recommendations carries none of them and would
* read as un-enriched.
*/
function providerReleaseYear(
info: XtreamDetailMetadataSource
): number | undefined {
if (info.tmdb_supplied_release_date) {
return undefined;
}
return (
extractYear(info.releasedate ?? info.releaseDate ?? null) ?? undefined
);
}
/**
* Identity of a patch, for the components' "already backfilled this" guard.
* Every field is included: enrichment fills them in at different times, so a
* key built from the backdrop alone would suppress the write that carries
* the TMDB id arriving moments later.
*/
export function xtreamContentMetadataKey(
patch: ContentMetadataPatch | null
): string {
return patch
? [
patch.backdropUrl ?? '',
patch.tmdbId ?? '',
patch.releaseYear ?? '',
patch.originalTitle ?? '',
].join('|')
: '';
}
@@ -218,7 +218,7 @@ describe('SerialDetailsComponent', () => {
toggleFavorite: jest.fn(),
constructEpisodeStreamUrl,
addRecentItem,
backfillContentBackdrop: jest.fn(),
backfillContentMetadata: jest.fn(),
},
},
{
@@ -24,7 +24,10 @@ import {
SeasonContainerPlaybackToggleRequest,
} from '@iptvnator/ui/components';
import type { SeasonEpisodeDownloadAdapter } from '@iptvnator/portal/shared/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
registerContentMetadataBackfill,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import {
buildUpNextRailItems,
type PlaybackFallbackRequest,
@@ -134,7 +137,6 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
});
/** `playlistId:categoryId:serialId` of the last initialized view */
private readonly lastInitKey = signal<string | null>(null);
private readonly backdropBackfillKey = signal<string | null>(null);
/**
* Reactive route params: the component is reused when navigating
@@ -292,33 +294,12 @@ export class SerialDetailsComponent implements OnInit, OnDestroy {
this.initializeSerialDetails(playlistId, categoryId, serialId);
});
effect(() => {
const playlistId = this.currentPlaylistId();
const selectedItem = this.selectedItem();
const xtreamId = Number(selectedItem?.series_id ?? 0);
const backdropUrl = selectedItem?.info?.backdrop_path?.[0]?.trim();
if (
!playlistId ||
!Number.isFinite(xtreamId) ||
xtreamId <= 0 ||
!backdropUrl
) {
return;
}
const backfillKey = `${playlistId}:${xtreamId}:${backdropUrl}`;
if (this.backdropBackfillKey() === backfillKey) {
return;
}
this.backdropBackfillKey.set(backfillKey);
void this.xtreamStore.backfillContentBackdrop({
xtreamId,
contentType: 'series',
playlist: this.xtreamStore.currentPlaylist,
backdropUrl,
});
registerContentMetadataBackfill({
store: this.xtreamStore,
contentType: 'series',
playlistId: () => this.currentPlaylistId(),
xtreamId: () => Number(this.selectedItem()?.series_id ?? 0),
info: () => this.selectedItem()?.info,
});
}
@@ -239,6 +239,7 @@ describe('VodDetailsRouteComponent fallback actions', () => {
toggleFavorite,
constructVodStreamUrl,
addRecentItem: jest.fn(),
backfillContentMetadata: jest.fn(),
},
},
{
@@ -31,6 +31,7 @@ import {
isProviderOnlyDetailState,
} from '@iptvnator/portal/shared/util';
import {
registerContentMetadataBackfill,
resolveXtreamVodPlaybackSource,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
@@ -145,7 +146,6 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
private readonly logger = createLogger('VodDetailsRoute');
/** `playlistId:vodId` of the last initialized detail view */
private readonly lastInitKey = signal<string | null>(null);
private readonly backdropBackfillKey = signal<string | null>(null);
readonly inlinePlayback = this.playback.inlinePlayback;
readonly vodPlaybackPosition = this.playback.vodPlaybackPosition;
/** The route copy's own row — what Resume acts on. */
@@ -447,33 +447,12 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy {
this.initializeVodDetails(playlistId, vodId);
});
effect(() => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const vodId = this.selectedVodId();
const backdropUrl =
this.selectedVodInfo()?.backdrop_path?.[0]?.trim();
if (
!playlistId ||
!Number.isFinite(vodId) ||
vodId <= 0 ||
!backdropUrl
) {
return;
}
const backfillKey = `${playlistId}:${vodId}:${backdropUrl}`;
if (this.backdropBackfillKey() === backfillKey) {
return;
}
this.backdropBackfillKey.set(backfillKey);
void this.xtreamStore.backfillContentBackdrop({
xtreamId: vodId,
contentType: 'movie',
playlist: this.xtreamStore.currentPlaylist,
backdropUrl,
});
registerContentMetadataBackfill({
store: this.xtreamStore,
contentType: 'movie',
playlistId: () => this.xtreamStore.currentPlaylist()?.id,
xtreamId: () => this.selectedVodId(),
info: () => this.selectedVodInfo(),
});
}
@@ -197,6 +197,7 @@ export async function configureVodDetailsRouteTestBed(
toggleFavorite: stubs.toggleFavorite,
constructVodStreamUrl: stubs.constructVodStreamUrl,
addRecentItem: stubs.addRecentItem,
backfillContentMetadata: jest.fn(),
cancelDetailsRequest: stubs.cancelDetailsRequest,
vodStreamsPlaylistId: stubs.vodStreamsPlaylistId,
vodCategoriesPlaylistId: stubs.vodCategoriesPlaylistId,
@@ -4,7 +4,9 @@
*/
import { Injectable } from '@angular/core';
import { normalizeContentMetadataPatch } from '@iptvnator/shared/interfaces';
import type {
ContentMetadataPatch,
GlobalSearchPaginationOptions,
GlobalSearchResult,
GlobalSearchResultSource,
@@ -32,6 +34,11 @@ export interface XtreamContent {
added: string;
poster_url: string;
backdrop_url?: string | null;
// Identity a detail view recorded on the row; absent until one has been
// opened. See ContentMetadataPatch in @iptvnator/shared/interfaces.
tmdb_id?: number | null;
release_year?: number | null;
original_title?: string | null;
epg_channel_id?: string | null;
tv_archive?: number | null;
tv_archive_duration?: number | null;
@@ -837,30 +844,31 @@ export class DatabaseService {
}
/**
* Persist a backdrop URL onto an Xtream content row without touching
* recently viewed ordering or timestamps.
* Persist what a detail view learned onto an Xtream content row — the
* backdrop, and the identity that lets the dashboard repeat this view's
* TMDB lookup — without touching recently viewed ordering or timestamps.
*/
async setContentBackdropIfMissing(
async setContentMetadataIfMissing(
contentId: number,
backdropUrl?: string
patch?: ContentMetadataPatch
): Promise<boolean> {
const normalizedBackdropUrl = backdropUrl?.trim();
if (!normalizedBackdropUrl) {
const normalized = normalizeContentMetadataPatch(patch);
if (!normalized) {
return true;
}
if (!window.electron?.dbSetContentBackdropIfMissing) {
if (!window.electron?.dbSetContentMetadataIfMissing) {
return true;
}
try {
await window.electron.dbSetContentBackdropIfMissing(
await window.electron.dbSetContentMetadataIfMissing(
contentId,
normalizedBackdropUrl
normalized
);
return true;
} catch (error) {
console.error('Error backfilling content backdrop:', error);
console.error('Error backfilling content metadata:', error);
return false;
}
}
@@ -219,6 +219,22 @@ describe('mergeVodInfoWithTmdb', () => {
expect(merged.country).toBe('United States');
});
it('marks a release date it supplied itself', () => {
// The substitution above is silent, so afterwards nothing can tell who
// stated the date. Consumers that record it as a PROVIDER fact
// (`content.release_year`, `trustedReleaseYear`) read this marker.
const merged = mergeVodInfoWithTmdb(
providerVodInfo({ releasedate: '' }),
tmdbMovie
);
expect(merged.tmdb_supplied_release_date).toBe(true);
});
it('leaves the marker off when the provider stated the date', () => {
const merged = mergeVodInfoWithTmdb(providerVodInfo(), tmdbMovie);
expect(merged.tmdb_supplied_release_date).toBeUndefined();
});
it('does not mutate the provider object', () => {
const info = providerVodInfo();
const snapshot = JSON.parse(JSON.stringify(info));
+3
View File
@@ -128,6 +128,7 @@ export function mergeVodInfoWithTmdb(
rating_imdb:
info.rating_imdb || (rating !== null ? String(rating) : ''),
releasedate: info.releasedate || (details.release_date ?? ''),
...(info.releasedate ? {} : { tmdb_supplied_release_date: true }),
country: info.country || country,
movie_image: prefer(poster, info.movie_image),
cover_big: prefer(poster, info.cover_big),
@@ -168,6 +169,7 @@ export function mergeSerieInfoWithTmdb(
rating_5based:
rating !== null ? Math.round(rating * 5) / 10 : info.rating_5based,
releaseDate: info.releaseDate || (details.first_air_date ?? ''),
...(info.releaseDate ? {} : { tmdb_supplied_release_date: true }),
cover: prefer(poster, info.cover),
backdrop_path: mergedBackdrops(details, info.backdrop_path),
youtube_trailer: prefer(trailer, info.youtube_trailer),
@@ -228,6 +230,7 @@ export function mergeStalkerInfoWithTmdb(
director: prefer(director, info.director),
genre: prefer(genre, info.genre),
releasedate: info.releasedate || (releaseDate ?? ''),
...(info.releasedate ? {} : { tmdb_supplied_release_date: true }),
movie_image: prefer(poster, info.movie_image),
rating_imdb:
info.rating_imdb || (rating !== null ? String(rating) : ''),
@@ -291,6 +291,9 @@ describe('database schema statements', () => {
'ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT',
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0',
'ALTER TABLE content ADD COLUMN backdrop_url TEXT',
'ALTER TABLE content ADD COLUMN tmdb_id INTEGER',
'ALTER TABLE content ADD COLUMN release_year INTEGER',
'ALTER TABLE content ADD COLUMN original_title TEXT',
])
);
expect(columnMigrationStatements).toContain(
@@ -174,6 +174,9 @@ const CREATE_TABLE_STATEMENTS = [
added TEXT,
poster_url TEXT,
backdrop_url TEXT,
tmdb_id INTEGER,
release_year INTEGER,
original_title TEXT,
epg_channel_id TEXT,
tv_archive INTEGER,
tv_archive_duration INTEGER,
@@ -376,6 +379,11 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE content ADD COLUMN direct_source TEXT`,
// v1.5.0 -> v1.6.0: Cinematic backdrop persisted on first detail fetch
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
// Identity resolved by a detail view, so activity rows can repeat that
// lookup instead of rebuilding a weaker one from the display title
`ALTER TABLE content ADD COLUMN tmdb_id INTEGER`,
`ALTER TABLE content ADD COLUMN release_year INTEGER`,
`ALTER TABLE content ADD COLUMN original_title TEXT`,
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
+6
View File
@@ -103,6 +103,12 @@ export const content = sqliteTable(
added: text('added'),
posterUrl: text('poster_url'),
backdropUrl: text('backdrop_url'),
// Identity a detail view resolved for this item, so anything reading
// it later can repeat that lookup instead of re-deriving one from the
// display title. See ContentMetadataPatch in @iptvnator/shared/interfaces.
tmdbId: integer('tmdb_id'),
releaseYear: integer('release_year'),
originalTitle: text('original_title'),
epgChannelId: text('epg_channel_id'),
tvArchive: integer('tv_archive'),
tvArchiveDuration: integer('tv_archive_duration'),
+1
View File
@@ -1,6 +1,7 @@
export * from './lib/channel-drm.interface';
export * from './lib/channel.interface';
export * from './lib/channel.model';
export * from './lib/content-metadata.interface';
export * from './lib/dev-logger.util';
export * from './lib/download-metadata.interface';
export * from './lib/embedded-mpv-session.interface';
@@ -0,0 +1,79 @@
import { normalizeContentMetadataPatch } from './content-metadata.interface';
describe('normalizeContentMetadataPatch', () => {
it('keeps every usable field, trimmed', () => {
expect(
normalizeContentMetadataPatch({
backdropUrl: ' https://example.com/b.jpg ',
tmdbId: 603,
releaseYear: 1999,
originalTitle: ' The Matrix ',
})
).toEqual({
backdropUrl: 'https://example.com/b.jpg',
tmdbId: 603,
releaseYear: 1999,
originalTitle: 'The Matrix',
});
});
it.each([
['null', null],
['undefined', undefined],
['an empty patch', {}],
['only blank strings', { backdropUrl: ' ', originalTitle: '\t' }],
])('reports %s as nothing to write', (_label, patch) => {
expect(normalizeContentMetadataPatch(patch)).toBeNull();
});
it.each([
['zero', 0],
['a negative id', -5],
['a fractional id', 1.5],
['a non-numeric id', 'abc'],
['null', null],
])('drops %s as a tmdb id', (_label, tmdbId) => {
expect(
normalizeContentMetadataPatch({
tmdbId: tmdbId as number,
backdropUrl: 'https://example.com/b.jpg',
})
).toEqual({ backdropUrl: 'https://example.com/b.jpg' });
});
it('accepts a numeric string id, since providers stringify them', () => {
expect(normalizeContentMetadataPatch({ tmdbId: '603' as never })).toEqual(
{ tmdbId: 603 }
);
});
it.each([
['a year below the range', 1899],
['a year above the range', 2100],
['a bare season number', 2],
])('drops %s as a release year', (_label, releaseYear) => {
expect(
normalizeContentMetadataPatch({
releaseYear,
backdropUrl: 'https://example.com/b.jpg',
})
).toEqual({ backdropUrl: 'https://example.com/b.jpg' });
});
it.each([1900, 1999, 2026, 2099])('keeps %s as a release year', (year) => {
expect(normalizeContentMetadataPatch({ releaseYear: year })).toEqual({
releaseYear: year,
});
});
it('never emits keys for fields it dropped', () => {
// Callers spread the result onto an activity item, so a present-but-
// undefined key would overwrite a value another source supplied.
const normalized = normalizeContentMetadataPatch({
tmdbId: 0,
releaseYear: 2015,
});
expect(Object.keys(normalized ?? {})).toEqual(['releaseYear']);
});
});
@@ -0,0 +1,100 @@
/**
* Presentation and identity facts an Xtream detail view learns about a
* catalog item and hands back to the `content` row it came from.
*
* The catalog endpoints (`get_vod_streams`, `get_series`) carry a title and a
* poster and nothing else, so a `content` row starts life knowing only what a
* grid needs. The detail endpoints (`get_vod_info`, `get_series_info`) carry
* the release date, the original title and often a TMDB id — and TMDB
* enrichment resolves an id even when the provider ships none. Recording
* those on the row is what lets anything reading an activity item later
* (the dashboard hero and recommendation rails above all) repeat the lookup
* the detail view already did, instead of starting over from the display
* title. See `docs/architecture/tmdb-metadata-enrichment.md`.
*
* There is deliberately no media-type field: for Xtream the catalog files
* movies and series apart, so `content.type` already IS the media type.
* Stalker needs one because its embedded-VOD series are stored as movies,
* but Stalker items never reach the `content` table — they carry their own
* hints inside the stored entry (`extractStalkerItemTmdbHints`).
*/
export interface ContentMetadataPatch {
/** Wide 16:9 artwork for the dashboard hero */
backdropUrl?: string;
/**
* The id the detail view had on screen: a provider `tmdb_id` before
* enrichment runs, the id enrichment resolved afterwards. It is stored
* UNVETTED on purpose — every consumer reaches TMDB through
* `TmdbEnrichmentService`, whose `detailsForProviderId` runs
* `assessProviderId` on the payload and lets the title search take over
* when the years contradict. Vetting here as well would only record one
* verdict permanently, where the shared gate re-decides per lookup.
*/
tmdbId?: number;
/**
* Year the PROVIDER stated, never one read out of the title. A reader
* with no stored year already falls back to its own title extraction, so
* an absent column means "the provider gave no date" rather than "nobody
* looked" — and a title year like "2001: A Space Odyssey" can never be
* frozen into the row as that film's release year.
*/
releaseYear?: number;
/** The provider's `o_name`; movies only — series responses carry none */
originalTitle?: string;
}
/**
* `extractYear` only ever reads `19xx`/`20xx`, so anything outside that range
* reached us some other way and is not a release year.
*/
const MIN_RELEASE_YEAR = 1900;
const MAX_RELEASE_YEAR = 2099;
function readText(value: unknown): string | undefined {
const text = typeof value === 'string' ? value.trim() : '';
return text || undefined;
}
function readPositiveInteger(value: unknown): number | undefined {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function readReleaseYear(value: unknown): number | undefined {
const parsed = readPositiveInteger(value);
return parsed !== undefined &&
parsed >= MIN_RELEASE_YEAR &&
parsed <= MAX_RELEASE_YEAR
? parsed
: undefined;
}
/**
* Drop everything unusable from a patch, and report `null` when nothing
* usable is left — callers use that to skip the write entirely rather than
* spend an IPC round-trip on an empty update.
*
* Validating here, once, is what keeps a provider's `"0"`, `""` or `"N/A"`
* out of the column: the write path is shared by the detail views, the
* favorites/recent add paths and the DB worker, and each of them sees the
* raw provider shape.
*/
export function normalizeContentMetadataPatch(
patch: ContentMetadataPatch | null | undefined
): ContentMetadataPatch | null {
if (!patch) {
return null;
}
const normalized: ContentMetadataPatch = {};
const backdropUrl = readText(patch.backdropUrl);
if (backdropUrl) normalized.backdropUrl = backdropUrl;
const tmdbId = readPositiveInteger(patch.tmdbId);
if (tmdbId !== undefined) normalized.tmdbId = tmdbId;
const releaseYear = readReleaseYear(patch.releaseYear);
if (releaseYear !== undefined) normalized.releaseYear = releaseYear;
const originalTitle = readText(patch.originalTitle);
if (originalTitle) normalized.originalTitle = originalTitle;
return Object.keys(normalized).length > 0 ? normalized : null;
}
@@ -4,6 +4,7 @@ import {
EmbeddedMpvSession,
EmbeddedMpvSupport,
} from './embedded-mpv-session.interface';
import { ContentMetadataPatch } from './content-metadata.interface';
import { DownloadMetadataSnapshot } from './download-metadata.interface';
import { EpgChannelMetadata } from './epg-channel-metadata.model';
import { EpgProgram } from './epg-program.model';
@@ -957,9 +958,9 @@ export interface ElectronBridgeApi {
playlistId: string,
contentType?: ElectronBridgePortalContentType
) => Promise<ElectronBridgeXtreamContent | null>;
dbSetContentBackdropIfMissing: (
dbSetContentMetadataIfMissing: (
contentId: number,
backdropUrl?: string
patch?: ContentMetadataPatch
) => Promise<ElectronBridgeResult>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (
@@ -31,6 +31,19 @@ export interface PortalActivityItem {
* undefined.
*/
backdrop_url?: string;
/**
* TMDB identity a detail view recorded on this item's `content` row, so a
* reader can repeat the lookup that view performed instead of rebuilding a
* weaker one from `title` alone. Xtream only, and only once the item's
* detail page has been opened at least once — everything reading these
* must keep working without them. Stalker carries the same facts inside
* `stalker_item` instead (`extractStalkerItemTmdbHints`), and M3U rows
* never reach the `content` table at all.
*/
tmdb_id?: number;
/** The year the PROVIDER stated, never one read out of the title */
release_year?: number;
original_title?: string;
source?: 'xtream' | 'stalker' | 'm3u';
/** Original stalker item for navigation state; undefined for xtream. */
stalker_item?: StalkerPortalItem;
@@ -25,6 +25,8 @@ export interface XtreamSerieInfo {
director: string;
genre: string;
releaseDate: string;
/** See `XtreamVodInfo.tmdb_supplied_release_date` — same contract */
tmdb_supplied_release_date?: boolean;
last_modified: string;
rating: string;
rating_5based: number;
@@ -11,6 +11,18 @@ export interface XtreamVodInfo {
cover_big: string;
movie_image: string;
releasedate: string;
/**
* Set by TMDB enrichment when it filled `releasedate` itself because the
* provider sent none. Absent in raw provider responses, and absent after
* a merge that kept the provider's own date.
*
* The merge substitutes silently, so afterwards the field alone cannot
* say who stated the date. Anything recording it as a PROVIDER fact —
* `content.release_year`, and the exclusion index's `trustedReleaseYear`
* — must read this first, or an inferred year ends up gating decisions
* that are only sound on a stated one.
*/
tmdb_supplied_release_date?: boolean;
episode_run_time: number;
youtube_trailer: string;
director: string;
@@ -6,9 +6,11 @@ import {
import {
PortalAddedItem,
PlaylistMeta,
PortalActivityItem,
PortalActivityType,
PortalFavoriteItem,
PortalRecentItem,
normalizeContentMetadataPatch,
extractStalkerItemId,
extractStalkerItemPoster,
extractStalkerItemTitle,
@@ -31,6 +33,34 @@ export function getActivityTypeLabelKey(type: PortalActivityType): string {
// ────── Xtream DB → ViewModel ──────
/**
* The TMDB identity a detail view left on the `content` row, if any.
*
* Read through the same normalizer the write path uses, so a legacy row, a
* row whose detail page has never been opened, and a row carrying a value the
* provider fabricated all collapse to the same thing: absent fields, and a
* caller that falls back to the display title.
*/
function readContentTmdbIdentity(item: {
tmdb_id?: number | null;
release_year?: number | null;
original_title?: string | null;
}): Pick<PortalActivityItem, 'tmdb_id' | 'release_year' | 'original_title'> {
const identity = normalizeContentMetadataPatch({
tmdbId: item.tmdb_id ?? undefined,
releaseYear: item.release_year ?? undefined,
originalTitle: item.original_title ?? undefined,
});
return identity
? {
tmdb_id: identity.tmdbId,
release_year: identity.releaseYear,
original_title: identity.originalTitle,
}
: {};
}
export function mapDbFavoriteToItem(
item: DbGlobalFavoriteItem
): PortalFavoriteItem {
@@ -45,6 +75,7 @@ export function mapDbFavoriteToItem(
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
...readContentTmdbIdentity(item),
source: 'xtream',
};
}
@@ -61,6 +92,7 @@ export function mapDbRecentToItem(item: DbGlobalRecentItem): PortalRecentItem {
xtream_id: item.xtream_id,
poster_url: item.poster_url,
backdrop_url: item.backdrop_url ?? undefined,
...readContentTmdbIdentity(item),
source: 'xtream',
};
}
@@ -0,0 +1,152 @@
import {
buildDashboardTmdbAttempts,
dashboardTmdbLookupKey,
type DashboardTmdbLookupItem,
} from './dashboard-tmdb-lookup.util';
function xtreamItem(
overrides: Partial<DashboardTmdbLookupItem> = {}
): DashboardTmdbLookupItem {
return {
title: 'Inside Out',
type: 'movie',
source: 'xtream',
...overrides,
};
}
describe('buildDashboardTmdbAttempts', () => {
describe('without stored identity', () => {
it('falls back to the display title alone', () => {
expect(buildDashboardTmdbAttempts(xtreamItem())).toEqual([
{
mediaType: 'movie',
title: 'Inside Out',
originalTitle: undefined,
tmdbId: undefined,
year: null,
},
]);
});
it('still reads a year out of the title', () => {
const [attempt] = buildDashboardTmdbAttempts(
xtreamItem({ title: 'Subedaar (2026)' })
);
expect(attempt.year).toBe(2026);
});
});
describe('with identity a detail view stored', () => {
it('carries the provider year, the original title and the id', () => {
const [attempt] = buildDashboardTmdbAttempts(
xtreamItem({
tmdb_id: 150540,
release_year: 2015,
original_title: 'Inside Out',
})
);
expect(attempt).toEqual({
mediaType: 'movie',
title: 'Inside Out',
originalTitle: 'Inside Out',
tmdbId: 150540,
year: 2015,
});
});
it('prefers the stored year over one found in the title', () => {
// "2001: A Space Odyssey" is a 1968 film. Reading the year out of
// the title makes every genuine copy fail the confidence gate, so
// a stated provider year has to win.
const [attempt] = buildDashboardTmdbAttempts(
xtreamItem({
title: '2001: A Space Odyssey',
release_year: 1968,
})
);
expect(attempt.year).toBe(1968);
});
it('keeps the title fallback when the provider stated no year', () => {
// An absent column means the provider gave no date, NOT that the
// year is unknowable — the title may still state one.
const [attempt] = buildDashboardTmdbAttempts(
xtreamItem({ title: 'Subedaar (2026)', tmdb_id: 1262547 })
);
expect(attempt.year).toBe(2026);
});
it.each([
['a zero id', { tmdb_id: 0 }],
['a negative id', { tmdb_id: -1 }],
['an implausible year', { release_year: 12 }],
['a blank original title', { original_title: ' ' }],
])('ignores %s', (_label, overrides) => {
const [attempt] = buildDashboardTmdbAttempts(
xtreamItem(overrides)
);
expect(attempt.tmdbId).toBeUndefined();
expect(attempt.year).toBeNull();
expect(attempt.originalTitle).toBeUndefined();
});
it('does not add a tv retry for an xtream movie', () => {
// The xtream catalog files movies and series apart, so 'movie' is
// evidence rather than a default.
expect(
buildDashboardTmdbAttempts(xtreamItem({ tmdb_id: 150540 }))
).toHaveLength(1);
});
it('lets a stalker entry win over stored columns', () => {
const attempts = buildDashboardTmdbAttempts({
title: 'Ignored',
type: 'movie',
source: 'stalker',
tmdb_id: 999,
release_year: 1999,
stalker_item: {
category_id: 'series',
info: { name: 'The Mandalorian', releasedate: '2019' },
} as never,
});
expect(attempts[0]).toMatchObject({
mediaType: 'tv',
title: 'The Mandalorian',
year: 2019,
});
expect(attempts[0].tmdbId).toBeUndefined();
});
});
describe('lookup identity', () => {
it('changes once a detail view has stored identity', () => {
// Callers memoize on this key, so a row that has since learned its
// id must not keep serving the answer from its title-only lookup.
expect(dashboardTmdbLookupKey(xtreamItem())).not.toEqual(
dashboardTmdbLookupKey(
xtreamItem({ tmdb_id: 150540, release_year: 2015 })
)
);
});
it('is stable for the same stored identity', () => {
const item = xtreamItem({
tmdb_id: 150540,
release_year: 2015,
original_title: 'Inside Out',
});
expect(dashboardTmdbLookupKey(item)).toEqual(
dashboardTmdbLookupKey({ ...item })
);
});
});
});
@@ -1,14 +1,22 @@
import { extractYear } from '@iptvnator/services';
import {
ContentMetadataPatch,
PortalActivityItem,
TmdbMediaType,
extractStalkerItemTmdbHints,
normalizeContentMetadataPatch,
} from '@iptvnator/shared/interfaces';
/** Everything a dashboard TMDB lookup reads off an activity row */
export type DashboardTmdbLookupItem = Pick<
PortalActivityItem,
'title' | 'type' | 'stalker_item' | 'source'
| 'title'
| 'type'
| 'stalker_item'
| 'source'
| 'tmdb_id'
| 'release_year'
| 'original_title'
>;
/**
@@ -26,15 +34,38 @@ export interface DashboardTmdbAttempt {
}
/**
* Ordered lookup attempts for one activity row. Stalker rows carry the
* facts of the detail view's own enrichment, so they lead with those;
* everything else can only offer the display title.
* The identity an Xtream detail view recorded on this item's `content` row.
*
* Validated on read with the same normalizer the write path uses: rows
* imported before these columns existed, and rows whose detail page has never
* been opened, hold nulls and must come back as "no identity" rather than as
* zeroes.
*/
function storedIdentity(
item: DashboardTmdbLookupItem
): ContentMetadataPatch | null {
return normalizeContentMetadataPatch({
tmdbId: item.tmdb_id,
releaseYear: item.release_year,
originalTitle: item.original_title,
});
}
/**
* Ordered lookup attempts for one activity row. Both portal types carry the
* facts of the detail view's own enrichment and lead with those — Stalker
* inside its stored entry, Xtream on the `content` row the detail view
* backfilled. A row whose detail page has never been opened has neither, and
* falls back to the display title.
*
* The query is built to match what the detail view searched with, not just
* what the card displays. A title alone is weaker identity than the detail
* page had: without a year `pickConfidentMatch` falls back to requiring a
* single exact title match, which common titles never satisfy, and the miss
* is cached under a lookup key the detail view's hit can never be found at.
* single exact title match, which common titles never satisfy ("Inside Out"
* matches several films, so the gate returns nothing every time), and the
* miss is cached under a lookup key the detail view's hit can never be found
* at — the resolver keys the row on the FIRST search variant, which is the
* original title whenever one is known.
*
* A `'movie'` verdict gets a second attempt under `'tv'`, because for a
* Stalker row `'movie'` is what everything falls back to when nothing
@@ -76,12 +107,20 @@ export function buildDashboardTmdbAttempts(
const mediaType: TmdbMediaType =
hints?.mediaType ?? (item.type === 'series' ? 'tv' : 'movie');
const year = hints?.year ?? extractYear(null, title);
// Only a `content` row can carry these, and only Xtream reaches that
// table — so a row holding them is an Xtream row whose detail page has
// been opened. Stalker states the same facts through `hints`, which wins
// because it is the entry this very item was built from.
const stored = hints ? null : storedIdentity(item);
// Never `stored.releaseYear ?? extractYear(...)` alone: an absent column
// means the PROVIDER stated no date, not that nobody looked, so the
// title-derived fallback still has to run behind it.
const year = hints?.year ?? stored?.releaseYear ?? extractYear(null, title);
const primary: DashboardTmdbAttempt = {
mediaType,
title,
originalTitle: hints?.originalTitle,
tmdbId: hints?.tmdbId,
originalTitle: hints?.originalTitle ?? stored?.originalTitle,
tmdbId: hints?.tmdbId ?? stored?.tmdbId,
year,
};