feat(tmdb): metadata cache panel with a clear button in settings (#1244)

* feat(tmdb): metadata cache panel with a clear button in settings

Adds "Metadata cache — N entries · X MB" with a Clear button to
Settings > Metadata (TMDB), next to the API key it belongs to.

Three things it is good for: dropping stale or wrong metadata so the next
open refetches it, seeing what the cache actually costs on disk, and
reclaiming rows that a lookup-key version bump has orphaned — a bump makes
rows unreachable, not deleted, so nothing else would ever collect them.

Sizing the cache is a full table scan (LENGTH() on TEXT counts characters,
so the SUM casts to BLOB to get bytes), which is why stats load lazily and
only once the TMDB section is the active one rather than on every settings
open. Clearing is always safe: enrichment refetches on demand, so the only
cost is the next few requests.

Works in both environments — the PWA has no bridge, so the service reports
and clears its session-scoped in-memory map instead.

i18n: 4 keys across all 19 locales via the tools/i18n workflow;
placeholder integrity verified. Contract fixtures updated for both the
preload bridge and the DB-worker payload shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): make cache clearing durable and stop reporting failures as empty

Four review findings, all real:

- A metadata write already in flight when the user cleared would land
  afterwards and silently restore what they removed. Writes now carry the
  generation they started in; a write that outlives a clear is dropped
  (PWA) or undone (Electron).
- The PWA byte count used String.length, i.e. UTF-16 code units, so
  localized payloads under-reported and disagreed with the SQLite BLOB
  byte count. TextEncoder now measures actual bytes.
- A failed stats read returned a valid zero-entry result, so the panel
  claimed an empty cache and disabled Clear while rows were still there.
  getStats/clear now return null on failure and the panel says so instead
  of inventing state.
- No behavioural coverage existed for either side.

Tests: SQL ops (entry/byte reporting, empty table, missing row, delete
count) and the service (encoded bytes, clear count, and a write racing a
clear).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): make the cache clear precise and version skew visible

Review follow-ups on the cache panel:

- A write that was in flight when the user cleared used to trigger a
  second full-table clear once it landed, which also deleted anything
  written in between. clear() now waits for the writes issued before it
  and lets the single clear take them; later writes survive.
- An Electron shell without the maintenance ops fell through to the
  renderer map, which is always empty there — it reported an empty cache
  and disabled the Clear button while SQLite was full. Both operations
  now report unsupported instead.
- Component coverage for the panel (deferred scan, clear + re-read,
  failed clear, failed read) and Electron-path service coverage.
- The canonical IPC and settings sections of the enrichment doc, plus
  the matching CLAUDE.md lines, now list the maintenance ops.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): drop Promise.allSettled from the cache clear

The web target compiles against lib es2018, so allSettled broke the
Windows frontend build (TS2550). The pending writes swallow their own
errors, so a plain Promise.all over neutralized promises does the job.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): keep a synchronous bridge throw inside the cache write

Moving the write into a tracked promise dropped the try/catch that used
to cover the call itself, so a bridge that threw synchronously would
escape set(). Wrap it in an async IIFE, which turns that back into a
rejection the same handler swallows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): retry the cache size read when the section is reopened

The effect skipped the read once cacheError was set, so one transient
IPC failure left the panel showing "could not read the cache" for the
life of the settings page — and the only enabled control that could
shift it was the destructive Clear button. Gate on the stats signal
alone: reopening the section retries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tmdb): queue writes that start while the cache is being cleared

Awaiting the in-flight writes closed one side of the race and left the
other open: a set() that started during that wait dispatched its IPC
immediately, was absent from the snapshot, and could reach SQLite just
before the delete — so a row written after the user clicked Clear was
removed anyway.

clear() now holds its own promise for the whole operation and set() waits
on it, which puts such a write on the far side of the delete. Rows are
stamped when they are dispatched rather than when set() was called, since
a write may have waited. Covered by a test that fails without the guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(tmdb): add the release note for the cache panel

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(tmdb): cover the cache panel with an Electron E2E

The panel drives IPC and SQLite, and nothing exercised that path end to
end. The new test seeds a row through the preload bridge — enrichment
itself needs a TMDB key that CI does not have — then opens the section,
asserts the reported size, clears, and reads the database back to confirm
the row is gone rather than merely hidden.

Verified both ways: dropping the DELETE from clearTmdbMetadata fails it.

Settings nav buttons gained a data-test-id so the section can be opened
without matching translated labels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 4.8 authored and GitHub committed 2026-07-26 01:13:02 +02:00
1 parent d76b2d2a57
commit 0b967d66d4
39 files changed
+888 -24

No files matched your search

+37 -6
View File
@@ -75,8 +75,15 @@ also show a production-status chip (`tmdb_status`): TMDB returns `status`
as an ENGLISH string regardless of the request language, so it is
normalized to a token (`normalizeSeriesStatus`) and rendered through
translated labels (`seriesStatusLabelKey`) — the raw value never reaches
the UI. Unknown values are dropped rather than displayed. A "check key" button
in the settings section validates the API key against `/configuration`.
the UI. Unknown values are dropped rather than displayed. A "check key"
button in the settings section validates the API key against
`/configuration`, and a cache panel beside it shows the stored row count
plus payload size and can drop the lot (`DB_GET_TMDB_CACHE_STATS` /
`DB_CLEAR_TMDB_METADATA`, in-memory map in the PWA). Sizing is a full
scan, so it only runs once the TMDB section is the active one. Clearing
costs nothing but the next few requests — enrichment refetches on demand
— and it is also the escape hatch when a lookup-key version bump orphans
rows, since a bump makes them unreachable rather than deleting them.
## Match Confidence
@@ -303,12 +310,31 @@ person cache rows are unaffected.
Electron IPC path (follows the standard DB worker contract, see
[SQLite DB Worker](./sqlite-db-worker.md)):
- Worker ops: `DB_GET_TMDB_METADATA`, `DB_SET_TMDB_METADATA`
- Worker ops: `DB_GET_TMDB_METADATA`, `DB_SET_TMDB_METADATA`, plus the two
maintenance ops behind the settings cache panel,
`DB_GET_TMDB_CACHE_STATS` and `DB_CLEAR_TMDB_METADATA`
(`database-worker.types.ts`, `database.worker.ts`,
`operations/tmdb.operations.ts`)
- IPC registration: `events/database/tmdb.events.ts`
- Preload bridge: `dbGetTmdbMetadata` / `dbSetTmdbMetadata` on
`window.electron` (typed in `ElectronBridgeApi`)
- Preload bridge: `dbGetTmdbMetadata` / `dbSetTmdbMetadata` /
`dbGetTmdbCacheStats` / `dbClearTmdbMetadata` on `window.electron` (typed
in `ElectronBridgeApi`)
`TmdbCacheService` treats the maintenance pair as optional on the bridge: an
Electron shell that predates them reports `null` (unsupported) rather than
falling back to the renderer map, which is always empty in Electron and
would claim the SQLite cache is empty. A `clear()` first awaits the writes
already in flight so they land and are deleted with everything else, and
holds the clear promise for its duration so a write starting meanwhile
queues behind the delete instead of racing it. Rows written after the user
cleared are deliberately kept.
The panel's full path — settings button, preload bridge, DB worker, real
SQLite — is covered by `@settings @electron @persistence sizes and clears
the TMDB metadata cache` in `apps/electron-backend-e2e/src/settings.e2e.ts`.
It seeds a row through `dbSetTmdbMetadata` rather than through enrichment,
which needs an API key that builds outside the release pipeline do not
carry.
The PWA uses a session-scoped in-memory map (acceptable for phase 1; TMDB
supports CORS so the PWA calls the API directly).
@@ -317,7 +343,12 @@ supports CORS so the PWA calls the API directly).
`Settings.tmdb?: { enabled: boolean; apiKey?: string }`
(`libs/shared/interfaces/src/lib/tmdb.interface.ts`). The settings page has
a "Metadata (TMDB)" section (enable toggle + optional API key override).
a "Metadata (TMDB)" section: enable toggle, optional API key override with a
"check key" button (validates against `/configuration`), and a cache panel
showing the stored row count plus payload size with a button that drops the
lot. Sizing is a full table scan, so it runs only once that section is the
active one, and a failed read or clear says so instead of showing an empty
cache.
The embedded default key lives in `DEFAULT_TMDB_API_KEY`
(`libs/services/src/lib/tmdb/tmdb-config.ts`) and is an **empty placeholder