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
+7
View File
@@ -40,6 +40,13 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB
cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200).
- [x] C22 Media grid polish: per-kind status row
(`(Images/Videos) | Downloaded I/V | Cached I/V | Loaded I/V`), GIF tiles with a translucent
GIF label that play the animated original on hover (cached previews are static ffmpeg frames),
Tenor oEmbed preview resolution, and first-frame/inline playback for embeds that carry
Discord's `videoUrl` (Tenor/gifv now classify as video with `play_url`). `remotePreviewUrl`
exposes the animated source to the UI. Live: stats per kind correct; Tenor embed stored with
`play_url`.
- [x] C21 Playback of non-downloaded and expired media. `play_url` stores the embed's direct - [x] C21 Playback of non-downloaded and expired media. `play_url` stores the embed's direct
video (gifv/Tenor mp4); a new `/api/media/<scrape>/<item>/stream` route serves the local file video (gifv/Tenor mp4); a new `/api/media/<scrape>/<item>/stream` route serves the local file
or re-fetches the source message through slopbot to refresh expired signed CDN URLs and streams or re-fetches the source message through slopbot to refresh expired signed CDN URLs and streams
+3
View File
@@ -125,6 +125,9 @@ Previews run after every walk:
- Embeds without a thumbnail in Discord's data (fxtwitter, vxReddit, ...) are resolved first: - Embeds without a thumbnail in Discord's data (fxtwitter, vxReddit, ...) are resolved first:
provider oEmbed/fxtwitter API, then the page's `og:image`/`twitter:image` (512 KiB read cap, provider oEmbed/fxtwitter API, then the page's `og:image`/`twitter:image` (512 KiB read cap,
10 s timeout, one attempt). 10 s timeout, one attempt).
- Animated GIF previews are stored as a static first frame (`ffmpeg`, 480px WebP); the original
animated GIF URL stays in `preview_url` for hover playback. Tenor embeds without a thumbnail are
resolved through Tenor's oEmbed.
- One attempt per row, no retries; a failure marks `preview_error` and never fails the scrape. - One attempt per row, no retries; a failure marks `preview_error` and never fails the scrape.
- Stored by hash in `thumbs/<sha256>.<ext>`, where the extension follows the response type - Stored by hash in `thumbs/<sha256>.<ext>`, where the extension follows the response type
(`preview_ext`, default webp); `preview_hash` and `preview_bytes` land on the row, and the (`preview_ext`, default webp); `preview_hash` and `preview_bytes` land on the row, and the
+4 -3
View File
@@ -159,9 +159,10 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
for a fresh signed URL, so expired CDN links still play. Embed `videoUrl`s (gifv/Tenor mp4s) are for a fresh signed URL, so expired CDN links still play. Embed `videoUrl`s (gifv/Tenor mp4s) are
stored as `play_url` and used directly. The full link is a separate button that opens the stored as `play_url` and used directly. The full link is a separate button that opens the
original page with the warning. Length comes from Discord's attachment duration and, for original page with the warning. Length comes from Discord's attachment duration and, for
downloads, an ffprobe probe. The status row reads downloads, an ffprobe probe. GIF tiles carry a translucent **GIF** label top-left; the cached
`(Images/Videos) | Downloaded A/B | Cached C/B (excl Downl) | Loaded L/B`, where B is the total preview is a static frame and the animated GIF plays while hovering. The status row reads
for the active filters. A scrape strip shows live progress with Stop. `(Images/Videos) | Downloaded I/V | Cached I/V | Loaded I/V` — image counts include GIFs, and
each slot is the count for the active filters. A scrape strip shows live progress with Stop.
**New scrape…** (rightmost) opens a popup with Server and Group chat trees (servers expand to **New scrape…** (rightmost) opens a popup with Server and Group chat trees (servers expand to
channels; each row has Links and Download). Every scrape button asks for confirmation; the channels; each row has Links and Download). Every scrape button asks for confirmation; the
dialog takes optional From/To dates and a **Check Message Count** button (Discord search total, dialog takes optional From/To dates and a **Check Message Count** button (Discord search total,
+1 -1
View File
@@ -265,7 +265,7 @@ replaceable.
| GET | `/api/media/scrapes` | none | Every scrape with state and counters | | GET | `/api/media/scrapes` | none | Every scrape with state and counters |
| GET | `/api/media/status` | none | Slopbot connection state and the scrape list | | GET | `/api/media/status` | none | Slopbot connection state and the scrape list |
| GET | `/api/media/authors` | none | Distinct posters across every scrape, with counts | | GET | `/api/media/authors` | none | Distinct posters across every scrape, with counts |
| GET | `/api/media/stats` | same filters as `/api/media` | Counts: `total`, `downloaded`, `cached`, `notDownloaded` | | GET | `/api/media/stats` | same filters as `/api/media` | Counts per kind (`image`, `gif`, `video`, `audio`), each with `total`, `downloaded`, `cached`, `notDownloaded` |
| GET | `/api/media/storage` | none | Usage against the limit, per-kind sizes, `nearLimit`; marks old scrapes when over | | GET | `/api/media/storage` | none | Usage against the limit, per-kind sizes, `nearLimit`; marks old scrapes when over |
| POST | `/api/media/scrape` | `channelId`, `guildId?`, `mode?`, `backend?`, `confirm?`, `downloadDir?`, `after?`, `before?` | Start a media scrape. `backend` is `auto` (default), `slopbot`, or `puppet`; `downloadDir` is an absolute path for downloaded files (previews stay local); `after`/`before` are ISO dates bounding the walk. Near the limit: `409` with code `MEDIA_LIMIT` unless `confirm: true` | | POST | `/api/media/scrape` | `channelId`, `guildId?`, `mode?`, `backend?`, `confirm?`, `downloadDir?`, `after?`, `before?` | Start a media scrape. `backend` is `auto` (default), `slopbot`, or `puppet`; `downloadDir` is an absolute path for downloaded files (previews stay local); `after`/`before` are ISO dates bounding the walk. Near the limit: `409` with code `MEDIA_LIMIT` unless `confirm: true` |
| POST | `/api/media/count` | `channelId`, `guildId?`, `after?`, `before?` | Discord search total for the channel or range. Returns `{ count }` or `{ count: null, reason }` (slopbot guild channels only) | | POST | `/api/media/count` | `channelId`, `guildId?`, `after?`, `before?` | Discord search total for the channel or range. Returns `{ count }` or `{ count: null, reason }` (slopbot guild channels only) |
+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. */ /** The gap before the next file, in milliseconds. */
export function downloadGapMs(options: DownloadOptions = {}): number { export function downloadGapMs(options: DownloadOptions = {}): number {
const min = options.minGapMs ?? 1000; 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 (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") { if (host === "fxtwitter.com" || host === "vxtwitter.com" || host === "twitter.com" || host === "x.com") {
const match = /\/status\/(\d+)/.exec(parsed.pathname); const match = /\/status\/(\d+)/.exec(parsed.pathname);
if (match) { if (match) {
+20 -10
View File
@@ -3,12 +3,13 @@
* Rules: docs/media-scraper.md. * 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 { dirname, join } from "node:path";
import { import {
downloadGapMs, downloadGapMs,
downloadOne, downloadOne,
downloadPreview, downloadPreview,
gifPoster,
probeDimensions, probeDimensions,
videoPoster, videoPoster,
type DownloadOptions type DownloadOptions
@@ -378,21 +379,30 @@ export class ScrapeEngine {
const outcome = await downloadPreview(previewRow, join(dir, "thumbs"), options); const outcome = await downloadPreview(previewRow, join(dir, "thumbs"), options);
if (outcome.state === "downloaded" && outcome.hash) { if (outcome.state === "downloaded" && outcome.hash) {
const ext = outcome.ext ?? "webp"; let hash = outcome.hash;
store.markPreview(row.id, { hash: outcome.hash, bytes: outcome.bytes ?? 0, ext }); 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; meta.previewed = (meta.previewed ?? 0) + 1;
// Embed thumbnails also land in the custom destination, so the download has a // 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. // viewable poster for embeds while the media tab keeps its local copy.
if (options.destinationDir && row.source === "embed") { if (options.destinationDir && row.source === "embed") {
const target = join( const target = join(options.destinationDir, meta.id, "thumbnails", `${hash}.${ext}`);
options.destinationDir,
meta.id,
"thumbnails",
`${outcome.hash}.${ext}`
);
mkdirSync(dirname(target), { recursive: true }); mkdirSync(dirname(target), { recursive: true });
copyFileSync(join(dir, "thumbs", `${outcome.hash}.${ext}`), target); copyFileSync(join(dir, "thumbs", `${hash}.${ext}`), target);
} }
} else { } else {
store.markPreviewError(row.id, outcome.error ?? outcome.state); store.markPreviewError(row.id, outcome.error ?? outcome.state);
+1
View File
@@ -19,6 +19,7 @@ export {
downloadGapMs, downloadGapMs,
downloadOne, downloadOne,
downloadPreview, downloadPreview,
gifPoster,
previewUrlFor, previewUrlFor,
probeDimensions, probeDimensions,
videoPoster, videoPoster,
-30
View File
@@ -476,34 +476,4 @@ export class ScrapeStore {
return rows as unknown as MediaRow[]; 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 { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; 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 { ogImageFromHtml, resolveEmbedPreview } from "../src/embed-preview.js";
import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js"; import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js";
import { extractMedia } from "../src/extract.js"; import { extractMedia } from "../src/extract.js";
@@ -734,6 +734,11 @@ void (async () => {
status: 200 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")) { if (url.includes("api.fxtwitter.com")) {
return new Response( return new Response(
JSON.stringify({ tweet: { media: { videos: [{ thumbnail_url: "https://pbs.twimg.com/ok.jpg" }] } } }), 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" "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( assert.equal(
await resolveEmbedPreview( await resolveEmbedPreview(
{ url: "https://vxreddit.com/r/x/comments/1", source: "embed", preview_url: null }, { 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); 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", () => { await test("snowflake helpers round-trip dates", () => {
const id = snowflakeFromIso("2026-09-16T00:00:00.000Z"); const id = snowflakeFromIso("2026-09-16T00:00:00.000Z");
assert.ok(id); assert.ok(id);
+11
View File
@@ -114,6 +114,17 @@
color: var(--color-neutral-400); color: var(--color-neutral-400);
} }
.vk-media-gif-label {
position: absolute;
top: 4px;
left: 6px;
font: 600 10px/1 ui-monospace, Menlo, monospace;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.75);
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
pointer-events: none;
}
.vk-media-play { .vk-media-play {
position: absolute; position: absolute;
top: 4px; top: 4px;
+45 -6
View File
@@ -191,11 +191,31 @@
return `${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`; return `${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`;
} }
function kindTotals(field) {
const stats = state.stats ?? {};
return {
images: (stats.image?.[field] ?? 0) + (stats.gif?.[field] ?? 0),
videos: stats.video?.[field] ?? 0
};
}
function countLoaded() {
let images = 0;
let videos = 0;
for (const item of state.items) {
if (item.kind === "video") videos++;
else if (item.kind === "image" || item.kind === "gif") images++;
}
return { images, videos };
}
function renderStatus() { function renderStatus() {
const stats = state.stats ?? { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 }; const downloaded = kindTotals("downloaded");
const cached = kindTotals("cached");
const loaded = countLoaded();
setStatus( setStatus(
`(Images/Videos) | Downloaded ${stats.downloaded}/${stats.total} | ` + `(Images/Videos) | Downloaded ${downloaded.images}/${downloaded.videos} | ` +
`Cached ${stats.cached}/${stats.total} (excl Downl) | Loaded ${state.items.length}/${stats.total}` `Cached ${cached.images}/${cached.videos} | Loaded ${loaded.images}/${loaded.videos}`
); );
} }
@@ -428,15 +448,22 @@
const figure = el("figure", "vk-media-figure"); const figure = el("figure", "vk-media-figure");
const media = el("div", "vk-media-media"); const media = el("div", "vk-media-media");
const source = gridSource(item); const source = gridSource(item);
const playable = /\.(mp4|webm|m4v)(\?|$)/i.test(item.url ?? ""); const videoSource = item.localUrl || item.playUrl || (item.source !== "embed" ? item.url : null);
const playableVideo =
!!videoSource &&
(/\.(mp4|webm|m4v)(\?|$)/i.test(videoSource) || videoSource === item.playUrl);
const animatedGif =
item.kind === "gif" && item.remotePreviewUrl && /\.gif(\?|$)/i.test(item.remotePreviewUrl)
? item.remotePreviewUrl
: null;
if (item.kind === "video" && source && item.source !== "embed" && playable) { if (item.kind === "video" && videoSource && playableVideo) {
// Discord has no server-side video thumbnails; the browser shows the first frame. // Discord has no server-side video thumbnails; the browser shows the first frame.
const video = document.createElement("video"); const video = document.createElement("video");
video.preload = "metadata"; video.preload = "metadata";
video.muted = true; video.muted = true;
video.playsInline = true; video.playsInline = true;
video.src = source; video.src = videoSource;
video.addEventListener( video.addEventListener(
"loadedmetadata", "loadedmetadata",
() => { () => {
@@ -452,11 +479,23 @@
img.src = source; img.src = source;
img.alt = item.kind; img.alt = item.kind;
img.addEventListener("error", () => placeholder(media, item), { once: true }); img.addEventListener("error", () => placeholder(media, item), { once: true });
// GIFs stay static in the grid and play while hovered.
if (animatedGif) {
media.addEventListener("mouseenter", () => {
img.src = animatedGif;
});
media.addEventListener("mouseleave", () => {
img.src = source;
});
}
media.append(img); media.append(img);
} else { } else {
placeholder(media, item); placeholder(media, item);
} }
if (item.kind === "gif") media.append(el("span", "vk-media-gif-label", "GIF"));
if (item.kind === "video") media.append(playIcon(item)); if (item.kind === "video") media.append(playIcon(item));
media.addEventListener("click", () => openViewer(item, false)); media.addEventListener("click", () => openViewer(item, false));
+21 -6
View File
@@ -118,6 +118,8 @@ export interface MediaItemResult {
durationSecs: number | null; durationSecs: number | null;
url: string; url: string;
playUrl: string | null; playUrl: string | null;
/** The remote thumbnail URL from Discord's embed data (may be an animated GIF). */
remotePreviewUrl: string | null;
localUrl: string | null; localUrl: string | null;
thumbUrl: string | null; thumbUrl: string | null;
provider: string | null; provider: string | null;
@@ -539,9 +541,9 @@ export class MediaService {
resolution?: string; resolution?: string;
after?: string; after?: string;
before?: string; before?: string;
} = {}): { total: number; downloaded: number; cached: number; notDownloaded: number } { } = {}): Record<MediaKind, { total: number; downloaded: number; cached: number; notDownloaded: number }> {
// Deduplicate by URL across scrapes, newest scrape wins, like queryMedia. // Deduplicate by URL across scrapes, newest scrape wins, like queryMedia.
const byUrl = new Map<string, { downloaded: boolean; cached: boolean }>(); const byUrl = new Map<string, { kind: MediaKind; downloaded: boolean; cached: boolean }>();
for (const meta of this.engine.list()) { for (const meta of this.engine.list()) {
if (meta.state === "deleted") continue; if (meta.state === "deleted") continue;
@@ -559,6 +561,7 @@ export class MediaService {
for (const row of rows) { for (const row of rows) {
if (byUrl.has(row.url)) continue; if (byUrl.has(row.url)) continue;
byUrl.set(row.url, { byUrl.set(row.url, {
kind: row.kind,
downloaded: row.downloaded === 1, downloaded: row.downloaded === 1,
cached: row.preview_hash !== null cached: row.preview_hash !== null
}); });
@@ -571,13 +574,24 @@ export class MediaService {
} }
} }
const totals = { total: byUrl.size, downloaded: 0, cached: 0, notDownloaded: 0 }; const totals: Record<
MediaKind,
{ total: number; downloaded: number; cached: number; notDownloaded: number }
> = {
image: { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 },
gif: { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 },
video: { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 },
audio: { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 }
};
for (const entry of byUrl.values()) { for (const entry of byUrl.values()) {
const bucket = totals[entry.kind];
bucket.total++;
if (entry.downloaded) { if (entry.downloaded) {
totals.downloaded++; bucket.downloaded++;
} else { } else {
totals.notDownloaded++; bucket.notDownloaded++;
if (entry.cached) totals.cached++; if (entry.cached) bucket.cached++;
} }
} }
@@ -694,6 +708,7 @@ export class MediaService {
durationSecs: row.duration_secs, durationSecs: row.duration_secs,
url: row.url, url: row.url,
playUrl: row.play_url, playUrl: row.play_url,
remotePreviewUrl: row.preview_url,
localUrl: localUrl:
row.file_path && !meta.downloadDir row.file_path && !meta.downloadDir
? `/api/media/${meta.id}/${row.id}/file` ? `/api/media/${meta.id}/${row.id}/file`