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
+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",