Deduplicate media across scrapes and polish the viewer controls

This commit is contained in:
Fisto
2026-09-16 13:05:31 +02:00
parent 1b5fcfed2f
commit d3f9d20190
12 changed files with 198 additions and 85 deletions
+26 -8
View File
@@ -12,23 +12,41 @@ import type { MediaRow } from "./store.js";
const execFileAsync = promisify(execFile);
/** Read pixel dimensions with ffprobe. Null when the tool is missing or the file is not media. */
/** Read dimensions and duration with ffprobe. Null when nothing usable is found. */
export async function probeDimensions(
path: string,
ffprobe = process.env.VESKSLOP_FFPROBE ?? "ffprobe"
): Promise<{ width: number; height: number } | null> {
): Promise<{ width: number | null; height: number | null; durationSecs: number | null } | null> {
try {
const { stdout } = await execFileAsync(
ffprobe,
["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "json", path],
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height:format=duration",
"-of",
"json",
path
],
{ timeout: 15_000 }
);
const stream = JSON.parse(stdout)?.streams?.[0];
if (Number.isFinite(stream?.width) && Number.isFinite(stream?.height)) {
return { width: stream.width, height: stream.height };
}
const parsed = JSON.parse(stdout);
const stream = parsed?.streams?.[0];
const duration = Number(parsed?.format?.duration);
const width = Number.isFinite(stream?.width) ? stream.width : null;
const height = Number.isFinite(stream?.height) ? stream.height : null;
const durationSecs =
Number.isFinite(duration) && duration > 0 ? Math.round(duration * 10) / 10 : null;
if (width === null && height === null && durationSecs === null) return null;
return { width, height, durationSecs };
} catch {
// missing ffprobe, a timeout, or a non-visual file
// missing ffprobe, a timeout, or a non-media file
}
return null;
}
+7 -5
View File
@@ -59,8 +59,10 @@ export interface ScrapeEngineOptions {
download?: DownloadOptions;
/** Cache Discord-hosted image previews after every walk. Default true. */
previews?: boolean;
/** Read dimensions from a downloaded file. Defaults to ffprobe. */
probe?: (path: string) => Promise<{ width: number; height: number } | null>;
/** Read dimensions and duration from a downloaded file. Defaults to ffprobe. */
probe?: (
path: string
) => Promise<{ width: number | null; height: number | null; durationSecs: number | null } | null>;
/** Called when a scrape fails. */
onError?: (scrapeId: string, message: string) => void;
}
@@ -320,11 +322,11 @@ export class ScrapeEngine {
});
meta.downloaded = (meta.downloaded ?? 0) + 1;
if (row.width === null || row.height === null) {
const dimensions = await (this.options.probe ?? probeDimensions)(
if (row.width === null || row.height === null || row.duration_secs === null) {
const info = await (this.options.probe ?? probeDimensions)(
join(targetDir, outcome.path)
);
if (dimensions) store.updateDimensions(row.id, dimensions.width, dimensions.height);
if (info) store.updateMediaInfo(row.id, info);
}
if (row.kind === "video" && !row.preview_hash) {
+5 -1
View File
@@ -151,7 +151,11 @@ function extractFrom(message: MediaMessage): ExtractedMedia[] {
size: attachment.size,
width: attachment.width,
height: attachment.height,
durationSecs: attachment.durationSecs,
// Discord sends 0 for non-audio attachments; treat it as unknown.
durationSecs:
Number.isFinite(attachment.durationSecs) && (attachment.durationSecs as number) > 0
? attachment.durationSecs
: null,
downloaded: false,
previewUrl: null
});
+19 -5
View File
@@ -362,11 +362,25 @@ export class ScrapeStore {
this.db.prepare("UPDATE media SET preview_error = ? WHERE id = ?").run(error.slice(0, 200), id);
}
/** Fill in dimensions after a download probe. */
updateDimensions(id: number, width: number, height: number): void {
this.db
.prepare("UPDATE media SET width = ?, height = ?, aspect_ratio = ? WHERE id = ?")
.run(width, height, aspectRatioOf(width, height), id);
/** Fill in dimensions and duration after a download probe. */
updateMediaInfo(
id: number,
info: { width?: number | null; height?: number | null; durationSecs?: number | null }
): void {
const sets: string[] = [];
const values: Array<number | null> = [];
if (info.width != null && info.height != null) {
sets.push("width = ?", "height = ?", "aspect_ratio = ?");
values.push(info.width, info.height, aspectRatioOf(info.width, info.height));
}
if (info.durationSecs != null) {
sets.push("duration_secs = ?");
values.push(info.durationSecs);
}
if (!sets.length) return;
this.db.prepare(`UPDATE media SET ${sets.join(", ")} WHERE id = ?`).run(...values, id);
}
/** Distinct posters with their item counts, biggest first. */
+4 -2
View File
@@ -103,7 +103,7 @@ void (async () => {
attachments: [
attachment({ filename: "cat.png", contentType: "image/png" }),
attachment({ filename: "loop.gif" }),
attachment({ filename: "clip.mp4", contentType: "video/mp4", size: 1234 }),
attachment({ filename: "clip.mp4", contentType: "video/mp4", size: 1234, durationSecs: 0 }),
attachment({ filename: "note.ogg", durationSecs: 3.2 })
],
embeds: [
@@ -143,6 +143,7 @@ void (async () => {
assert.equal(items.find(item => item.url.endsWith("/stickers/55.gif"))?.kind, "gif");
assert.equal(items.some(item => item.url.includes("lottie")), false);
assert.equal(items.find(item => item.kind === "audio")?.durationSecs, 3.2);
assert.equal(items.find(item => item.url.endsWith("clip.mp4"))?.durationSecs, null);
});
await test("extract finds media in forwarded snapshots", () => {
@@ -491,7 +492,7 @@ void (async () => {
random: () => 0,
log: () => {},
previews: false,
probe: async () => ({ width: 1080, height: 1920 }),
probe: async () => ({ width: 1080, height: 1920, durationSecs: 12.3 }),
download: {
fetchImpl: async () =>
new Response("png-bytes", { status: 200, headers: { "content-type": "image/png" } }),
@@ -509,6 +510,7 @@ void (async () => {
assert.equal(row.width, 1080);
assert.equal(row.height, 1920);
assert.equal(row.aspect_ratio, 0.563);
assert.equal(row.duration_secs, 12.3);
store.close();
});