Cache the merged media catalog and trim card rendering
This commit is contained in:
@@ -40,6 +40,14 @@ mobile draft at 390px.
|
|||||||
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
|
||||||
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
|
||||||
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
|
||||||
|
- [x] C61 Media browsing performance. The grid's merge step rescanned every scrape database per
|
||||||
|
request (about 300 ms for a page, 250 ms for stats, both refetched on every scroll append).
|
||||||
|
`MediaService` now keeps a candidate cache keyed by a scrape revision plus the normalized
|
||||||
|
filter shape (paging and empties dropped): one scan per filter set, then ~5 ms per page, and
|
||||||
|
stats share the scan. The client skips stats when the filters are unchanged (60 s TTL), updates
|
||||||
|
only the pager instead of rebuilding the toolbar on page loads, and renders tiles with
|
||||||
|
`content-visibility`/`decoding=async`/low fetch priority. Measured: 302 ms first page, 5 ms
|
||||||
|
later pages; smoke test streamed 2,480 tiles with no console errors.
|
||||||
- [x] C60 Incremental re-scrapes and archive cleanup. A new scrape of a channel that already
|
- [x] C60 Incremental re-scrapes and archive cleanup. A new scrape of a channel that already
|
||||||
has a completed walk now starts after that walk's newest message (the best completed scrape by
|
has a completed walk now starts after that walk's newest message (the best completed scrape by
|
||||||
message count), so it only covers the newer gap instead of re-fetching history; the previous
|
message count), so it only covers the newer gap instead of re-fetching history; the previous
|
||||||
|
|||||||
@@ -190,6 +190,17 @@ Previews run after every walk:
|
|||||||
of 25 with a 60 s cooldown between them, because bursts of thumbnail fetches trigger Discord and
|
of 25 with a 60 s cooldown between them, because bursts of thumbnail fetches trigger Discord and
|
||||||
provider rate limits. A batch counts rows, not successes.
|
provider rate limits. A batch counts rows, not successes.
|
||||||
|
|
||||||
|
## Browsing performance
|
||||||
|
|
||||||
|
Every grid request used to rescan every scrape database to merge URLs. `MediaService` now keeps a
|
||||||
|
short-lived candidate cache: the key is a revision of the scrape metadata (id, state, update time,
|
||||||
|
preview count) plus the normalized filter shape — page limits, offsets, and empty values are
|
||||||
|
dropped, so every page of one filter set shares a single scan and later pages return in
|
||||||
|
milliseconds. The 15 s TTL and the revision key cover new pages, finished scrapes, and freshly
|
||||||
|
cached previews. The client only refetches stats when the filters change (or after a minute), and
|
||||||
|
grid cards use lazy async decoding with CSS `content-visibility` so a long stream does not pay
|
||||||
|
layout and paint for off-screen tiles.
|
||||||
|
|
||||||
## Rate limits
|
## Rate limits
|
||||||
|
|
||||||
Discord and the provider APIs throttle bursts. The engine detects limiting and holds all media
|
Discord and the provider APIs throttle bursts. The engine detects limiting and holds all media
|
||||||
|
|||||||
@@ -78,6 +78,12 @@
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skip layout and paint for cards far off screen: the DOM can grow to thousands. */
|
||||||
|
.vk-media-figure {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 170px;
|
||||||
|
}
|
||||||
|
|
||||||
.vk-media-figure {
|
.vk-media-figure {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+28
-5
@@ -359,14 +359,23 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats(force = false) {
|
||||||
|
const key = filterQuery();
|
||||||
|
const fresh = state.statsAt && Date.now() - state.statsAt < 60_000;
|
||||||
|
if (!force && fresh && state.stats && state.statsKey === key) {
|
||||||
|
renderStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.stats = await window.VK.api.get(`/api/media/stats?${filterQuery()}`);
|
state.stats = await window.VK.api.get(`/api/media/stats?${key}`);
|
||||||
|
state.statsKey = key;
|
||||||
|
state.statsAt = Date.now();
|
||||||
} catch {
|
} catch {
|
||||||
state.stats = null;
|
state.stats = null;
|
||||||
}
|
}
|
||||||
renderStatus();
|
renderStatus();
|
||||||
if (state.pageMode === "page" && state.view === "media") renderToolbar();
|
updatePager();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Append only the cards that are new, so existing images never get rebuilt. */
|
/** Append only the cards that are new, so existing images never get rebuilt. */
|
||||||
@@ -444,13 +453,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
layoutBuffer();
|
layoutBuffer();
|
||||||
void loadStats();
|
void loadStats(reset);
|
||||||
if (state.pageMode === "infinite") {
|
if (state.pageMode === "infinite") {
|
||||||
void prefetchNext();
|
void prefetchNext();
|
||||||
void fillBuffer();
|
void fillBuffer();
|
||||||
} else {
|
} else {
|
||||||
state.pageCooldownUntil = Date.now() + 2000;
|
state.pageCooldownUntil = Date.now() + 2000;
|
||||||
renderToolbar();
|
updatePager();
|
||||||
void prefetchNext();
|
void prefetchNext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1307,6 +1316,8 @@
|
|||||||
if (imageSource) {
|
if (imageSource) {
|
||||||
const img = document.createElement("img");
|
const img = document.createElement("img");
|
||||||
img.loading = "lazy";
|
img.loading = "lazy";
|
||||||
|
img.decoding = "async";
|
||||||
|
img.fetchPriority = "low";
|
||||||
img.src = imageSource;
|
img.src = imageSource;
|
||||||
img.alt = item.kind;
|
img.alt = item.kind;
|
||||||
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
||||||
@@ -1389,9 +1400,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Dropdown with the metadata-field checkboxes. */
|
/** Dropdown with the metadata-field checkboxes. */
|
||||||
|
/** Re-render only the pager after a page load, not the whole toolbar. */
|
||||||
|
function updatePager() {
|
||||||
|
if (state.pageMode !== "page") return;
|
||||||
|
if (!state.pagerHost?.isConnected) return;
|
||||||
|
state.pagerHost.replaceWith(pageControls());
|
||||||
|
}
|
||||||
|
|
||||||
/** Infinite/page flippy toggle plus the pager shown in page mode. */
|
/** Infinite/page flippy toggle plus the pager shown in page mode. */
|
||||||
function pageControls() {
|
function pageControls() {
|
||||||
const wrap = el("span", "vk-media-page-controls");
|
const wrap = el("span", "vk-media-page-controls");
|
||||||
|
state.pagerHost = wrap;
|
||||||
|
|
||||||
const toggle = el("span", "vk-media-toggle");
|
const toggle = el("span", "vk-media-toggle");
|
||||||
for (const [mode, label] of [
|
for (const [mode, label] of [
|
||||||
@@ -1514,6 +1533,8 @@
|
|||||||
function applyDraft() {
|
function applyDraft() {
|
||||||
state.page = 1;
|
state.page = 1;
|
||||||
state.pagePrefetch = null;
|
state.pagePrefetch = null;
|
||||||
|
state.statsKey = null;
|
||||||
|
state.statsAt = 0;
|
||||||
savePage();
|
savePage();
|
||||||
state.kind = state.draft.kind;
|
state.kind = state.draft.kind;
|
||||||
state.filters = {
|
state.filters = {
|
||||||
@@ -2035,6 +2056,8 @@
|
|||||||
state.page = 1;
|
state.page = 1;
|
||||||
state.pageCooldownUntil = 0;
|
state.pageCooldownUntil = 0;
|
||||||
state.pagePrefetch = null;
|
state.pagePrefetch = null;
|
||||||
|
state.statsKey = null;
|
||||||
|
state.statsAt = 0;
|
||||||
savePage();
|
savePage();
|
||||||
state.prefetched = null;
|
state.prefetched = null;
|
||||||
state.items = [];
|
state.items = [];
|
||||||
|
|||||||
@@ -191,6 +191,13 @@ export class MediaService {
|
|||||||
/** The newest bot and scrape errors, capped. Surfaced in the Bot tab. */
|
/** The newest bot and scrape errors, capped. Surfaced in the Bot tab. */
|
||||||
private readonly errors: MediaErrorEntry[] = [];
|
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. */
|
/** One-shot maintenance: re-fetch previews for Discord rows closed out with HTTP errors. */
|
||||||
private readonly recover = {
|
private readonly recover = {
|
||||||
state: "idle" as "idle" | "running" | "paused" | "done",
|
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
|
* 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.
|
* 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: {
|
private collectCandidates(options: {
|
||||||
kind?: MediaKind;
|
kind?: MediaKind;
|
||||||
source?: string;
|
source?: string;
|
||||||
@@ -1195,6 +1210,54 @@ export class MediaService {
|
|||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
sort?: MediaSort;
|
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 }> {
|
}): Array<{ meta: ScrapeMeta; row: MediaRow }> {
|
||||||
const candidates = new Map<string, { meta: ScrapeMeta; row: MediaRow }>();
|
const candidates = new Map<string, { meta: ScrapeMeta; row: MediaRow }>();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user