Add daemon restart, preview diagnostics, browse caching, and rate-limit pauses

This commit is contained in:
Fisto
2026-09-17 07:33:35 +02:00
parent cba7ae8143
commit dea1724eb3
13 changed files with 367 additions and 56 deletions
+33 -4
View File
@@ -9,6 +9,7 @@
* VESKSLOP_SOCKET=/path/to/vesktop-puppet.sock MCP_PORT=8788 pnpm control:bridge
*/
import { spawn } from "node:child_process";
import { config as loadEnv } from "dotenv";
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -286,6 +287,7 @@ const webui = startWebUi({
transcripts,
preferences,
media,
restartDaemon,
connectAudioDevices: () => autoConnectAudio(requireCore())
});
@@ -1004,11 +1006,38 @@ app.listen(PORT, HOST, (error?: Error) => {
if (stale) console.warn(`[daemon] ${stale}`);
});
function shutdown() {
function shutdown(keepCore = false) {
stopSpeakingPoll();
core?.close();
void Promise.allSettled([coreProcess.dispose(4000), audio.dispose()]).finally(() => process.exit(0));
const tasks: Array<Promise<unknown>> = [audio.dispose()];
if (!keepCore) tasks.push(coreProcess.dispose(4000));
void Promise.allSettled(tasks).finally(() => process.exit(0));
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
/** Replace this daemon with a fresh copy, leaving the client running. */
let restartingDaemon = false;
function restartDaemon(): void {
if (restartingDaemon) return;
restartingDaemon = true;
const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`;
const args = [...process.execArgv, ...process.argv.slice(1)];
const command = `${quote(process.execPath)} ${args.map(quote).join(" ")}`;
console.log("[daemon] restart requested: spawning a replacement");
const child = spawn("sh", ["-c", `sleep 2; exec ${command}`], {
detached: true,
stdio: "ignore",
cwd: process.cwd(),
env: { ...process.env, VESKSLOP_DAEMON_RESTART: "1" }
});
child.unref();
setTimeout(() => shutdown(true), 300);
}
process.on("SIGINT", () => shutdown());
process.on("SIGTERM", () => shutdown());
+51
View File
@@ -123,6 +123,8 @@ export interface MediaItemResult {
remotePreviewUrl: string | null;
/** Why no preview exists, when a fetch failed (for example "http 404"). */
previewError: string | null;
/** Local animated GIF for hover playback, when the original was cached. */
animUrl: string | null;
localUrl: string | null;
thumbUrl: string | null;
provider: string | null;
@@ -175,6 +177,7 @@ export class MediaService {
log: line => this.deps.log?.(line),
onError: (scrapeId, message) => this.recordError("scrape", `${scrapeId}: ${message}`),
walkCyclePages: number(process.env.VESKSLOP_MEDIA_WALK_CYCLE_PAGES, 5),
rateLimitPauseMs: number(process.env.VESKSLOP_MEDIA_RATELIMIT_PAUSE_MS, 60_000),
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),
@@ -559,6 +562,17 @@ export class MediaService {
};
}
/** Absolute path for a cached animated GIF, or null. */
getMediaAnim(scrapeId: string, itemId: number): { path: string; contentType: string } | null {
const item = this.getMediaItem(scrapeId, itemId);
if (!item?.anim_hash) return null;
return {
path: join(this.rootDir, scrapeId, "thumbs", `${item.anim_hash}.gif`),
contentType: "image/gif"
};
}
/** Absolute path for a cached preview, or null. */
getMediaThumb(scrapeId: string, itemId: number): { path: string; contentType: string } | null {
const item = this.getMediaItem(scrapeId, itemId);
@@ -653,6 +667,42 @@ export class MediaService {
return totals;
}
/** Preview failures across scrapes with their reasons, newest scrape first. */
previewFailures(
limit = 50
): Array<{
scrapeId: string;
itemId: number;
kind: MediaKind;
provider: string | null;
url: string;
error: string;
}> {
const results: Array<{
scrapeId: string;
itemId: number;
kind: MediaKind;
provider: string | null;
url: string;
error: string;
}> = [];
for (const meta of this.engine.list()) {
if (results.length >= limit) break;
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
try {
for (const row of store.previewFailures(limit - results.length)) {
results.push({ scrapeId: meta.id, itemId: row.id, ...row });
}
} finally {
store.close();
}
}
return results;
}
/** 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 }>();
@@ -801,6 +851,7 @@ export class MediaService {
playUrl: row.play_url,
remotePreviewUrl: row.preview_url,
previewError: row.preview_error,
animUrl: row.anim_hash ? `/api/media/${meta.id}/${row.id}/anim` : null,
localUrl:
row.file_path && !meta.downloadDir
? `/api/media/${meta.id}/${row.id}/file`
+27
View File
@@ -75,6 +75,8 @@ export interface WebUiDeps {
transcripts: TranscriptStore;
preferences: PreferencesStore;
media: MediaService;
/** Replace the daemon process with a fresh copy, keeping the client running. */
restartDaemon: () => void;
connectAudioDevices: () => Promise<{ connected: boolean; message: string }>;
}
@@ -267,6 +269,22 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
})
);
app.post(
"/api/daemon/restart",
wrap(async (_req, res) => {
res.json({ ok: true, result: { restarting: true } });
deps.restartDaemon();
})
);
app.get(
"/api/media/preview-failures",
wrap(async (req, res) => {
const limit = clampLimit(req.query.limit, 50, 500);
res.json({ ok: true, result: deps.media.previewFailures(limit) });
})
);
app.post(
"/api/core/restart",
wrap(async (req, res) => {
@@ -764,6 +782,15 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
})
);
app.get(
"/api/media/:scrapeId/:itemId/anim",
wrap(async (req, res) => {
const file = deps.media.getMediaAnim(String(req.params.scrapeId), Number(req.params.itemId));
if (!file || !existsSync(file.path)) throw new HttpError(404, "anim not found");
res.type(file.contentType).sendFile(file.path);
})
);
app.get(
"/api/media/:scrapeId/:itemId/file",
wrap(async (req, res) => {