Files
Veskslop-t/poc/public/media.js
T

1436 lines
55 KiB
JavaScript

"use strict";
/*
* Media archive and slopbot views.
*
* Self-contained: this file owns its overlay, so app.js does not change except
* for the two scope buttons in index.html. See docs/media.md.
*/
(() => {
const KINDS = ["all", "image", "gif", "video", "audio"];
const KIND_LABELS = { all: "All", image: "Images", gif: "GIFs", video: "Videos", audio: "Audio" };
const state = {
view: null,
kind: "all",
offset: 0,
items: [],
scrapes: [],
channels: [],
guilds: [],
dms: [],
loadedChannels: false,
authors: null,
errors: [],
loaded: false,
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 = `
<div class="vk-media-panel">
<div class="vk-media-head">
<span id="media-title" class="vk-media-title">Media archive</span>
<span id="media-refresh" class="btn btn-secondary vk-small-btn">Refresh</span>
<span id="media-close" class="btn btn-secondary vk-small-btn">Close</span>
</div>
<div id="media-toolbar" class="vk-media-toolbar"></div>
<div id="media-status" class="vk-media-status"></div>
<div id="media-scrapes" class="vk-media-scrapes"></div>
<div id="media-grid" class="vk-media-grid"></div>
</div>
`;
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 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) 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(" · "));
}
function placeholder(media, item) {
media.replaceChildren(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
}
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;
const streamPath = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
const animatedGif =
item.kind === "gif" && item.remotePreviewUrl && /\.gif(\?|$)/i.test(item.remotePreviewUrl)
? item.remotePreviewUrl
: 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 (item.kind === "video") {
// No thumbnail: take the first frame through the same-origin stream endpoint,
// which refreshes expired URLs and avoids cross-origin blocking.
const video = document.createElement("video");
video.preload = "metadata";
video.muted = true;
video.playsInline = true;
video.src = streamPath;
video.addEventListener(
"loadedmetadata",
() => {
if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2);
},
{ once: true }
);
video.addEventListener("error", () => placeholder(media, item), { once: true });
media.append(video);
} 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."));
}
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 = `
<div class="vk-media-modal-box vk-media-wide">
<div class="vk-media-head">
<strong>New scrape</strong>
<span class="vk-media-spacer"></span>
<button id="scrape-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
</div>
<div class="vk-media-toolbar">
<button id="scrape-tab-server" class="vk-chip vk-chip-on" type="button">Server</button>
<button id="scrape-tab-group" class="vk-chip vk-chip-off" type="button">Group chat</button>
<span id="scrape-hint" class="vk-media-status"></span>
</div>
<div id="scrape-tree" class="vk-media-tree"></div>
<div class="vk-media-toolbar">
<button id="scrape-cancel" class="btn btn-secondary vk-small-btn" type="button">Close</button>
</div>
</div>
`;
document.body.append(scrapeModal);
let scrapeTab = "server";
function channelRow(channelId, guildId, label) {
const row = el("div", "vk-media-channel");
row.append(el("span", "vk-media-channel-name", label));
row.append(button("Links", () => askScrape(channelId, guildId, "links", label)));
row.append(button("Download", () => askScrape(channelId, guildId, "download", label)));
return row;
}
function renderScrapeTree() {
const tree = scrapeModal.querySelector("#scrape-tree");
const hint = scrapeModal.querySelector("#scrape-hint");
tree.replaceChildren();
if (scrapeTab === "group") {
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 = `
<div class="vk-media-modal-box">
<strong id="confirm-title">Start scrape?</strong>
<div id="confirm-note" class="vk-media-status"></div>
<label id="confirm-dest-row" class="vk-media-check" hidden>
<input id="confirm-custom-dest" type="checkbox" /> Custom download destination
</label>
<input
id="confirm-dest"
class="vk-media-input vk-media-dest"
type="text"
placeholder="/path/to/download/"
hidden
/>
<div class="vk-media-toolbar">
<span class="vk-media-status">From</span>
<input id="confirm-after" class="vk-media-input" type="date" />
<span class="vk-media-status">To</span>
<input id="confirm-before" class="vk-media-input" type="date" />
<button id="confirm-count" class="btn btn-secondary vk-small-btn" type="button">
Check Message Count
</button>
<span id="confirm-count-result" class="vk-media-status"></span>
</div>
<div class="vk-media-toolbar">
<button id="confirm-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
<button id="confirm-start" class="btn btn-primary vk-small-btn" type="button">Start</button>
</div>
</div>
`;
document.body.append(confirmModal);
let confirmAction = null;
function askScrape(channelId, guildId, mode, label) {
const isDownload = mode === "download";
const destRow = confirmModal.querySelector("#confirm-dest-row");
const destInput = confirmModal.querySelector("#confirm-dest");
const checkbox = confirmModal.querySelector("#confirm-custom-dest");
confirmModal.querySelector("#confirm-title").textContent =
isDownload ? "Download scrape?" : "Links scrape?";
confirmModal.querySelector("#confirm-note").textContent = isDownload
? `${label} — downloads files${guildId ? "" : " with the puppet"}.`
: `${label} — indexes links and caches previews${guildId ? "" : " with the puppet"}.`;
destRow.hidden = !isDownload;
destInput.hidden = true;
destInput.value = "";
checkbox.checked = false;
checkbox.onchange = () => {
destInput.hidden = !checkbox.checked;
};
const afterInput = confirmModal.querySelector("#confirm-after");
const beforeInput = confirmModal.querySelector("#confirm-before");
const countResult = confirmModal.querySelector("#confirm-count-result");
afterInput.value = "";
beforeInput.value = "";
countResult.textContent = "";
confirmModal.querySelector("#confirm-count").onclick = async () => {
countResult.textContent = "checking...";
try {
const response = await window.VK.api.post("/api/media/count", {
channelId,
guildId: guildId ?? null,
after: afterInput.value ? `${afterInput.value}T00:00:00.000Z` : null,
before: beforeInput.value ? `${beforeInput.value}T23:59:59.999Z` : null
});
countResult.textContent =
response.count === null
? response.reason ?? "unavailable"
: `${response.count} messages`;
} catch (error) {
countResult.textContent = `failed: ${error.message}`;
}
};
confirmAction = () =>
startScrape(
channelId,
guildId,
mode,
isDownload && checkbox.checked ? destInput.value.trim() : null,
afterInput.value ? `${afterInput.value}T00:00:00.000Z` : null,
beforeInput.value ? `${beforeInput.value}T23:59:59.999Z` : null
);
confirmModal.hidden = false;
}
confirmModal.querySelector("#confirm-cancel").addEventListener("click", () => {
confirmModal.hidden = true;
});
confirmModal.querySelector("#confirm-start").addEventListener("click", () => {
const action = confirmAction;
confirmModal.hidden = true;
void action?.();
});
confirmModal.addEventListener("click", event => {
if (event.target === confirmModal) confirmModal.hidden = true;
});
// ---- History popup ----
const historyModal = document.createElement("div");
historyModal.className = "vk-media-modal";
historyModal.hidden = true;
historyModal.innerHTML = `
<div class="vk-media-modal-box vk-media-wide">
<div class="vk-media-head">
<strong>Scrape history</strong>
<span class="vk-media-spacer"></span>
<button id="history-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
</div>
<div id="history-list" class="vk-media-modal-list"></div>
<div class="vk-media-toolbar">
<button id="history-close" class="btn btn-secondary vk-small-btn" type="button">Close</button>
</div>
</div>
`;
document.body.append(historyModal);
function durationLabel(scrape) {
const start = new Date(scrape.createdAt).getTime();
const end = scrape.finishedAt ? new Date(scrape.finishedAt).getTime() : Date.now();
const seconds = Math.max(0, Math.round((end - start) / 1000));
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
}
function channelLabel(channelId) {
return state.channels.find(channel => channel.id === channelId)?.label ?? channelId;
}
async function openHistoryModal() {
const list = historyModal.querySelector("#history-list");
list.replaceChildren(el("div", null, "loading..."));
historyModal.hidden = false;
try {
const [scrapes, storage] = await Promise.all([
window.VK.api.get("/api/media/scrapes"),
window.VK.api.get("/api/media/storage")
]);
await loadChannels();
list.replaceChildren();
const bytesById = new Map((storage?.scrapes ?? []).map(entry => [entry.id, entry.bytes]));
for (const scrape of scrapes) {
const media = scrape.media ?? {};
const row = el("div", "vk-media-history-row");
row.append(el("strong", null, channelLabel(scrape.channelId)));
row.append(
el(
"span",
null,
`${scrape.state} · started ${new Date(scrape.createdAt).toLocaleString()} · ` +
`took ${durationLabel(scrape)} · ${scrape.pages}p/${scrape.messages}m · ` +
(scrape.estimatedMessages != null
? `~${scrape.estimatedMessages} total · `
: "") +
(scrape.fromDate || scrape.toDate
? `range ${
scrape.fromDate
? new Date(scrape.fromDate).toLocaleDateString()
: "..."
} to ${
scrape.toDate ? new Date(scrape.toDate).toLocaleDateString() : "..."
} · `
: "") +
`img ${media.image ?? 0} gif ${media.gif ?? 0} vid ${media.video ?? 0} aud ${media.audio ?? 0} · ` +
`${formatBytes(bytesById.get(scrape.id) ?? 0)} · files ${scrape.downloaded ?? 0} · previews ${scrape.previewed ?? 0}`
)
);
list.append(row);
}
if (!scrapes.length) list.append(el("div", null, "No scrapes yet."));
} catch (error) {
list.replaceChildren(el("div", null, `history failed: ${error.message}`));
}
}
historyModal.querySelector("#history-close").addEventListener("click", () => {
historyModal.hidden = true;
});
historyModal.querySelector("#history-x").addEventListener("click", () => {
historyModal.hidden = true;
});
historyModal.addEventListener("click", event => {
if (event.target === historyModal) historyModal.hidden = true;
});
// ---- Media viewer ----
function embedUrlFor(item) {
if (item.source !== "embed") return null;
try {
const url = new URL(item.url);
const host = url.hostname.replace(/^www\./, "");
if (host === "youtube.com" || host === "m.youtube.com") {
const id = url.searchParams.get("v");
if (id) return `https://www.youtube.com/embed/${id}`;
}
if (host === "youtu.be") return `https://www.youtube.com/embed${url.pathname}`;
if (host === "vimeo.com") return `https://player.vimeo.com/video${url.pathname}`;
if (host === "clips.twitch.tv") {
return `https://player.twitch.tv/?clip=${url.pathname.slice(1)}&parent=${location.hostname}`;
}
if (host === "streamable.com") return `https://streamable.com/e${url.pathname}`;
} catch {
// not embeddable
}
return null;
}
const viewerModal = document.createElement("div");
viewerModal.className = "vk-media-modal";
viewerModal.hidden = true;
viewerModal.innerHTML = `
<div class="vk-media-modal-box vk-media-viewer-box">
<div class="vk-media-head">
<strong id="viewer-title">Preview</strong>
<span class="vk-media-spacer"></span>
<button id="viewer-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
</div>
<div id="viewer-body" class="vk-media-viewer wide">
<div id="viewer-media" class="vk-media-viewer-media"></div>
<div id="viewer-meta" class="vk-media-viewer-meta"></div>
</div>
</div>
`;
document.body.append(viewerModal);
function openViewer(item, play) {
const body = viewerModal.querySelector("#viewer-body");
const media = viewerModal.querySelector("#viewer-media");
const meta = viewerModal.querySelector("#viewer-meta");
const vertical = item.aspectRatio != null && item.aspectRatio < 1;
viewerModal.querySelector("#viewer-title").textContent = typeLabel(item);
body.className = `vk-media-viewer ${vertical ? "vertical" : "wide"}`;
media.replaceChildren();
// The stream endpoint serves the local file or a refreshed remote URL, so signed
// CDN links that expired no longer break playback.
const streamUrl = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
const source = item.localUrl || item.thumbUrl || streamUrl;
const embed = embedUrlFor(item);
const directVideo = item.localUrl || (item.kind === "video" ? streamUrl : null);
const canPlay = !!embed || (item.kind === "video" && !!directVideo);
// An embeddable item (YouTube, Vimeo, ...) opens straight into its player.
const showPlayer = play || !!embed;
// A play request on something we cannot play falls back to the external page.
if (play && !canPlay) openLinkWarning(item.url);
if (showPlayer && embed) {
const frame = document.createElement("iframe");
frame.src = embed;
frame.allow = "autoplay; fullscreen; picture-in-picture";
frame.allowFullscreen = true;
media.append(frame);
} else if (showPlayer && directVideo) {
const video = document.createElement("video");
video.controls = true;
video.autoplay = true;
video.src = directVideo;
video.addEventListener(
"error",
() => {
media.replaceChildren(
el("div", "vk-media-status", "This format is not supported in the browser.")
);
media.append(
button("Open in new tab", () =>
window.open(directVideo, "_blank", "noreferrer")
)
);
},
{ once: true }
);
media.append(video);
} else if (item.kind === "video") {
// No still available: show the first frame through the stream endpoint.
const video = document.createElement("video");
video.preload = "metadata";
video.muted = true;
video.playsInline = true;
video.src = streamUrl;
video.addEventListener(
"loadedmetadata",
() => {
if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2);
},
{ once: true }
);
media.append(video);
media.append(viewerPlayIcon(item));
} 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);
// "View full" opens the embedded media (player, local file, or a refreshed stream).
const streamPath = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
const fullUrl =
embedUrlFor(item) ??
item.localUrl ??
(item.kind === "video" ? streamPath : item.playUrl ?? item.thumbUrl ?? streamPath);
meta.append(button("View full", () => window.open(fullUrl, "_blank", "noreferrer")));
viewerModal.hidden = false;
}
viewerModal.querySelector("#viewer-x").addEventListener("click", () => {
viewerModal.hidden = true;
});
viewerModal.addEventListener("click", event => {
if (event.target === viewerModal) viewerModal.hidden = true;
});
// ---- External link warning ----
const linkModal = document.createElement("div");
linkModal.className = "vk-media-modal";
linkModal.hidden = true;
linkModal.innerHTML = `
<div class="vk-media-modal-box">
<strong>Open external link?</strong>
<div id="link-target" class="vk-media-status"></div>
<div class="vk-media-status">This opens a new website in a new tab.</div>
<div class="vk-media-toolbar">
<button id="link-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
<button id="link-continue" class="btn btn-primary vk-small-btn" type="button">Continue</button>
</div>
</div>
`;
document.body.append(linkModal);
let pendingLink = null;
function openLinkWarning(url) {
pendingLink = url;
linkModal.querySelector("#link-target").textContent = url;
linkModal.hidden = false;
}
linkModal.querySelector("#link-cancel").addEventListener("click", () => {
linkModal.hidden = true;
});
linkModal.querySelector("#link-continue").addEventListener("click", () => {
if (pendingLink) window.open(pendingLink, "_blank", "noreferrer");
linkModal.hidden = true;
});
linkModal.addEventListener("click", event => {
if (event.target === linkModal) linkModal.hidden = true;
});
// ---- Settings: media storage ----
function formatBytes(bytes) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GiB`;
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MiB`;
return `${bytes} B`;
}
function renderUsageBar(status) {
const bar = document.querySelector("#media-usage-bar");
if (!bar || !status) return;
bar.replaceChildren();
const limit = Math.max(status.limitBytes, 1);
const measured =
(status.byKind?.image ?? 0) +
(status.byKind?.gif ?? 0) +
(status.byKind?.video ?? 0) +
(status.byKind?.audio ?? 0);
const other = Math.max(0, status.usedBytes - measured);
for (const [kind, bytes] of [
["image", status.byKind?.image ?? 0],
["gif", status.byKind?.gif ?? 0],
["video", status.byKind?.video ?? 0],
["audio", status.byKind?.audio ?? 0],
["other", other]
]) {
if (bytes <= 0) continue;
const segment = el("div", `vk-media-usage-seg vk-seg-${kind}`);
segment.style.width = `${Math.min(100, (bytes / limit) * 100)}%`;
segment.title = `${kind}: ${formatBytes(bytes)}`;
bar.append(segment);
}
}
async function loadMediaSettings() {
try {
const [storage, status] = await Promise.all([
window.VK.api.get("/api/media/storage"),
window.VK.api.get("/api/media/status")
]);
state.storage = storage;
const bot = status?.slopbot;
const slopbotRow = document.querySelector("#media-slopbot");
if (slopbotRow) slopbotRow.textContent = bot?.connected ? `connected as ${bot.user}` : "disabled";
const limit = document.querySelector("#media-limit");
if (limit && document.activeElement !== limit) {
limit.value = (storage.limitBytes / 1024 ** 3).toFixed(2);
}
const used = document.querySelector("#media-used");
if (used) {
used.textContent = `${formatBytes(storage.usedBytes)} of ${formatBytes(storage.limitBytes)}${
storage.nearLimit ? " (near limit)" : ""
}`;
}
renderUsageBar(storage);
} catch (error) {
const used = document.querySelector("#media-used");
if (used) used.textContent = `unavailable: ${error.message}`;
}
}
document.querySelector("#media-save")?.addEventListener("click", async () => {
const input = document.querySelector("#media-limit");
const gib = Number(input?.value);
if (!Number.isFinite(gib) || gib <= 0) return;
try {
await window.VK.api.post("/api/preferences", { media: { limitBytes: Math.round(gib * 1024 ** 3) } });
setStatus("media limit saved");
const save = document.querySelector("#media-save");
if (save) save.disabled = true;
} catch (error) {
setStatus(`save failed: ${error.message}`);
}
void loadMediaSettings();
});
document.querySelector("#media-limit")?.addEventListener("input", () => {
const save = document.querySelector("#media-save");
if (save) save.disabled = false;
});
const cleanModal = document.createElement("div");
cleanModal.className = "vk-media-modal";
cleanModal.hidden = true;
cleanModal.innerHTML = `
<div class="vk-media-modal-box">
<strong>Delete media scrapes?</strong>
<div id="clean-note" class="vk-media-status"></div>
<div id="clean-list" class="vk-media-modal-list"></div>
<div class="vk-media-toolbar">
<button id="clean-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
<button id="clean-confirm" class="btn btn-primary vk-small-btn" type="button" disabled>Delete</button>
</div>
</div>
`;
document.body.append(cleanModal);
let cleanTimer = null;
function openCleanModal() {
const scrapes = state.storage?.scrapes ?? [];
const list = cleanModal.querySelector("#clean-list");
const note = cleanModal.querySelector("#clean-note");
const confirm = cleanModal.querySelector("#clean-confirm");
list.replaceChildren();
for (const scrape of scrapes) {
list.append(el("div", null, `${scrape.id} · ${scrape.state} · ${formatBytes(scrape.bytes)}`));
}
const bytes = scrapes.reduce((sum, scrape) => sum + scrape.bytes, 0);
note.textContent = scrapes.length
? `Everything above will be deleted permanently, including files and databases (${formatBytes(bytes)}). This cannot be undone.`
: "No scrapes to delete.";
confirm.disabled = true;
let left = 5;
confirm.textContent = `Delete (${left})`;
cleanModal.hidden = false;
clearInterval(cleanTimer);
if (!scrapes.length) {
confirm.textContent = "Delete";
return;
}
cleanTimer = setInterval(() => {
left--;
if (left <= 0) {
clearInterval(cleanTimer);
confirm.disabled = false;
confirm.textContent = "Delete";
return;
}
confirm.textContent = `Delete (${left})`;
}, 1000);
}
cleanModal.querySelector("#clean-cancel").addEventListener("click", () => {
clearInterval(cleanTimer);
cleanModal.hidden = true;
});
cleanModal.querySelector("#clean-confirm").addEventListener("click", async () => {
const ids = (state.storage?.scrapes ?? []).map(scrape => scrape.id);
try {
await window.VK.api.post("/api/media/clean", { ids });
} catch (error) {
setStatus(`clean failed: ${error.message}`);
}
cleanModal.hidden = true;
void loadMediaSettings();
void loadScrapes();
});
document.querySelector("#media-clean")?.addEventListener("click", openCleanModal);
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 };
})();