"use strict"; /* * Media archive and slopbot views. * * Self-contained: this file owns its overlay, so app.js does not change except * for the two scope buttons in index.html. See docs/media.md. */ (() => { const KINDS = ["all", "image", "gif", "video", "audio"]; const KIND_LABELS = { all: "All", image: "Images", gif: "GIFs", video: "Videos", audio: "Audio" }; const state = { view: null, kind: "all", offset: 0, items: [], scrapes: [], channels: [], guilds: [], dms: [], loadedChannels: false, authors: null, errors: [], loaded: false, loading: false, more: false, stats: null, metaFields: JSON.parse( localStorage.getItem("vk-media-meta-fields") ?? '["user","type","date","source","resolution"]' ), filters: defaultFilters(), zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50), draft: null, prefetched: null, pageSize: 60 }; 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 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() { 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(); } 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 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}${aspect}${resolution}${after}${before}`; } function kindTotals(field) { const stats = state.stats ?? {}; return { images: (stats.image?.[field] ?? 0) + (stats.gif?.[field] ?? 0), videos: stats.video?.[field] ?? 0 }; } function countLoaded() { let images = 0; let videos = 0; for (const item of state.items) { if (item.kind === "video") videos++; else if (item.kind === "image" || item.kind === "gif") images++; } return { images, videos }; } function renderStatus() { const downloaded = kindTotals("downloaded"); const cached = kindTotals("cached"); const loaded = countLoaded(); setStatus( `(Images/Videos) | Downloaded ${downloaded.images}/${downloaded.videos} | ` + `Cached ${cached.images}/${cached.videos} | Loaded ${loaded.images}/${loaded.videos}` ); } async function loadStats() { try { state.stats = await window.VK.api.get(`/api/media/stats?${filterQuery()}`); } catch { state.stats = null; } renderStatus(); } async function loadMedia(reset) { if (!state.loaded || state.loading) return; state.loading = true; if (reset) { state.items = []; state.offset = 0; state.more = false; state.prefetched = null; } try { const items = await window.VK.api.get( `/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}` ); state.items = state.items.concat(items); state.offset += items.length; state.more = items.length >= state.pageSize; } catch (error) { setStatus(`media load failed: ${error.message}`); } finally { state.loading = false; } renderGrid(); void loadStats(); void prefetchNext(); } /** Warm the next page's JSON and preview images before the user reaches them. */ async function prefetchNext() { if (!state.loaded || !state.more || state.prefetched || state.loading) return; try { const items = await window.VK.api.get( `/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}` ); if (!items.length) { state.more = false; return; } state.prefetched = items; for (const item of items) { const src = item.thumbUrl || item.localUrl; if (!src) continue; const image = new Image(); image.src = src; } } catch { // Preloading is best effort; the scroll handler falls back to a request. } } async function startScrape(channelId, guildId, mode, downloadDir, after, before) { if (!channelId) { setStatus("no channel selected"); return; } try { const scrape = await window.VK.api.post("/api/media/scrape", { channelId, guildId: guildId ?? null, mode, backend: guildId ? "auto" : "puppet", downloadDir: downloadDir ?? null, after: after ?? null, before: before ?? null }); setStatus(`started ${scrape.id} (${mode})`); scrapeModal.hidden = true; } catch (error) { if (error.code === "MEDIA_LIMIT" || String(error.message).includes("MEDIA_LIMIT")) { setStatus(`near the storage limit: ${error.message}`); } else { setStatus(`start failed: ${error.message}`); } } void loadScrapes(); } function renderScrapes() { scrapeList.replaceChildren(); // Only live jobs: a paused scrape is not running and belongs in History, not the strip. const active = state.scrapes.filter(scrape => ["queued", "running"].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")); } // 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}` : "") ) ); 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(" · ")); } function placeholder(media, item) { const error = item.previewError ?? ""; let label = item.kind.toUpperCase(); if (/\b403\b/.test(error)) label = "PRIVATE"; else if (/\b(404|410)\b/.test(error)) label = "GONE"; const node = el("span", "vk-media-card-kind", label); if (error) node.title = error; media.replaceChildren(node); } function playIcon(item) { const play = el("span", "vk-media-play", "▶"); play.title = "Play"; play.addEventListener("click", event => { event.stopPropagation(); openViewer(item, true); }); return play; } function viewerPlayIcon(item) { const play = el("span", "vk-media-play vk-media-play-big", "▶"); play.title = "Play"; play.addEventListener("click", () => openViewer(item, true)); return play; } function renderCard(item) { const figure = el("figure", "vk-media-figure"); const media = el("div", "vk-media-media"); const imageSource = item.localUrl || item.thumbUrl; const fallbackSource = item.source !== "embed" ? item.url : null; // Hover plays the cached animated GIF from the archive, never the provider. const animatedGif = item.kind === "gif" && item.animUrl ? item.animUrl : null; if (imageSource) { const img = document.createElement("img"); img.loading = "lazy"; img.src = imageSource; img.alt = item.kind; img.addEventListener("error", () => placeholder(media, item), { once: true }); // GIFs stay static in the grid and play while hovered. if (animatedGif) { media.addEventListener("mouseenter", () => { img.src = animatedGif; }); media.addEventListener("mouseleave", () => { img.src = imageSource; }); } media.append(img); } else if (fallbackSource && /^(https?:|\/)/.test(fallbackSource)) { const img = document.createElement("img"); img.loading = "lazy"; img.src = fallbackSource; img.alt = item.kind; img.addEventListener("error", () => placeholder(media, item), { once: true }); media.append(img); } else { placeholder(media, item); } if (item.kind === "gif") media.append(el("span", "vk-media-gif-label", "GIF")); if (item.kind === "video") media.append(playIcon(item)); media.addEventListener("click", () => openViewer(item, false)); figure.append(media); const banner = metaBanner(item, state.metaFields); if (banner) figure.append(banner); return figure; } function renderGrid() { grid.replaceChildren(); if (!state.loaded) { const prompt = el("div", "vk-media-prompt"); prompt.append(el("div", "vk-media-status", "Use the filters above to filter media.")); const load = el("button", "btn btn-primary vk-media-load", "Load media"); load.type = "button"; load.addEventListener("click", () => { state.loaded = true; void loadMedia(true); }); prompt.append(load); grid.append(prompt); return; } for (const item of state.items) grid.append(renderCard(item)); if (!state.items.length) { grid.append(el("div", "vk-media-status", "No media matches the filters.")); } else if (state.more) { const more = el("button", "btn btn-secondary vk-media-load-more", "Load more"); more.type = "button"; more.addEventListener("click", () => void loadMedia(false)); grid.append(more); } } function filterSelect(options, value, onChange) { const select = document.createElement("select"); select.className = "vk-chip vk-chip-off"; for (const [optionValue, label] of options) { const option = document.createElement("option"); option.value = optionValue; option.textContent = label; select.append(option); } select.value = value; select.addEventListener("change", () => onChange(select.value)); return select; } function dateInput(value, onChange) { const input = document.createElement("input"); input.type = "date"; input.className = "vk-media-input"; input.value = value ?? ""; input.addEventListener("change", () => onChange(input.value)); return input; } /** Dropdown with the metadata-field checkboxes. */ function metaMenu() { const wrap = el("span", "vk-media-meta"); const panel = el("div", "vk-media-meta-menu"); panel.hidden = true; const toggle = button("Metadata", () => { panel.hidden = !panel.hidden; }); for (const [key, label] of META_FIELDS) { const row = el("label", "vk-media-check"); const box = document.createElement("input"); box.type = "checkbox"; box.checked = state.metaFields.includes(key); box.addEventListener("change", () => { state.metaFields = box.checked ? [...new Set([...state.metaFields, key])] : state.metaFields.filter(field => field !== key); localStorage.setItem("vk-media-meta-fields", JSON.stringify(state.metaFields)); renderGrid(); }); row.append(box, document.createTextNode(` ${label}`)); panel.append(row); } wrap.append(toggle, panel); return wrap; } function defaultFilters() { return { source: "", channelId: "", authorIds: [], origin: "", cachedOnly: false, aspect: "", resolution: "", after: "", before: "" }; } function ensureDraft() { if (!state.draft) { state.draft = { kind: state.kind, ...state.filters, authorIds: [...state.filters.authorIds] }; } } function applyDraft() { state.kind = state.draft.kind; state.filters = { source: state.draft.source, channelId: state.draft.channelId, authorIds: [...state.draft.authorIds], origin: state.draft.origin, cachedOnly: state.draft.cachedOnly, aspect: state.draft.aspect, resolution: state.draft.resolution, after: state.draft.after, before: state.draft.before }; filterDrawer.hidden = true; if (state.loaded) { void loadMedia(true); } else { void loadStats(); } } /** Compact multi-select for the posters, shown in the bar. */ function peopleMenu() { const wrap = el("span", "vk-media-meta"); const panel = el("div", "vk-media-meta-menu vk-media-people-menu"); panel.hidden = true; const toggle = button( `People${state.draft.authorIds.length ? ` (${state.draft.authorIds.length})` : ""}`, () => { panel.hidden = !panel.hidden; } ); const list = el("div", "vk-media-people"); for (const author of state.authors ?? []) { if (!author.id) continue; const row = el("label", "vk-media-check"); const box = document.createElement("input"); box.type = "checkbox"; box.checked = state.draft.authorIds.includes(author.id); box.addEventListener("change", () => { state.draft.authorIds = box.checked ? [...new Set([...state.draft.authorIds, author.id])] : state.draft.authorIds.filter(id => id !== author.id); panel.hidden = true; renderToolbar(); }); row.append(box, document.createTextNode(` ${author.name} (${author.count})`)); list.append(row); } if (!list.childElementCount) list.append(el("span", "vk-media-status", "No posters indexed yet.")); panel.append(list); wrap.append(toggle, panel); return wrap; } function renderToolbar() { toolbar.replaceChildren(); if (state.view !== "media") { toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md.")); return; } ensureDraft(); for (const kind of KINDS) { const chip = el( "button", `vk-chip ${state.draft.kind === kind ? "vk-chip-on" : "vk-chip-off"}`, KIND_LABELS[kind] ); chip.type = "button"; chip.addEventListener("click", () => { state.draft.kind = kind; renderToolbar(); }); toolbar.append(chip); } toolbar.append( filterSelect( [ ["", "Any source"], ["attachment", "Attachments"], ["sticker", "Stickers"], ["embed", "Embeds"] ], state.draft.source, value => { state.draft.source = value; } ) ); toolbar.append( filterSelect( [ ["", "Anywhere"], ["guild", "Server channels"], ["dm", "DMs and group chats"] ], state.draft.origin, value => { state.draft.origin = value; } ) ); toolbar.append(peopleMenu()); toolbar.append(button("Advanced search", toggleFilterDrawer)); toolbar.append(metaMenu()); toolbar.append(el("span", "vk-media-spacer")); toolbar.append(button("Apply filters", applyDraft)); toolbar.append(button("Reset", resetMediaView)); toolbar.append(button("History", openHistoryModal)); toolbar.append(button("New scrape…", openScrapeModal)); } function toggleFilterDrawer() { filterDrawer.hidden = !filterDrawer.hidden; if (!filterDrawer.hidden) renderFilterDrawer(); } /** The drawer holds the less common filters; the basic ones live in the bar. */ function renderFilterDrawer() { ensureDraft(); filterDrawer.replaceChildren(); const draft = state.draft; const section = label => { const wrap = el("div", "vk-media-filter-section"); wrap.append(el("div", "vk-media-status", label)); return wrap; }; const filters = section("Filters"); filters.append( filterSelect( [ ["", "Any ratio"], ["vertical", "Vertical"], ["square", "1:1"], ["wide43", "4:3"], ["wide169", "16:9"], ["ultrawide", "21:9"], ["other", "Other"] ], draft.aspect, value => { draft.aspect = value; } ), filterSelect( [ ["", "Any quality"], ["sd", "SD (<=480p)"], ["hd", "HD (720p)"], ["fhd", "FHD (1080p)"], ["qhd", "QHD (1440p)"], ["uhd", "4K+"], ["unknown", "Unknown"] ], draft.resolution, value => { draft.resolution = value; } ) ); const channelSelect = document.createElement("select"); channelSelect.className = "vk-chip vk-chip-off"; const anyChannel = document.createElement("option"); anyChannel.value = ""; anyChannel.textContent = "Any channel"; channelSelect.append(anyChannel); const seen = new Set(); for (const scrape of state.scrapes) { if (seen.has(scrape.channelId)) continue; seen.add(scrape.channelId); const option = document.createElement("option"); option.value = scrape.channelId; option.textContent = state.channels.find(channel => channel.id === scrape.channelId)?.label ?? scrape.channelId; channelSelect.append(option); } channelSelect.value = draft.channelId; channelSelect.addEventListener("change", () => { draft.channelId = channelSelect.value; }); filters.append(channelSelect); filters.append( el("span", "vk-media-status", "From"), dateInput(draft.after, value => { draft.after = value; }), el("span", "vk-media-status", "To"), dateInput(draft.before, value => { draft.before = value; }) ); const 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); filterDrawer.append(filters); // Zoom applies live; it is not part of the filter draft. const zoomSection = section("Card size"); const zoom = document.createElement("input"); zoom.type = "range"; zoom.min = "0"; zoom.max = "100"; zoom.value = String(state.zoom); zoom.className = "vk-media-zoom"; zoom.addEventListener("input", () => { state.zoom = Number(zoom.value); localStorage.setItem("vk-media-zoom", String(state.zoom)); applyZoom(); }); zoomSection.append(zoom); filterDrawer.append(zoomSection); filterDrawer.append( el("span", "vk-media-status", "Press Apply filters in the bar to run the search.") ); } /** Card width from the zoom slider: 2/3 current, current, up to a full row. */ function cardWidth() { const gridWidth = grid.clientWidth || 900; const zoom = Math.max(0, Math.min(100, state.zoom)); if (zoom <= 50) return Math.round(100 + (zoom / 50) * 50); return Math.round(150 + ((zoom - 50) / 50) * Math.max(0, gridWidth - 150)); } /** Items per page: full rows, about 1.5 screens tall. */ function computePageSize() { const width = cardWidth(); const columns = Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8))); const rowHeight = width * 1.16 + 30; const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (rowHeight + 8))); return Math.max(columns, columns * Math.max(1, Math.ceil(rows * 1.5))); } function applyZoom() { const width = cardWidth(); const gridWidth = grid.clientWidth || 900; grid.style.gridTemplateColumns = width >= gridWidth - 24 ? "1fr" : `repeat(auto-fill, minmax(${width}px, 1fr))`; state.pageSize = computePageSize(); } function resetMediaView() { state.kind = "all"; state.filters = defaultFilters(); state.zoom = 50; localStorage.setItem("vk-media-zoom", "50"); state.loaded = false; state.loading = false; state.more = false; state.prefetched = null; state.items = []; state.offset = 0; state.draft = null; filterDrawer.hidden = true; applyZoom(); renderGrid(); renderToolbar(); void loadStats(); } function render() { title.textContent = state.view === "bot" ? "Slopbot" : "Media archive"; toolbar.hidden = false; grid.hidden = state.view === "bot"; 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); // Keep the running-scrape strip live while the media view is open. setInterval(() => { if (state.view === "media") void loadScrapes(); }, 4000); // Infinite scroll: fetch the next page before the grid bottom is reached. grid.addEventListener("scroll", () => { if (state.loading || !state.loaded) return; if (grid.scrollTop + grid.clientHeight < grid.scrollHeight - 240) return; if (state.prefetched) { const items = state.prefetched; state.prefetched = null; state.items = state.items.concat(items); state.offset += items.length; state.more = items.length >= state.pageSize; renderGrid(); void loadStats(); void prefetchNext(); return; } if (state.more) void loadMedia(false); }); // ---- Scrape popup: server or group-chat trees ---- const scrapeModal = document.createElement("div"); scrapeModal.className = "vk-media-modal"; scrapeModal.hidden = true; scrapeModal.innerHTML = `
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 (["paused", "queued"].includes(scrape.state)) { row.append( button("Resume", async () => { try { await window.VK.api.post("/api/media/scrape/resume", { id: scrape.id }); } catch (error) { setStatus(`resume failed: ${error.message}`); } void loadScrapes(); void openHistoryModal(); }) ); } list.append(row); } if (!scrapes.length) list.append(el("div", null, "No scrapes yet.")); } catch (error) { list.replaceChildren(el("div", null, `history failed: ${error.message}`)); } } historyModal.querySelector("#history-close").addEventListener("click", () => { historyModal.hidden = true; }); historyModal.querySelector("#history-x").addEventListener("click", () => { historyModal.hidden = true; }); historyModal.addEventListener("click", event => { if (event.target === historyModal) historyModal.hidden = true; }); // ---- Media viewer ---- function embedUrlFor(item) { if (item.source !== "embed") return null; try { const url = new URL(item.url); const host = url.hostname.replace(/^www\./, ""); if (host === "youtube.com" || host === "m.youtube.com") { const id = url.searchParams.get("v"); if (id) return `https://www.youtube.com/embed/${id}`; } if (host === "youtu.be") return `https://www.youtube.com/embed${url.pathname}`; if (host === "vimeo.com") return `https://player.vimeo.com/video${url.pathname}`; if (host === "clips.twitch.tv") { return `https://player.twitch.tv/?clip=${url.pathname.slice(1)}&parent=${location.hostname}`; } if (host === "streamable.com") return `https://streamable.com/e${url.pathname}`; } catch { // not embeddable } return null; } const viewerModal = document.createElement("div"); viewerModal.className = "vk-media-modal"; viewerModal.hidden = true; viewerModal.innerHTML = `
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 }; })();