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

+8
View File
@@ -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.
+2 -2
View File
@@ -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:<id>` 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`
@@ -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(
@@ -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]],
@@ -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
@@ -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);
});
});
@@ -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<TmdbCacheStats> {
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 };
}
@@ -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 }));
@@ -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']],
@@ -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;
@@ -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);
@@ -99,6 +99,47 @@
</div>
}
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.TMDB_CACHE' | translate }}</h4>
<p>
{{ 'SETTINGS.TMDB_CACHE_DESCRIPTION' | translate }}
</p>
<p class="tmdb-cache__size" data-test-id="tmdb-cache-size">
@if (cacheError()) {
<span class="tmdb-cache__error">
{{ 'SETTINGS.TMDB_CACHE_ERROR' | translate }}
</span>
} @else if (cacheStats(); as stats) {
{{
'SETTINGS.TMDB_CACHE_SIZE'
| translate
: {
entries: stats.entries,
size: formatBytes(stats.bytes),
}
}}
}
</p>
</div>
<div class="setting-item__control">
<button
mat-stroked-button
type="button"
[disabled]="isClearing() || cacheStats()?.entries === 0"
(click)="clearCache()"
data-test-id="tmdb-clear-cache"
>
@if (isClearing()) {
<mat-spinner diameter="16" />
} @else {
<mat-icon>delete_sweep</mat-icon>
}
{{ 'SETTINGS.TMDB_CACHE_CLEAR' | translate }}
</button>
</div>
</div>
<div class="setting-item setting-item--full-control">
<div class="setting-item__meta">
<img
@@ -0,0 +1,143 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { TmdbApiService, TmdbCacheService } from '@iptvnator/services';
import { SettingsTmdbSectionComponent } from './settings-tmdb-section.component';
const CACHE_SIZE_LABEL = '{{entries}} entries · {{size}}';
const CACHE_ERROR_LABEL = 'Could not read the cache';
describe('SettingsTmdbSectionComponent', () => {
let fixture: ComponentFixture<SettingsTmdbSectionComponent>;
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');
});
});
@@ -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<FormGroup>();
readonly activeSection = input.required<string>();
readonly keyTestState = signal<TmdbKeyTestState>('idle');
readonly cacheStats = signal<TmdbCacheStats | null>(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<void> {
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<void> {
const stats = await this.tmdbCache.getStats();
this.cacheError.set(stats === null);
this.cacheStats.set(stats);
}
}
+5
View File
@@ -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": "تفضيلات التطبيق",
+5
View File
@@ -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": "تفضيلات التطبيق",
+5
View File
@@ -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": "Параметры праграмы",
+5
View File
@@ -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",
+5
View File
@@ -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": "Προτιμήσεις εφαρμογής",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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": "アプリ設定",
+5
View File
@@ -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": "앱 환경설정",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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",
+5
View File
@@ -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": "Параметры приложения",
+5
View File
@@ -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",
+5
View File
@@ -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": "应用偏好",
+5
View File
@@ -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": "應用程式偏好設定",
+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
@@ -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<void>((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();
});
});
+140 -10
View File
@@ -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<string, TmdbCacheEntry>();
/**
* 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<Promise<unknown>>();
/** Set for the duration of {@link clear}; writes queue behind it */
private clearInFlight: Promise<unknown> | 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<void> {
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<TmdbCacheStats | null> {
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<number | null> {
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<number | null> {
// 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;
}
}
}
@@ -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<TmdbCacheEntry | null>;
dbSetTmdbMetadata: (entry: TmdbCacheEntry) => Promise<ElectronBridgeResult>;
/** Row count + payload bytes for the settings cache panel */
dbGetTmdbCacheStats: () => Promise<TmdbCacheStats>;
dbClearTmdbMetadata: () => Promise<
ElectronBridgeResult & { deleted: number }
>;
/** Cross-playlist title matching (actor page "All portals" scope) */
dbMatchTitles: (titles: string[]) => Promise<CatalogTitleMatch[]>;
onChannelChange?: (
@@ -106,6 +106,13 @@ export interface TmdbRecommendation {
* - `title:<normalized>|year:<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;
@@ -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)"
>
<mat-icon>{{ section.icon }}</mat-icon>