Add message links, suspect retries, and Discord preview recovery

This commit is contained in:
Fisto
2026-09-18 11:47:08 +02:00
parent 9481286169
commit 8752b36209
16 changed files with 831 additions and 35 deletions
+162 -12
View File
@@ -41,6 +41,7 @@
pageSize: 60,
backfill: null,
titles: null,
recover: null,
preferences: null,
/** Scrapes that finished while this page was open, until the tab is reloaded. */
completed: new Set()
@@ -77,7 +78,16 @@
</div>
<div id="backfill-summary" class="vk-bot-summary"></div>
<div id="title-lane" class="vk-bot-lane"></div>
<div id="recover-lane" class="vk-bot-lane"></div>
<div id="backfill-jobs" class="vk-bot-jobs"></div>
<div id="task-history" class="vk-bot-history"></div>
<div class="vk-bot-hint">
Failure policy: a failed preview gets one delayed retry (about 50 s). If a
batch is two-thirds suspect failures, the cooldown doubles (60 s floor) and
the retries run before the next batch. Rows that still fail are left
unverified and skipped by later passes; the recovery task above retries
Discord-CDN ones on demand.
</div>
</div>
<div class="vk-bot-section">
@@ -104,6 +114,8 @@
const backfillSummary = overlay.querySelector("#backfill-summary");
const backfillJobs = overlay.querySelector("#backfill-jobs");
const titleLane = overlay.querySelector("#title-lane");
const recoverLane = overlay.querySelector("#recover-lane");
const taskHistory = overlay.querySelector("#task-history");
const tunablesHost = overlay.querySelector("#bot-tunables");
const botErrors = overlay.querySelector("#bot-errors");
@@ -793,15 +805,24 @@
"vk-bot-job-member",
`${member.scrapeId} \u00b7 ${member.state} \u00b7 ${member.mode} \u00b7 ` +
`${member.backend} \u00b7 ${member.pending} pending` +
(member.suspect ? ` + ${member.suspect} suspect` : "") +
(member.unverified ? ` + ${member.unverified} unverified` : "") +
(kinds ? ` (${kinds})` : "")
)
);
}
const total = Math.max(1, job.total);
const progress = el("div", "vk-bot-progress");
const fill = el("div", "vk-bot-progress-fill");
fill.style.width = `${Math.min(100, Math.round((job.cached / Math.max(1, job.total)) * 100))}%`;
progress.append(fill);
for (const [className, value] of [
["vk-bot-progress-cached", job.cached],
["vk-bot-progress-pending", job.pending + (job.suspect ?? 0)],
["vk-bot-progress-failed", job.failed]
]) {
const segment = el("div", className);
segment.style.width = `${Math.min(100, (value / total) * 100)}%`;
progress.append(segment);
}
row.append(progress);
const actions = el("div", "vk-bot-job-actions");
@@ -879,6 +900,76 @@
renderTitleLane(state.titles);
}
function renderRecover(status) {
if (!status) return;
recoverLane.replaceChildren();
const head = el("div", "vk-bot-job-head");
head.append(el("span", "vk-bot-job-title", "Recover Discord previews"));
head.append(el("span", `vk-bot-state vk-bot-state-${status.state}`, status.state));
const parts = [`${status.total} to retry`];
if (status.processed) parts.push(`${status.processed} processed`);
if (status.recovered) parts.push(`${status.recovered} recovered`);
if (status.gone) parts.push(`${status.gone} unavailable`);
if (status.currentScrapeId) parts.push(`on ${channelLabel(status.currentScrapeId)}`);
head.append(el("span", "vk-bot-job-meta", parts.join(" \u00b7 ")));
recoverLane.append(head);
const actions = el("div", "vk-bot-job-actions");
if (status.state === "running") {
actions.append(button("Pause", () => void recoverAction("pause")));
} else if (status.total > 0) {
actions.append(button("Start", () => void recoverAction("start")));
}
recoverLane.append(actions);
recoverLane.append(
el(
"div",
"vk-bot-job-member",
"Re-fetches previews for Discord CDN rows closed as gone, refreshing each signed " +
"URL through the source message (bot or puppet). Runs once; the result lands in " +
"the history below."
)
);
renderTaskHistory(status.history ?? []);
}
function renderTaskHistory(history) {
taskHistory.replaceChildren();
if (!history.length) return;
taskHistory.append(el("div", "vk-bot-history-title", "Task history"));
for (const entry of history) {
taskHistory.append(
el(
"div",
"vk-bot-history-entry",
`${new Date(entry.at).toLocaleString()} \u00b7 ${entry.name} \u00b7 ${entry.detail}`
)
);
}
}
async function recoverAction(action) {
try {
state.recover = await window.VK.api.post(`/api/media/recover/${action}`, {});
renderRecover(state.recover);
} catch (error) {
setStatus(`recover ${action} failed: ${error.message}`);
}
}
async function refreshRecover() {
try {
state.recover = await window.VK.api.get("/api/media/recover");
} catch {
state.recover = null;
}
renderRecover(state.recover);
}
async function refreshBackfill() {
try {
state.backfill = await window.VK.api.get("/api/media/backfill");
@@ -1043,7 +1134,10 @@
["date", "Date"],
["source", "Source"],
["resolution", "Resolution"],
["length", "Length"]
["length", "Length"],
["channel", "Channel"],
["scrape", "Scrape"],
["title", "Title"]
];
/** "mp4 video", "webp image", "YouTube video", ... */
@@ -1062,7 +1156,14 @@
date: item.postedAt ? new Date(item.postedAt).toLocaleString() : "—",
source: item.provider ?? "discord",
resolution: item.width && item.height ? `${item.width}x${item.height}` : "—",
length: item.durationSecs ? `${item.durationSecs.toFixed(1)}s` : "—"
length: item.durationSecs ? `${item.durationSecs.toFixed(1)}s` : "—",
channel: channelLabel(item.channelId),
scrape: item.scrapeId,
title: item.title
? item.titleAuthor
? `${item.title} — ${item.titleAuthor}`
: item.title
: ""
};
}
@@ -1819,6 +1920,7 @@
if (!state.preferences) void refreshPreferences();
void refreshBackfill();
void refreshTitles();
void refreshRecover();
void loadScrapes().then(renderScrapes);
void refreshErrors();
} else {
@@ -1839,6 +1941,7 @@
if (state.view === "bot") {
void refreshBackfill();
void refreshTitles();
void refreshRecover();
}
});
@@ -1872,6 +1975,7 @@
if (state.view === "bot") {
void refreshBackfill();
void refreshTitles();
void refreshRecover();
}
}, 3000);
@@ -2290,6 +2394,22 @@
// ---- Media viewer ----
/** The canonical watch URL for YouTube items, for the fallback button. */
function youtubeWatchUrl(item) {
try {
const url = new URL(item.url);
const host = url.hostname.replace(/^www\./, "");
if (host === "youtube.com" || host === "m.youtube.com") {
const id = url.searchParams.get("v") ?? /^\/shorts\/([A-Za-z0-9_-]+)/.exec(url.pathname)?.[1];
return id ? `https://www.youtube.com/watch?v=${id}` : null;
}
if (host === "youtu.be") return `https://www.youtube.com/watch${url.pathname}`;
} catch {
// not a URL
}
return null;
}
function embedUrlFor(item) {
if (item.source !== "embed") return null;
@@ -2396,26 +2516,38 @@
meta.replaceChildren();
const values = metaValues(item);
for (const [key, label] of META_FIELDS) {
if (!values[key]) continue;
const line = el("div", "vk-media-meta-line");
line.append(el("span", "vk-media-meta-key", `${label}: `));
line.append(el("span", null, values[key]));
meta.append(line);
}
const link = el("button", "vk-media-link", item.url);
// The permanent message link, not the expiring CDN URL.
const linkUrl = item.messageUrl ?? item.url;
const link = el("button", "vk-media-link", linkUrl);
link.type = "button";
link.title = "Open the original link";
link.addEventListener("click", () => openLinkWarning(item.url));
link.title = item.messageUrl ? "Open the message" : "Open the original link";
link.addEventListener("click", () => openLinkWarning(linkUrl));
meta.append(link);
// "View full" opens the embedded media (player, local file, or a refreshed stream).
// "View full" plays in a new tab: the stream endpoint for Discord media (always a fresh
// source), the provider player for embeddable items.
const streamPath = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
const fullUrl =
embedUrlFor(item) ??
item.localUrl ??
(item.kind === "video" ? streamPath : item.playUrl ?? item.thumbUrl ?? streamPath);
item.messageUrl && item.kind === "video"
? streamPath
: embedUrlFor(item) ??
item.localUrl ??
(item.kind === "video" ? streamPath : item.playUrl ?? item.thumbUrl ?? streamPath);
meta.append(button("View full", () => window.open(fullUrl, "_blank", "noreferrer")));
// Embeds break for age-gated or embed-disabled videos: keep a direct provider link.
const watchUrl = youtubeWatchUrl(item);
if (watchUrl) {
meta.append(button("Open on YouTube", () => window.open(watchUrl, "_blank", "noreferrer")));
}
viewerModal.hidden = false;
}
@@ -2437,6 +2569,7 @@
<div id="link-target" class="vk-media-status"></div>
<div class="vk-media-status">This opens a new website in a new tab.</div>
<div class="vk-media-toolbar">
<button id="link-copy" class="btn btn-secondary vk-small-btn" type="button">Copy link</button>
<button id="link-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
<button id="link-continue" class="btn btn-primary vk-small-btn" type="button">Continue</button>
</div>
@@ -2448,12 +2581,29 @@
function openLinkWarning(url) {
pendingLink = url;
linkModal.querySelector("#link-copy").textContent = "Copy link";
linkModal.querySelector("#link-target").textContent = url;
linkModal.hidden = false;
}
linkModal.querySelector("#link-copy").addEventListener("click", async () => {
if (!pendingLink) return;
try {
await navigator.clipboard.writeText(pendingLink);
} catch {
const area = document.createElement("textarea");
area.value = pendingLink;
document.body.append(area);
area.select();
document.execCommand("copy");
area.remove();
}
linkModal.querySelector("#link-copy").textContent = "Copied";
});
linkModal.querySelector("#link-cancel").addEventListener("click", () => {
linkModal.hidden = true;
linkModal.querySelector("#link-copy").textContent = "Copy link";
});
linkModal.querySelector("#link-continue").addEventListener("click", () => {
if (pendingLink) window.open(pendingLink, "_blank", "noreferrer");