"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, prefetchPromise: null, itemKeys: new Set(), loadError: false, 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 = `
Media archive Refresh Close
`; 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"); // The stream buffer: extra scrollable room so hitting the bottom is never a dead stop. const buffer = el("div", "vk-media-buffer"); const bufferNote = el("div", "vk-media-buffer-note", ""); const bufferRetry = button("Retry", () => void loadNextPage()); bufferRetry.classList.add("vk-media-buffer-retry"); bufferRetry.hidden = true; buffer.append(bufferNote, bufferRetry); 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(); } const SIZE_UNITS = { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 }; function sizeUnit(unit) { return SIZE_UNITS[unit] ?? 1; } 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 sort = state.filters.sort && state.filters.sort !== "newest" ? `sort=${state.filters.sort}&` : ""; const excludeProviders = state.filters.excludeProviders.length ? `excludeProviders=${state.filters.excludeProviders.join(",")}&` : ""; const excludeSources = state.filters.excludeSources.length ? `excludeSources=${state.filters.excludeSources.join(",")}&` : ""; const excludeExts = state.filters.excludeExts.length ? `excludeExt=${state.filters.excludeExts.join(",")}&` : ""; const includeProviders = state.filters.includeProviders.length ? `includeProviders=${state.filters.includeProviders.join(",")}&` : ""; const includeSources = state.filters.includeSources.length ? `includeSources=${state.filters.includeSources.join(",")}&` : ""; const amount = (value, unit) => { const number = Number(value); if (value === "" || !Number.isFinite(number) || number < 0) return ""; return String(Math.round(number * (unit ? sizeUnit(unit) : 1))); }; const title = state.filters.title.trim() ? `title=${encodeURIComponent(state.filters.title.trim())}&${ state.filters.titleExact ? "titleExact=1&" : "" }` : ""; const sizeLt = state.filters.sizeLt !== "" ? `sizeLt=${amount(state.filters.sizeLt, state.filters.sizeLtUnit)}&` : ""; const sizeGt = state.filters.sizeGt !== "" ? `sizeGt=${amount(state.filters.sizeGt, state.filters.sizeGtUnit)}&` : ""; const lenLt = state.filters.lenLt !== "" ? `lenLt=${amount(state.filters.lenLt)}&` : ""; const lenGt = state.filters.lenGt !== "" ? `lenGt=${amount(state.filters.lenGt)}&` : ""; 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}` + `${sort}${title}${excludeProviders}${excludeSources}${excludeExts}` + `${includeProviders}${includeSources}${sizeLt}${sizeGt}${lenLt}${lenGt}` + `${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(); } /** Append only the cards that are new, so existing images never get rebuilt. */ function appendPage(items) { const fresh = []; for (const item of items) { const key = `${item.scrapeId}:${item.itemId}`; if (state.itemKeys.has(key)) continue; state.itemKeys.add(key); fresh.push(item); } state.items = state.items.concat(fresh); state.offset += items.length; state.more = items.length >= state.pageSize; state.loadError = false; for (const item of fresh) grid.insertBefore(renderCard(item), buffer); layoutBuffer(); renderStatus(); } /** Reset the list and render everything from scratch. */ function renderCards() { grid.replaceChildren(); 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.")); grid.append(buffer); layoutBuffer(); } async function loadMedia(reset) { if (!state.loaded || state.loading) return; state.loading = true; state.loadError = false; layoutBuffer(); if (reset) { state.items = []; state.itemKeys = new Set(); state.offset = 0; state.more = true; state.prefetched = null; state.prefetchPromise = null; } try { const items = await window.VK.api.get( `/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}` ); if (reset) { for (const item of items) state.itemKeys.add(`${item.scrapeId}:${item.itemId}`); state.items = items; state.offset += items.length; state.more = items.length >= state.pageSize; renderCards(); } else { appendPage(items); } } catch (error) { state.loadError = true; setStatus(`media load failed: ${error.message}`); } finally { state.loading = false; } layoutBuffer(); void loadStats(); void prefetchNext(); void fillBuffer(); } /** 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.prefetchPromise || state.loading) return; state.prefetchPromise = window.VK.api .get(`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`) .then(items => { 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. }) .finally(() => { state.prefetchPromise = null; }); await state.prefetchPromise; } /** Ensure the next page is on its way when the buffer is close or already in view. */ async function loadNextPage() { if (state.loading || !state.loaded || !state.more) return; if (state.prefetchPromise) await state.prefetchPromise; if (state.prefetched) { const items = state.prefetched; state.prefetched = null; appendPage(items); void prefetchNext(); void fillBuffer(); return; } await loadMedia(false); } /** * The buffer keeps scolling alive while the next page loads. Its height is capped at * 1.5 viewport heights and shrinks as the end of the filtered results comes closer. */ function layoutBuffer() { if (!state.loaded) { buffer.style.height = "0px"; bufferNote.textContent = ""; bufferRetry.hidden = true; return; } if (!state.more && !state.loadError) { buffer.style.height = ""; bufferNote.textContent = state.items.length ? "End of results" : ""; bufferRetry.hidden = true; return; } const cap = Math.round((grid.clientHeight || 600) * 1.5); const rowHeight = gridRowHeight(); const total = filteredTotal(); const remainingRows = total === null ? null : Math.ceil(Math.max(0, total - state.offset) / gridColumns()); const wanted = remainingRows === null ? cap : remainingRows * rowHeight; buffer.style.height = `${Math.max(rowHeight, Math.min(cap, wanted))}px`; bufferNote.textContent = state.loadError ? "Could not load more." : state.loading ? "Loading more\u2026" : "Scroll for more"; bufferRetry.hidden = !state.loadError; } /** Filtered total from the stats the status row already uses; null until known. */ function filteredTotal() { const stats = state.stats; if (!stats) return null; return ( (stats.image?.total ?? 0) + (stats.gif?.total ?? 0) + (stats.video?.total ?? 0) + (stats.audio?.total ?? 0) ); } /** Keep loading while the buffer is on screen, so short filtered pages never dead-stop. */ function fillBuffer() { if (!state.more || state.loading || !state.loaded) return; const top = buffer.getBoundingClientRect().top; const bottom = grid.getBoundingClientRect().bottom; if (top <= bottom + 320) void loadNextPage(); } 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 }; const etaLine = el( "div", "vk-bot-summary-line vk-bot-eta", `ETA ${formatEta(totals.etaTotalMs ?? totals.etaMs)} at ${totals.perMinute ?? 0} rows/min ` + `(settings estimate ${status.estimatedPerMinute ?? 0}/min)` ); etaLine.title = `first job starts now \u00b7 queue drains in ${formatEta(totals.etaRunMs ?? totals.etaMs)}`; 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` ), etaLine, 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`); head.append(el("span", "vk-bot-job-meta", parts.join(" \u00b7 "))); if (job.pending > 0) { const eta = el( "span", "vk-bot-job-meta vk-bot-eta", `ETA ${formatEta(job.etaTotalMs ?? job.etaMs)}` ); eta.title = `starts in ${formatEta(job.etaStartMs ?? 0)} \u00b7 ` + `runs for ${formatEta(job.etaRunMs ?? job.etaMs)}`; head.append(eta); } 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` + (member.pendingTitles ? ` + ${member.pendingTitles} titles` : "") + (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" || job.state === "queued") { 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"); if (item.title) figure.title = item.title; 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; } renderCards(); } 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"], sort: "newest", title: "", titleExact: false, excludeProviders: [], excludeSources: [], includeProviders: [], includeSources: [], excludeExts: [], sizeLt: "", sizeGt: "", sizeLtUnit: "MB", sizeGtUnit: "MB", lenLt: "", lenGt: "", aspect: "", resolution: "", after: "", before: "" }; } function ensureDraft() { if (!state.draft) { state.draft = { ...state.filters, kind: state.kind, authorIds: [...state.filters.authorIds], exts: [...state.filters.exts], excludeHosts: [...state.filters.excludeHosts], excludeProviders: [...state.filters.excludeProviders], excludeSources: [...state.filters.excludeSources], includeProviders: [...state.filters.includeProviders], includeSources: [...state.filters.includeSources], title: state.filters.title, titleExact: state.filters.titleExact, excludeExts: [...state.filters.excludeExts] }; } } 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], sort: state.draft.sort, excludeProviders: [...state.draft.excludeProviders], excludeSources: [...state.draft.excludeSources], includeProviders: [...state.draft.includeProviders], includeSources: [...state.draft.includeSources], title: state.draft.title, titleExact: state.draft.titleExact, excludeExts: [...state.draft.excludeExts], sizeLt: state.draft.sizeLt, sizeGt: state.draft.sizeGt, sizeLtUnit: state.draft.sizeLtUnit, sizeGtUnit: state.draft.sizeGtUnit, lenLt: state.draft.lenLt, lenGt: state.draft.lenGt, 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(); if (!state.status?.providerFilters?.length) { void loadScrapes().then(() => { 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( [ ["newest", "Newest first"], ["oldest", "Oldest first"], ["user", "User (A\u2192Z)"], ["size", "Largest size"], ["length", "Longest length"] ], draft.sort, value => { draft.sort = value; } ), (() => { const row = el("div", "vk-media-toolbar"); const input = document.createElement("input"); input.type = "text"; input.className = "vk-media-input"; input.placeholder = "Video title (Ma Ca Ju; *car, c*r* wildcards)"; input.value = draft.title; input.addEventListener("input", () => { draft.title = input.value; }); row.append(input); const exact = el("label", "vk-media-check"); const box = document.createElement("input"); box.type = "checkbox"; box.checked = draft.titleExact; box.title = "Match the whole phrase instead of per-word prefixes; * still works as a wildcard"; box.addEventListener("change", () => { draft.titleExact = box.checked; }); exact.append(box, document.createTextNode(" Exact match (quotes)")); row.append(exact); return row; })(), 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 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); // "Do not include" and "Must include": mirrored collapsible groups. const EXTS = [ "mp4", "webm", "mov", "mkv", "png", "jpg", "gif", "webp", "avif", "mp3", "ogg", "wav", "m4a", "flac" ]; const SOURCE_KINDS = [ ["embed", "Embeds"], ["attachment", "Attachments"], ["sticker", "Stickers"] ]; const groupBlock = title => { const detailsNode = document.createElement("details"); detailsNode.className = "vk-media-exclude"; detailsNode.append(el("summary", "vk-media-exclude-title", title)); return detailsNode; }; const group = (parent, title) => { const detailsNode = document.createElement("details"); detailsNode.className = "vk-media-exclude-group"; detailsNode.append(el("summary", "vk-media-status", title)); parent.append(detailsNode); return detailsNode; }; const checkRow = (label, checked, onToggle) => { const row = el("label", "vk-media-check"); const box = document.createElement("input"); box.type = "checkbox"; box.checked = checked; box.addEventListener("change", () => { onToggle(box.checked); renderFilterDrawer(); }); row.append(box, document.createTextNode(` ${label}`)); return row; }; const toggle = (list, value, on) => on ? [...new Set([...list, value])] : list.filter(entry => entry !== value); const toggleMany = (list, values, on) => on ? [...new Set([...list, ...values])] : list.filter(entry => !values.includes(entry)); /** Provider boxes; a list entry may carry several substrings ("giphy,tenor"). */ const providerChecks = (parent, key) => { const node = group(parent, "Sources"); for (const entry of state.status?.providerFilters ?? []) { const values = entry.split(","); const label = values.map(value => value.charAt(0).toUpperCase() + value.slice(1)).join("/"); node.append( checkRow( label, values.every(value => draft[key].includes(value)), on => { draft[key] = toggleMany(draft[key], values, on); } ) ); } for (const [value, label] of SOURCE_KINDS) { node.append( checkRow(label, draft[key === "excludeProviders" ? "excludeSources" : "includeSources"].includes(value), on => { const listKey = key === "excludeProviders" ? "excludeSources" : "includeSources"; draft[listKey] = toggle(draft[listKey], value, on); }) ); } return node; }; const extChips = (parent, key, mode) => { const node = group(parent, "File types"); const row = el("div", "vk-media-toolbar"); for (const extension of EXTS) { const active = draft[key].includes(extension); const chip = el( "button", mode === "exclude" ? `vk-chip ${active ? "vk-chip-off vk-chip-strike" : "vk-chip-on"}` : `vk-chip ${active ? "vk-chip-on" : "vk-chip-off"}`, extension ); chip.type = "button"; chip.title = active ? mode === "exclude" ? `Excluding ${extension}` : `Only ${extension}` : mode === "exclude" ? `${extension} included` : `${extension} not required`; chip.addEventListener("click", () => { draft[key] = toggle(draft[key], extension, !active); renderFilterDrawer(); }); row.append(chip); } node.append(row); return node; }; const amountRow = (label, key, unitKey) => { const row = el("div", "vk-media-toolbar"); row.append(el("span", "vk-media-status", label)); const input = document.createElement("input"); input.type = "number"; input.min = "0"; input.step = "any"; input.className = "vk-media-input vk-media-small-input"; input.value = draft[key]; input.addEventListener("input", () => { draft[key] = input.value; }); row.append(input); if (unitKey) { const unit = document.createElement("select"); unit.className = "vk-chip vk-chip-off"; for (const value of Object.keys(SIZE_UNITS)) { const option = document.createElement("option"); option.value = value; option.textContent = value; unit.append(option); } unit.value = draft[unitKey]; unit.addEventListener("change", () => { draft[unitKey] = unit.value; }); row.append(unit); } else { row.append(el("span", "vk-media-status", "seconds")); } return row; }; const exclude = groupBlock("Do not include"); providerChecks(exclude, "excludeProviders"); extChips(exclude, "excludeExts", "exclude"); filters.append(exclude); const include = groupBlock("Must include"); providerChecks(include, "includeProviders"); extChips(include, "exts", "include"); const sizeGroup = group(include, "Size"); sizeGroup.append( amountRow("Less than", "sizeLt", "sizeLtUnit"), amountRow("Greater than", "sizeGt", "sizeGtUnit") ); const lengthGroup = group(include, "Length"); lengthGroup.append(amountRow("Less than", "lenLt"), amountRow("Greater than", "lenGt")); filters.append(include); 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)); } function gridColumns() { const width = cardWidth(); return Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8))); } function gridRowHeight() { return cardWidth() * 1.16 + 30; } /** Items per page: full rows, about 1.5 screens tall. */ function computePageSize() { const columns = gridColumns(); const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (gridRowHeight() + 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(); layoutBuffer(); } 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"; // The filter bar and its drawer belong to the Media tab only. toolbar.hidden = state.view === "bot"; if (state.view === "bot") filterDrawer.hidden = true; 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: the buffer entering the viewport starts the next page. const bufferObserver = new IntersectionObserver( entries => { if (entries.some(entry => entry.isIntersecting)) void loadNextPage(); }, { root: grid, rootMargin: "400px 0px" } ); bufferObserver.observe(buffer); grid.addEventListener("scroll", () => { if (!state.loaded) return; const remaining = grid.scrollHeight - grid.scrollTop - grid.clientHeight; if (remaining < 400) void loadNextPage(); }); // Past the buffer, a small elastic nudge instead of a hard stop while more is loading. let elasticOffset = 0; let elasticTimer = null; function elastic(delta) { if (!state.more || !state.loading) return; const atBottom = grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 2; if (!atBottom) return; elasticOffset = Math.max(-18, Math.min(0, elasticOffset - delta * 0.35)); grid.style.transition = "none"; grid.style.transform = `translateY(${elasticOffset}px)`; clearTimeout(elasticTimer); elasticTimer = setTimeout(() => { grid.style.transition = "transform 160ms ease-out"; grid.style.transform = ""; elasticOffset = 0; }, 60); } grid.addEventListener( "wheel", event => { if (event.deltaY > 0) elastic(event.deltaY); }, { passive: true } ); let touchStartY = 0; grid.addEventListener( "touchstart", event => { touchStartY = event.touches[0]?.clientY ?? 0; }, { passive: true } ); grid.addEventListener( "touchmove", event => { const y = event.touches[0]?.clientY ?? 0; if (touchStartY - y > 0) elastic((touchStartY - y) / 8); }, { passive: true } ); // ---- Scrape popup: server or group-chat trees ---- const scrapeModal = document.createElement("div"); scrapeModal.className = "vk-media-modal"; scrapeModal.hidden = true; scrapeModal.innerHTML = `
New scrape
`; 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 = `
Start scrape?
From To
`; 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 = `
Scrape history
`; 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") ?? /^\/shorts\/([A-Za-z0-9_-]+)/.exec(url.pathname)?.[1]; 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 = `
Preview
`; 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 = `
Open external link?
This opens a new website in a new tab.
`; 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 = `
Delete media scrapes?
`; 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 }; })();