Deduplicate media across scrapes and polish the viewer controls
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user