diff --git a/.changes/dashboard-tmdb-identity.md b/.changes/dashboard-tmdb-identity.md new file mode 100644 index 000000000..ca2cebb5d --- /dev/null +++ b/.changes/dashboard-tmdb-identity.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index e55ed6af3..c2f1fd3e4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 73a72dc30..7b1a57352 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -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', diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index c4fd4b4d5..6cc03004c 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -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), diff --git a/apps/electron-backend/src/app/database/operations/content-backdrop.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content-backdrop.operations.spec.ts deleted file mode 100644 index 9bf39bd1f..000000000 --- a/apps/electron-backend/src/app/database/operations/content-backdrop.operations.spec.ts +++ /dev/null @@ -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 - ); - }); -}); diff --git a/apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts b/apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts deleted file mode 100644 index dc02ca7c1..000000000 --- a/apps/electron-backend/src/app/database/operations/content-backdrop.operations.ts +++ /dev/null @@ -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 { - 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 }; -} diff --git a/apps/electron-backend/src/app/database/operations/content-metadata.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content-metadata.operations.spec.ts new file mode 100644 index 000000000..d7b2fbbc3 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-metadata.operations.spec.ts @@ -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 + ); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts b/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts new file mode 100644 index 000000000..3d17011cf --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts @@ -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 { + 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 }; +} diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index 251747d5b..6deaf27bf 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -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, diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts index f959cb911..a58d6556d 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.spec.ts @@ -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 } ); }); }); diff --git a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts index 7a2c06657..f6f9a12bc 100644 --- a/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts +++ b/apps/electron-backend/src/app/database/operations/recently-viewed.operations.ts @@ -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 }; } diff --git a/apps/electron-backend/src/app/events/database/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index 2879a258b..b14130458 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -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, }) ); diff --git a/apps/electron-backend/src/app/events/database/worker-events.utils.ts b/apps/electron-backend/src/app/events/database/worker-events.utils.ts index 4a4b2f836..2e0e92cf1 100644 --- a/apps/electron-backend/src/app/events/database/worker-events.utils.ts +++ b/apps/electron-backend/src/app/events/database/worker-events.utils.ts @@ -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 { + await getDatabase(); +} + export function forwardWorkerEvent( event: Electron.IpcMainInvokeEvent, workerEvent: DbOperationEvent @@ -22,6 +45,7 @@ export function handleWorkerRequest( ): 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( channel: DbWorkerOperation, payload: TPayload ): Promise { + await awaitSchemaReady(); return databaseWorkerClient.request(channel, payload, { onEvent: (workerEvent) => forwardWorkerEvent(event, workerEvent), }); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 7980c2840..d1d82c11c 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -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', diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts index e42dc4e84..0729cd338 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec.ts @@ -21,6 +21,7 @@ type MockIpcEvent = { const mockRegisteredHandlers = new Map(); 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 { 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)) diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 7b4c07c60..4dceab348 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -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', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 00f5f185f..77346f858 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -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 ); } diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index a11cca36a..d4365357d 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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 diff --git a/libs/portal/xtream/data-access/src/index.ts b/libs/portal/xtream/data-access/src/index.ts index 78a8a65b9..5449f5ca6 100644 --- a/libs/portal/xtream/data-access/src/index.ts +++ b/libs/portal/xtream/data-access/src/index.ts @@ -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'; diff --git a/libs/portal/xtream/data-access/src/lib/content-metadata-backfill.ts b/libs/portal/xtream/data-access/src/lib/content-metadata-backfill.ts new file mode 100644 index 000000000..9c31907dc --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/content-metadata-backfill.ts @@ -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; +} + +/** + * 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(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, + }); + }); +} diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts index 5a1005632..39f51eb64 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -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: [], diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 2251c7b0b..354f2b7fe 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -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 playlistId; - await this.dbService.setContentBackdropIfMissing( - contentId, - backdropUrl - ); + await this.dbService.setContentMetadataIfMissing(contentId, patch); } // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts index bddcda14d..7169fbd39 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -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); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts index 51a0673b2..35b55890b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -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') || '{}' diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index fab1a3724..e2de3e839 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -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 { const normalizedContentId = this.normalizeStoredId(contentId); - const normalizedBackdropUrl = backdropUrl.trim(); + const normalizedBackdropUrl = patch.backdropUrl?.trim(); if (normalizedContentId == null || !normalizedBackdropUrl) { return; } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 2c1a281d3..feb136a3b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -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; /** - * 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; // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts index ea2698fc8..7486245ad 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts @@ -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(); }); diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index ba60a3204..c420bdce1 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -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 { 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 }>( diff --git a/libs/portal/xtream/data-access/src/lib/xtream-content-metadata.util.spec.ts b/libs/portal/xtream/data-access/src/lib/xtream-content-metadata.util.spec.ts new file mode 100644 index 000000000..e0660f6cd --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/xtream-content-metadata.util.spec.ts @@ -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(''); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/xtream-content-metadata.util.ts b/libs/portal/xtream/data-access/src/lib/xtream-content-metadata.util.ts new file mode 100644 index 000000000..1becfcb45 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/xtream-content-metadata.util.ts @@ -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('|') + : ''; +} diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts index d04a17720..3805770f4 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.spec.ts @@ -218,7 +218,7 @@ describe('SerialDetailsComponent', () => { toggleFavorite: jest.fn(), constructEpisodeStreamUrl, addRecentItem, - backfillContentBackdrop: jest.fn(), + backfillContentMetadata: jest.fn(), }, }, { diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts index 2ba367e6b..7b0c0874f 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details.component.ts @@ -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(null); - private readonly backdropBackfillKey = signal(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, }); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts index e24bc806d..478150ee7 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.actions.spec.ts @@ -239,6 +239,7 @@ describe('VodDetailsRouteComponent fallback actions', () => { toggleFavorite, constructVodStreamUrl, addRecentItem: jest.fn(), + backfillContentMetadata: jest.fn(), }, }, { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 93e9264e4..0e10b7aad 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -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(null); - private readonly backdropBackfillKey = signal(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(), }); } diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts index 95198a26f..e8adbc67f 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.harness.ts @@ -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, diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 92247f64c..5510a0b01 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -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 { - 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; } } diff --git a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts index c64af3f98..69f9ee58a 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.spec.ts @@ -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)); diff --git a/libs/services/src/lib/tmdb/tmdb-merge.ts b/libs/services/src/lib/tmdb/tmdb-merge.ts index 13d8669c8..d46849147 100644 --- a/libs/services/src/lib/tmdb/tmdb-merge.ts +++ b/libs/services/src/lib/tmdb/tmdb-merge.ts @@ -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) : ''), diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index 740abeb08..ccba0fd02 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -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( diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index db2881ae9..006bf26af 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -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 diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 2037fbdfc..ec69a15fa 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -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'), diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index ee3d210a9..31cf76dd3 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -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'; diff --git a/libs/shared/interfaces/src/lib/content-metadata.interface.spec.ts b/libs/shared/interfaces/src/lib/content-metadata.interface.spec.ts new file mode 100644 index 000000000..b35e13440 --- /dev/null +++ b/libs/shared/interfaces/src/lib/content-metadata.interface.spec.ts @@ -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']); + }); +}); diff --git a/libs/shared/interfaces/src/lib/content-metadata.interface.ts b/libs/shared/interfaces/src/lib/content-metadata.interface.ts new file mode 100644 index 000000000..7a0807ba2 --- /dev/null +++ b/libs/shared/interfaces/src/lib/content-metadata.interface.ts @@ -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; +} diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 03ff1594b..48893ba6b 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -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; - dbSetContentBackdropIfMissing: ( + dbSetContentMetadataIfMissing: ( contentId: number, - backdropUrl?: string + patch?: ContentMetadataPatch ) => Promise; dbGetAppState: (key: string) => Promise; dbSetAppState: ( diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index 41b5053b9..79f808bb3 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts index b98f8cbf3..583ae4d9a 100644 --- a/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-serie-details.interface.ts @@ -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; diff --git a/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts b/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts index 10fbf2e1f..f31067bcb 100644 --- a/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts +++ b/libs/shared/interfaces/src/lib/xtream-vod-details.interface.ts @@ -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; diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts index 0cc69a600..6330e83f8 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts @@ -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 { + 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', }; } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.spec.ts new file mode 100644 index 000000000..14fdd049a --- /dev/null +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.spec.ts @@ -0,0 +1,152 @@ +import { + buildDashboardTmdbAttempts, + dashboardTmdbLookupKey, + type DashboardTmdbLookupItem, +} from './dashboard-tmdb-lookup.util'; + +function xtreamItem( + overrides: Partial = {} +): 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 }) + ); + }); + }); +}); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.ts index a95f7bfb0..3672dc355 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-tmdb-lookup.util.ts @@ -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, };