Cache the merged media catalog and trim card rendering

This commit is contained in:
Fisto
2026-09-21 10:52:18 +02:00
parent 41732bd96f
commit 6761a2192e
5 changed files with 116 additions and 5 deletions
+63
View File
@@ -191,6 +191,13 @@ export class MediaService {
/** The newest bot and scrape errors, capped. Surfaced in the Bot tab. */
private readonly errors: MediaErrorEntry[] = [];
/**
* Merged-candidate cache. Every grid request otherwise rescans every scrape database; the
* key includes a revision of the scrape metadata and preview counters, so new pages, state
* changes, and fresh previews invalidate it. The TTL catches anything the revision misses.
*/
private readonly candidateCache = new Map<string, { at: number; value: Array<{ meta: ScrapeMeta; row: MediaRow }> }>();
/** One-shot maintenance: re-fetch previews for Discord rows closed out with HTTP errors. */
private readonly recover = {
state: "idle" as "idle" | "running" | "paused" | "done",
@@ -1168,6 +1175,14 @@ export class MediaService {
* wins by default; an older row replaces it only when it is richer (downloaded, or with a
* cached preview). Without this, a stopped or partial scrape hides usable previews.
*/
/** Cheap change signal for the candidate cache: ids, states, update times, preview counts. */
private catalogRevision(): string {
return this.engine
.list()
.map(meta => `${meta.id}:${meta.state}:${meta.updatedAt}:${meta.previewed ?? 0}`)
.join("|");
}
private collectCandidates(options: {
kind?: MediaKind;
source?: string;
@@ -1195,6 +1210,54 @@ export class MediaService {
after?: string;
before?: string;
sort?: MediaSort;
}): Array<{ meta: ScrapeMeta; row: MediaRow }> {
// Drop paging and empty values: every page of one filter set shares a single scan.
const shape: Record<string, unknown> = {};
for (const [name, value] of Object.entries(options)) {
if (name === "limit" || name === "offset") continue;
if (value === undefined || value === null || value === "") continue;
if (Array.isArray(value) && value.length === 0) continue;
shape[name] = value;
}
const key = `${this.catalogRevision()}\u0000${JSON.stringify(shape)}`;
const hit = this.candidateCache.get(key);
const now = Date.now();
if (hit && now - hit.at < 15_000) return hit.value;
const value = this.collectCandidatesUncached(options);
if (this.candidateCache.size > 60) this.candidateCache.clear();
this.candidateCache.set(key, { at: now, value });
return value;
}
private collectCandidatesUncached(options: {
kind?: MediaKind;
source?: string;
scrapeId?: string;
channelId?: string;
authorId?: string;
authorIds?: string[];
origin?: "dm" | "guild";
cachedOnly?: boolean;
exts?: string[];
excludeHosts?: string[];
excludeExts?: string[];
excludeProviders?: string[];
excludeSources?: string[];
includeProviders?: string[];
includeSources?: string[];
titleQuery?: string;
titleExact?: boolean;
sizeLessThan?: number;
sizeGreaterThan?: number;
lengthLessThan?: number;
lengthGreaterThan?: number;
aspect?: string;
resolution?: string;
after?: string;
before?: string;
sort?: MediaSort;
}): Array<{ meta: ScrapeMeta; row: MediaRow }> {
const candidates = new Map<string, { meta: ScrapeMeta; row: MediaRow }>();