From 0b967d66d48d43e7b714c15fbf2f243ea0f8eb04 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:13:02 +0200 Subject: [PATCH] feat(tmdb): metadata cache panel with a clear button in settings (#1244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 * docs(tmdb): add the release note for the cache panel Co-Authored-By: Claude Opus 4.8 * 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 --------- Co-authored-by: Claude Opus 4.8 --- .changes/tmdb-cache-panel.md | 8 + CLAUDE.md | 4 +- apps/electron-backend-e2e/src/settings.e2e.ts | 51 ++++++ .../src/app/api/main.preload.spec-data.ts | 12 ++ .../src/app/api/main.preload.ts | 2 + .../operations/tmdb.operations.spec.ts | 53 ++++++- .../database/operations/tmdb.operations.ts | 42 ++++- .../src/app/events/database/tmdb.events.ts | 4 + .../database/worker-ipc-contract.spec-data.ts | 10 ++ .../src/app/workers/database-worker.types.ts | 2 + .../src/app/workers/database.worker.ts | 8 + .../settings-tmdb-section.component.html | 41 +++++ .../settings-tmdb-section.component.spec.ts | 143 +++++++++++++++++ .../settings-tmdb-section.component.ts | 84 +++++++++- apps/web/src/assets/i18n/ar.json | 5 + apps/web/src/assets/i18n/ary.json | 5 + apps/web/src/assets/i18n/by.json | 5 + apps/web/src/assets/i18n/de.json | 5 + apps/web/src/assets/i18n/el.json | 5 + apps/web/src/assets/i18n/en.json | 5 + apps/web/src/assets/i18n/es.json | 5 + apps/web/src/assets/i18n/fr.json | 5 + apps/web/src/assets/i18n/hu.json | 5 + apps/web/src/assets/i18n/it.json | 5 + apps/web/src/assets/i18n/ja.json | 5 + apps/web/src/assets/i18n/ko.json | 5 + apps/web/src/assets/i18n/nl.json | 5 + apps/web/src/assets/i18n/pl.json | 5 + apps/web/src/assets/i18n/pt.json | 5 + apps/web/src/assets/i18n/ru.json | 5 + apps/web/src/assets/i18n/tr.json | 5 + apps/web/src/assets/i18n/zh.json | 5 + apps/web/src/assets/i18n/zhtw.json | 5 + docs/architecture/tmdb-metadata-enrichment.md | 43 ++++- .../src/lib/tmdb/tmdb-cache.service.spec.ts | 141 ++++++++++++++++ .../src/lib/tmdb/tmdb-cache.service.ts | 150 ++++++++++++++++-- .../src/lib/electron-api.interface.ts | 11 +- .../interfaces/src/lib/tmdb.interface.ts | 7 + ...kspace-settings-context-panel.component.ts | 1 + 39 files changed, 888 insertions(+), 24 deletions(-) create mode 100644 .changes/tmdb-cache-panel.md create mode 100644 apps/web/src/app/settings/settings-tmdb-section.component.spec.ts diff --git a/.changes/tmdb-cache-panel.md b/.changes/tmdb-cache-panel.md new file mode 100644 index 000000000..0156ea055 --- /dev/null +++ b/.changes/tmdb-cache-panel.md @@ -0,0 +1,8 @@ +--- +type: feature +area: tmdb +--- + +Settings → Metadata (TMDB) now shows how many entries the metadata cache +holds and how much space they take, with a button to empty it. Clearing +costs nothing but the next few lookups — enrichment refetches on demand. diff --git a/CLAUDE.md b/CLAUDE.md index f699a20a9..96363b01a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -835,10 +835,10 @@ engine` (restart required) or - 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-merge.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` -- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) +- Opt-in via `Settings > Metadata (TMDB)` (sends titles to TMDB); the section also has a "check key" button and a cache panel (row count + payload size, with a clear button); optional user API key overrides the embedded default (`DEFAULT_TMDB_API_KEY` in `libs/services/src/lib/tmdb/tmdb-config.ts` — an empty placeholder in the repo by design; the real key lives in the `TMDB_API_KEY` GitHub Actions secret and is injected at CI build time by `tools/tmdb/inject-tmdb-key.mjs`) - Match confidence: a provider `tmdb_id` is a strong hint, not gospel — its payload is weighed against the item (`assessProviderId`: title or year agrees → use it; both years known and incompatible → the search may take over; title-only mismatch → keep it, since TMDB localizes titles). A 404 marks the id dead (`badProviderId:` row); transient failures never do. Without a usable id: normalized-title + year (±1) search with a strict gate — no confident match means no enrichment - Detail views render provider data immediately; enrichment patches the selection asynchronously (staleness-guarded) -- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. +- Cached in SQLite `tmdb_metadata` (Electron, via DB worker ops `DB_GET/SET_TMDB_METADATA`, plus `DB_GET_TMDB_CACHE_STATS` / `DB_CLEAR_TMDB_METADATA` behind the settings cache panel) or in-memory (PWA); localized via the app language setting. Search-match lookup keys are versioned, and connection startup removes obsolete unversioned rows once through the `migration:tmdb-search-lookup-v2-cache-cleanup:v1` app-state marker. - Service layer: `libs/services/src/lib/tmdb/`; store glue: `libs/portal/xtream/data-access/src/lib/stores/xtream-tmdb-enrichment.ts` and `libs/portal/stalker/data-access/src/lib/stores/stalker-tmdb-enrichment.ts` (hooked in `withStalkerSelection().setSelectedItem`) - TMDB attribution (logo + disclaimer) is required and shown in the settings TMDB section and About - See `docs/architecture/tmdb-metadata-enrichment.md` diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 03ccc34b3..c803ab8ed 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -604,6 +604,57 @@ test.describe('Electron Settings', () => { await closeElectronApp(app); } }); + + test('@settings @electron @persistence sizes and clears the TMDB metadata cache', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + // Seeded through the same preload bridge the enrichment service + // uses, so this exercises real IPC -> DB worker -> SQLite. + // Enrichment itself cannot run here: it needs a TMDB API key, + // which builds outside the release pipeline do not carry. + await app.mainWindow.evaluate(async () => { + await window.electron.dbSetTmdbMetadata({ + mediaType: 'movie', + lookupKey: 'id:603|v2', + language: 'en-US', + tmdbId: 603, + payload: JSON.stringify({ id: 603, title: 'The Matrix' }), + fetchedAt: new Date().toISOString(), + }); + }); + + await openSettings(app.mainWindow); + await app.mainWindow.getByTestId('settings-section-tmdb').click(); + + // Sizing is deferred until this section is the active one + await expect( + app.mainWindow.getByTestId('tmdb-cache-size') + ).toHaveText(/\b1 entries/); + + const clearButton = + app.mainWindow.getByTestId('tmdb-clear-cache'); + await expect(clearButton).toBeEnabled(); + await clearButton.click(); + + // Re-read reports an empty cache, so there is nothing to clear + await expect(clearButton).toBeDisabled(); + await expect( + app.mainWindow.getByTestId('tmdb-cache-size') + ).toHaveText(/\b0 entries/); + + // ...and the row is gone from the database, not just the panel + const remaining = await app.mainWindow.evaluate(() => + window.electron.dbGetTmdbMetadata('movie', 'id:603|v2', 'en-US') + ); + expect(remaining).toBeNull(); + } finally { + await closeElectronApp(app); + } + }); + }); async function selectSettingsOption( 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 3b8b5cfab..effaa9c1d 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 @@ -370,6 +370,18 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_SET_TMDB_METADATA', forwardedArgs: [tmdbCacheEntry], }, + { + method: 'dbGetTmdbCacheStats', + args: [], + channel: 'DB_GET_TMDB_CACHE_STATS', + forwardedArgs: [], + }, + { + method: 'dbClearTmdbMetadata', + args: [], + channel: 'DB_CLEAR_TMDB_METADATA', + forwardedArgs: [], + }, { method: 'dbMatchTitles', args: [[['The Matrix', 'Inception']][0]], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 771a48359..bd078b223 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -788,6 +788,8 @@ const electronApi: ElectronBridgeApi = { ), dbSetTmdbMetadata: (entry: TmdbCacheEntry) => ipcRenderer.invoke('DB_SET_TMDB_METADATA', entry), + dbGetTmdbCacheStats: () => ipcRenderer.invoke('DB_GET_TMDB_CACHE_STATS'), + dbClearTmdbMetadata: () => ipcRenderer.invoke('DB_CLEAR_TMDB_METADATA'), dbMatchTitles: (titles: string[]) => ipcRenderer.invoke('DB_MATCH_TITLES', titles), // Playback Positions diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts index 8b1e6a3d2..94ded9e3e 100644 --- a/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.spec.ts @@ -1,6 +1,11 @@ import type { TmdbCacheEntry } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; -import { getTmdbMetadata, setTmdbMetadata } from './tmdb.operations'; +import { + clearTmdbMetadata, + getTmdbCacheStats, + getTmdbMetadata, + setTmdbMetadata, +} from './tmdb.operations'; function createSelectMock(rows: unknown[]) { const limit = jest.fn().mockResolvedValue(rows); @@ -105,3 +110,49 @@ describe('tmdb.operations', () => { ); }); }); + +describe('tmdb cache maintenance', () => { + function createStatsDb(rows: unknown[]) { + const all = jest.fn().mockResolvedValue(rows); + const run = jest.fn().mockResolvedValue(undefined); + return { db: { all, run } as unknown as AppDatabase, all, run }; + } + + it('reports entry count and payload bytes', async () => { + const { db } = createStatsDb([{ entries: 42, bytes: 123456 }]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 42, + bytes: 123456, + }); + }); + + it('reports zeroes for an empty table', async () => { + // COALESCE keeps SUM(NULL) from surfacing as NaN + const { db } = createStatsDb([{ entries: 0, bytes: 0 }]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('survives a driver returning no stats row at all', async () => { + const { db } = createStatsDb([]); + + await expect(getTmdbCacheStats(db)).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('deletes every row and reports how many there were', async () => { + const { db, run } = createStatsDb([{ entries: 7, bytes: 900 }]); + + await expect(clearTmdbMetadata(db)).resolves.toEqual({ + success: true, + deleted: 7, + }); + expect(run).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts index 75fc67865..ae0a37c47 100644 --- a/apps/electron-backend/src/app/database/operations/tmdb.operations.ts +++ b/apps/electron-backend/src/app/database/operations/tmdb.operations.ts @@ -1,6 +1,10 @@ -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; -import type { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +import type { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; export async function getTmdbMetadata( @@ -67,3 +71,37 @@ export async function setTmdbMetadata( return { success: true }; } + +/** + * Row count and payload size for the settings panel. `LENGTH()` on TEXT + * counts CHARACTERS, so the cast to BLOB is what makes this bytes. + * Negative-match rows have a NULL payload; SUM skips them. + */ +export async function getTmdbCacheStats( + db: AppDatabase +): Promise { + const rows = (await db.all(sql` + SELECT + COUNT(*) AS entries, + COALESCE(SUM(LENGTH(CAST(payload AS BLOB))), 0) AS bytes + FROM tmdb_metadata + `)) as { entries: number; bytes: number }[]; + + return { + entries: Number(rows[0]?.entries ?? 0), + bytes: Number(rows[0]?.bytes ?? 0), + }; +} + +/** + * Drops the whole cache. Enrichment simply refetches on demand, so this + * is always safe. Also the escape hatch when a lookup-key version bump + * orphans rows: a bump makes them unreachable, not deleted. + */ +export async function clearTmdbMetadata( + db: AppDatabase +): Promise<{ success: boolean; deleted: number }> { + const { entries } = await getTmdbCacheStats(db); + await db.run(sql`DELETE FROM tmdb_metadata`); + return { success: true, deleted: entries }; +} diff --git a/apps/electron-backend/src/app/events/database/tmdb.events.ts b/apps/electron-backend/src/app/events/database/tmdb.events.ts index 19b6b84da..796ab78bc 100644 --- a/apps/electron-backend/src/app/events/database/tmdb.events.ts +++ b/apps/electron-backend/src/app/events/database/tmdb.events.ts @@ -19,4 +19,8 @@ handleWorkerRequest('DB_SET_TMDB_METADATA', (entry: TmdbCacheEntry) => ({ entry, })); +handleWorkerRequest('DB_GET_TMDB_CACHE_STATS', () => ({})); + +handleWorkerRequest('DB_CLEAR_TMDB_METADATA', () => ({})); + handleWorkerRequest('DB_MATCH_TITLES', (titles: string[]) => ({ titles })); 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 6fb9e60f8..f03e24b3f 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 @@ -323,6 +323,16 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [tmdbCacheEntry], payload: { entry: tmdbCacheEntry }, }, + { + operation: 'DB_GET_TMDB_CACHE_STATS', + args: [], + payload: {}, + }, + { + operation: 'DB_CLEAR_TMDB_METADATA', + args: [], + payload: {}, + }, { operation: 'DB_MATCH_TITLES', args: [['The Matrix', 'Inception']], 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 7b1f21df6..15d8783b3 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -51,6 +51,8 @@ export const DB_WORKER_OPERATIONS = [ 'DB_CLEAR_PLAYBACK_POSITION', 'DB_GET_TMDB_METADATA', 'DB_SET_TMDB_METADATA', + 'DB_GET_TMDB_CACHE_STATS', + 'DB_CLEAR_TMDB_METADATA', 'DB_MATCH_TITLES', ] as const; diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index 3cfd67144..e702baa59 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -79,6 +79,8 @@ import { } from '../database/operations/recently-viewed.operations'; import { matchTitles } from '../database/operations/title-match.operations'; import { + clearTmdbMetadata, + getTmdbCacheStats, getTmdbMetadata, setTmdbMetadata, } from '../database/operations/tmdb.operations'; @@ -614,6 +616,12 @@ async function executeRequest(message: DbWorkerRequestMessage) { return setTmdbMetadata(db, payload.entry); } + case 'DB_GET_TMDB_CACHE_STATS': + return getTmdbCacheStats(db); + + case 'DB_CLEAR_TMDB_METADATA': + return clearTmdbMetadata(db); + case 'DB_MATCH_TITLES': { const payload = message.payload as { titles: string[] }; return matchTitles(db, payload.titles); diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.html b/apps/web/src/app/settings/settings-tmdb-section.component.html index 09e3a73e6..99e5fa6d5 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.html +++ b/apps/web/src/app/settings/settings-tmdb-section.component.html @@ -99,6 +99,47 @@ } +
+
+

{{ 'SETTINGS.TMDB_CACHE' | translate }}

+

+ {{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }} +

+

+ @if (cacheError()) { + + {{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }} + + } @else if (cacheStats(); as stats) { + {{ + 'SETTINGS.TMDB_CACHE_SIZE' + | translate + : { + entries: stats.entries, + size: formatBytes(stats.bytes), + } + }} + } +

+
+
+ +
+
+
{ + let fixture: ComponentFixture; + let getStats: jest.Mock; + let clear: jest.Mock; + + const createForm = () => + new FormGroup({ + tmdb: new FormGroup({ + enabled: new FormControl(true), + apiKey: new FormControl(''), + }), + }); + + const queryByTestId = (testId: string): HTMLElement | null => + fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`); + + const clearButton = () => + queryByTestId('tmdb-clear-cache') as HTMLButtonElement; + + /** The stats effect only fires once the TMDB section is the active one */ + const activate = async (section = 'tmdb') => { + fixture.componentRef.setInput('activeSection', section); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + beforeEach(async () => { + getStats = jest.fn().mockResolvedValue({ entries: 42, bytes: 2048 }); + clear = jest.fn().mockResolvedValue(42); + + await TestBed.configureTestingModule({ + imports: [ + SettingsTmdbSectionComponent, + NoopAnimationsModule, + ReactiveFormsModule, + TranslateModule.forRoot(), + ], + providers: [ + { + provide: TmdbCacheService, + useValue: { getStats, clear }, + }, + { + provide: TmdbApiService, + useValue: { validateApiKey: jest.fn() }, + }, + ], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation( + 'en', + { + SETTINGS: { + TMDB_CACHE_SIZE: CACHE_SIZE_LABEL, + TMDB_CACHE_ERROR: CACHE_ERROR_LABEL, + }, + }, + true + ); + translate.use('en'); + + fixture = TestBed.createComponent(SettingsTmdbSectionComponent); + fixture.componentRef.setInput('form', createForm()); + fixture.componentRef.setInput('activeSection', 'general'); + fixture.detectChanges(); + }); + + it('defers the full-table scan until the section is opened', async () => { + expect(getStats).not.toHaveBeenCalled(); + + await activate(); + + expect(getStats).toHaveBeenCalledTimes(1); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); + }); + + it('clears the cache and re-reads the size', async () => { + await activate(); + getStats.mockResolvedValue({ entries: 0, bytes: 0 }); + + clearButton().click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(clear).toHaveBeenCalledTimes(1); + expect(getStats).toHaveBeenCalledTimes(2); + // Nothing left to clear — the button stops offering it + expect(clearButton().disabled).toBe(true); + }); + + it('surfaces a failed clear instead of claiming an empty cache', async () => { + await activate(); + clear.mockResolvedValue(null); + + clearButton().click(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + // Still actionable: the rows are there, the user can retry + expect(clearButton().disabled).toBe(false); + }); + + it('surfaces a failed size read the same way', async () => { + getStats.mockResolvedValue(null); + + await activate(); + + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + }); + + it('retries the size read next time the section is opened', async () => { + getStats.mockResolvedValueOnce(null); + await activate(); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain( + CACHE_ERROR_LABEL + ); + + await activate('general'); + await activate(); + + // Otherwise a single transient failure sticks for the life of the + // page, and only the destructive Clear button can shift it + expect(getStats).toHaveBeenCalledTimes(2); + expect(queryByTestId('tmdb-cache-size')?.textContent).toContain('42'); + }); +}); diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 593364775..e7a6f7e3c 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -1,4 +1,12 @@ -import { Component, inject, input, signal, ViewEncapsulation } from '@angular/core'; +import { + Component, + effect, + inject, + input, + signal, + untracked, + ViewEncapsulation, +} from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -7,7 +15,8 @@ import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { TranslateModule } from '@ngx-translate/core'; -import { TmdbApiService } from '@iptvnator/services'; +import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; +import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; @@ -59,16 +68,46 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; .tmdb-key-test__result--error { color: #f44336; } + + .tmdb-cache__size { + font-variant-numeric: tabular-nums; + } + + .tmdb-cache__error { + color: #f44336; + } `, ], }) export class SettingsTmdbSectionComponent { private readonly tmdbApi = inject(TmdbApiService); + private readonly tmdbCache = inject(TmdbCacheService); readonly form = input.required(); readonly activeSection = input.required(); readonly keyTestState = signal('idle'); + readonly cacheStats = signal(null); + readonly cacheError = signal(false); + readonly isClearing = signal(false); + + constructor() { + // Sizing the cache is a full table scan, so it waits until the + // user is actually looking at this section. + effect(() => { + if (this.activeSection() !== 'tmdb') { + return; + } + untracked(() => { + // A failed read leaves both signals in their "unknown" + // state, so reopening the section retries rather than + // showing the error until the page is rebuilt. + if (this.cacheStats() === null) { + void this.refreshCacheStats(); + } + }); + }); + } get enteredApiKey(): string { return (this.form().value.tmdb?.apiKey ?? '').trim(); @@ -84,4 +123,45 @@ export class SettingsTmdbSectionComponent { const isValid = await this.tmdbApi.validateApiKey(apiKey); this.keyTestState.set(isValid ? 'success' : 'error'); } + + async clearCache(): Promise { + if (this.isClearing()) { + return; + } + this.isClearing.set(true); + try { + const deleted = await this.tmdbCache.clear(); + if (deleted === null) { + // Do not claim an empty cache we failed to empty + this.cacheError.set(true); + this.cacheStats.set(null); + return; + } + await this.refreshCacheStats(); + } finally { + this.isClearing.set(false); + } + } + + /** "12.4 MB" — cache payloads are JSON, so decimal units read right */ + formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[unit]}`; + } + + private async refreshCacheStats(): Promise { + const stats = await this.tmdbCache.getStats(); + this.cacheError.set(stats === null); + this.cacheStats.set(stats); + } + } diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index b7681d351..9c2ef982f 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "التحقق من المفتاح", "TMDB_TEST_SUCCESS": "المفتاح يعمل — استجاب TMDB بنجاح", "TMDB_TEST_FAILED": "فشل التحقق — رفض TMDB المفتاح أو تعذر الوصول إليه", + "TMDB_CACHE": "ذاكرة التخزين المؤقت للبيانات الوصفية", + "TMDB_CACHE_DESCRIPTION": "تُحفظ البيانات الوصفية المنزّلة محلياً لتفتح صفحات التفاصيل فوراً. مسحها يجعل التطبيق يجلب بيانات جديدة عند الزيارة التالية.", + "TMDB_CACHE_SIZE": "{{entries}} عنصر · {{size}}", + "TMDB_CACHE_CLEAR": "مسح الذاكرة المؤقتة", + "TMDB_CACHE_ERROR": "تعذّر قراءة الذاكرة المؤقتة — حاول مجدداً", "TMDB_CREDITS": "البيانات الوصفية", "TMDB_ATTRIBUTION": "يستخدم هذا المنتج TMDB API ولكنه غير معتمد أو مصدّق من قِبل TMDB.", "APP_PREFERENCES": "تفضيلات التطبيق", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 1034eafee..6f8a3ca06 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "تأكد من المفتاح", "TMDB_TEST_SUCCESS": "المفتاح خدام — TMDB جاوب مزيان", "TMDB_TEST_FAILED": "التحقق ما نجحش — TMDB رفض المفتاح ولا ما يمكنش الوصول ليه", + "TMDB_CACHE": "كاش ديال البيانات الوصفية", + "TMDB_CACHE_DESCRIPTION": "البيانات الوصفية اللي تحملات كتبقى محفوظة محلياً باش صفحات التفاصيل تتفتح دغيا. إلا مسحتيها، التطبيق غايجيب بيانات جديدة فالمرة الجاية.", + "TMDB_CACHE_SIZE": "{{entries}} عنصر · {{size}}", + "TMDB_CACHE_CLEAR": "مسح الكاش", + "TMDB_CACHE_ERROR": "ماقدرناش نقراو الكاش — عاود جرب", "TMDB_CREDITS": "الميتاداتا", "TMDB_ATTRIBUTION": "هاد المنتج كيستعمل TMDB API ولكن ماشي معتمد ولا مصادق عليه من طرف TMDB.", "APP_PREFERENCES": "تفضيلات التطبيق", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 5485a3871..12ef0bfb5 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Праверыць ключ", "TMDB_TEST_SUCCESS": "Ключ працуе — TMDB паспяхова адказаў", "TMDB_TEST_FAILED": "Праверка не ўдалася — TMDB адхіліў ключ або недаступны", + "TMDB_CACHE": "Кэш метаданых", + "TMDB_CACHE_DESCRIPTION": "Спампаваныя метаданыя захоўваюцца лакальна, каб старонкі дэталяў адкрываліся імгненна. Пасля ачысткі праграма загрузіць свежыя даныя пры наступным адкрыцці.", + "TMDB_CACHE_SIZE": "{{entries}} запісаў · {{size}}", + "TMDB_CACHE_CLEAR": "Ачысціць кэш", + "TMDB_CACHE_ERROR": "Не ўдалося прачытаць кэш — паспрабуйце зноў", "TMDB_CREDITS": "Метаданыя", "TMDB_ATTRIBUTION": "Гэты прадукт выкарыстоўвае TMDB API, але не ўхвалены і не сертыфікаваны TMDB.", "APP_PREFERENCES": "Параметры праграмы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 6f55673e3..8786a0736 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Schlüssel prüfen", "TMDB_TEST_SUCCESS": "Der Schlüssel funktioniert — TMDB hat erfolgreich geantwortet", "TMDB_TEST_FAILED": "Prüfung fehlgeschlagen — TMDB hat den Schlüssel abgelehnt oder ist nicht erreichbar", + "TMDB_CACHE": "Metadaten-Cache", + "TMDB_CACHE_DESCRIPTION": "Heruntergeladene Metadaten werden lokal gespeichert, damit Detailseiten sofort öffnen. Nach dem Leeren lädt die App beim nächsten Besuch frische Daten.", + "TMDB_CACHE_SIZE": "{{entries}} Einträge · {{size}}", + "TMDB_CACHE_CLEAR": "Cache leeren", + "TMDB_CACHE_ERROR": "Cache konnte nicht gelesen werden — erneut versuchen", "TMDB_CREDITS": "Metadaten", "TMDB_ATTRIBUTION": "Dieses Produkt verwendet die TMDB API, wird jedoch nicht von TMDB unterstützt oder zertifiziert.", "APP_PREFERENCES": "App-Einstellungen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index f0ef6ff16..8ca4913ce 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Έλεγχος κλειδιού", "TMDB_TEST_SUCCESS": "Το κλειδί λειτουργεί — το TMDB απάντησε με επιτυχία", "TMDB_TEST_FAILED": "Ο έλεγχος απέτυχε — το TMDB απέρριψε το κλειδί ή δεν είναι προσβάσιμο", + "TMDB_CACHE": "Προσωρινή μνήμη μεταδεδομένων", + "TMDB_CACHE_DESCRIPTION": "Τα ληφθέντα μεταδεδομένα αποθηκεύονται τοπικά ώστε οι σελίδες λεπτομερειών να ανοίγουν αμέσως. Η εκκαθάριση αναγκάζει την εφαρμογή να κατεβάσει νέα δεδομένα.", + "TMDB_CACHE_SIZE": "{{entries}} εγγραφές · {{size}}", + "TMDB_CACHE_CLEAR": "Εκκαθάριση", + "TMDB_CACHE_ERROR": "Αδυναμία ανάγνωσης της μνήμης — δοκιμάστε ξανά", "TMDB_CREDITS": "Μεταδεδομένα", "TMDB_ATTRIBUTION": "Αυτό το προϊόν χρησιμοποιεί το TMDB API, αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB.", "APP_PREFERENCES": "Προτιμήσεις εφαρμογής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index ded4da1d5..130b118e9 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Check key", "TMDB_TEST_SUCCESS": "The key works — TMDB responded successfully", "TMDB_TEST_FAILED": "Check failed — TMDB rejected the key or is unreachable", + "TMDB_CACHE": "Metadata cache", + "TMDB_CACHE_DESCRIPTION": "Downloaded metadata is stored locally so detail pages open instantly. Clearing it makes the app fetch fresh data on the next visit.", + "TMDB_CACHE_SIZE": "{{entries}} entries · {{size}}", + "TMDB_CACHE_CLEAR": "Clear cache", + "TMDB_CACHE_ERROR": "Could not read the cache — try again", "TMDB_CREDITS": "Metadata", "TMDB_ATTRIBUTION": "This product uses the TMDB API but is not endorsed or certified by TMDB.", "APP_PREFERENCES": "App preferences", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 8dc779349..256f10042 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Comprobar clave", "TMDB_TEST_SUCCESS": "La clave funciona — TMDB respondió correctamente", "TMDB_TEST_FAILED": "La comprobación falló — TMDB rechazó la clave o no está disponible", + "TMDB_CACHE": "Caché de metadatos", + "TMDB_CACHE_DESCRIPTION": "Los metadatos descargados se guardan localmente para que las páginas de detalles se abran al instante. Al vaciarla, la app descargará datos nuevos en la próxima visita.", + "TMDB_CACHE_SIZE": "{{entries}} entradas · {{size}}", + "TMDB_CACHE_CLEAR": "Vaciar caché", + "TMDB_CACHE_ERROR": "No se pudo leer la caché: inténtalo de nuevo", "TMDB_CREDITS": "Metadatos", "TMDB_ATTRIBUTION": "Este producto utiliza la API de TMDB, pero no está avalado ni certificado por TMDB.", "APP_PREFERENCES": "Preferencias de la app", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index d1a2cbe55..cb86e2daa 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Vérifier la clé", "TMDB_TEST_SUCCESS": "La clé fonctionne — TMDB a répondu avec succès", "TMDB_TEST_FAILED": "Échec de la vérification — TMDB a rejeté la clé ou est injoignable", + "TMDB_CACHE": "Cache des métadonnées", + "TMDB_CACHE_DESCRIPTION": "Les métadonnées téléchargées sont stockées localement pour que les pages de détails s'ouvrent instantanément. Après le vidage, l'application récupérera des données fraîches.", + "TMDB_CACHE_SIZE": "{{entries}} entrées · {{size}}", + "TMDB_CACHE_CLEAR": "Vider le cache", + "TMDB_CACHE_ERROR": "Impossible de lire le cache — réessayez", "TMDB_CREDITS": "Métadonnées", "TMDB_ATTRIBUTION": "Ce produit utilise l'API TMDB mais n'est ni approuvé ni certifié par TMDB.", "APP_PREFERENCES": "Préférences de l'application", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 929b2936d..83183c4a4 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Kulcs ellenőrzése", "TMDB_TEST_SUCCESS": "A kulcs működik – a TMDB sikeresen válaszolt", "TMDB_TEST_FAILED": "Az ellenőrzés sikertelen – a TMDB elutasította a kulcsot, vagy nem érhető el", + "TMDB_CACHE": "Metaadat-gyorsítótár", + "TMDB_CACHE_DESCRIPTION": "A letöltött metaadatok helyben tárolódnak, így a részletek azonnal megnyílnak. Ürítés után az alkalmazás friss adatokat tölt le.", + "TMDB_CACHE_SIZE": "{{entries}} bejegyzés · {{size}}", + "TMDB_CACHE_CLEAR": "Gyorsítótár ürítése", + "TMDB_CACHE_ERROR": "A gyorsítótár nem olvasható — próbálja újra", "TMDB_CREDITS": "Metaadatok", "TMDB_ATTRIBUTION": "Ez a termék a TMDB API-ját használja, de a TMDB nem hagyta jóvá és nem tanúsította.", "APP_PREFERENCES": "Az alkalmazás beállításai", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 809ac023a..a57ea5146 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Verifica chiave", "TMDB_TEST_SUCCESS": "La chiave funziona — TMDB ha risposto correttamente", "TMDB_TEST_FAILED": "Verifica non riuscita — TMDB ha rifiutato la chiave o non è raggiungibile", + "TMDB_CACHE": "Cache dei metadati", + "TMDB_CACHE_DESCRIPTION": "I metadati scaricati vengono salvati localmente perché le pagine dei dettagli si aprano subito. Svuotandola, l'app scaricherà dati aggiornati alla prossima visita.", + "TMDB_CACHE_SIZE": "{{entries}} voci · {{size}}", + "TMDB_CACHE_CLEAR": "Svuota cache", + "TMDB_CACHE_ERROR": "Impossibile leggere la cache — riprova", "TMDB_CREDITS": "Metadati", "TMDB_ATTRIBUTION": "Questo prodotto utilizza l'API di TMDB ma non è approvato né certificato da TMDB.", "APP_PREFERENCES": "Preferenze app", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 31fbfd08f..ccbdfeddb 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "キーを確認", "TMDB_TEST_SUCCESS": "キーは有効です — TMDB が正常に応答しました", "TMDB_TEST_FAILED": "確認に失敗しました — TMDB がキーを拒否したか、接続できません", + "TMDB_CACHE": "メタデータキャッシュ", + "TMDB_CACHE_DESCRIPTION": "ダウンロードしたメタデータをローカルに保存し、詳細ページを即座に表示します。消去すると次回アクセス時に再取得します。", + "TMDB_CACHE_SIZE": "{{entries}} 件 · {{size}}", + "TMDB_CACHE_CLEAR": "キャッシュを消去", + "TMDB_CACHE_ERROR": "キャッシュを読み取れません — 再試行してください", "TMDB_CREDITS": "メタデータ", "TMDB_ATTRIBUTION": "本製品は TMDB API を使用していますが、TMDB による承認または認証を受けたものではありません。", "APP_PREFERENCES": "アプリ設定", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index ba89e55b2..1c7351c29 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "키 확인", "TMDB_TEST_SUCCESS": "키가 작동합니다 — TMDB가 정상적으로 응답했습니다", "TMDB_TEST_FAILED": "확인에 실패했습니다 — TMDB가 키를 거부했거나 연결할 수 없습니다", + "TMDB_CACHE": "메타데이터 캐시", + "TMDB_CACHE_DESCRIPTION": "내려받은 메타데이터를 로컬에 저장해 상세 페이지가 즉시 열립니다. 지우면 다음 방문 시 새로 가져옵니다.", + "TMDB_CACHE_SIZE": "{{entries}}개 · {{size}}", + "TMDB_CACHE_CLEAR": "캐시 지우기", + "TMDB_CACHE_ERROR": "캐시를 읽지 못했습니다 — 다시 시도하세요", "TMDB_CREDITS": "메타데이터", "TMDB_ATTRIBUTION": "이 제품은 TMDB API를 사용하지만 TMDB의 보증이나 인증을 받지 않았습니다.", "APP_PREFERENCES": "앱 환경설정", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 8743e9b09..c28d9c7e3 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Sleutel controleren", "TMDB_TEST_SUCCESS": "De sleutel werkt — TMDB heeft succesvol gereageerd", "TMDB_TEST_FAILED": "Controle mislukt — TMDB heeft de sleutel geweigerd of is niet bereikbaar", + "TMDB_CACHE": "Metadata-cache", + "TMDB_CACHE_DESCRIPTION": "Gedownloade metadata wordt lokaal opgeslagen zodat detailpagina's direct openen. Na het wissen haalt de app bij het volgende bezoek verse gegevens op.", + "TMDB_CACHE_SIZE": "{{entries}} items · {{size}}", + "TMDB_CACHE_CLEAR": "Cache wissen", + "TMDB_CACHE_ERROR": "Cache kon niet worden gelezen — probeer opnieuw", "TMDB_CREDITS": "Metadata", "TMDB_ATTRIBUTION": "Dit product maakt gebruik van de TMDB API, maar wordt niet onderschreven of gecertificeerd door TMDB.", "APP_PREFERENCES": "App-voorkeuren", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 9919dc02d..46f5db3c9 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Sprawdź klucz", "TMDB_TEST_SUCCESS": "Klucz działa — TMDB odpowiedziało poprawnie", "TMDB_TEST_FAILED": "Sprawdzenie nie powiodło się — TMDB odrzuciło klucz lub jest niedostępne", + "TMDB_CACHE": "Pamięć podręczna metadanych", + "TMDB_CACHE_DESCRIPTION": "Pobrane metadane są zapisywane lokalnie, dzięki czemu strony szczegółów otwierają się natychmiast. Po wyczyszczeniu aplikacja pobierze świeże dane.", + "TMDB_CACHE_SIZE": "{{entries}} wpisów · {{size}}", + "TMDB_CACHE_CLEAR": "Wyczyść pamięć", + "TMDB_CACHE_ERROR": "Nie udało się odczytać pamięci — spróbuj ponownie", "TMDB_CREDITS": "Metadane", "TMDB_ATTRIBUTION": "Ten produkt korzysta z API TMDB, ale nie jest wspierany ani certyfikowany przez TMDB.", "APP_PREFERENCES": "Preferencje aplikacji", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 7f87a7027..da02cce09 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Verificar chave", "TMDB_TEST_SUCCESS": "A chave funciona — o TMDB respondeu com sucesso", "TMDB_TEST_FAILED": "A verificação falhou — o TMDB rejeitou a chave ou está inacessível", + "TMDB_CACHE": "Cache de metadados", + "TMDB_CACHE_DESCRIPTION": "Os metadados transferidos são guardados localmente para que as páginas de detalhes abram de imediato. Ao limpar, a app irá obter dados novos na próxima visita.", + "TMDB_CACHE_SIZE": "{{entries}} entradas · {{size}}", + "TMDB_CACHE_CLEAR": "Limpar cache", + "TMDB_CACHE_ERROR": "Não foi possível ler a cache — tente novamente", "TMDB_CREDITS": "Metadados", "TMDB_ATTRIBUTION": "Este produto usa a API do TMDB, mas não é endossado nem certificado pelo TMDB.", "APP_PREFERENCES": "Preferências do aplicativo", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 7b43ff48b..c2eb29748 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Проверить ключ", "TMDB_TEST_SUCCESS": "Ключ работает — TMDB успешно ответил", "TMDB_TEST_FAILED": "Проверка не удалась — TMDB отклонил ключ или недоступен", + "TMDB_CACHE": "Кэш метаданных", + "TMDB_CACHE_DESCRIPTION": "Загруженные метаданные хранятся локально, поэтому страницы деталей открываются мгновенно. После очистки приложение загрузит свежие данные при следующем открытии.", + "TMDB_CACHE_SIZE": "{{entries}} записей · {{size}}", + "TMDB_CACHE_CLEAR": "Очистить кэш", + "TMDB_CACHE_ERROR": "Не удалось прочитать кэш — попробуйте ещё раз", "TMDB_CREDITS": "Метаданные", "TMDB_ATTRIBUTION": "Этот продукт использует TMDB API, но не одобрен и не сертифицирован TMDB.", "APP_PREFERENCES": "Параметры приложения", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 7f03f8c15..5df212dc2 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "Anahtarı kontrol et", "TMDB_TEST_SUCCESS": "Anahtar çalışıyor — TMDB başarıyla yanıt verdi", "TMDB_TEST_FAILED": "Kontrol başarısız — TMDB anahtarı reddetti veya erişilemiyor", + "TMDB_CACHE": "Meta veri önbelleği", + "TMDB_CACHE_DESCRIPTION": "İndirilen meta veriler yerel olarak saklanır, böylece detay sayfaları anında açılır. Temizlediğinizde uygulama bir sonraki ziyarette yeni veri indirir.", + "TMDB_CACHE_SIZE": "{{entries}} kayıt · {{size}}", + "TMDB_CACHE_CLEAR": "Önbelleği temizle", + "TMDB_CACHE_ERROR": "Önbellek okunamadı — tekrar deneyin", "TMDB_CREDITS": "Meta veri", "TMDB_ATTRIBUTION": "Bu ürün TMDB API'sini kullanır ancak TMDB tarafından onaylanmamış veya sertifikalandırılmamıştır.", "APP_PREFERENCES": "Uygulama tercihleri", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 6ecf42801..3f0dc3ac2 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "检查密钥", "TMDB_TEST_SUCCESS": "密钥有效 — TMDB 响应成功", "TMDB_TEST_FAILED": "检查失败 — TMDB 拒绝了该密钥或无法访问", + "TMDB_CACHE": "元数据缓存", + "TMDB_CACHE_DESCRIPTION": "已下载的元数据保存在本地,详情页可立即打开。清除后应用会在下次访问时重新获取。", + "TMDB_CACHE_SIZE": "{{entries}} 条 · {{size}}", + "TMDB_CACHE_CLEAR": "清除缓存", + "TMDB_CACHE_ERROR": "无法读取缓存 — 请重试", "TMDB_CREDITS": "元数据", "TMDB_ATTRIBUTION": "本产品使用 TMDB API,但未获得 TMDB 的认可或认证。", "APP_PREFERENCES": "应用偏好", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 619d60d72..042b7c831 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -251,6 +251,11 @@ "TMDB_TEST_BUTTON": "檢查金鑰", "TMDB_TEST_SUCCESS": "金鑰有效 — TMDB 回應成功", "TMDB_TEST_FAILED": "檢查失敗 — TMDB 拒絕該金鑰或無法連線", + "TMDB_CACHE": "中繼資料快取", + "TMDB_CACHE_DESCRIPTION": "已下載的中繼資料儲存在本機,詳細頁面可立即開啟。清除後應用程式會在下次瀏覽時重新取得。", + "TMDB_CACHE_SIZE": "{{entries}} 筆 · {{size}}", + "TMDB_CACHE_CLEAR": "清除快取", + "TMDB_CACHE_ERROR": "無法讀取快取 — 請重試", "TMDB_CREDITS": "中繼資料", "TMDB_ATTRIBUTION": "本產品使用 TMDB API,但未經 TMDB 認可或認證。", "APP_PREFERENCES": "應用程式偏好設定", diff --git a/docs/architecture/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 49a329d9f..5c30adc29 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -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 diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts index d7a4e40bd..d9e2da417 100644 --- a/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.spec.ts @@ -50,4 +50,145 @@ describe('TmdbCacheService (in-memory LRU)', () => { const cached = await service.get('movie', 'id:same', 'en-US'); expect(cached?.lookupKey).toBe('id:same'); }); + + describe('stats and clearing (PWA path)', () => { + it('counts entries and ENCODED payload bytes', async () => { + // String.length would report 3 for a 6-byte Cyrillic payload + await service.set({ ...entry('id:1'), payload: '"да"' }); + + const stats = await service.getStats(); + expect(stats?.entries).toBe(1); + expect(stats?.bytes).toBe( + new TextEncoder().encode('"да"').length + ); + }); + + it('clears the map and reports how many entries went', async () => { + await service.set(entry('id:1')); + await service.set(entry('id:2')); + + await expect(service.clear()).resolves.toBe(2); + await expect(service.getStats()).resolves.toEqual({ + entries: 0, + bytes: 0, + }); + }); + + it('drops a write that was in flight when the cache was cleared', async () => { + // Otherwise the late write silently restores exactly the data + // the user just asked to remove + const pending = service.set(entry('id:late')); + await service.clear(); + await pending; + + await expect( + service.get('movie', 'id:late', 'en-US') + ).resolves.toBeNull(); + }); + }); +}); + +describe('TmdbCacheService (Electron bridge)', () => { + const entry = (lookupKey: string): TmdbCacheEntry => ({ + mediaType: 'movie', + lookupKey, + language: 'en-US', + tmdbId: 1, + payload: '{}', + }); + + interface ElectronStub { + dbGetTmdbMetadata: jest.Mock; + dbSetTmdbMetadata: jest.Mock; + dbGetTmdbCacheStats?: jest.Mock; + dbClearTmdbMetadata?: jest.Mock; + } + + let electron: ElectronStub; + + /** Resolves the next dbSetTmdbMetadata call on demand */ + let releaseWrite: () => void; + + beforeEach(() => { + releaseWrite = () => undefined; + electron = { + dbGetTmdbMetadata: jest.fn().mockResolvedValue(null), + dbSetTmdbMetadata: jest.fn( + () => + new Promise((resolve) => { + releaseWrite = resolve; + }) + ), + dbGetTmdbCacheStats: jest + .fn() + .mockResolvedValue({ entries: 3, bytes: 90 }), + dbClearTmdbMetadata: jest.fn().mockResolvedValue({ deleted: 3 }), + }; + (window as unknown as { electron: unknown }).electron = electron; + }); + + afterEach(() => { + delete (window as unknown as { electron?: unknown }).electron; + }); + + it('waits for an in-flight write instead of re-clearing after it', async () => { + const service = new TmdbCacheService(); + const pending = service.set(entry('id:late')); + + const cleared = service.clear(); + // The clear must not reach SQLite before the write it has to remove + await Promise.resolve(); + expect(electron.dbClearTmdbMetadata).not.toHaveBeenCalled(); + + releaseWrite(); + await pending; + await expect(cleared).resolves.toBe(3); + expect(electron.dbClearTmdbMetadata).toHaveBeenCalledTimes(1); + }); + + it('keeps rows written after the clear', async () => { + const service = new TmdbCacheService(); + await service.clear(); + + const pending = service.set(entry('id:fresh')); + releaseWrite(); + await pending; + + // A second clear here would delete data the user never asked to lose + expect(electron.dbClearTmdbMetadata).toHaveBeenCalledTimes(1); + }); + + it('lands a write that starts during the clear after the delete', async () => { + const service = new TmdbCacheService(); + const order: string[] = []; + electron.dbClearTmdbMetadata = jest.fn(async () => { + order.push('clear'); + return { deleted: 3 }; + }); + + const first = service.set(entry('id:before')); + const cleared = service.clear(); + + // Starts while the clear is waiting on the first write — without + // serialization its row would reach SQLite before the delete + const second = service.set(entry('id:during')); + electron.dbSetTmdbMetadata.mockImplementation(async () => { + order.push('write'); + }); + + releaseWrite(); + await Promise.all([first, cleared, second]); + + expect(order).toEqual(['clear', 'write']); + }); + + it('reports an unsupported shell instead of the empty renderer map', async () => { + // Newer renderer, older preload: rows are in SQLite but out of reach + delete electron.dbGetTmdbCacheStats; + delete electron.dbClearTmdbMetadata; + const service = new TmdbCacheService(); + + await expect(service.getStats()).resolves.toBeNull(); + await expect(service.clear()).resolves.toBeNull(); + }); }); diff --git a/libs/services/src/lib/tmdb/tmdb-cache.service.ts b/libs/services/src/lib/tmdb/tmdb-cache.service.ts index b2bb18d33..60dd2f975 100644 --- a/libs/services/src/lib/tmdb/tmdb-cache.service.ts +++ b/libs/services/src/lib/tmdb/tmdb-cache.service.ts @@ -1,5 +1,9 @@ import { Injectable } from '@angular/core'; -import { TmdbCacheEntry, TmdbCacheMediaType } from '@iptvnator/shared/interfaces'; +import { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from '@iptvnator/shared/interfaces'; /** PWA in-memory cache ceiling — details payloads are a few KB each */ const MEMORY_CACHE_MAX_ENTRIES = 300; @@ -18,6 +22,17 @@ const MEMORY_CACHE_MAX_ENTRIES = 300; export class TmdbCacheService { private readonly memoryCache = new Map(); + /** + * Electron writes that have not landed yet. {@link clear} waits for + * them, so a write already in flight cannot outlive the clear that + * followed it — and, unlike re-clearing after the late write lands, + * waiting cannot take rows written *after* the user cleared with it. + */ + private readonly pendingWrites = new Set>(); + + /** Set for the duration of {@link clear}; writes queue behind it */ + private clearInFlight: Promise | null = null; + private get bridge() { // typeof checks guard against version skew: an older Electron shell // may not expose the TMDB cache methods yet @@ -59,17 +74,29 @@ export class TmdbCacheService { } async set(entry: TmdbCacheEntry): Promise { - const stamped: TmdbCacheEntry = { - ...entry, - fetchedAt: new Date().toISOString(), - }; - const bridge = this.bridge; if (bridge) { + // A clear owns the table until it finishes. Dispatching now + // would race the delete and lose a row the user never asked to + // lose, so this write waits it out and lands after it. + while (this.clearInFlight) { + await this.clearInFlight; + } + + // The IIFE also converts a synchronous throw from the bridge + // into a rejection, so `set` never throws at its caller + const write = (async () => { + try { + await bridge.dbSetTmdbMetadata(this.stamp(entry)); + } catch (error) { + console.warn('TMDB cache write failed:', error); + } + })(); + this.pendingWrites.add(write); try { - await bridge.dbSetTmdbMetadata(stamped); - } catch (error) { - console.warn('TMDB cache write failed:', error); + await write; + } finally { + this.pendingWrites.delete(write); } return; } @@ -80,7 +107,7 @@ export class TmdbCacheService { entry.language ); this.memoryCache.delete(key); - this.memoryCache.set(key, stamped); + this.memoryCache.set(key, this.stamp(entry)); // delete-then-reinsert above means at most one entry over the cap if (this.memoryCache.size > MEMORY_CACHE_MAX_ENTRIES) { const oldest = this.memoryCache.keys().next().value; @@ -90,6 +117,11 @@ export class TmdbCacheService { } } + /** Stamped at write time, not at call time — a write may have waited */ + private stamp(entry: TmdbCacheEntry): TmdbCacheEntry { + return { ...entry, fetchedAt: new Date().toISOString() }; + } + isFresh(entry: TmdbCacheEntry | null, ttlMs: number): boolean { if (!entry?.fetchedAt) { return false; @@ -106,4 +138,102 @@ export class TmdbCacheService { ): string { return `${mediaType}:${language}:${lookupKey}`; } + + /** + * Cache size for the settings panel. In the PWA the cache is the + * session-scoped map, so the numbers describe that instead. + */ + async getStats(): Promise { + const bridge = this.bridge; + if (bridge) { + if (!bridge.dbGetTmdbCacheStats) { + // Version skew: this shell persists to SQLite but predates + // the maintenance ops. The renderer map is empty in + // Electron, so falling back to it would report a cache that + // is anything but empty as having nothing in it. + return null; + } + try { + return await bridge.dbGetTmdbCacheStats(); + } catch (error) { + // `null`, not zeros: reporting a failed read as an empty + // cache would hide real rows and disable the Clear button + console.warn('TMDB cache stats failed:', error); + return null; + } + } + + // TextEncoder measures encoded bytes; String.length counts UTF-16 + // code units, which understates non-ASCII payloads and would + // disagree with the SQLite BLOB byte count. + const encoder = new TextEncoder(); + let bytes = 0; + for (const entry of this.memoryCache.values()) { + bytes += entry.payload ? encoder.encode(entry.payload).length : 0; + } + return { entries: this.memoryCache.size, bytes }; + } + + /** + * Drops everything. Enrichment refetches on demand, so this only + * costs the next few requests. + */ + async clear(): Promise { + const bridge = this.bridge; + const clearedFromMemory = this.memoryCache.size; + this.memoryCache.clear(); + + if (!bridge) { + return clearedFromMemory; + } + + const clearMetadata = bridge.dbClearTmdbMetadata; + if (!clearMetadata) { + // Same version skew as getStats(): the rows are in SQLite and + // stay there, so reporting a successful clear would be a lie + return null; + } + + // Called through the bridge so the preload keeps its receiver + const run = this.runClear(() => clearMetadata.call(bridge)); + this.clearInFlight = run; + try { + return await run; + } finally { + this.clearInFlight = null; + } + } + + /** + * Everything between the writes already in flight and the delete. Held + * in `clearInFlight` for its duration, which is what puts writes that + * start meanwhile on the far side of the delete rather than under it. + */ + private async runClear( + clearMetadata: () => Promise<{ deleted: number } | undefined> + ): Promise { + // Writes issued before the clear land first, so the delete takes + // them too. + if (this.pendingWrites.size > 0) { + // Not Promise.allSettled: the web target compiles against + // lib es2018. The writes swallow their own errors anyway. + await Promise.all( + [...this.pendingWrites].map((write) => + write.then( + () => undefined, + () => undefined + ) + ) + ); + } + + try { + const result = await clearMetadata(); + return result?.deleted ?? 0; + } catch (error) { + // `null` so the panel can say it failed and stay actionable + console.warn('TMDB cache clear failed:', error); + return null; + } + } } diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index fe63da397..b36355207 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -32,7 +32,11 @@ import { import { PortalDebugEvent } from './portal-debug.interface'; import { CatalogTitleMatch } from './catalog-title-match.interface'; import { Settings } from './settings.interface'; -import { TmdbCacheEntry, TmdbCacheMediaType } from './tmdb.interface'; +import { + TmdbCacheEntry, + TmdbCacheMediaType, + TmdbCacheStats, +} from './tmdb.interface'; import { XtreamCategory } from './xtream-category.interface'; export const ELECTRON_BRIDGE_CONTENT_TYPES = { @@ -821,6 +825,11 @@ export interface ElectronBridgeApi { language: string ) => Promise; dbSetTmdbMetadata: (entry: TmdbCacheEntry) => Promise; + /** Row count + payload bytes for the settings cache panel */ + dbGetTmdbCacheStats: () => Promise; + dbClearTmdbMetadata: () => Promise< + ElectronBridgeResult & { deleted: number } + >; /** Cross-playlist title matching (actor page "All portals" scope) */ dbMatchTitles: (titles: string[]) => Promise; onChannelChange?: ( diff --git a/libs/shared/interfaces/src/lib/tmdb.interface.ts b/libs/shared/interfaces/src/lib/tmdb.interface.ts index 0174b3265..a00db1381 100644 --- a/libs/shared/interfaces/src/lib/tmdb.interface.ts +++ b/libs/shared/interfaces/src/lib/tmdb.interface.ts @@ -106,6 +106,13 @@ export interface TmdbRecommendation { * - `title:|year:` — search resolution result; a `null` * `tmdbId` is a cached "no confident match" verdict (negative cache) */ +/** Size of the persisted TMDB cache, shown in the settings panel */ +export interface TmdbCacheStats { + entries: number; + /** Total payload size in bytes */ + bytes: number; +} + export interface TmdbCacheEntry { mediaType: TmdbCacheMediaType; lookupKey: string; diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts index b4d59da5b..50e1e1e23 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/workspace-settings-context-panel.component.ts @@ -17,6 +17,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util'; type="button" class="nav-item settings-section-item" [class.active]="ctx.activeSection() === section.id" + [attr.data-test-id]="'settings-section-' + section.id" (click)="ctx.navigateToSection(section.id)" > {{ section.icon }}