"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, stats: null, metaFields: JSON.parse( localStorage.getItem("vk-media-meta-fields") ?? '["user","type","date","source","resolution"]' ), filters: { source: "", channelId: "", authorId: "", aspect: "", resolution: "", after: "", before: "" } }; 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"); 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 === "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() { if (state.loadedChannels) return; 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 { state.dms = []; } state.loadedChannels = true; } catch { state.guilds = []; state.channels = []; } } async function loadAuthors() { if (state.authors) return; try { state.authors = await window.VK.api.get("/api/media/authors"); } catch { state.authors = []; } } async function loadScrapes() { 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}`); } renderScrapes(); if (state.view === "media") renderToolbar(); } 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 author = state.filters.authorId ? `authorId=${state.filters.authorId}&` : ""; 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}${author}${aspect}${resolution}${after}${before}`; } function renderStatus() { const stats = state.stats ?? { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 }; setStatus( `downloaded ${stats.downloaded}/${stats.total} (images/videos) | ` + `cached ${stats.cached}/${stats.notDownloaded} (excludes downloaded) | ${state.items.length} loaded` ); } 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) return; if (reset) { state.items = []; state.offset = 0; } try { const items = await window.VK.api.get( `/api/media?limit=60&offset=${state.offset}&${filterQuery()}` ); state.items = state.items.concat(items); state.offset += items.length; } catch (error) { setStatus(`media load failed: ${error.message}`); renderGrid(); return; } renderGrid(); void loadStats(); } 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(); const active = state.scrapes.filter(scrape => ["queued", "running", "paused"].includes(scrape.state)); for (const scrape of active) { const row = el("div", "vk-media-scrape"); const media = scrape.media ?? {}; if (scrape.state === "running" || scrape.state === "queued") { row.append(el("span", "vk-media-pulse")); } row.append( el( "span", null, `${channelLabel(scrape.channelId)} · ${scrape.state} · ${scrape.pages}p · ${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}` : "") ) ); row.append( button("Stop", async () => { await window.VK.api.post("/api/media/scrape/stop", { id: scrape.id }); void loadScrapes(); }) ); scrapeList.append(row); } } // ---- 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() { scrapeList.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 ?? []) { scrapeList.append( el( "div", "vk-media-scrape vk-media-error", `${new Date(entry.at).toLocaleTimeString()} · ${entry.source} · ${entry.message}` ) ); } if (!(state.errors ?? []).length) scrapeList.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(" · ")); } /** Grid thumbnail source: cached preview, then the local file, then the original link. */ function gridSource(item) { return item.thumbUrl || item.localUrl || item.url; } function renderCard(item) { const figure = el("figure", "vk-media-figure"); const media = el("div", "vk-media-media"); const source = gridSource(item); const visual = item.kind === "image" || item.kind === "gif" || item.kind === "video"; if (visual && /^(https?:|\/)/.test(source)) { const img = document.createElement("img"); img.loading = "lazy"; 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") { const play = el("span", "vk-media-play", "▶"); play.title = "Play"; play.addEventListener("click", event => { event.stopPropagation(); openViewer(item, true); }); media.append(play); } 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.")); } 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 renderToolbar() { toolbar.replaceChildren(); if (state.view === "media") { for (const kind of KINDS) { const chip = el("button", `vk-chip ${state.kind === kind ? "vk-chip-on" : "vk-chip-off"}`, KIND_LABELS[kind]); chip.type = "button"; chip.addEventListener("click", () => { state.kind = kind; void loadMedia(true); renderToolbar(); }); toolbar.append(chip); } const sourceSelect = document.createElement("select"); sourceSelect.className = "vk-chip vk-chip-off"; for (const [value, label] of [ ["", "Any source"], ["attachment", "Attachments"], ["sticker", "Stickers"], ["embed", "Embeds"] ]) { const option = document.createElement("option"); option.value = value; option.textContent = label; sourceSelect.append(option); } sourceSelect.value = state.filters.source; sourceSelect.addEventListener("change", () => { state.filters.source = sourceSelect.value; void loadMedia(true); }); toolbar.append(sourceSelect); 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 = state.filters.channelId; channelSelect.addEventListener("change", () => { state.filters.channelId = channelSelect.value; void loadMedia(true); }); toolbar.append(channelSelect); const authorOptions = [["", "Any user"]].concat( (state.authors ?? []) .filter(author => author.id) .map(author => [author.id, `${author.name} (${author.count})`]) ); toolbar.append( filterSelect(authorOptions, state.filters.authorId, value => { state.filters.authorId = value; void loadMedia(true); }) ); toolbar.append( filterSelect( [ ["", "Any ratio"], ["vertical", "Vertical"], ["square", "1:1"], ["wide43", "4:3"], ["wide169", "16:9"], ["ultrawide", "21:9"], ["other", "Other"] ], state.filters.aspect, value => { state.filters.aspect = value; void loadMedia(true); } ) ); toolbar.append( filterSelect( [ ["", "Any quality"], ["sd", "SD (<=480p)"], ["hd", "HD (720p)"], ["fhd", "FHD (1080p)"], ["qhd", "QHD (1440p)"], ["uhd", "4K+"], ["unknown", "Unknown"] ], state.filters.resolution, value => { state.filters.resolution = value; void loadMedia(true); } ) ); toolbar.append( el("span", "vk-media-status", "From"), dateInput(state.filters.after, value => { state.filters.after = value; void loadMedia(true); }), el("span", "vk-media-status", "To"), dateInput(state.filters.before, value => { state.filters.before = value; void loadMedia(true); }) ); toolbar.append(metaMenu()); toolbar.append(el("span", "vk-media-spacer")); toolbar.append(button("History", openHistoryModal)); toolbar.append(button("New scrape…", openScrapeModal)); } else { toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md.")); } } function render() { title.textContent = state.view === "bot" ? "Slopbot" : "Media archive"; toolbar.hidden = false; grid.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)"; 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", () => { void loadScrapes(); if (state.view === "media") void loadMedia(true); }); 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); // ---- 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") { 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().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}` ) ); 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 = `
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(); const source = item.localUrl || item.thumbUrl || item.url; const embed = play ? embedUrlFor(item) : null; if (embed) { const frame = document.createElement("iframe"); frame.src = embed; frame.allow = "autoplay; fullscreen; picture-in-picture"; frame.allowFullscreen = true; media.append(frame); } else if (item.kind === "video" && /^(https?:|\/)/.test(source)) { if (play) { const video = document.createElement("video"); video.controls = true; video.autoplay = true; video.src = source; media.append(video); } else { const img = document.createElement("img"); img.src = source; img.alt = item.kind; media.append(img); media.append(button("Play", () => openViewer(item, true))); } } else if (/^(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())); } 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); meta.append( button("View full", () => window.open(item.localUrl || item.url, "_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); window.addEventListener("resize", () => { if (state.view) layoutTop(); }); 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 }; })();