mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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:
1 parent
d76b2d2a57
commit
0b967d66d4
39 files changed
+888
-24
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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": "تفضيلات التطبيق",
|
||||
|
||||
@@ -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": "تفضيلات التطبيق",
|
||||
|
||||
@@ -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": "Параметры праграмы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Προτιμήσεις εφαρμογής",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アプリ設定",
|
||||
|
||||
@@ -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": "앱 환경설정",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Параметры приложения",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "应用偏好",
|
||||
|
||||
@@ -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": "應用程式偏好設定",
|
||||
|
||||
Reference in new issue
Block a user