diff --git a/CURRENT.md b/CURRENT.md index cbbdf2e..01f703e 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,13 @@ mobile draft at 390px. - [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`: `previewed: 2`; thumbs served over `/api/media///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 video (gifv/Tenor mp4); a new `/api/media///stream` route serves the local file or re-fetches the source message through slopbot to refresh expired signed CDN URLs and streams diff --git a/docs/media-scraper.md b/docs/media-scraper.md index b21d34f..cb222a6 100644 --- a/docs/media-scraper.md +++ b/docs/media-scraper.md @@ -125,6 +125,9 @@ Previews run after every walk: - 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, 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. - Stored by hash in `thumbs/.`, where the extension follows the response type (`preview_ext`, default webp); `preview_hash` and `preview_bytes` land on the row, and the diff --git a/docs/media.md b/docs/media.md index 66b4ae9..9ebfec3 100644 --- a/docs/media.md +++ b/docs/media.md @@ -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 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 - downloads, an ffprobe probe. The status row reads - `(Images/Videos) | Downloaded A/B | Cached C/B (excl Downl) | Loaded L/B`, where B is the total - for the active filters. A scrape strip shows live progress with Stop. + downloads, an ffprobe probe. GIF tiles carry a translucent **GIF** label top-left; the cached + preview is a static frame and the animated GIF plays while hovering. The status row reads + `(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 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, diff --git a/docs/webui.md b/docs/webui.md index 5c5a90e..40c9378 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -265,7 +265,7 @@ replaceable. | 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/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 | | 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) | diff --git a/media-scrape/src/download.ts b/media-scrape/src/download.ts index 872cea3..9838eaa 100644 --- a/media-scrape/src/download.ts +++ b/media-scrape/src/download.ts @@ -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; diff --git a/media-scrape/src/embed-preview.ts b/media-scrape/src/embed-preview.ts index 27df121..d9b406e 100644 --- a/media-scrape/src/embed-preview.ts +++ b/media-scrape/src/embed-preview.ts @@ -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) { diff --git a/media-scrape/src/engine.ts b/media-scrape/src/engine.ts index 418707a..3ff1370 100644 --- a/media-scrape/src/engine.ts +++ b/media-scrape/src/engine.ts @@ -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); diff --git a/media-scrape/src/index.ts b/media-scrape/src/index.ts index b453977..25c7bf6 100644 --- a/media-scrape/src/index.ts +++ b/media-scrape/src/index.ts @@ -19,6 +19,7 @@ export { downloadGapMs, downloadOne, downloadPreview, + gifPoster, previewUrlFor, probeDimensions, videoPoster, diff --git a/media-scrape/src/store.ts b/media-scrape/src/store.ts index c13115e..a6735e3 100644 --- a/media-scrape/src/store.ts +++ b/media-scrape/src/store.ts @@ -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 - }; - } } diff --git a/media-scrape/test/run.ts b/media-scrape/test/run.ts index 46dc712..c273a2f 100644 --- a/media-scrape/test/run.ts +++ b/media-scrape/test/run.ts @@ -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); diff --git a/poc/public/media.css b/poc/public/media.css index a4c568d..b9a692e 100644 --- a/poc/public/media.css +++ b/poc/public/media.css @@ -114,6 +114,17 @@ 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 { position: absolute; top: 4px; diff --git a/poc/public/media.js b/poc/public/media.js index a3e4f9e..09deacb 100644 --- a/poc/public/media.js +++ b/poc/public/media.js @@ -191,11 +191,31 @@ 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() { - const stats = state.stats ?? { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 }; + const downloaded = kindTotals("downloaded"); + const cached = kindTotals("cached"); + const loaded = countLoaded(); setStatus( - `(Images/Videos) | Downloaded ${stats.downloaded}/${stats.total} | ` + - `Cached ${stats.cached}/${stats.total} (excl Downl) | Loaded ${state.items.length}/${stats.total}` + `(Images/Videos) | Downloaded ${downloaded.images}/${downloaded.videos} | ` + + `Cached ${cached.images}/${cached.videos} | Loaded ${loaded.images}/${loaded.videos}` ); } @@ -428,15 +448,22 @@ const figure = el("figure", "vk-media-figure"); const media = el("div", "vk-media-media"); 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. const video = document.createElement("video"); video.preload = "metadata"; video.muted = true; video.playsInline = true; - video.src = source; + video.src = videoSource; video.addEventListener( "loadedmetadata", () => { @@ -452,11 +479,23 @@ img.src = source; img.alt = item.kind; 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); } else { placeholder(media, item); } + if (item.kind === "gif") media.append(el("span", "vk-media-gif-label", "GIF")); if (item.kind === "video") media.append(playIcon(item)); media.addEventListener("click", () => openViewer(item, false)); diff --git a/poc/src/media.ts b/poc/src/media.ts index 5e5cc53..62a6254 100644 --- a/poc/src/media.ts +++ b/poc/src/media.ts @@ -118,6 +118,8 @@ export interface MediaItemResult { durationSecs: number | null; url: string; playUrl: string | null; + /** The remote thumbnail URL from Discord's embed data (may be an animated GIF). */ + remotePreviewUrl: string | null; localUrl: string | null; thumbUrl: string | null; provider: string | null; @@ -539,9 +541,9 @@ export class MediaService { resolution?: string; after?: string; before?: string; - } = {}): { total: number; downloaded: number; cached: number; notDownloaded: number } { + } = {}): Record { // Deduplicate by URL across scrapes, newest scrape wins, like queryMedia. - const byUrl = new Map(); + const byUrl = new Map(); for (const meta of this.engine.list()) { if (meta.state === "deleted") continue; @@ -559,6 +561,7 @@ export class MediaService { for (const row of rows) { if (byUrl.has(row.url)) continue; byUrl.set(row.url, { + kind: row.kind, downloaded: row.downloaded === 1, 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()) { + const bucket = totals[entry.kind]; + bucket.total++; if (entry.downloaded) { - totals.downloaded++; + bucket.downloaded++; } else { - totals.notDownloaded++; - if (entry.cached) totals.cached++; + bucket.notDownloaded++; + if (entry.cached) bucket.cached++; } } @@ -694,6 +708,7 @@ export class MediaService { durationSecs: row.duration_secs, url: row.url, playUrl: row.play_url, + remotePreviewUrl: row.preview_url, localUrl: row.file_path && !meta.downloadDir ? `/api/media/${meta.id}/${row.id}/file`