Add links-mode embed thumbnails, item metadata, and media filters

This commit is contained in:
Fisto
2026-09-16 11:44:09 +02:00
parent 1fe204b91e
commit 973b9cff92
23 changed files with 863 additions and 82 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ Event line:
| `user.search` | `query`, optional `guildId`, optional `limit` | Users from the client cache. Guild members when `guildId` is set. |
| `dm.list` | optional `limit` | Cached DM and group DM channels with recipients, icons, and avatars. Cache-only. |
| `message.send` | `channelId`, `content` | `{ channelId, messageId, sent: true }` |
| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. Messages carry the author avatar, attachment metadata (id, filename, URL, content type, size, dimensions, duration), embeds (video, image, and thumbnail URLs), sticker items, forwarded snapshots, a voice-message flag, `editedAt`, and `replyTo`. |
| `message.read` | `channelId`, `limit` | `{ cached, messages }`. Cache-only. Messages carry the author avatar, attachment metadata (id, filename, URL, content type, size, dimensions, duration), embeds (video, image, and thumbnail URLs plus their dimensions), sticker items, forwarded snapshots, a voice-message flag, `editedAt`, and `replyTo`. |
| `message.fetch` | `channelId`, `limit`, optional `before` or `after` | `{ fetched, messages }`. Fetches from Discord. Same message shape. |
| `reaction.add` | `channelId`, `messageId`, `emoji` | `{ added: true }` |
| `dm.send` | `userId`, `content` | `{ userId, channelId, sent: true }` |
+1
View File
@@ -175,6 +175,7 @@ Verified live on 2026-09-13: the web UI started the client under auto-Xvfb, the
| `VESKSLOP_MEDIA_DB_DIR` | `<repo>/media-scrape/db` | Media archive root: per-scrape databases, downloads, previews. |
| `VESKSLOP_MEDIA_MAX_FILE_BYTES` | `52428800` | Download size cap (50 MiB). Larger files are skipped. |
| `VESKSLOP_MEDIA_PREVIEW_MAX_BYTES` | `5242880` | Preview size cap (5 MiB). Larger previews are skipped. |
| `VESKSLOP_FFPROBE` | `ffprobe` | Probe binary used to fill missing dimensions after a download. |
| `VESKSLOP_MEDIA_DOWNLOAD_MIN_MS` / `_MAX_MS` | `1000` / `3000` | Gap between downloaded files. |
| `VESKSLOP_AUDIO_DUCK` | `30` | Sink volume percent while others speak. |
| `VESKSLOP_TTS_LEAD_MS` | `150` | Lead-in silence before each TTS line (0 disables, max 2000). |
+4 -2
View File
@@ -23,8 +23,10 @@ interface MediaService {
}
```
`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `limit`, `offset`. Planned:
`guildId`, `authorId`, `after`, `before`, `text`.
`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `authorId`, `aspect`,
`resolution`, `after`, `before`, `limit`, `offset`. The `aspect` and `resolution` values are the
preset keys from `filter-presets.ts`; `after`/`before` are ISO times on `posted_at`. Planned:
`guildId`, `text`.
`StorageStatus`: `usedBytes`, `limitBytes`, `warnPercent`, `overrunPercent`, `byKind`,
`scrapes: [{ id, bytes, state, createdAt }]`.
+10 -6
View File
@@ -81,12 +81,16 @@ One SQLite file per scrape (`index.db`, `node:sqlite`).
| Table | Columns |
| --- | --- |
| `messages` | `id`, `author_id`, `author_name`, `timestamp`, `content` |
| `media` | `id`, `kind`, `url`, `provider`, `file_hash`, `preview_hash`, `content_type`, `size`, `width`, `height`, `duration_ms`, `downloaded`, `created_at` |
| `media` | `id`, `kind`, `source`, `author_id`, `author_name`, `posted_at`, `url`, `provider`, `file_hash`, `preview_hash`, `content_type`, `size`, `width`, `height`, `aspect_ratio`, `duration_secs`, `downloaded`, `file_path`, `created_at` |
| `media_sources` | `media_id`, `message_id` |
| `downloads` | `media_id`, `state`, `attempts`, `error` |
Indexes: `(kind, timestamp)` via `media_sources`, `channel` metadata in `scrape.json`, and
`file_hash` for dedupe. The database is an index; the media folder holds bytes.
Indexes: `kind`, `posted_at`, and `media_sources(message_id)`. The database is an index; the media
folder holds bytes. Every item carries its poster (`author_id`, `author_name`), the post time
(`posted_at`), and its dimensions (`width`, `height`, `aspect_ratio`). Filter presets live in
`media-scrape/src/filter-presets.ts`: aspect buckets (vertical, square, 4:3, 16:9, 21:9, other) and
resolution buckets by the shortest side (SD, HD, FHD, QHD, 4K+, unknown). A missing resolution is
filled with `ffprobe` after a download when `VESKSLOP_FFPROBE` is available.
## Downloads and previews
@@ -105,9 +109,9 @@ Previews run after every walk:
- In both modes, Discord-hosted image and GIF URLs (`cdn.discordapp.com`, `media.discordapp.net`)
are transformed to the CDN resizer (`media.discordapp.net?...width=320&height=320&format=webp`).
- In `download` mode only, embed rows also get their stored `preview_url` (YouTube, X, TikTok
thumbnails, and so on) cached. The embed's full media URL is never fetched; embed rows are
excluded from `pendingDownloads`.
- In both modes, embed rows also get their stored `preview_url` (YouTube, X, TikTok thumbnails,
and so on) cached. The embed's full media URL is never fetched; embed rows are excluded from
`pendingDownloads`.
- One attempt per row, no retries; a failure marks `preview_error` and never fails the scrape.
- Stored by hash in `thumbs/<sha256>.webp`; `preview_hash` and `preview_bytes` land on the row, and
the counter is `previewed`. Previews count toward the storage limit.
+16 -10
View File
@@ -81,11 +81,13 @@ media-scrape/db/
are never download targets. The Media tab prefers the local file when one exists, so the archive
survives CDN URL expiry.
- Third-party embeds are **never downloaded as media**: YouTube, Instagram, X, TikTok, and every
other embed stays a link. In `download` mode their thumbnail is cached (below); the video itself
is never fetched.
- Previews are cached small (WebP, 320px) in `thumbs/` after every walk. In both modes this covers
Discord-hosted images and GIFs; in `download` mode it also covers embed thumbnails, so the grid
does not depend on expiring CDN links. Previews count toward the storage limit.
other embed stays a link. Its thumbnail is cached (below); the video itself is never fetched.
- Previews are cached small (WebP, 320px) in `thumbs/` after every walk, in both modes: Discord
hosted images and GIFs, and third-party embed thumbnails (YouTube, X, TikTok, and so on), so the
grid does not depend on expiring CDN links. Previews count toward the storage limit.
- Every item records who posted it, when, and its dimensions: `author_id`, `author_name`,
`posted_at`, `width`, `height`, and `aspect_ratio`. In `download` mode a missing resolution is
filled in with `ffprobe` after the file lands.
- Discord attachment URLs are signed and expire in about a day. In `links` mode an expired URL is
refreshed by re-fetching its message; in `download` mode the bytes are caught before expiry.
@@ -127,11 +129,15 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
## Web UI
- **Media** tab: filter chips (All, Images, GIFs, Videos, Audio), a source filter (any,
attachments, stickers, embeds), and a source-channel filter built from the scrape list. Date and
text search are planned. Grid with lazy loading; previews come from the cache, then the local
file, then the CDN. A scrape-status strip shows progress with Start and Stop. The view fills the
window below the header and the call strip.
- **Media** tab: filter chips (All, Images, GIFs, Videos, Audio), and filters for source (any,
attachments, stickers, embeds), source channel, poster, aspect ratio (Vertical, 1:1, 4:3, 16:9,
21:9, Other), resolution (SD, HD, FHD, QHD, 4K+, Unknown), and a posted-date range (from/to).
Grid with lazy loading; previews come from the cache, then the local file, then the CDN; each
tile's tooltip shows the poster and posted time. A scrape strip shows live progress with Stop.
**New scrape…** opens a popup with Server and Group chat trees (servers expand to channels; each
row has Links and Download), and **History** lists each scrape with its start time, duration,
pages, messages, media counts, bytes, files, and previews. The view fills the window below the
header and the call strip.
- **Bot** tab: slopbot connection state, the scrape queue, and the storage limit with a usage bar.
The bar is segmented and colour coded by media kind (images, GIFs, video, audio, other) plus the
database overhead.
+11 -6
View File
@@ -124,11 +124,15 @@ box live in Chat, not Call, and the connection stats are not chips yet.
- **Media** and **Bot** are scope buttons beside Guild and DM. They open a full-window view below
the header and the call strip (the shell is covered; only the header, and the call strip when it
is visible, stay on screen). Mobile is out of scope for the media feature.
- Media lists scraped items with kind chips (All, Images, GIFs, Videos, Audio), a source filter
(any, attachments, stickers, embeds), a source-channel filter built from the scrape list, a
channel picker with **Start links** / **Start download**, and the active scrapes with **Stop**.
Images and GIFs render from the cached preview when one exists, then the local file, then the
Discord CDN URL; videos and audio are link cards.
- Media lists scraped items with kind chips (All, Images, GIFs, Videos, Audio) and filters for
source (any, attachments, stickers, embeds), source channel, poster, aspect ratio, resolution,
and a posted-date range (from/to). **New scrape…** opens a popup with **Server** and **Group
chat** tabs: servers expand into their text channels, each channel (and each group chat) has
**Links** and **Download** buttons, and group chats always use the puppet. **History** lists
each scrape with start time, duration, pages, messages, media counts, bytes, files, and previews.
The scrape strip shows live progress with a pulse and **Stop**. Images and GIFs render from the
cached preview when one exists, then the local file, then the CDN URL; videos and audio are link
cards with the poster and posted time in the tooltip.
- Bot shows the slopbot connection state and the scrape queue.
- The views are self-contained: `poc/public/media.js` and `media.css`, over the `/api/media/*`
routes in the table below. See [media.md](media.md).
@@ -252,11 +256,12 @@ replaceable.
| POST | `/api/audio/stop` | none | Stop speech and clear the queue |
| POST | `/api/audio/capture` | `start` | Start or stop capture |
| GET | `/api/transcript` | `limit?`, `sessionId?` | Sessions and captured segments |
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `limit?`, `offset?` | Media items across every scrape, newest first. `source` is `attachment`, `sticker`, or `embed`; `channelId` filters by source channel. `localUrl`/`thumbUrl` when a file or preview exists |
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `authorId?`, `aspect?`, `resolution?`, `after?`, `before?`, `limit?`, `offset?` | Media items across every scrape, newest first. `source` is `attachment`, `sticker`, or `embed`; `aspect` and `resolution` are the preset keys; `after`/`before` are ISO times on `posted_at`. Items carry `authorName`, `postedAt`, `width`, `height`, `aspectRatio`; `localUrl`/`thumbUrl` when a file or preview exists |
| GET | `/api/media/:scrapeId/:itemId/file` | none | The downloaded file, content-typed from its extension |
| GET | `/api/media/:scrapeId/:itemId/thumb` | none | The cached WebP preview |
| 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/storage` | none | Usage against the limit, per-kind sizes, `nearLimit`; marks old scrapes when over |
| POST | `/api/media/scrape` | `channelId`, `guildId?`, `mode?`, `backend?`, `confirm?` | Start a media scrape. `backend` is `auto` (default), `slopbot`, or `puppet`. Near the limit: `409` with code `MEDIA_LIMIT` unless `confirm: true` |
| POST | `/api/media/scrape/stop` | `id` | Stop a media scrape |