Cache the merged media catalog and trim card rendering
This commit is contained in:
@@ -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 }>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user