Add per-kind stats, GIF hover playback, and static gif previews

This commit is contained in:
Fisto
2026-09-16 13:36:51 +02:00
parent 612b2504dc
commit 0081224954
13 changed files with 189 additions and 57 deletions
+32
View File
@@ -312,6 +312,38 @@ export async function videoPoster(
}
}
/** Static first-frame WebP from an animated GIF. Null when ffmpeg is missing or fails. */
export async function gifPoster(
inputPath: string,
outDir: string,
ffmpeg = process.env.VESKSLOP_FFMPEG ?? "ffmpeg"
): Promise<{ hash: string; bytes: number } | null> {
const temp = join(outDir, `.gif-${Date.now()}-${Math.random().toString(16).slice(2)}.webp`);
try {
await execFileAsync(
ffmpeg,
["-v", "error", "-y", "-i", inputPath, "-frames:v", "1", "-vf", "scale=480:-1", temp],
{ timeout: 30_000 }
);
const buffer = readFileSync(temp);
if (!buffer.length) return null;
const hash = createHash("sha256").update(buffer).digest("hex");
renameSync(temp, join(outDir, `${hash}.webp`));
return { hash, bytes: buffer.length };
} catch {
return null;
} finally {
try {
rmSync(temp, { force: true });
} catch {
// already renamed
}
}
}
/** The gap before the next file, in milliseconds. */
export function downloadGapMs(options: DownloadOptions = {}): number {
const min = options.minGapMs ?? 1000;
+5
View File
@@ -98,6 +98,11 @@ export async function resolveEmbedPreview(
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
}
if (host === "tenor.com") {
const data = await getJson(`https://tenor.com/oembed?url=${encodeURIComponent(media.url)}`, options);
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
}
if (host === "fxtwitter.com" || host === "vxtwitter.com" || host === "twitter.com" || host === "x.com") {
const match = /\/status\/(\d+)/.exec(parsed.pathname);
if (match) {
+20 -10
View File
@@ -3,12 +3,13 @@
* Rules: docs/media-scraper.md.
*/
import { copyFileSync, mkdirSync } from "node:fs";
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import {
downloadGapMs,
downloadOne,
downloadPreview,
gifPoster,
probeDimensions,
videoPoster,
type DownloadOptions
@@ -378,21 +379,30 @@ export class ScrapeEngine {
const outcome = await downloadPreview(previewRow, join(dir, "thumbs"), options);
if (outcome.state === "downloaded" && outcome.hash) {
const ext = outcome.ext ?? "webp";
store.markPreview(row.id, { hash: outcome.hash, bytes: outcome.bytes ?? 0, ext });
let hash = outcome.hash;
let bytes = outcome.bytes ?? 0;
let ext = outcome.ext ?? "webp";
// Animated GIFs are kept static in the grid; hovering plays the original.
if (ext === "gif") {
const poster = await gifPoster(join(dir, "thumbs", `${hash}.gif`), join(dir, "thumbs"));
if (poster) {
rmSync(join(dir, "thumbs", `${hash}.gif`), { force: true });
hash = poster.hash;
bytes = poster.bytes;
ext = "webp";
}
}
store.markPreview(row.id, { hash, bytes, ext });
meta.previewed = (meta.previewed ?? 0) + 1;
// Embed thumbnails also land in the custom destination, so the download has a
// viewable poster for embeds while the media tab keeps its local copy.
if (options.destinationDir && row.source === "embed") {
const target = join(
options.destinationDir,
meta.id,
"thumbnails",
`${outcome.hash}.${ext}`
);
const target = join(options.destinationDir, meta.id, "thumbnails", `${hash}.${ext}`);
mkdirSync(dirname(target), { recursive: true });
copyFileSync(join(dir, "thumbs", `${outcome.hash}.${ext}`), target);
copyFileSync(join(dir, "thumbs", `${hash}.${ext}`), target);
}
} else {
store.markPreviewError(row.id, outcome.error ?? outcome.state);
+1
View File
@@ -19,6 +19,7 @@ export {
downloadGapMs,
downloadOne,
downloadPreview,
gifPoster,
previewUrlFor,
probeDimensions,
videoPoster,
-30
View File
@@ -476,34 +476,4 @@ export class ScrapeStore {
return rows as unknown as MediaRow[];
}
/** Counts for the status row, under the same filters as queryMedia. */
stats(options: MediaFilterOptions = {}): {
total: number;
downloaded: number;
cached: number;
notDownloaded: number;
} {
const { where, values } = this.mediaWhere(options);
const row = this.db
.prepare(
`SELECT COUNT(*) AS total,
COALESCE(SUM(downloaded), 0) AS downloaded,
COALESCE(SUM(CASE WHEN downloaded = 0 AND preview_hash IS NOT NULL THEN 1 ELSE 0 END), 0) AS cached,
COALESCE(SUM(CASE WHEN downloaded = 0 THEN 1 ELSE 0 END), 0) AS not_downloaded
FROM media ${where}`
)
.get(...values) as unknown as {
total: number;
downloaded: number;
cached: number;
not_downloaded: number;
};
return {
total: row?.total ?? 0,
downloaded: row?.downloaded ?? 0,
cached: row?.cached ?? 0,
notDownloaded: row?.not_downloaded ?? 0
};
}
}
+39 -1
View File
@@ -9,7 +9,7 @@ import { createHash } from "node:crypto";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { downloadOne, downloadPreview, previewUrlFor, videoPoster } from "../src/download.js";
import { downloadOne, downloadPreview, gifPoster, previewUrlFor, videoPoster } from "../src/download.js";
import { ogImageFromHtml, resolveEmbedPreview } from "../src/embed-preview.js";
import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js";
import { extractMedia } from "../src/extract.js";
@@ -734,6 +734,11 @@ void (async () => {
status: 200
});
}
if (url.includes("tenor.com/oembed")) {
return new Response(JSON.stringify({ thumbnail_url: "https://media.tenor.com/ok.png" }), {
status: 200
});
}
if (url.includes("api.fxtwitter.com")) {
return new Response(
JSON.stringify({ tweet: { media: { videos: [{ thumbnail_url: "https://pbs.twimg.com/ok.jpg" }] } } }),
@@ -763,6 +768,13 @@ void (async () => {
),
"https://pbs.twimg.com/ok.jpg"
);
assert.equal(
await resolveEmbedPreview(
{ url: "https://tenor.com/view/abc-123", source: "embed", preview_url: null },
{ fetchImpl }
),
"https://media.tenor.com/ok.png"
);
assert.equal(
await resolveEmbedPreview(
{ url: "https://vxreddit.com/r/x/comments/1", source: "embed", preview_url: null },
@@ -823,6 +835,32 @@ void (async () => {
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
});
await test("gifPoster freezes an animated gif", async () => {
const dir = join(root, "gif-test");
mkdirSync(dir, { recursive: true });
const gifPath = join(dir, "anim.gif");
try {
execFileSync("ffmpeg", [
"-v",
"error",
"-y",
"-f",
"lavfi",
"-i",
"testsrc=duration=1:size=64x64:rate=2",
gifPath
]);
} catch {
console.log("ok - gifPoster skipped (no ffmpeg)");
return;
}
const poster = await gifPoster(gifPath, dir);
assert.ok(poster?.hash);
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
});
await test("snowflake helpers round-trip dates", () => {
const id = snowflakeFromIso("2026-09-16T00:00:00.000Z");
assert.ok(id);