Add custom download destinations, confirmations, and the bot error feed

This commit is contained in:
Fisto
2026-09-16 11:53:59 +02:00
parent 973b9cff92
commit 42fd52b96b
14 changed files with 471 additions and 62 deletions
+34
View File
@@ -286,3 +286,37 @@
background: var(--color-accent-400, #4f8cff);
animation: vk-media-pulse 1.2s ease-in-out infinite;
}
.vk-media-spacer {
flex: 1;
}
.vk-media-check {
display: flex;
align-items: center;
gap: 6px;
font: 400 11px/1.4 ui-monospace, Menlo, monospace;
color: var(--color-neutral-300);
}
.vk-media-dest {
width: 100%;
}
.vk-media-error {
color: #ff8080;
}
.vk-media-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 14px;
height: 14px;
margin-left: 4px;
padding: 0 4px;
border-radius: 7px;
background: #e5484d;
color: #fff;
font: 600 9px/1 ui-monospace, Menlo, monospace;
}
+189 -21
View File
@@ -22,6 +22,7 @@
dms: [],
loadedChannels: false,
authors: null,
errors: [],
filters: {
source: "",
channelId: "",
@@ -92,6 +93,13 @@
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();
}
@@ -191,7 +199,7 @@
renderGrid();
}
async function startScrape(channelId, guildId, mode) {
async function startScrape(channelId, guildId, mode, downloadDir) {
if (!channelId) {
setStatus("no channel selected");
return;
@@ -202,7 +210,8 @@
channelId,
guildId: guildId ?? null,
mode,
backend: guildId ? "auto" : "puppet"
backend: guildId ? "auto" : "puppet",
downloadDir: downloadDir ?? null
});
setStatus(`started ${scrape.id} (${mode})`);
scrapeModal.hidden = true;
@@ -219,8 +228,8 @@
function renderScrapes() {
scrapeList.replaceChildren();
const active = state.scrapes.filter(scrape => ["queued", "running", "paused"].includes(scrape.state));
const rows = active.length ? active : state.scrapes.slice(0, 5);
for (const scrape of rows) {
for (const scrape of active) {
const row = el("div", "vk-media-scrape");
const media = scrape.media ?? {};
@@ -239,21 +248,79 @@
)
);
if (active.includes(scrape)) {
row.append(
button("Stop", async () => {
await window.VK.api.post("/api/media/scrape/stop", { id: scrape.id });
void loadScrapes();
})
);
} else {
row.append(el("span", "vk-media-status", durationLabel(scrape)));
}
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();
}
function renderGrid() {
grid.replaceChildren();
for (const item of state.items) {
@@ -319,8 +386,7 @@
toolbar.append(chip);
}
toolbar.append(button("New scrape…", openScrapeModal));
toolbar.append(button("History", openHistoryModal));
const sourceSelect = document.createElement("select");
sourceSelect.className = "vk-chip vk-chip-off";
@@ -428,6 +494,10 @@
void loadMedia(true);
})
);
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."));
}
@@ -444,12 +514,14 @@
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 {
void loadChannels().then(renderToolbar);
void loadAuthors().then(renderToolbar);
void loadMedia(true);
void loadScrapes();
}
void loadScrapes();
}
overlay.querySelector("#media-close").addEventListener("click", hide);
@@ -465,6 +537,16 @@
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");
@@ -472,7 +554,11 @@
scrapeModal.hidden = true;
scrapeModal.innerHTML = `
<div class="vk-media-modal-box vk-media-wide">
<strong>New scrape</strong>
<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>
@@ -491,8 +577,8 @@
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", () => void startScrape(channelId, guildId, "links")));
row.append(button("Download", () => void startScrape(channelId, guildId, "download")));
row.append(button("Links", () => askScrape(channelId, guildId, "links", label)));
row.append(button("Download", () => askScrape(channelId, guildId, "download", label)));
return row;
}
@@ -558,6 +644,78 @@
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">
<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;
};
confirmAction = () =>
startScrape(channelId, guildId, mode, isDownload && checkbox.checked ? destInput.value.trim() : 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 ----
@@ -566,7 +724,11 @@
historyModal.hidden = true;
historyModal.innerHTML = `
<div class="vk-media-modal-box vk-media-wide">
<strong>Scrape history</strong>
<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>
@@ -630,6 +792,12 @@
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;
});
// ---- Settings: media storage ----
+16 -3
View File
@@ -774,11 +774,24 @@ function buildServer(): McpServer {
.enum(["auto", "slopbot", "puppet"])
.optional()
.describe("auto (default) picks slopbot for its guilds, the client otherwise"),
confirm: z.boolean().optional().describe("Accept the near-limit warning")
confirm: z.boolean().optional().describe("Accept the near-limit warning"),
downloadDir: z
.string()
.optional()
.describe("Absolute destination for downloads; previews stay local")
}
},
async ({ channelId, guildId, mode, backend, confirm }) =>
jsonText(await media.startScrape({ channelId, guildId: guildId ?? null, mode, backend, confirm }))
async ({ channelId, guildId, mode, backend, confirm, downloadDir }) =>
jsonText(
await media.startScrape({
channelId,
guildId: guildId ?? null,
mode,
backend,
confirm,
downloadDir: downloadDir ?? null
})
)
);
server.registerTool(
+71 -22
View File
@@ -6,7 +6,7 @@
*/
import { rmSync } from "node:fs";
import { join } from "node:path";
import { isAbsolute, join } from "node:path";
import {
dirSizeBytes,
isNearLimit,
@@ -49,6 +49,14 @@ export interface StartScrapeInput {
backend?: "auto" | "slopbot" | "puppet";
/** Set by a re-run of the command after the near-limit warning. */
confirm?: boolean;
/** Absolute destination for downloaded files. Null keeps them under the scrape dir. */
downloadDir?: string | null;
}
export interface MediaErrorEntry {
at: string;
source: string;
message: string;
}
export interface ScrapeBytes {
@@ -138,6 +146,9 @@ export class MediaService {
private botFetcher: SlopbotFetcher | null = null;
private botStarting: Promise<void> | null = null;
/** The newest bot and scrape errors, capped. Surfaced in the Bot tab. */
private readonly errors: MediaErrorEntry[] = [];
constructor(private readonly deps: MediaServiceDeps) {
const number = (value: string | undefined, fallback: number) => {
const parsed = Number(value);
@@ -147,6 +158,7 @@ export class MediaService {
this.engine = new ScrapeEngine({
rootDir: this.rootDir,
log: line => this.deps.log?.(line),
onError: (scrapeId, message) => this.recordError("scrape", `${scrapeId}: ${message}`),
download: {
maxFileBytes: number(process.env.VESKSLOP_MEDIA_MAX_FILE_BYTES, 50 * 1024 * 1024),
previewMaxBytes: number(process.env.VESKSLOP_MEDIA_PREVIEW_MAX_BYTES, 5 * 1024 * 1024),
@@ -171,7 +183,9 @@ export class MediaService {
this.deps.log?.("slopbot connected");
})
.catch(error => {
this.deps.log?.(`slopbot login failed: ${String(error)}`);
const message = `slopbot login failed: ${String(error)}`;
this.deps.log?.(message);
this.recordError("slopbot", message);
});
}
@@ -264,6 +278,10 @@ export class MediaService {
}
async startScrape(input: StartScrapeInput): Promise<ScrapeMeta> {
if (input.downloadDir && !isAbsolute(input.downloadDir)) {
throw new Error("downloadDir must be an absolute path");
}
this.applyRetention();
const status = this.storageStatus();
@@ -275,30 +293,38 @@ export class MediaService {
);
}
const mode = input.mode ?? "links";
let backend: ScrapeBackend;
try {
const mode = input.mode ?? "links";
let backend: ScrapeBackend;
if (input.backend === "puppet") {
backend = this.puppetBackend();
} else {
const available = await this.ensureSlopbot();
const inGuild = !!input.guildId && (this.bot?.guilds.cache.has(input.guildId) ?? false);
if (input.backend === "puppet") {
backend = this.puppetBackend();
} else {
const available = await this.ensureSlopbot();
const inGuild = !!input.guildId && (this.bot?.guilds.cache.has(input.guildId) ?? false);
if (input.backend === "slopbot" && (!available || !inGuild)) {
throw new Error("slopbot is not connected to that guild");
if (input.backend === "slopbot" && (!available || !inGuild)) {
throw new Error("slopbot is not connected to that guild");
}
const fetcher = this.botFetcher;
backend =
available && inGuild && fetcher
? new SlopbotBackend(fetcher)
: this.puppetBackend();
}
const fetcher = this.botFetcher;
backend =
available && inGuild && fetcher ? new SlopbotBackend(fetcher) : this.puppetBackend();
return this.engine.start({
channelId: input.channelId,
guildId: input.guildId ?? null,
mode,
backend,
downloadDir: input.downloadDir ?? null
});
} catch (error) {
this.recordError("scrape", error instanceof Error ? error.message : String(error));
throw error;
}
return this.engine.start({
channelId: input.channelId,
guildId: input.guildId ?? null,
mode,
backend
});
}
stopScrape(id: string): void {
@@ -322,6 +348,10 @@ export class MediaService {
/** Absolute path and content type for a downloaded file, or null. */
getMediaFile(scrapeId: string, itemId: number): { path: string; contentType: string } | null {
// A custom destination is the user's filesystem, outside the archive: not served.
const meta = this.engine.get(scrapeId);
if (meta?.downloadDir) return null;
const item = this.getMediaItem(scrapeId, itemId);
if (!item?.file_path) return null;
@@ -343,6 +373,22 @@ export class MediaService {
};
}
private recordError(source: string, message: string): void {
this.errors.unshift({ at: new Date().toISOString(), source, message });
if (this.errors.length > 50) this.errors.length = 50;
}
/** Bot and scrape errors, newest first. */
listErrors(): MediaErrorEntry[] {
return [...this.errors];
}
clearErrors(): number {
const count = this.errors.length;
this.errors.length = 0;
return count;
}
/** Distinct posters across every scrape, biggest first. */
listAuthors(): Array<{ id: string | null; name: string | null; count: number }> {
const totals = new Map<string, { id: string | null; name: string | null; count: number }>();
@@ -434,7 +480,10 @@ export class MediaService {
height: row.height,
aspectRatio: row.aspect_ratio,
url: row.url,
localUrl: row.file_path ? `/api/media/${meta.id}/${row.id}/file` : null,
localUrl:
row.file_path && !meta.downloadDir
? `/api/media/${meta.id}/${row.id}/file`
: null,
thumbUrl: row.preview_hash ? `/api/media/${meta.id}/${row.id}/thumb` : null,
provider: row.provider,
downloaded: row.downloaded === 1,
+23 -1
View File
@@ -832,6 +832,20 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
})
);
app.get(
"/api/media/errors",
wrap(async (_req, res) => {
res.json({ ok: true, result: deps.media.listErrors() });
})
);
app.post(
"/api/media/errors/clear",
wrap(async (_req, res) => {
res.json({ ok: true, result: { cleared: deps.media.clearErrors() } });
})
);
app.post(
"/api/media/scrape",
wrap(async (req, res) => {
@@ -844,8 +858,16 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
const requested = String(req.body?.backend ?? "auto");
const backend =
requested === "slopbot" || requested === "puppet" ? requested : "auto";
const downloadDir = req.body?.downloadDir ? String(req.body.downloadDir) : null;
const scrape = await deps.media.startScrape({ channelId, guildId, mode, confirm, backend });
const scrape = await deps.media.startScrape({
channelId,
guildId,
mode,
confirm,
backend,
downloadDir
});
deps.getPuppet()?.appendAction({
type: "media",
action: "scrape",