2144 lines
82 KiB
JavaScript
2144 lines
82 KiB
JavaScript
"use strict";
|
|
|
|
/*
|
|
* Media archive and slopbot views.
|
|
*
|
|
* Self-contained: this file owns its overlay, so app.js does not change except
|
|
* for the two scope buttons in index.html. See docs/media.md.
|
|
*/
|
|
|
|
(() => {
|
|
const KINDS = ["all", "image", "gif", "video", "audio"];
|
|
const KIND_LABELS = { all: "All", image: "Images", gif: "GIFs", video: "Videos", audio: "Audio" };
|
|
|
|
const state = {
|
|
view: null,
|
|
kind: "all",
|
|
offset: 0,
|
|
items: [],
|
|
scrapes: [],
|
|
channels: [],
|
|
guilds: [],
|
|
dms: [],
|
|
loadedChannels: false,
|
|
authors: null,
|
|
errors: [],
|
|
loaded: false,
|
|
loading: false,
|
|
more: false,
|
|
stats: null,
|
|
metaFields: JSON.parse(
|
|
localStorage.getItem("vk-media-meta-fields") ??
|
|
'["user","type","date","source","resolution"]'
|
|
),
|
|
filters: defaultFilters(),
|
|
zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50),
|
|
draft: null,
|
|
prefetched: null,
|
|
pageSize: 60,
|
|
backfill: null,
|
|
preferences: null,
|
|
/** Scrapes that finished while this page was open, until the tab is reloaded. */
|
|
completed: new Set()
|
|
};
|
|
|
|
const overlay = document.createElement("div");
|
|
overlay.className = "vk-media-overlay";
|
|
overlay.hidden = true;
|
|
overlay.innerHTML = `
|
|
<div class="vk-media-panel">
|
|
<div class="vk-media-head">
|
|
<span id="media-title" class="vk-media-title">Media archive</span>
|
|
<span id="media-refresh" class="btn btn-secondary vk-small-btn">Refresh</span>
|
|
<span id="media-close" class="btn btn-secondary vk-small-btn">Close</span>
|
|
</div>
|
|
<div id="media-toolbar" class="vk-media-toolbar"></div>
|
|
<div id="media-status" class="vk-media-status"></div>
|
|
<div id="media-scrapes" class="vk-media-scrapes"></div>
|
|
<div id="media-grid" class="vk-media-grid"></div>
|
|
<div id="media-bot" class="vk-media-bot" hidden>
|
|
<div id="bot-limited" class="vk-bot-limited" hidden></div>
|
|
|
|
<div class="vk-bot-section">
|
|
<div class="vk-bot-head">
|
|
<span class="vk-bot-title">Backfill</span>
|
|
<span class="vk-bot-actions">
|
|
<button id="backfill-start" class="btn btn-primary vk-small-btn" type="button">
|
|
Start backfill
|
|
</button>
|
|
<button id="backfill-pause" class="btn btn-secondary vk-small-btn" type="button">
|
|
Pause all
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<div id="backfill-summary" class="vk-bot-summary"></div>
|
|
<div id="backfill-jobs" class="vk-bot-jobs"></div>
|
|
</div>
|
|
|
|
<div class="vk-bot-section">
|
|
<div class="vk-bot-head"><span class="vk-bot-title">Settings</span></div>
|
|
<div id="bot-tunables" class="vk-bot-settings"></div>
|
|
</div>
|
|
|
|
<div class="vk-bot-section">
|
|
<div class="vk-bot-head"><span class="vk-bot-title">Errors</span></div>
|
|
<div id="bot-errors" class="vk-bot-errors"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.append(overlay);
|
|
|
|
const title = overlay.querySelector("#media-title");
|
|
const toolbar = overlay.querySelector("#media-toolbar");
|
|
const statusLine = overlay.querySelector("#media-status");
|
|
const scrapeList = overlay.querySelector("#media-scrapes");
|
|
const grid = overlay.querySelector("#media-grid");
|
|
const botPanel = overlay.querySelector("#media-bot");
|
|
const botLimited = overlay.querySelector("#bot-limited");
|
|
const backfillSummary = overlay.querySelector("#backfill-summary");
|
|
const backfillJobs = overlay.querySelector("#backfill-jobs");
|
|
const tunablesHost = overlay.querySelector("#bot-tunables");
|
|
const botErrors = overlay.querySelector("#bot-errors");
|
|
|
|
const filterDrawer = el("div", "vk-media-drawer");
|
|
filterDrawer.hidden = true;
|
|
toolbar.after(filterDrawer);
|
|
|
|
function setStatus(text) {
|
|
statusLine.textContent = text;
|
|
}
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function button(label, handler) {
|
|
const node = el("button", "btn btn-secondary vk-small-btn", label);
|
|
node.type = "button";
|
|
node.addEventListener("click", handler);
|
|
return node;
|
|
}
|
|
|
|
/** Start the view below the header and the call strip, filling the rest. */
|
|
function layoutTop() {
|
|
const header = document.querySelector(".vk-header");
|
|
const callStrip = document.querySelector("#call-strip");
|
|
let top = header ? header.getBoundingClientRect().bottom : 0;
|
|
if (callStrip && !callStrip.hidden) top = Math.max(top, callStrip.getBoundingClientRect().bottom);
|
|
overlay.style.setProperty("--media-top", `${Math.round(top)}px`);
|
|
}
|
|
|
|
function show(view) {
|
|
state.view = view;
|
|
layoutTop();
|
|
overlay.hidden = false;
|
|
document.querySelector("#scope-media")?.classList.toggle("active", view === "media");
|
|
document.querySelector("#scope-bot")?.classList.toggle("active", view === "bot");
|
|
document.querySelector("#scope-guild")?.classList.remove("active");
|
|
document.querySelector("#scope-dm")?.classList.remove("active");
|
|
|
|
if (view === "media") applyZoom();
|
|
|
|
if (view === "bot") {
|
|
localStorage.setItem("vk-media-errors-seen", String(errorCount));
|
|
updateErrorBadge();
|
|
void refreshErrors();
|
|
}
|
|
|
|
render();
|
|
}
|
|
|
|
function hide() {
|
|
state.view = null;
|
|
overlay.hidden = true;
|
|
document.querySelector("#scope-media")?.classList.remove("active");
|
|
document.querySelector("#scope-bot")?.classList.remove("active");
|
|
}
|
|
|
|
async function loadChannels(force = false) {
|
|
if (state.loadedChannels && !force) return;
|
|
state.dmsError = null;
|
|
|
|
try {
|
|
const data = await window.VK.api.get("/api/state");
|
|
state.guilds = data?.guilds ?? [];
|
|
state.channels = [];
|
|
for (const guild of state.guilds) {
|
|
for (const channel of guild.channels ?? []) {
|
|
if (channel.type !== 0 && channel.type !== 5) continue;
|
|
state.channels.push({
|
|
id: channel.id,
|
|
guildId: guild.id,
|
|
label: `${guild.name} / #${channel.name}`
|
|
});
|
|
}
|
|
}
|
|
|
|
// DMs and group DMs: the bot cannot see them, so they always use the puppet.
|
|
try {
|
|
const dmData = await window.VK.api.get("/api/dms?limit=50");
|
|
state.dms = dmData?.channels ?? [];
|
|
for (const dm of state.dms) {
|
|
const names = (dm.recipients ?? [])
|
|
.map(recipient => recipient.username)
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
state.channels.push({
|
|
id: dm.id,
|
|
guildId: null,
|
|
label: `${dm.name || names || "DM"} (${dm.type === 3 ? "group DM" : "DM"})`
|
|
});
|
|
}
|
|
} catch (error) {
|
|
state.dms = [];
|
|
state.dmsError = error.message;
|
|
}
|
|
|
|
state.loadedChannels = true;
|
|
} catch (error) {
|
|
state.guilds = [];
|
|
state.channels = [];
|
|
state.dmsError = error.message;
|
|
}
|
|
}
|
|
|
|
async function loadAuthors() {
|
|
if (state.authors) return;
|
|
try {
|
|
state.authors = await window.VK.api.get("/api/media/authors");
|
|
} catch {
|
|
state.authors = [];
|
|
}
|
|
}
|
|
|
|
async function loadScrapes() {
|
|
const wasActive = new Set(
|
|
state.scrapes.filter(scrape => scrape.state === "running" || scrape.state === "queued").map(s => s.id)
|
|
);
|
|
|
|
try {
|
|
state.scrapes = await window.VK.api.get("/api/media/scrapes");
|
|
state.status = await window.VK.api.get("/api/media/status");
|
|
} catch (error) {
|
|
setStatus(`media status failed: ${error.message}`);
|
|
}
|
|
|
|
// A scrape that was running and is now done counts once for the strip notice.
|
|
const ids = new Set(state.scrapes.map(scrape => scrape.id));
|
|
for (const scrape of state.scrapes) {
|
|
if (wasActive.has(scrape.id) && scrape.state === "done") state.completed.add(scrape.id);
|
|
}
|
|
state.completed = new Set([...state.completed].filter(id => ids.has(id)));
|
|
|
|
const dismissed = prefs.dismissed.filter(id => ids.has(id));
|
|
if (dismissed.length !== prefs.dismissed.length) prefs.setDismissed(dismissed);
|
|
|
|
renderScrapes();
|
|
}
|
|
|
|
function filterQuery() {
|
|
const kind = state.kind === "all" ? "" : `kind=${state.kind}&`;
|
|
const source = state.filters.source ? `source=${state.filters.source}&` : "";
|
|
const channel = state.filters.channelId ? `channelId=${state.filters.channelId}&` : "";
|
|
const authors = state.filters.authorIds.length
|
|
? `authorId=${state.filters.authorIds.join(",")}&`
|
|
: "";
|
|
const origin = state.filters.origin ? `origin=${state.filters.origin}&` : "";
|
|
const cached = state.filters.cachedOnly ? "cachedOnly=1&" : "";
|
|
const exts = state.filters.exts.length ? `ext=${state.filters.exts.join(",")}&` : "";
|
|
const excludeHosts = state.filters.excludeHosts.length
|
|
? `excludeHosts=${state.filters.excludeHosts.join(",")}&`
|
|
: "";
|
|
const aspect = state.filters.aspect ? `aspect=${state.filters.aspect}&` : "";
|
|
const resolution = state.filters.resolution ? `resolution=${state.filters.resolution}&` : "";
|
|
const after = state.filters.after ? `after=${state.filters.after}T00:00:00.000Z&` : "";
|
|
const before = state.filters.before ? `before=${state.filters.before}T23:59:59.999Z&` : "";
|
|
return `${kind}${source}${channel}${authors}${origin}${cached}${exts}${excludeHosts}${aspect}${resolution}${after}${before}`;
|
|
}
|
|
|
|
function kindTotals(field) {
|
|
const stats = state.stats ?? {};
|
|
return {
|
|
images: (stats.image?.[field] ?? 0) + (stats.gif?.[field] ?? 0),
|
|
videos: stats.video?.[field] ?? 0
|
|
};
|
|
}
|
|
|
|
function countLoaded() {
|
|
let images = 0;
|
|
let videos = 0;
|
|
for (const item of state.items) {
|
|
if (item.kind === "video") videos++;
|
|
else if (item.kind === "image" || item.kind === "gif") images++;
|
|
}
|
|
return { images, videos };
|
|
}
|
|
|
|
function renderStatus() {
|
|
const downloaded = kindTotals("downloaded");
|
|
const cached = kindTotals("cached");
|
|
const loaded = countLoaded();
|
|
setStatus(
|
|
`(Images/Videos) | Downloaded ${downloaded.images}/${downloaded.videos} | ` +
|
|
`Cached ${cached.images}/${cached.videos} | Loaded ${loaded.images}/${loaded.videos}`
|
|
);
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
state.stats = await window.VK.api.get(`/api/media/stats?${filterQuery()}`);
|
|
} catch {
|
|
state.stats = null;
|
|
}
|
|
renderStatus();
|
|
}
|
|
|
|
async function loadMedia(reset) {
|
|
if (!state.loaded || state.loading) return;
|
|
|
|
state.loading = true;
|
|
if (reset) {
|
|
state.items = [];
|
|
state.offset = 0;
|
|
state.more = false;
|
|
state.prefetched = null;
|
|
}
|
|
|
|
try {
|
|
const items = await window.VK.api.get(
|
|
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
|
);
|
|
state.items = state.items.concat(items);
|
|
state.offset += items.length;
|
|
state.more = items.length >= state.pageSize;
|
|
} catch (error) {
|
|
setStatus(`media load failed: ${error.message}`);
|
|
} finally {
|
|
state.loading = false;
|
|
}
|
|
|
|
renderGrid();
|
|
void loadStats();
|
|
void prefetchNext();
|
|
}
|
|
|
|
/** Warm the next page's JSON and preview images before the user reaches them. */
|
|
async function prefetchNext() {
|
|
if (!state.loaded || !state.more || state.prefetched || state.loading) return;
|
|
|
|
try {
|
|
const items = await window.VK.api.get(
|
|
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
|
);
|
|
if (!items.length) {
|
|
state.more = false;
|
|
return;
|
|
}
|
|
|
|
state.prefetched = items;
|
|
for (const item of items) {
|
|
const src = item.thumbUrl || item.localUrl;
|
|
if (!src) continue;
|
|
const image = new Image();
|
|
image.src = src;
|
|
}
|
|
} catch {
|
|
// Preloading is best effort; the scroll handler falls back to a request.
|
|
}
|
|
}
|
|
|
|
async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
|
|
if (!channelId) {
|
|
setStatus("no channel selected");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const scrape = await window.VK.api.post("/api/media/scrape", {
|
|
channelId,
|
|
guildId: guildId ?? null,
|
|
mode,
|
|
backend: guildId ? "auto" : "puppet",
|
|
downloadDir: downloadDir ?? null,
|
|
after: after ?? null,
|
|
before: before ?? null
|
|
});
|
|
setStatus(`started ${scrape.id} (${mode})`);
|
|
scrapeModal.hidden = true;
|
|
} catch (error) {
|
|
if (error.code === "MEDIA_LIMIT" || String(error.message).includes("MEDIA_LIMIT")) {
|
|
setStatus(`near the storage limit: ${error.message}`);
|
|
} else {
|
|
setStatus(`start failed: ${error.message}`);
|
|
}
|
|
}
|
|
void loadScrapes();
|
|
}
|
|
|
|
function renderScrapes() {
|
|
scrapeList.replaceChildren();
|
|
|
|
// The strip shows live jobs plus the newest paused scrape. Stop on a paused one only
|
|
// hides it here; it stays resumable, and the next paused scrape can take its place.
|
|
const active = state.scrapes.filter(scrape => ["queued", "running"].includes(scrape.state));
|
|
const paused = state.scrapes
|
|
.filter(scrape => scrape.state === "paused" && !prefs.dismissed.includes(scrape.id))
|
|
.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
|
|
.slice(0, 1);
|
|
|
|
const limitedUntil = state.status?.limitedUntil ? new Date(state.status.limitedUntil).getTime() : 0;
|
|
const limited = limitedUntil > Date.now();
|
|
const limitedText = limited
|
|
? ` · API limited, retry in ${Math.max(0, Math.ceil((limitedUntil - Date.now()) / 1000))}s`
|
|
: "";
|
|
|
|
for (const scrape of [...active, ...paused]) {
|
|
const row = el("div", `vk-media-scrape${limited ? " vk-media-limited" : ""}`);
|
|
const media = scrape.media ?? {};
|
|
|
|
if (scrape.state === "running" || scrape.state === "queued") {
|
|
row.append(el("span", "vk-media-pulse"));
|
|
}
|
|
|
|
// Discord search gives the total, so the page count is known up front.
|
|
const estimatedPages =
|
|
scrape.estimatedMessages != null ? Math.max(1, Math.ceil(scrape.estimatedMessages / 100)) : null;
|
|
const pageText = estimatedPages ? `${scrape.pages}/${estimatedPages}p` : `${scrape.pages}p`;
|
|
const percent =
|
|
estimatedPages && scrape.state !== "queued"
|
|
? ` (~${Math.min(100, Math.round((scrape.pages / estimatedPages) * 100))}%)`
|
|
: "";
|
|
|
|
row.append(
|
|
el(
|
|
"span",
|
|
null,
|
|
`${channelLabel(scrape.channelId)} · ${scrape.state} · ${pageText}${percent} · ${scrape.messages}m · ` +
|
|
`img ${media.image ?? 0} gif ${media.gif ?? 0} vid ${media.video ?? 0} aud ${media.audio ?? 0}` +
|
|
(scrape.downloaded ? ` · files ${scrape.downloaded}` : "") +
|
|
(scrape.previewed ? ` · previews ${scrape.previewed}` : "") +
|
|
limitedText
|
|
)
|
|
);
|
|
|
|
if (scrape.state === "paused") {
|
|
row.append(
|
|
button("Resume", async () => {
|
|
try {
|
|
await window.VK.api.post("/api/media/scrape/resume", { id: scrape.id });
|
|
} catch (error) {
|
|
setStatus(`resume failed: ${error.message}`);
|
|
}
|
|
void loadScrapes();
|
|
})
|
|
);
|
|
}
|
|
|
|
row.append(
|
|
button("Stop", async () => {
|
|
if (scrape.state === "paused") {
|
|
// Hidden here, still resumable in the Bot tab.
|
|
prefs.setDismissed([...prefs.dismissed, scrape.id]);
|
|
renderScrapes();
|
|
return;
|
|
}
|
|
await window.VK.api.post("/api/media/scrape/stop", { id: scrape.id });
|
|
void loadScrapes();
|
|
})
|
|
);
|
|
|
|
scrapeList.append(row);
|
|
}
|
|
|
|
// Session notice only: it clears on reload, not because the data changed.
|
|
if (state.completed.size) {
|
|
scrapeList.append(
|
|
el(
|
|
"div",
|
|
"vk-media-scrape vk-media-done-note",
|
|
`${state.completed.size} ${state.completed.size === 1 ? "scrape" : "scrapes"} completed`
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
const prefs = {
|
|
get dismissed() {
|
|
try {
|
|
const raw = JSON.parse(localStorage.getItem("vk-media-dismissed") ?? "[]");
|
|
return Array.isArray(raw) ? raw.map(String) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
setDismissed(ids) {
|
|
localStorage.setItem("vk-media-dismissed", JSON.stringify([...new Set(ids.map(String))]));
|
|
}
|
|
};
|
|
|
|
// ---- Backfill (Bot tab) ----
|
|
|
|
const TUNABLE_GROUPS = {
|
|
backfill: {
|
|
title: "Backfilling",
|
|
fields: [
|
|
["batchSize", "Batch size (rows)", 1, 500, 1],
|
|
["batchPauseMs", "Cooldown between batches (ms)", 0, 600000, 100],
|
|
["maxAttempts", "Tries before giving up", 1, 100, 1],
|
|
["previewMinGapMs", "Preview gap min (ms)", 0, 60000, 10],
|
|
["previewMaxGapMs", "Preview gap max (ms)", 0, 60000, 10],
|
|
["maxBytes", "Max preview size (MiB)", 0.0625, 2048, 0.5, "mib"],
|
|
["auto", "Start automatically on daemon boot", null, null, null, "bool"]
|
|
]
|
|
},
|
|
scrape: {
|
|
title: "Scraping",
|
|
fields: [
|
|
["walkCyclePages", "Preview every N walk pages", 0, 100, 1],
|
|
["maxFileBytes", "Max file size (MiB)", 1, 8192, 1, "mib"],
|
|
["downloadMinGapMs", "Download gap min (ms)", 0, 600000, 100],
|
|
["downloadMaxGapMs", "Download gap max (ms)", 0, 600000, 100],
|
|
["maxRetries", "Page fetch retries", 0, 10, 1]
|
|
]
|
|
},
|
|
timers: {
|
|
title: "Timers",
|
|
fields: [
|
|
["pageMinMs", "Walk page gap min (ms)", 0, 600000, 100],
|
|
["pageMaxMs", "Walk page gap max (ms)", 0, 600000, 100],
|
|
["rateLimitPauseMs", "Rate-limit cooldown (ms)", 0, 3600000, 5000]
|
|
]
|
|
}
|
|
};
|
|
|
|
function formatEta(ms) {
|
|
if (ms === null || ms === undefined || !Number.isFinite(ms)) return "\u2014";
|
|
const seconds = Math.max(0, Math.round(ms / 1000));
|
|
if (seconds < 90) return `${seconds}s`;
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 90) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const rest = minutes % 60;
|
|
return rest ? `${hours}h ${rest}m` : `${hours}h`;
|
|
}
|
|
|
|
function pendingKinds(member) {
|
|
const by = member.pendingByKind ?? {};
|
|
return ["video", "image", "gif", "audio"]
|
|
.filter(kind => by[kind] > 0)
|
|
.map(kind => `${by[kind]} ${kind}`)
|
|
.join(" \u00b7 ");
|
|
}
|
|
|
|
function renderBackfill() {
|
|
const status = state.backfill;
|
|
if (!status) return;
|
|
|
|
const until = status.limitedUntil ? new Date(status.limitedUntil).getTime() : 0;
|
|
const limited = until > Date.now();
|
|
botLimited.hidden = !limited;
|
|
if (limited) {
|
|
botLimited.textContent =
|
|
`API timed out, increase timers \u2014 media traffic paused for ` +
|
|
formatEta(until - Date.now());
|
|
}
|
|
|
|
const totals = status.totals ?? {};
|
|
const backend = totals.byBackend ?? {};
|
|
const bot = backend.slopbot ?? { pending: 0, cached: 0, failed: 0, total: 0 };
|
|
const puppet = backend.puppet ?? { pending: 0, cached: 0, failed: 0, total: 0 };
|
|
|
|
backfillSummary.replaceChildren(
|
|
el(
|
|
"div",
|
|
"vk-bot-summary-line",
|
|
`${totals.pending ?? 0} pending \u00b7 ${totals.cached ?? 0} cached \u00b7 ` +
|
|
`${totals.failed ?? 0} failed \u00b7 ${totals.downloaded ?? 0} downloaded \u00b7 ` +
|
|
`${formatBytes(totals.bytes ?? 0)} stored`
|
|
),
|
|
el(
|
|
"div",
|
|
"vk-bot-summary-line",
|
|
`ETA ${formatEta(totals.etaMs)} at ${status.estimatedPerMinute ?? 0} rows/min ` +
|
|
`(current settings)`
|
|
),
|
|
el(
|
|
"div",
|
|
"vk-bot-summary-line vk-dim",
|
|
`Bot: ${bot.pending} pending, ${bot.cached} cached, ${bot.failed} failed \u00b7 ` +
|
|
`Puppet: ${puppet.pending} pending, ${puppet.cached} cached, ${puppet.failed} failed`
|
|
)
|
|
);
|
|
|
|
backfillJobs.replaceChildren();
|
|
const jobs = (status.jobs ?? []).filter(job => job.pending > 0 || job.state !== "idle");
|
|
if (!jobs.length) {
|
|
backfillJobs.append(el("div", "vk-bot-empty", "Nothing waiting to backfill."));
|
|
return;
|
|
}
|
|
|
|
for (const job of jobs) {
|
|
const row = el("div", `vk-bot-job vk-bot-job-${job.state}`);
|
|
const head = el("div", "vk-bot-job-head");
|
|
head.append(el("span", "vk-bot-job-title", channelLabel(job.chatId)));
|
|
head.append(el("span", `vk-bot-state vk-bot-state-${job.state}`, job.state));
|
|
|
|
const parts = [`${job.pending} pending`];
|
|
if (job.cached) parts.push(`${job.cached} cached`);
|
|
if (job.failed) parts.push(`${job.failed} failed`);
|
|
if (job.pending > 0) parts.push(`ETA ${formatEta(job.etaMs)}`);
|
|
head.append(el("span", "vk-bot-job-meta", parts.join(" \u00b7 ")));
|
|
row.append(head);
|
|
|
|
for (const member of job.members) {
|
|
if (member.pending === 0 && member.state !== "running") continue;
|
|
const kinds = pendingKinds(member);
|
|
row.append(
|
|
el(
|
|
"div",
|
|
"vk-bot-job-member",
|
|
`${member.scrapeId} \u00b7 ${member.state} \u00b7 ${member.mode} \u00b7 ` +
|
|
`${member.backend} \u00b7 ${member.pending} pending` +
|
|
(kinds ? ` (${kinds})` : "")
|
|
)
|
|
);
|
|
}
|
|
|
|
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);
|
|
row.append(progress);
|
|
|
|
const actions = el("div", "vk-bot-job-actions");
|
|
if (job.state === "running") {
|
|
actions.append(button("Pause", () => void backfillAction("pause", job.chatId)));
|
|
} else if (job.pending > 0) {
|
|
actions.append(button("Start", () => void backfillAction("start", job.chatId)));
|
|
}
|
|
row.append(actions);
|
|
|
|
backfillJobs.append(row);
|
|
}
|
|
}
|
|
|
|
async function backfillAction(action, chatId) {
|
|
try {
|
|
state.backfill = await window.VK.api.post(
|
|
`/api/media/backfill/${action}`,
|
|
chatId ? { chatId } : {}
|
|
);
|
|
renderBackfill();
|
|
} catch (error) {
|
|
setStatus(`backfill ${action} failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function refreshBackfill() {
|
|
try {
|
|
state.backfill = await window.VK.api.get("/api/media/backfill");
|
|
} catch {
|
|
state.backfill = null;
|
|
}
|
|
renderBackfill();
|
|
}
|
|
|
|
function tunableValue(raw, key, scale) {
|
|
const value = raw?.[key];
|
|
if (scale === "bool") return value !== false;
|
|
const number = Number(value);
|
|
if (!Number.isFinite(number)) return null;
|
|
return scale === "mib" ? Math.round((number / 1024 ** 2) * 100) / 100 : number;
|
|
}
|
|
|
|
function renderTunables() {
|
|
const tunables = state.preferences?.media?.tunables;
|
|
if (!tunables) return;
|
|
|
|
tunablesHost.replaceChildren();
|
|
|
|
for (const [group, spec] of Object.entries(TUNABLE_GROUPS)) {
|
|
const details = el("details", "vk-bot-group");
|
|
details.open = group === "backfill";
|
|
details.append(el("summary", "vk-bot-group-title", spec.title));
|
|
|
|
const body = el("div", "vk-bot-group-body");
|
|
for (const [key, label, min, max, step, scale] of spec.fields) {
|
|
const row = el("label", "vk-bot-field");
|
|
row.append(el("span", "vk-bot-field-label", label));
|
|
|
|
const input = document.createElement("input");
|
|
input.dataset.group = group;
|
|
input.dataset.key = key;
|
|
input.dataset.scale = scale ?? "";
|
|
|
|
if (scale === "bool") {
|
|
input.type = "checkbox";
|
|
input.checked = tunables[group]?.[key] !== false;
|
|
} else {
|
|
input.type = "number";
|
|
input.className = "vk-media-input";
|
|
input.min = String(min);
|
|
input.max = String(max);
|
|
input.step = String(step);
|
|
input.value = String(tunableValue(tunables[group] ?? {}, key, scale) ?? "");
|
|
}
|
|
|
|
row.append(input);
|
|
body.append(row);
|
|
}
|
|
|
|
const save = button("Save", () => void saveTunables(group));
|
|
save.classList.add("vk-bot-save");
|
|
body.append(save);
|
|
details.append(body);
|
|
tunablesHost.append(details);
|
|
}
|
|
}
|
|
|
|
async function saveTunables(group) {
|
|
const patch = {};
|
|
|
|
for (const input of tunablesHost.querySelectorAll(`input[data-group="${group}"]`)) {
|
|
const scale = input.dataset.scale;
|
|
if (scale === "bool") {
|
|
patch[input.dataset.key] = input.checked;
|
|
continue;
|
|
}
|
|
|
|
const value = Number(input.value);
|
|
if (!Number.isFinite(value)) continue;
|
|
patch[input.dataset.key] = scale === "mib" ? Math.round(value * 1024 ** 2) : Math.round(value);
|
|
}
|
|
|
|
try {
|
|
await window.VK.api.post("/api/preferences", { media: { tunables: { [group]: patch } } });
|
|
setStatus(`${TUNABLE_GROUPS[group].title.toLowerCase()} settings saved`);
|
|
await refreshPreferences();
|
|
void refreshBackfill();
|
|
} catch (error) {
|
|
setStatus(`save failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
async function refreshPreferences() {
|
|
try {
|
|
state.preferences = await window.VK.api.get("/api/preferences");
|
|
} catch {
|
|
return;
|
|
}
|
|
renderTunables();
|
|
}
|
|
|
|
// ---- Bot errors ----
|
|
|
|
let errorCount = 0;
|
|
|
|
function seenErrorCount() {
|
|
return Number(localStorage.getItem("vk-media-errors-seen") ?? 0);
|
|
}
|
|
|
|
function updateErrorBadge() {
|
|
const badge = document.querySelector("#scope-bot-badge");
|
|
if (!badge) return;
|
|
|
|
const unseen = Math.max(0, errorCount - seenErrorCount());
|
|
badge.textContent = String(unseen);
|
|
badge.hidden = unseen === 0;
|
|
}
|
|
|
|
function renderErrors() {
|
|
botErrors.replaceChildren();
|
|
|
|
const head = el("div", "vk-media-scrape");
|
|
head.append(el("span", null, `${(state.errors ?? []).length} error(s)`));
|
|
head.append(
|
|
button("Clear errors", async () => {
|
|
try {
|
|
await window.VK.api.post("/api/media/errors/clear", {});
|
|
} catch {
|
|
// the list stays until the next refresh
|
|
}
|
|
state.errors = [];
|
|
errorCount = 0;
|
|
localStorage.setItem("vk-media-errors-seen", "0");
|
|
updateErrorBadge();
|
|
renderErrors();
|
|
})
|
|
);
|
|
scrapeList.append(head);
|
|
|
|
for (const entry of state.errors ?? []) {
|
|
botErrors.append(
|
|
el(
|
|
"div",
|
|
"vk-media-scrape vk-media-error",
|
|
`${new Date(entry.at).toLocaleTimeString()} · ${entry.source} · ${entry.message}`
|
|
)
|
|
);
|
|
}
|
|
|
|
if (!(state.errors ?? []).length) botErrors.append(el("div", "vk-media-scrape", "No errors."));
|
|
}
|
|
|
|
async function refreshErrors() {
|
|
try {
|
|
state.errors = await window.VK.api.get("/api/media/errors");
|
|
} catch {
|
|
state.errors = [];
|
|
}
|
|
errorCount = (state.errors ?? []).length;
|
|
updateErrorBadge();
|
|
if (state.view === "bot") renderErrors();
|
|
}
|
|
|
|
const META_FIELDS = [
|
|
["user", "User"],
|
|
["type", "Type"],
|
|
["date", "Date"],
|
|
["source", "Source"],
|
|
["resolution", "Resolution"],
|
|
["length", "Length"]
|
|
];
|
|
|
|
/** "mp4 video", "webp image", "YouTube video", ... */
|
|
function typeLabel(item) {
|
|
if (item.source === "embed") return `${item.provider ?? "embed"} ${item.kind}`;
|
|
|
|
const match = /\.([a-z0-9]{2,5})$/i.exec((item.url ?? "").split("?")[0]);
|
|
const extension = (match?.[1] ?? "").toLowerCase();
|
|
return extension ? `${extension} ${item.kind}` : item.kind;
|
|
}
|
|
|
|
function metaValues(item) {
|
|
return {
|
|
user: item.authorName ?? "unknown",
|
|
type: typeLabel(item),
|
|
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` : "—"
|
|
};
|
|
}
|
|
|
|
function metaBanner(item, fields, className) {
|
|
const values = metaValues(item);
|
|
const parts = fields.filter(field => field in values).map(field => values[field]);
|
|
if (!parts.length) return null;
|
|
return el(className ?? "figcaption", "vk-media-banner", parts.join(" · "));
|
|
}
|
|
|
|
function placeholder(media, item) {
|
|
const error = item.previewError ?? "";
|
|
let label = item.kind.toUpperCase();
|
|
if (/\b403\b/.test(error)) label = "PRIVATE";
|
|
else if (/\b(404|410)\b/.test(error)) label = "GONE";
|
|
|
|
const node = el("span", "vk-media-card-kind", label);
|
|
if (error) node.title = error;
|
|
media.replaceChildren(node);
|
|
}
|
|
|
|
function playIcon(item) {
|
|
const play = el("span", "vk-media-play", "▶");
|
|
play.title = "Play";
|
|
play.addEventListener("click", event => {
|
|
event.stopPropagation();
|
|
openViewer(item, true);
|
|
});
|
|
return play;
|
|
}
|
|
|
|
function viewerPlayIcon(item) {
|
|
const play = el("span", "vk-media-play vk-media-play-big", "▶");
|
|
play.title = "Play";
|
|
play.addEventListener("click", () => openViewer(item, true));
|
|
return play;
|
|
}
|
|
|
|
function renderCard(item) {
|
|
const figure = el("figure", "vk-media-figure");
|
|
const media = el("div", "vk-media-media");
|
|
const imageSource = item.localUrl || item.thumbUrl;
|
|
const fallbackSource = item.source !== "embed" ? item.url : null;
|
|
// Hover plays the cached animated GIF from the archive, never the provider.
|
|
const animatedGif = item.kind === "gif" && item.animUrl ? item.animUrl : null;
|
|
|
|
if (imageSource) {
|
|
const img = document.createElement("img");
|
|
img.loading = "lazy";
|
|
img.src = imageSource;
|
|
img.alt = item.kind;
|
|
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
|
|
|
// GIFs stay static in the grid and play while hovered.
|
|
if (animatedGif) {
|
|
media.addEventListener("mouseenter", () => {
|
|
img.src = animatedGif;
|
|
});
|
|
media.addEventListener("mouseleave", () => {
|
|
img.src = imageSource;
|
|
});
|
|
}
|
|
|
|
media.append(img);
|
|
} else if (fallbackSource && /^(https?:|\/)/.test(fallbackSource)) {
|
|
const img = document.createElement("img");
|
|
img.loading = "lazy";
|
|
img.src = fallbackSource;
|
|
img.alt = item.kind;
|
|
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
|
media.append(img);
|
|
} else {
|
|
placeholder(media, item);
|
|
}
|
|
|
|
if (item.kind === "gif") media.append(el("span", "vk-media-gif-label", "GIF"));
|
|
if (item.kind === "video") media.append(playIcon(item));
|
|
|
|
media.addEventListener("click", () => openViewer(item, false));
|
|
figure.append(media);
|
|
|
|
const banner = metaBanner(item, state.metaFields);
|
|
if (banner) figure.append(banner);
|
|
|
|
return figure;
|
|
}
|
|
|
|
function renderGrid() {
|
|
grid.replaceChildren();
|
|
|
|
if (!state.loaded) {
|
|
const prompt = el("div", "vk-media-prompt");
|
|
prompt.append(el("div", "vk-media-status", "Use the filters above to filter media."));
|
|
const load = el("button", "btn btn-primary vk-media-load", "Load media");
|
|
load.type = "button";
|
|
load.addEventListener("click", () => {
|
|
state.loaded = true;
|
|
void loadMedia(true);
|
|
});
|
|
prompt.append(load);
|
|
grid.append(prompt);
|
|
return;
|
|
}
|
|
|
|
for (const item of state.items) grid.append(renderCard(item));
|
|
|
|
if (!state.items.length) {
|
|
grid.append(el("div", "vk-media-status", "No media matches the filters."));
|
|
} else if (state.more) {
|
|
const more = el("button", "btn btn-secondary vk-media-load-more", "Load more");
|
|
more.type = "button";
|
|
more.addEventListener("click", () => void loadMedia(false));
|
|
grid.append(more);
|
|
}
|
|
}
|
|
|
|
function filterSelect(options, value, onChange) {
|
|
const select = document.createElement("select");
|
|
select.className = "vk-chip vk-chip-off";
|
|
for (const [optionValue, label] of options) {
|
|
const option = document.createElement("option");
|
|
option.value = optionValue;
|
|
option.textContent = label;
|
|
select.append(option);
|
|
}
|
|
select.value = value;
|
|
select.addEventListener("change", () => onChange(select.value));
|
|
return select;
|
|
}
|
|
|
|
function dateInput(value, onChange) {
|
|
const input = document.createElement("input");
|
|
input.type = "date";
|
|
input.className = "vk-media-input";
|
|
input.value = value ?? "";
|
|
input.addEventListener("change", () => onChange(input.value));
|
|
return input;
|
|
}
|
|
|
|
/** Dropdown with the metadata-field checkboxes. */
|
|
function metaMenu() {
|
|
const wrap = el("span", "vk-media-meta");
|
|
const panel = el("div", "vk-media-meta-menu");
|
|
panel.hidden = true;
|
|
|
|
const toggle = button("Metadata", () => {
|
|
panel.hidden = !panel.hidden;
|
|
});
|
|
|
|
for (const [key, label] of META_FIELDS) {
|
|
const row = el("label", "vk-media-check");
|
|
const box = document.createElement("input");
|
|
box.type = "checkbox";
|
|
box.checked = state.metaFields.includes(key);
|
|
box.addEventListener("change", () => {
|
|
state.metaFields = box.checked
|
|
? [...new Set([...state.metaFields, key])]
|
|
: state.metaFields.filter(field => field !== key);
|
|
localStorage.setItem("vk-media-meta-fields", JSON.stringify(state.metaFields));
|
|
renderGrid();
|
|
});
|
|
row.append(box, document.createTextNode(` ${label}`));
|
|
panel.append(row);
|
|
}
|
|
|
|
wrap.append(toggle, panel);
|
|
return wrap;
|
|
}
|
|
|
|
function defaultFilters() {
|
|
return {
|
|
source: "",
|
|
channelId: "",
|
|
authorIds: [],
|
|
origin: "",
|
|
cachedOnly: false,
|
|
exts: [],
|
|
excludeHosts: ["4chan.org", "i.4cdn.org"],
|
|
aspect: "",
|
|
resolution: "",
|
|
after: "",
|
|
before: ""
|
|
};
|
|
}
|
|
|
|
function ensureDraft() {
|
|
if (!state.draft) {
|
|
state.draft = { kind: state.kind, ...state.filters, authorIds: [...state.filters.authorIds] };
|
|
}
|
|
}
|
|
|
|
function applyDraft() {
|
|
state.kind = state.draft.kind;
|
|
state.filters = {
|
|
source: state.draft.source,
|
|
channelId: state.draft.channelId,
|
|
authorIds: [...state.draft.authorIds],
|
|
origin: state.draft.origin,
|
|
cachedOnly: state.draft.cachedOnly,
|
|
exts: [...state.draft.exts],
|
|
excludeHosts: [...state.draft.excludeHosts],
|
|
aspect: state.draft.aspect,
|
|
resolution: state.draft.resolution,
|
|
after: state.draft.after,
|
|
before: state.draft.before
|
|
};
|
|
filterDrawer.hidden = true;
|
|
if (state.loaded) {
|
|
void loadMedia(true);
|
|
} else {
|
|
void loadStats();
|
|
}
|
|
}
|
|
|
|
/** Compact multi-select for the posters, shown in the bar. */
|
|
function peopleMenu() {
|
|
const wrap = el("span", "vk-media-meta");
|
|
const panel = el("div", "vk-media-meta-menu vk-media-people-menu");
|
|
panel.hidden = true;
|
|
|
|
const toggle = button(
|
|
`People${state.draft.authorIds.length ? ` (${state.draft.authorIds.length})` : ""}`,
|
|
() => {
|
|
panel.hidden = !panel.hidden;
|
|
}
|
|
);
|
|
|
|
const list = el("div", "vk-media-people");
|
|
for (const author of state.authors ?? []) {
|
|
if (!author.id) continue;
|
|
const row = el("label", "vk-media-check");
|
|
const box = document.createElement("input");
|
|
box.type = "checkbox";
|
|
box.checked = state.draft.authorIds.includes(author.id);
|
|
box.addEventListener("change", () => {
|
|
state.draft.authorIds = box.checked
|
|
? [...new Set([...state.draft.authorIds, author.id])]
|
|
: state.draft.authorIds.filter(id => id !== author.id);
|
|
panel.hidden = true;
|
|
renderToolbar();
|
|
});
|
|
row.append(box, document.createTextNode(` ${author.name} (${author.count})`));
|
|
list.append(row);
|
|
}
|
|
if (!list.childElementCount) list.append(el("span", "vk-media-status", "No posters indexed yet."));
|
|
|
|
panel.append(list);
|
|
wrap.append(toggle, panel);
|
|
return wrap;
|
|
}
|
|
|
|
function renderToolbar() {
|
|
toolbar.replaceChildren();
|
|
if (state.view !== "media") {
|
|
toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md."));
|
|
return;
|
|
}
|
|
|
|
ensureDraft();
|
|
|
|
for (const kind of KINDS) {
|
|
const chip = el(
|
|
"button",
|
|
`vk-chip ${state.draft.kind === kind ? "vk-chip-on" : "vk-chip-off"}`,
|
|
KIND_LABELS[kind]
|
|
);
|
|
chip.type = "button";
|
|
chip.addEventListener("click", () => {
|
|
state.draft.kind = kind;
|
|
renderToolbar();
|
|
});
|
|
toolbar.append(chip);
|
|
}
|
|
|
|
toolbar.append(
|
|
filterSelect(
|
|
[
|
|
["", "Any source"],
|
|
["attachment", "Attachments"],
|
|
["sticker", "Stickers"],
|
|
["embed", "Embeds"]
|
|
],
|
|
state.draft.source,
|
|
value => {
|
|
state.draft.source = value;
|
|
}
|
|
)
|
|
);
|
|
|
|
toolbar.append(
|
|
filterSelect(
|
|
[
|
|
["", "Anywhere"],
|
|
["guild", "Server channels"],
|
|
["dm", "DMs and group chats"]
|
|
],
|
|
state.draft.origin,
|
|
value => {
|
|
state.draft.origin = value;
|
|
}
|
|
)
|
|
);
|
|
|
|
toolbar.append(peopleMenu());
|
|
toolbar.append(button("Advanced search", toggleFilterDrawer));
|
|
toolbar.append(metaMenu());
|
|
toolbar.append(el("span", "vk-media-spacer"));
|
|
toolbar.append(button("Apply filters", applyDraft));
|
|
toolbar.append(button("Reset", resetMediaView));
|
|
toolbar.append(button("History", openHistoryModal));
|
|
toolbar.append(button("New scrape…", openScrapeModal));
|
|
}
|
|
|
|
function toggleFilterDrawer() {
|
|
filterDrawer.hidden = !filterDrawer.hidden;
|
|
if (!filterDrawer.hidden) renderFilterDrawer();
|
|
}
|
|
|
|
/** The drawer holds the less common filters; the basic ones live in the bar. */
|
|
function renderFilterDrawer() {
|
|
ensureDraft();
|
|
filterDrawer.replaceChildren();
|
|
const draft = state.draft;
|
|
|
|
const section = label => {
|
|
const wrap = el("div", "vk-media-filter-section");
|
|
wrap.append(el("div", "vk-media-status", label));
|
|
return wrap;
|
|
};
|
|
|
|
const filters = section("Filters");
|
|
filters.append(
|
|
filterSelect(
|
|
[
|
|
["", "Any ratio"],
|
|
["vertical", "Vertical"],
|
|
["square", "1:1"],
|
|
["wide43", "4:3"],
|
|
["wide169", "16:9"],
|
|
["ultrawide", "21:9"],
|
|
["other", "Other"]
|
|
],
|
|
draft.aspect,
|
|
value => {
|
|
draft.aspect = value;
|
|
}
|
|
),
|
|
filterSelect(
|
|
[
|
|
["", "Any quality"],
|
|
["sd", "SD (<=480p)"],
|
|
["hd", "HD (720p)"],
|
|
["fhd", "FHD (1080p)"],
|
|
["qhd", "QHD (1440p)"],
|
|
["uhd", "4K+"],
|
|
["unknown", "Unknown"]
|
|
],
|
|
draft.resolution,
|
|
value => {
|
|
draft.resolution = value;
|
|
}
|
|
)
|
|
);
|
|
|
|
const channelSelect = document.createElement("select");
|
|
channelSelect.className = "vk-chip vk-chip-off";
|
|
const anyChannel = document.createElement("option");
|
|
anyChannel.value = "";
|
|
anyChannel.textContent = "Any channel";
|
|
channelSelect.append(anyChannel);
|
|
const seen = new Set();
|
|
for (const scrape of state.scrapes) {
|
|
if (seen.has(scrape.channelId)) continue;
|
|
seen.add(scrape.channelId);
|
|
const option = document.createElement("option");
|
|
option.value = scrape.channelId;
|
|
option.textContent =
|
|
state.channels.find(channel => channel.id === scrape.channelId)?.label ?? scrape.channelId;
|
|
channelSelect.append(option);
|
|
}
|
|
channelSelect.value = draft.channelId;
|
|
channelSelect.addEventListener("change", () => {
|
|
draft.channelId = channelSelect.value;
|
|
});
|
|
filters.append(channelSelect);
|
|
|
|
filters.append(
|
|
el("span", "vk-media-status", "From"),
|
|
dateInput(draft.after, value => {
|
|
draft.after = value;
|
|
}),
|
|
el("span", "vk-media-status", "To"),
|
|
dateInput(draft.before, value => {
|
|
draft.before = value;
|
|
})
|
|
);
|
|
|
|
const typeRow = (label, extensions) => {
|
|
const row = el("div", "vk-media-toolbar");
|
|
row.append(el("span", "vk-media-status", label));
|
|
for (const extension of extensions) {
|
|
const chip = el(
|
|
"button",
|
|
`vk-chip ${draft.exts.includes(extension) ? "vk-chip-on" : "vk-chip-off"}`,
|
|
extension
|
|
);
|
|
chip.type = "button";
|
|
chip.addEventListener("click", () => {
|
|
draft.exts = draft.exts.includes(extension)
|
|
? draft.exts.filter(value => value !== extension)
|
|
: [...draft.exts, extension];
|
|
renderFilterDrawer();
|
|
});
|
|
row.append(chip);
|
|
}
|
|
return row;
|
|
};
|
|
|
|
filters.append(
|
|
el("div", "vk-media-status", "File types"),
|
|
typeRow("Video", ["mp4", "webm", "mov", "mkv"]),
|
|
typeRow("Image", ["png", "jpg", "gif", "webp", "avif"]),
|
|
typeRow("Audio", ["mp3", "ogg", "wav", "m4a", "flac"])
|
|
);
|
|
|
|
const cachedRow = el("label", "vk-media-check");
|
|
const cachedBox = document.createElement("input");
|
|
cachedBox.type = "checkbox";
|
|
cachedBox.checked = draft.cachedOnly;
|
|
cachedBox.addEventListener("change", () => {
|
|
draft.cachedOnly = cachedBox.checked;
|
|
});
|
|
cachedRow.append(cachedBox, document.createTextNode(" Show only cached previews"));
|
|
filters.append(cachedRow);
|
|
|
|
const deadRow = el("label", "vk-media-check");
|
|
const deadBox = document.createElement("input");
|
|
deadBox.type = "checkbox";
|
|
deadBox.checked = draft.excludeHosts.includes("4chan.org");
|
|
deadBox.addEventListener("change", () => {
|
|
draft.excludeHosts = deadBox.checked ? ["4chan.org", "i.4cdn.org"] : [];
|
|
});
|
|
deadRow.append(deadBox, document.createTextNode(" Hide 4chan links (usually dead)"));
|
|
filters.append(deadRow);
|
|
filterDrawer.append(filters);
|
|
|
|
// Zoom applies live; it is not part of the filter draft.
|
|
const zoomSection = section("Card size");
|
|
const zoom = document.createElement("input");
|
|
zoom.type = "range";
|
|
zoom.min = "0";
|
|
zoom.max = "100";
|
|
zoom.value = String(state.zoom);
|
|
zoom.className = "vk-media-zoom";
|
|
zoom.addEventListener("input", () => {
|
|
state.zoom = Number(zoom.value);
|
|
localStorage.setItem("vk-media-zoom", String(state.zoom));
|
|
applyZoom();
|
|
});
|
|
zoomSection.append(zoom);
|
|
filterDrawer.append(zoomSection);
|
|
|
|
filterDrawer.append(
|
|
el("span", "vk-media-status", "Press Apply filters in the bar to run the search.")
|
|
);
|
|
}
|
|
|
|
/** Card width from the zoom slider: 2/3 current, current, up to a full row. */
|
|
function cardWidth() {
|
|
const gridWidth = grid.clientWidth || 900;
|
|
const zoom = Math.max(0, Math.min(100, state.zoom));
|
|
if (zoom <= 50) return Math.round(100 + (zoom / 50) * 50);
|
|
return Math.round(150 + ((zoom - 50) / 50) * Math.max(0, gridWidth - 150));
|
|
}
|
|
|
|
/** Items per page: full rows, about 1.5 screens tall. */
|
|
function computePageSize() {
|
|
const width = cardWidth();
|
|
const columns = Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8)));
|
|
const rowHeight = width * 1.16 + 30;
|
|
const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (rowHeight + 8)));
|
|
return Math.max(columns, columns * Math.max(1, Math.ceil(rows * 1.5)));
|
|
}
|
|
|
|
function applyZoom() {
|
|
const width = cardWidth();
|
|
const gridWidth = grid.clientWidth || 900;
|
|
grid.style.gridTemplateColumns =
|
|
width >= gridWidth - 24 ? "1fr" : `repeat(auto-fill, minmax(${width}px, 1fr))`;
|
|
state.pageSize = computePageSize();
|
|
}
|
|
|
|
function resetMediaView() {
|
|
state.kind = "all";
|
|
state.filters = defaultFilters();
|
|
state.zoom = 50;
|
|
localStorage.setItem("vk-media-zoom", "50");
|
|
state.loaded = false;
|
|
state.loading = false;
|
|
state.more = false;
|
|
state.prefetched = null;
|
|
state.items = [];
|
|
state.offset = 0;
|
|
state.draft = null;
|
|
filterDrawer.hidden = true;
|
|
applyZoom();
|
|
renderGrid();
|
|
renderToolbar();
|
|
void loadStats();
|
|
}
|
|
|
|
function render() {
|
|
title.textContent = state.view === "bot" ? "Slopbot" : "Media archive";
|
|
toolbar.hidden = false;
|
|
grid.hidden = state.view === "bot";
|
|
botPanel.hidden = state.view !== "bot";
|
|
scrapeList.hidden = state.view === "bot";
|
|
renderToolbar();
|
|
|
|
if (state.view === "bot") {
|
|
const bot = state.status?.slopbot;
|
|
statusLine.textContent = bot?.connected
|
|
? `connected as ${bot.user} in ${bot.guilds.length} guild(s)`
|
|
: "slopbot is disabled or not logged in (VESKSLOP_SLOPBOT_ENABLED=1 and a token)";
|
|
if (!state.preferences) void refreshPreferences();
|
|
void refreshBackfill();
|
|
void loadScrapes().then(renderScrapes);
|
|
void refreshErrors();
|
|
} else {
|
|
renderGrid();
|
|
void loadChannels().then(renderToolbar);
|
|
void loadAuthors().then(renderToolbar);
|
|
void loadStats();
|
|
void loadMedia(true);
|
|
void loadScrapes();
|
|
}
|
|
}
|
|
|
|
overlay.querySelector("#media-close").addEventListener("click", hide);
|
|
overlay.querySelector("#media-refresh").addEventListener("click", () => {
|
|
state.completed.clear();
|
|
void loadScrapes();
|
|
if (state.view === "media") void loadMedia(true);
|
|
if (state.view === "bot") void refreshBackfill();
|
|
});
|
|
|
|
overlay.querySelector("#backfill-start").addEventListener("click", () => void backfillAction("start"));
|
|
overlay.querySelector("#backfill-pause").addEventListener("click", () => void backfillAction("pause"));
|
|
|
|
const scopeMedia = document.querySelector("#scope-media");
|
|
const scopeBot = document.querySelector("#scope-bot");
|
|
scopeMedia?.addEventListener("click", () => show("media"));
|
|
scopeBot?.addEventListener("click", () => show("bot"));
|
|
document.querySelector("#scope-guild")?.addEventListener("click", hide);
|
|
document.querySelector("#scope-dm")?.addEventListener("click", hide);
|
|
|
|
const errorBadge = el("span", "vk-media-badge");
|
|
errorBadge.id = "scope-bot-badge";
|
|
errorBadge.hidden = true;
|
|
scopeBot?.append(errorBadge);
|
|
|
|
void refreshErrors();
|
|
setInterval(() => {
|
|
if (state.view !== "bot") void refreshErrors();
|
|
}, 15000);
|
|
|
|
// Keep the running-scrape strip live while the media view is open.
|
|
setInterval(() => {
|
|
if (state.view === "media") void loadScrapes();
|
|
}, 4000);
|
|
|
|
// Backfill progress while the Bot tab is open.
|
|
setInterval(() => {
|
|
if (state.view === "bot") void refreshBackfill();
|
|
}, 3000);
|
|
|
|
// Infinite scroll: fetch the next page before the grid bottom is reached.
|
|
grid.addEventListener("scroll", () => {
|
|
if (state.loading || !state.loaded) return;
|
|
if (grid.scrollTop + grid.clientHeight < grid.scrollHeight - 240) return;
|
|
|
|
if (state.prefetched) {
|
|
const items = state.prefetched;
|
|
state.prefetched = null;
|
|
state.items = state.items.concat(items);
|
|
state.offset += items.length;
|
|
state.more = items.length >= state.pageSize;
|
|
renderGrid();
|
|
void loadStats();
|
|
void prefetchNext();
|
|
return;
|
|
}
|
|
|
|
if (state.more) void loadMedia(false);
|
|
});
|
|
|
|
// ---- Scrape popup: server or group-chat trees ----
|
|
|
|
const scrapeModal = document.createElement("div");
|
|
scrapeModal.className = "vk-media-modal";
|
|
scrapeModal.hidden = true;
|
|
scrapeModal.innerHTML = `
|
|
<div class="vk-media-modal-box vk-media-wide">
|
|
<div class="vk-media-head">
|
|
<strong>New scrape</strong>
|
|
<span class="vk-media-spacer"></span>
|
|
<button id="scrape-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
|
|
</div>
|
|
<div class="vk-media-toolbar">
|
|
<button id="scrape-tab-server" class="vk-chip vk-chip-on" type="button">Server</button>
|
|
<button id="scrape-tab-group" class="vk-chip vk-chip-off" type="button">Group chat</button>
|
|
<span id="scrape-hint" class="vk-media-status"></span>
|
|
</div>
|
|
<div id="scrape-tree" class="vk-media-tree"></div>
|
|
<div class="vk-media-toolbar">
|
|
<button id="scrape-cancel" class="btn btn-secondary vk-small-btn" type="button">Close</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.append(scrapeModal);
|
|
|
|
let scrapeTab = "server";
|
|
|
|
function channelRow(channelId, guildId, label) {
|
|
const row = el("div", "vk-media-channel");
|
|
row.append(el("span", "vk-media-channel-name", label));
|
|
row.append(button("Links", () => askScrape(channelId, guildId, "links", label)));
|
|
row.append(button("Download", () => askScrape(channelId, guildId, "download", label)));
|
|
return row;
|
|
}
|
|
|
|
function renderScrapeTree() {
|
|
const tree = scrapeModal.querySelector("#scrape-tree");
|
|
const hint = scrapeModal.querySelector("#scrape-hint");
|
|
tree.replaceChildren();
|
|
|
|
if (scrapeTab === "group") {
|
|
if (state.dmsError) {
|
|
hint.textContent = `Group chats need the core client: ${state.dmsError}`;
|
|
tree.append(
|
|
button("Retry", () => {
|
|
void loadChannels(true).then(renderScrapeTree);
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
const groups = state.dms.filter(dm => dm.type === 3);
|
|
hint.textContent = groups.length ? "Group chats use the puppet account." : "No cached group chats yet.";
|
|
for (const dm of groups) {
|
|
const names = (dm.recipients ?? [])
|
|
.map(recipient => recipient.username)
|
|
.filter(Boolean)
|
|
.join(", ");
|
|
tree.append(channelRow(dm.id, null, dm.name || names || dm.id));
|
|
}
|
|
return;
|
|
}
|
|
|
|
hint.textContent = state.guilds.length ? "" : "No guilds cached yet.";
|
|
for (const guild of state.guilds) {
|
|
const group = el("div", "vk-media-tree-guild");
|
|
const head = el("button", "vk-media-tree-head", `> ${guild.name}`);
|
|
head.type = "button";
|
|
const children = el("div", "vk-media-tree-children");
|
|
children.hidden = true;
|
|
|
|
head.addEventListener("click", () => {
|
|
children.hidden = !children.hidden;
|
|
head.textContent = `${children.hidden ? ">" : "v"} ${guild.name}`;
|
|
});
|
|
|
|
group.append(head, children);
|
|
for (const channel of guild.channels ?? []) {
|
|
if (channel.type !== 0 && channel.type !== 5) continue;
|
|
children.append(channelRow(channel.id, guild.id, `#${channel.name}`));
|
|
}
|
|
tree.append(group);
|
|
}
|
|
}
|
|
|
|
function openScrapeModal() {
|
|
renderScrapeTree();
|
|
scrapeModal.hidden = false;
|
|
void loadChannels(true).then(renderScrapeTree);
|
|
}
|
|
|
|
for (const [tab, id] of [
|
|
["server", "#scrape-tab-server"],
|
|
["group", "#scrape-tab-group"]
|
|
]) {
|
|
scrapeModal.querySelector(id).addEventListener("click", () => {
|
|
scrapeTab = tab;
|
|
scrapeModal.querySelector("#scrape-tab-server").className =
|
|
tab === "server" ? "vk-chip vk-chip-on" : "vk-chip vk-chip-off";
|
|
scrapeModal.querySelector("#scrape-tab-group").className =
|
|
tab === "group" ? "vk-chip vk-chip-on" : "vk-chip vk-chip-off";
|
|
renderScrapeTree();
|
|
});
|
|
}
|
|
scrapeModal.querySelector("#scrape-cancel").addEventListener("click", () => {
|
|
scrapeModal.hidden = true;
|
|
});
|
|
scrapeModal.querySelector("#scrape-x").addEventListener("click", () => {
|
|
scrapeModal.hidden = true;
|
|
});
|
|
scrapeModal.addEventListener("click", event => {
|
|
if (event.target === scrapeModal) scrapeModal.hidden = true;
|
|
});
|
|
|
|
// ---- Confirmation popup for Links / Download ----
|
|
|
|
const confirmModal = document.createElement("div");
|
|
confirmModal.className = "vk-media-modal";
|
|
confirmModal.hidden = true;
|
|
confirmModal.innerHTML = `
|
|
<div class="vk-media-modal-box">
|
|
<strong id="confirm-title">Start scrape?</strong>
|
|
<div id="confirm-note" class="vk-media-status"></div>
|
|
<label id="confirm-dest-row" class="vk-media-check" hidden>
|
|
<input id="confirm-custom-dest" type="checkbox" /> Custom download destination
|
|
</label>
|
|
<input
|
|
id="confirm-dest"
|
|
class="vk-media-input vk-media-dest"
|
|
type="text"
|
|
placeholder="/path/to/download/"
|
|
hidden
|
|
/>
|
|
<div class="vk-media-toolbar">
|
|
<span class="vk-media-status">From</span>
|
|
<input id="confirm-after" class="vk-media-input" type="date" />
|
|
<span class="vk-media-status">To</span>
|
|
<input id="confirm-before" class="vk-media-input" type="date" />
|
|
<button id="confirm-count" class="btn btn-secondary vk-small-btn" type="button">
|
|
Check Message Count
|
|
</button>
|
|
<span id="confirm-count-result" class="vk-media-status"></span>
|
|
</div>
|
|
<div class="vk-media-toolbar">
|
|
<button id="confirm-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
|
|
<button id="confirm-start" class="btn btn-primary vk-small-btn" type="button">Start</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.append(confirmModal);
|
|
|
|
let confirmAction = null;
|
|
|
|
function askScrape(channelId, guildId, mode, label) {
|
|
const isDownload = mode === "download";
|
|
const destRow = confirmModal.querySelector("#confirm-dest-row");
|
|
const destInput = confirmModal.querySelector("#confirm-dest");
|
|
const checkbox = confirmModal.querySelector("#confirm-custom-dest");
|
|
|
|
confirmModal.querySelector("#confirm-title").textContent =
|
|
isDownload ? "Download scrape?" : "Links scrape?";
|
|
confirmModal.querySelector("#confirm-note").textContent = isDownload
|
|
? `${label} — downloads files${guildId ? "" : " with the puppet"}.`
|
|
: `${label} — indexes links and caches previews${guildId ? "" : " with the puppet"}.`;
|
|
|
|
destRow.hidden = !isDownload;
|
|
destInput.hidden = true;
|
|
destInput.value = "";
|
|
checkbox.checked = false;
|
|
checkbox.onchange = () => {
|
|
destInput.hidden = !checkbox.checked;
|
|
};
|
|
|
|
const afterInput = confirmModal.querySelector("#confirm-after");
|
|
const beforeInput = confirmModal.querySelector("#confirm-before");
|
|
const countResult = confirmModal.querySelector("#confirm-count-result");
|
|
afterInput.value = "";
|
|
beforeInput.value = "";
|
|
countResult.textContent = "";
|
|
|
|
confirmModal.querySelector("#confirm-count").onclick = async () => {
|
|
countResult.textContent = "checking...";
|
|
try {
|
|
const response = await window.VK.api.post("/api/media/count", {
|
|
channelId,
|
|
guildId: guildId ?? null,
|
|
after: afterInput.value ? `${afterInput.value}T00:00:00.000Z` : null,
|
|
before: beforeInput.value ? `${beforeInput.value}T23:59:59.999Z` : null
|
|
});
|
|
countResult.textContent =
|
|
response.count === null
|
|
? response.reason ?? "unavailable"
|
|
: `${response.count} messages`;
|
|
} catch (error) {
|
|
countResult.textContent = `failed: ${error.message}`;
|
|
}
|
|
};
|
|
|
|
confirmAction = () =>
|
|
startScrape(
|
|
channelId,
|
|
guildId,
|
|
mode,
|
|
isDownload && checkbox.checked ? destInput.value.trim() : null,
|
|
afterInput.value ? `${afterInput.value}T00:00:00.000Z` : null,
|
|
beforeInput.value ? `${beforeInput.value}T23:59:59.999Z` : null
|
|
);
|
|
confirmModal.hidden = false;
|
|
}
|
|
|
|
confirmModal.querySelector("#confirm-cancel").addEventListener("click", () => {
|
|
confirmModal.hidden = true;
|
|
});
|
|
confirmModal.querySelector("#confirm-start").addEventListener("click", () => {
|
|
const action = confirmAction;
|
|
confirmModal.hidden = true;
|
|
void action?.();
|
|
});
|
|
confirmModal.addEventListener("click", event => {
|
|
if (event.target === confirmModal) confirmModal.hidden = true;
|
|
});
|
|
|
|
// ---- History popup ----
|
|
|
|
const historyModal = document.createElement("div");
|
|
historyModal.className = "vk-media-modal";
|
|
historyModal.hidden = true;
|
|
historyModal.innerHTML = `
|
|
<div class="vk-media-modal-box vk-media-wide">
|
|
<div class="vk-media-head">
|
|
<strong>Scrape history</strong>
|
|
<span class="vk-media-spacer"></span>
|
|
<button id="history-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
|
|
</div>
|
|
<div id="history-list" class="vk-media-modal-list"></div>
|
|
<div class="vk-media-toolbar">
|
|
<button id="history-close" class="btn btn-secondary vk-small-btn" type="button">Close</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.append(historyModal);
|
|
|
|
function durationLabel(scrape) {
|
|
const start = new Date(scrape.createdAt).getTime();
|
|
const end = scrape.finishedAt ? new Date(scrape.finishedAt).getTime() : Date.now();
|
|
const seconds = Math.max(0, Math.round((end - start) / 1000));
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
}
|
|
|
|
function channelLabel(channelId) {
|
|
return state.channels.find(channel => channel.id === channelId)?.label ?? channelId;
|
|
}
|
|
|
|
async function openHistoryModal() {
|
|
const list = historyModal.querySelector("#history-list");
|
|
list.replaceChildren(el("div", null, "loading..."));
|
|
historyModal.hidden = false;
|
|
|
|
try {
|
|
const [scrapes, storage] = await Promise.all([
|
|
window.VK.api.get("/api/media/scrapes"),
|
|
window.VK.api.get("/api/media/storage")
|
|
]);
|
|
await loadChannels();
|
|
|
|
list.replaceChildren();
|
|
const bytesById = new Map((storage?.scrapes ?? []).map(entry => [entry.id, entry.bytes]));
|
|
|
|
for (const scrape of scrapes) {
|
|
const media = scrape.media ?? {};
|
|
const row = el("div", "vk-media-history-row");
|
|
row.append(el("strong", null, channelLabel(scrape.channelId)));
|
|
row.append(
|
|
el(
|
|
"span",
|
|
null,
|
|
`${scrape.state} · started ${new Date(scrape.createdAt).toLocaleString()} · ` +
|
|
`took ${durationLabel(scrape)} · ${scrape.pages}p/${scrape.messages}m · ` +
|
|
(scrape.estimatedMessages != null
|
|
? `~${scrape.estimatedMessages} total · `
|
|
: "") +
|
|
(scrape.fromDate || scrape.toDate
|
|
? `range ${
|
|
scrape.fromDate
|
|
? new Date(scrape.fromDate).toLocaleDateString()
|
|
: "..."
|
|
} to ${
|
|
scrape.toDate ? new Date(scrape.toDate).toLocaleDateString() : "..."
|
|
} · `
|
|
: "") +
|
|
`img ${media.image ?? 0} gif ${media.gif ?? 0} vid ${media.video ?? 0} aud ${media.audio ?? 0} · ` +
|
|
`${formatBytes(bytesById.get(scrape.id) ?? 0)} · files ${scrape.downloaded ?? 0} · previews ${scrape.previewed ?? 0}`
|
|
)
|
|
);
|
|
if (scrape.state !== "running" && scrape.state !== "queued") {
|
|
row.append(
|
|
button("Delete", async () => {
|
|
if (
|
|
!window.confirm(
|
|
`Delete scrape ${scrape.id}? This removes its database, downloads, and previews.`
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await window.VK.api.post("/api/media/clean", { ids: [scrape.id] });
|
|
} catch (error) {
|
|
setStatus(`delete failed: ${error.message}`);
|
|
}
|
|
void loadScrapes();
|
|
void openHistoryModal();
|
|
})
|
|
);
|
|
}
|
|
|
|
if (["paused", "queued"].includes(scrape.state)) {
|
|
row.append(
|
|
button("Resume", async () => {
|
|
try {
|
|
await window.VK.api.post("/api/media/scrape/resume", { id: scrape.id });
|
|
} catch (error) {
|
|
setStatus(`resume failed: ${error.message}`);
|
|
}
|
|
void loadScrapes();
|
|
void openHistoryModal();
|
|
})
|
|
);
|
|
}
|
|
|
|
list.append(row);
|
|
}
|
|
|
|
if (!scrapes.length) list.append(el("div", null, "No scrapes yet."));
|
|
} catch (error) {
|
|
list.replaceChildren(el("div", null, `history failed: ${error.message}`));
|
|
}
|
|
}
|
|
|
|
historyModal.querySelector("#history-close").addEventListener("click", () => {
|
|
historyModal.hidden = true;
|
|
});
|
|
historyModal.querySelector("#history-x").addEventListener("click", () => {
|
|
historyModal.hidden = true;
|
|
});
|
|
historyModal.addEventListener("click", event => {
|
|
if (event.target === historyModal) historyModal.hidden = true;
|
|
});
|
|
|
|
// ---- Media viewer ----
|
|
|
|
function embedUrlFor(item) {
|
|
if (item.source !== "embed") return null;
|
|
|
|
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");
|
|
if (id) return `https://www.youtube.com/embed/${id}`;
|
|
}
|
|
if (host === "youtu.be") return `https://www.youtube.com/embed${url.pathname}`;
|
|
if (host === "vimeo.com") return `https://player.vimeo.com/video${url.pathname}`;
|
|
if (host === "clips.twitch.tv") {
|
|
return `https://player.twitch.tv/?clip=${url.pathname.slice(1)}&parent=${location.hostname}`;
|
|
}
|
|
if (host === "streamable.com") return `https://streamable.com/e${url.pathname}`;
|
|
} catch {
|
|
// not embeddable
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
const viewerModal = document.createElement("div");
|
|
viewerModal.className = "vk-media-modal";
|
|
viewerModal.hidden = true;
|
|
viewerModal.innerHTML = `
|
|
<div class="vk-media-modal-box vk-media-viewer-box">
|
|
<div class="vk-media-head">
|
|
<strong id="viewer-title">Preview</strong>
|
|
<span class="vk-media-spacer"></span>
|
|
<button id="viewer-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
|
|
</div>
|
|
<div id="viewer-body" class="vk-media-viewer wide">
|
|
<div id="viewer-media" class="vk-media-viewer-media"></div>
|
|
<div id="viewer-meta" class="vk-media-viewer-meta"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.append(viewerModal);
|
|
|
|
function openViewer(item, play) {
|
|
const body = viewerModal.querySelector("#viewer-body");
|
|
const media = viewerModal.querySelector("#viewer-media");
|
|
const meta = viewerModal.querySelector("#viewer-meta");
|
|
const vertical = item.aspectRatio != null && item.aspectRatio < 1;
|
|
|
|
viewerModal.querySelector("#viewer-title").textContent = typeLabel(item);
|
|
body.className = `vk-media-viewer ${vertical ? "vertical" : "wide"}`;
|
|
media.replaceChildren();
|
|
|
|
// The stream endpoint serves the local file or a refreshed remote URL, so signed
|
|
// CDN links that expired no longer break playback.
|
|
const streamUrl = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
|
|
const source = item.localUrl || item.thumbUrl || streamUrl;
|
|
const embed = embedUrlFor(item);
|
|
const directVideo = item.localUrl || (item.kind === "video" ? streamUrl : null);
|
|
const canPlay = !!embed || (item.kind === "video" && !!directVideo);
|
|
|
|
// An embeddable item (YouTube, Vimeo, ...) opens straight into its player.
|
|
const showPlayer = play || !!embed;
|
|
|
|
// A play request on something we cannot play falls back to the external page.
|
|
if (play && !canPlay) openLinkWarning(item.url);
|
|
|
|
if (showPlayer && embed) {
|
|
const frame = document.createElement("iframe");
|
|
frame.src = embed;
|
|
frame.allow = "autoplay; fullscreen; picture-in-picture";
|
|
frame.allowFullscreen = true;
|
|
media.append(frame);
|
|
} else if (showPlayer && directVideo) {
|
|
const video = document.createElement("video");
|
|
video.controls = true;
|
|
video.autoplay = true;
|
|
video.src = directVideo;
|
|
video.addEventListener(
|
|
"error",
|
|
() => {
|
|
media.replaceChildren(
|
|
el("div", "vk-media-status", "This format is not supported in the browser.")
|
|
);
|
|
media.append(
|
|
button("Open in new tab", () =>
|
|
window.open(item.playUrl || directVideo, "_blank", "noreferrer")
|
|
)
|
|
);
|
|
},
|
|
{ once: true }
|
|
);
|
|
media.append(video);
|
|
} else if ((item.kind === "image" || item.kind === "gif") && /^(https?:|\/)/.test(source)) {
|
|
const img = document.createElement("img");
|
|
img.src = source;
|
|
img.alt = item.kind;
|
|
media.append(img);
|
|
} else {
|
|
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
|
if (item.kind === "video") media.append(viewerPlayIcon(item));
|
|
}
|
|
|
|
meta.replaceChildren();
|
|
const values = metaValues(item);
|
|
for (const [key, label] of META_FIELDS) {
|
|
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);
|
|
link.type = "button";
|
|
link.title = "Open the original link";
|
|
link.addEventListener("click", () => openLinkWarning(item.url));
|
|
meta.append(link);
|
|
|
|
// "View full" opens the embedded media (player, local file, or a refreshed stream).
|
|
const streamPath = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
|
|
const fullUrl =
|
|
embedUrlFor(item) ??
|
|
item.localUrl ??
|
|
(item.kind === "video" ? streamPath : item.playUrl ?? item.thumbUrl ?? streamPath);
|
|
meta.append(button("View full", () => window.open(fullUrl, "_blank", "noreferrer")));
|
|
|
|
viewerModal.hidden = false;
|
|
}
|
|
|
|
viewerModal.querySelector("#viewer-x").addEventListener("click", () => {
|
|
viewerModal.hidden = true;
|
|
});
|
|
viewerModal.addEventListener("click", event => {
|
|
if (event.target === viewerModal) viewerModal.hidden = true;
|
|
});
|
|
|
|
// ---- External link warning ----
|
|
|
|
const linkModal = document.createElement("div");
|
|
linkModal.className = "vk-media-modal";
|
|
linkModal.hidden = true;
|
|
linkModal.innerHTML = `
|
|
<div class="vk-media-modal-box">
|
|
<strong>Open external link?</strong>
|
|
<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-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>
|
|
</div>
|
|
`;
|
|
document.body.append(linkModal);
|
|
|
|
let pendingLink = null;
|
|
|
|
function openLinkWarning(url) {
|
|
pendingLink = url;
|
|
linkModal.querySelector("#link-target").textContent = url;
|
|
linkModal.hidden = false;
|
|
}
|
|
|
|
linkModal.querySelector("#link-cancel").addEventListener("click", () => {
|
|
linkModal.hidden = true;
|
|
});
|
|
linkModal.querySelector("#link-continue").addEventListener("click", () => {
|
|
if (pendingLink) window.open(pendingLink, "_blank", "noreferrer");
|
|
linkModal.hidden = true;
|
|
});
|
|
linkModal.addEventListener("click", event => {
|
|
if (event.target === linkModal) linkModal.hidden = true;
|
|
});
|
|
|
|
// ---- Settings: media storage ----
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`;
|
|
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MiB`;
|
|
return `${bytes} B`;
|
|
}
|
|
|
|
function renderUsageBar(status) {
|
|
const bar = document.querySelector("#media-usage-bar");
|
|
if (!bar || !status) return;
|
|
|
|
bar.replaceChildren();
|
|
const limit = Math.max(status.limitBytes, 1);
|
|
const measured =
|
|
(status.byKind?.image ?? 0) +
|
|
(status.byKind?.gif ?? 0) +
|
|
(status.byKind?.video ?? 0) +
|
|
(status.byKind?.audio ?? 0);
|
|
const other = Math.max(0, status.usedBytes - measured);
|
|
|
|
for (const [kind, bytes] of [
|
|
["image", status.byKind?.image ?? 0],
|
|
["gif", status.byKind?.gif ?? 0],
|
|
["video", status.byKind?.video ?? 0],
|
|
["audio", status.byKind?.audio ?? 0],
|
|
["other", other]
|
|
]) {
|
|
if (bytes <= 0) continue;
|
|
const segment = el("div", `vk-media-usage-seg vk-seg-${kind}`);
|
|
segment.style.width = `${Math.min(100, (bytes / limit) * 100)}%`;
|
|
segment.title = `${kind}: ${formatBytes(bytes)}`;
|
|
bar.append(segment);
|
|
}
|
|
}
|
|
|
|
async function loadMediaSettings() {
|
|
try {
|
|
const [storage, status] = await Promise.all([
|
|
window.VK.api.get("/api/media/storage"),
|
|
window.VK.api.get("/api/media/status")
|
|
]);
|
|
state.storage = storage;
|
|
|
|
const bot = status?.slopbot;
|
|
const slopbotRow = document.querySelector("#media-slopbot");
|
|
if (slopbotRow) slopbotRow.textContent = bot?.connected ? `connected as ${bot.user}` : "disabled";
|
|
|
|
const limit = document.querySelector("#media-limit");
|
|
if (limit && document.activeElement !== limit) {
|
|
limit.value = (storage.limitBytes / 1024 ** 3).toFixed(2);
|
|
}
|
|
|
|
const used = document.querySelector("#media-used");
|
|
if (used) {
|
|
used.textContent = `${formatBytes(storage.usedBytes)} of ${formatBytes(storage.limitBytes)}${
|
|
storage.nearLimit ? " (near limit)" : ""
|
|
}`;
|
|
}
|
|
|
|
renderUsageBar(storage);
|
|
} catch (error) {
|
|
const used = document.querySelector("#media-used");
|
|
if (used) used.textContent = `unavailable: ${error.message}`;
|
|
}
|
|
}
|
|
|
|
document.querySelector("#media-save")?.addEventListener("click", async () => {
|
|
const input = document.querySelector("#media-limit");
|
|
const gib = Number(input?.value);
|
|
if (!Number.isFinite(gib) || gib <= 0) return;
|
|
|
|
try {
|
|
await window.VK.api.post("/api/preferences", { media: { limitBytes: Math.round(gib * 1024 ** 3) } });
|
|
setStatus("media limit saved");
|
|
const save = document.querySelector("#media-save");
|
|
if (save) save.disabled = true;
|
|
} catch (error) {
|
|
setStatus(`save failed: ${error.message}`);
|
|
}
|
|
void loadMediaSettings();
|
|
});
|
|
|
|
document.querySelector("#media-limit")?.addEventListener("input", () => {
|
|
const save = document.querySelector("#media-save");
|
|
if (save) save.disabled = false;
|
|
});
|
|
|
|
const cleanModal = document.createElement("div");
|
|
cleanModal.className = "vk-media-modal";
|
|
cleanModal.hidden = true;
|
|
cleanModal.innerHTML = `
|
|
<div class="vk-media-modal-box">
|
|
<strong>Delete media scrapes?</strong>
|
|
<div id="clean-note" class="vk-media-status"></div>
|
|
<div id="clean-list" class="vk-media-modal-list"></div>
|
|
<div class="vk-media-toolbar">
|
|
<button id="clean-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
|
|
<button id="clean-confirm" class="btn btn-primary vk-small-btn" type="button" disabled>Delete</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.append(cleanModal);
|
|
|
|
let cleanTimer = null;
|
|
|
|
function openCleanModal() {
|
|
const scrapes = state.storage?.scrapes ?? [];
|
|
const list = cleanModal.querySelector("#clean-list");
|
|
const note = cleanModal.querySelector("#clean-note");
|
|
const confirm = cleanModal.querySelector("#clean-confirm");
|
|
|
|
list.replaceChildren();
|
|
for (const scrape of scrapes) {
|
|
list.append(el("div", null, `${scrape.id} · ${scrape.state} · ${formatBytes(scrape.bytes)}`));
|
|
}
|
|
|
|
const bytes = scrapes.reduce((sum, scrape) => sum + scrape.bytes, 0);
|
|
note.textContent = scrapes.length
|
|
? `Everything above will be deleted permanently, including files and databases (${formatBytes(bytes)}). This cannot be undone.`
|
|
: "No scrapes to delete.";
|
|
|
|
confirm.disabled = true;
|
|
let left = 5;
|
|
confirm.textContent = `Delete (${left})`;
|
|
cleanModal.hidden = false;
|
|
|
|
clearInterval(cleanTimer);
|
|
if (!scrapes.length) {
|
|
confirm.textContent = "Delete";
|
|
return;
|
|
}
|
|
|
|
cleanTimer = setInterval(() => {
|
|
left--;
|
|
if (left <= 0) {
|
|
clearInterval(cleanTimer);
|
|
confirm.disabled = false;
|
|
confirm.textContent = "Delete";
|
|
return;
|
|
}
|
|
confirm.textContent = `Delete (${left})`;
|
|
}, 1000);
|
|
}
|
|
|
|
cleanModal.querySelector("#clean-cancel").addEventListener("click", () => {
|
|
clearInterval(cleanTimer);
|
|
cleanModal.hidden = true;
|
|
});
|
|
|
|
cleanModal.querySelector("#clean-confirm").addEventListener("click", async () => {
|
|
const ids = (state.storage?.scrapes ?? []).map(scrape => scrape.id);
|
|
try {
|
|
await window.VK.api.post("/api/media/clean", { ids });
|
|
} catch (error) {
|
|
setStatus(`clean failed: ${error.message}`);
|
|
}
|
|
cleanModal.hidden = true;
|
|
void loadMediaSettings();
|
|
void loadScrapes();
|
|
});
|
|
|
|
document.querySelector("#media-clean")?.addEventListener("click", openCleanModal);
|
|
|
|
document.querySelector("#daemon-restart")?.addEventListener("click", async () => {
|
|
if (!window.confirm("Restart the daemon? The page will disconnect for a few seconds.")) return;
|
|
|
|
try {
|
|
await window.VK.api.post("/api/daemon/restart", {});
|
|
} catch {
|
|
// The daemon exits mid-response on purpose.
|
|
}
|
|
setStatus("daemon restarting; reload the page in a few seconds");
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
if (!state.view) return;
|
|
layoutTop();
|
|
if (state.view === "media") applyZoom();
|
|
});
|
|
|
|
const callStrip = document.querySelector("#call-strip");
|
|
if (callStrip) {
|
|
new MutationObserver(() => {
|
|
if (state.view) layoutTop();
|
|
}).observe(callStrip, { attributes: true, attributeFilter: ["hidden"] });
|
|
}
|
|
|
|
const settingsOverlay = document.querySelector("#settings-overlay");
|
|
if (settingsOverlay) {
|
|
new MutationObserver(() => {
|
|
if (!settingsOverlay.hidden) void loadMediaSettings();
|
|
}).observe(settingsOverlay, { attributes: true, attributeFilter: ["hidden"] });
|
|
}
|
|
|
|
window.VK.media = { show, hide, loadMediaSettings };
|
|
})();
|