Files
iptvnator/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts
T
4gray 61fca6f016 fix(dashboard): reuse the detail view's TMDB identity for activity rows (#1423)
An Xtream activity row is built from its `content` row, and the catalog
endpoints that create those rows carry only a title and a poster. So the
dashboard hero and the recommendations rail rebuilt their TMDB query from the
display title alone, while the detail view had searched with the original
title, the release date and often a TMDB id. Without a year
`pickConfidentMatch` requires a globally unique exact title, which common
titles never satisfy — "Inside Out" matches several films and resolves to
nothing, every time.

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

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

Contracts worth preserving:

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

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

Not covered: the PWA, whose catalog cache is rebuilt from the API on every
load, so a stored id would never outlive the detail view that resolved it.
2026-08-13 18:51:26 +02:00

117 lines
4.1 KiB
TypeScript

import { and, eq, or, sql, type SQL } from 'drizzle-orm';
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';
import * as schema from '@iptvnator/shared/database/schema';
import {
ContentMetadataPatch,
normalizeContentMetadataPatch,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
/**
* One patchable `content` column: the Drizzle field to write, how to
* recognise that it holds no value yet, and what to put there.
*
* Text columns count `''` as absent — provider payloads routinely carry empty
* strings where a field is unknown, and an empty backdrop is not a backdrop.
* Integer columns only count SQL NULL; `0` cannot reach here, since the patch
* normalizer rejects non-positive ids and out-of-range years.
*/
interface PatchableColumn {
key: 'backdropUrl' | 'tmdbId' | 'releaseYear' | 'originalTitle';
column: SQLiteColumn;
value: string | number;
isText: boolean;
}
function patchableColumns(patch: ContentMetadataPatch): PatchableColumn[] {
const candidates: PatchableColumn[] = [
{
key: 'backdropUrl',
column: schema.content.backdropUrl,
value: patch.backdropUrl as string,
isText: true,
},
{
key: 'tmdbId',
column: schema.content.tmdbId,
value: patch.tmdbId as number,
isText: false,
},
{
key: 'releaseYear',
column: schema.content.releaseYear,
value: patch.releaseYear as number,
isText: false,
},
{
key: 'originalTitle',
column: schema.content.originalTitle,
value: patch.originalTitle as string,
isText: true,
},
];
return candidates.filter(({ value }) => value !== undefined);
}
/** `col IS NULL` — plus `col = ''` for text columns */
function isAbsent({ column, isText }: PatchableColumn): SQL {
return isText
? sql`(${column} IS NULL OR ${column} = '')`
: sql`${column} IS NULL`;
}
/**
* Fill in the facts an Xtream detail view learned about a `content` row,
* one column at a time and only where the row has nothing yet.
*
* Per-column rather than per-row: enrichment supplies the pieces at different
* times — the release date and original title arrive with the provider's
* detail response, the TMDB id only once enrichment resolves one (and never,
* if the user leaves enrichment off). A row-level "already populated" guard
* would let whichever piece landed first block all the others forever.
*
* Existing values are never overwritten, so the first detail open wins and
* repeat opens are no-ops. That is what makes this safe to call
* unconditionally from the detail views' backfill effect.
*/
export async function persistContentMetadataIfMissing(
db: AppDatabase,
contentId: number,
patch?: ContentMetadataPatch
): Promise<void> {
const normalized = normalizeContentMetadataPatch(patch);
if (!normalized) {
return;
}
const columns = patchableColumns(normalized);
// `or()` only returns undefined for an empty list, which a non-null
// normalized patch rules out.
const anyAbsent = or(...columns.map(isAbsent)) as SQL;
await db
.update(schema.content)
.set(
Object.fromEntries(
columns.map(({ key, column, value }) => [
key,
// Every requested column is written in the one statement
// the `anyAbsent` guard admits, so a column that already
// holds a value must preserve it here — otherwise filling
// an empty neighbour would clobber it.
sql`COALESCE(NULLIF(${column}, ''), ${value})`,
])
)
)
.where(and(eq(schema.content.id, contentId), anyAbsent));
}
export async function setContentMetadataIfMissing(
db: AppDatabase,
contentId: number,
patch?: ContentMetadataPatch
): Promise<{ success: boolean }> {
await persistContentMetadataIfMissing(db, contentId, patch);
return { success: true };
}