Add daemon restart, preview diagnostics, browse caching, and rate-limit pauses
This commit is contained in:
@@ -463,7 +463,8 @@
|
||||
<span class="vk-settings-actions">
|
||||
<button id="core-start" class="btn btn-primary vk-small-btn" type="button">Start</button>
|
||||
<button id="core-stop" class="btn btn-secondary vk-small-btn" type="button">Stop</button>
|
||||
<button id="core-restart" class="btn btn-secondary vk-small-btn" type="button">Restart</button>
|
||||
<button id="core-restart" class="btn btn-secondary vk-small-btn" type="button">Restart client</button>
|
||||
<button id="daemon-restart" class="btn btn-secondary vk-small-btn" type="button">Restart daemon</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="core-status" class="vk-settings-card"></div>
|
||||
|
||||
+14
-45
@@ -443,13 +443,6 @@
|
||||
media.replaceChildren(node);
|
||||
}
|
||||
|
||||
/** Only real video files can be framed; embed pages (YouTube etc.) never are. */
|
||||
function canFrameVideo(item) {
|
||||
const direct = /\.(mp4|webm|m4v)(\?|$)/i;
|
||||
if (item.playUrl && direct.test(item.playUrl)) return true;
|
||||
return item.source !== "embed" && direct.test(item.url ?? "");
|
||||
}
|
||||
|
||||
function playIcon(item) {
|
||||
const play = el("span", "vk-media-play", "▶");
|
||||
play.title = "Play";
|
||||
@@ -472,11 +465,8 @@
|
||||
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;
|
||||
// Hover plays the cached animated GIF from the archive, never the provider.
|
||||
const animatedGif = item.kind === "gif" && item.animUrl ? item.animUrl : null;
|
||||
|
||||
if (imageSource) {
|
||||
const img = document.createElement("img");
|
||||
@@ -496,23 +486,6 @@
|
||||
}
|
||||
|
||||
media.append(img);
|
||||
} else if (item.kind === "video" && canFrameVideo(item)) {
|
||||
// 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";
|
||||
@@ -1231,22 +1204,6 @@
|
||||
{ once: true }
|
||||
);
|
||||
media.append(video);
|
||||
} else if (item.kind === "video" && canFrameVideo(item)) {
|
||||
// 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 ((item.kind === "image" || item.kind === "gif") && /^(https?:|\/)/.test(source)) {
|
||||
const img = document.createElement("img");
|
||||
img.src = source;
|
||||
@@ -1254,6 +1211,7 @@
|
||||
media.append(img);
|
||||
} else {
|
||||
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||
if (item.kind === "video") media.append(viewerPlayIcon(item));
|
||||
}
|
||||
|
||||
meta.replaceChildren();
|
||||
@@ -1490,6 +1448,17 @@
|
||||
|
||||
document.querySelector("#media-clean")?.addEventListener("click", openCleanModal);
|
||||
|
||||
document.querySelector("#daemon-restart")?.addEventListener("click", async () => {
|
||||
if (!window.confirm("Restart the daemon? The page will disconnect for a few seconds.")) return;
|
||||
|
||||
try {
|
||||
await window.VK.api.post("/api/daemon/restart", {});
|
||||
} catch {
|
||||
// The daemon exits mid-response on purpose.
|
||||
}
|
||||
setStatus("daemon restarting; reload the page in a few seconds");
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
if (state.view) layoutTop();
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user