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
};
}
}