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 }}