231887c3b7
Adds DownloadJob/ExternalSearchResult/SourceInfo contract types + mappers, the downloads + search RTKQ endpoints, and the SearchDownloadPage (search external sources, per-result download states) and DownloadsManagerPage (active/history, progress, retry/cancel, poll-while-active). en/ru i18n. Snapshot also bundles in-progress queue/metadata-editor/storage UI work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
91 lines
3.0 KiB
TypeScript
91 lines
3.0 KiB
TypeScript
/*
|
|
* Tier 2 offline support: persist the RTK Query cache (last-seen server data —
|
|
* library/albums/artists/…) to the active backend's localStorage namespace and
|
|
* replay it into the cache on startup. With the backend down, components keep
|
|
* rendering the last-known data read-only instead of an error state.
|
|
*
|
|
* Per the architecture invariant, server data is NOT copied into a slice: this
|
|
* snapshots the RTKQ cache itself and feeds it back through RTKQ's own
|
|
* `extractRehydrationInfo` mechanism (see `api/index.ts`).
|
|
*/
|
|
import { instanceStorage } from '../config/instances';
|
|
import { rehydrateApi, type RehydrateApiPayload } from '../api/rehydrate';
|
|
import type { RootState } from './index';
|
|
|
|
const CACHE_KEY = 'rtkq';
|
|
|
|
type ApiState = RootState['api'];
|
|
type QueryEntry = ApiState['queries'][string];
|
|
|
|
/**
|
|
* Keep only successfully-fulfilled query results — pending/rejected entries
|
|
* carry no usable data and subscriptions are rebuilt by components on mount.
|
|
* Mutation results are never restored.
|
|
*/
|
|
const EMPTY_PROVIDED = { tags: {}, keys: {} };
|
|
|
|
function snapshot(apiState: ApiState): RehydrateApiPayload {
|
|
const queries: Record<string, unknown> = {};
|
|
for (const [key, entry] of Object.entries(apiState.queries)) {
|
|
const q = entry as QueryEntry | undefined;
|
|
if (q && q.status === 'fulfilled') queries[key] = q;
|
|
}
|
|
// Carry `provided` along so RTKQ can re-register invalidation tags for the
|
|
// restored entries; it is also required structurally (see RehydrateApiPayload).
|
|
return {
|
|
queries,
|
|
mutations: {},
|
|
provided: apiState.provided ?? EMPTY_PROVIDED,
|
|
};
|
|
}
|
|
|
|
function load(): RehydrateApiPayload | null {
|
|
try {
|
|
const raw = instanceStorage.get(CACHE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as Partial<RehydrateApiPayload>;
|
|
if (!parsed.queries) return null;
|
|
// `provided` may be absent in snapshots written before this field existed —
|
|
// default it so the invalidation slice doesn't crash on `provided.tags`.
|
|
return {
|
|
queries: parsed.queries,
|
|
mutations: {},
|
|
provided: parsed.provided ?? EMPTY_PROVIDED,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Replay the persisted cache into RTKQ. Call once after the store is created. */
|
|
export function rehydrateApiCache(dispatch: (action: unknown) => void): void {
|
|
const cached = load();
|
|
if (cached) dispatch(rehydrateApi(cached));
|
|
}
|
|
|
|
/**
|
|
* Subscribe a store so the RTKQ cache is flushed to localStorage. Throttled,
|
|
* since cache state churns on every in-flight query transition.
|
|
*/
|
|
export function startApiPersistence(store: {
|
|
getState: () => RootState;
|
|
subscribe: (listener: () => void) => () => void;
|
|
}): () => void {
|
|
let last = '';
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const flush = () => {
|
|
timer = null;
|
|
const snap = JSON.stringify(snapshot(store.getState().api));
|
|
if (snap !== last) {
|
|
instanceStorage.set(CACHE_KEY, snap);
|
|
last = snap;
|
|
}
|
|
};
|
|
|
|
return store.subscribe(() => {
|
|
if (timer) return;
|
|
timer = setTimeout(flush, 2000);
|
|
});
|
|
}
|