Add custom download destinations, confirmations, and the bot error feed
This commit is contained in:
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user