# Media archive Status: design approved 2026-09-16. The engine, the slopbot backend, the `scrape media` command, the `/api/media/*` routes, storage limits with retention, the Settings group, the MCP tools, and the byte downloader are implemented (C3-C8, C10). The Media and Bot tabs are C6. Live-verified: - slopbot on the test channel: 17 messages (4 images, 13 videos), 0 errors. - slopbot on a larger channel: 11 pages, 1088 messages (96 images, 1 GIF, 203 videos), 0 errors. - puppet on a group DM and a 1:1 DM: done, 0 errors, backend `puppet`. Preview caching is implemented and live-verified (2 WebP thumbs served over the thumb route). Remaining: a 429 observation on a very large walk. Checkpoints live in [CURRENT.md](../CURRENT.md). The media archive saves the media history of a channel. A trusted user runs `scrape media` in the channel. The daemon walks the history through a scrape backend and indexes every image, GIF, video, and audio item into a per-scrape database. Two new Web UI tabs expose it: **Media** browses the archive, **Bot** controls slopbot and the storage settings. The feature lives in [`media-scrape/`](../media-scrape/README.md) and touches the daemon at three points: the Web UI route mount, the MCP tool registration, and the message shape in the core. ## Command ``` @puppet scrape media # index links (default) @puppet scrape media download # also download attachment bytes @puppet scrape media status @puppet scrape media stop ``` - The target is the channel the command is sent in. Group DMs and guild channels work. - The command needs the `trusted` tier. `stop` and `download` needs `admin`. - `scrape media` with no argument re-runs the last scrape state; near the storage limit it turns into the confirmation (see Retention). - The reply follows the response policy. The job runs in the background. ## Backends The walk runs through one of two backends. The daemon picks automatically; a scrape can pin one. | | slopbot (bot account) | puppet (user account) | | --- | --- | --- | | Guild channels | Yes. Preferred. | Fallback when slopbot is not in the guild. | | Group DMs and DMs | **No.** Discord bots cannot see them. | Yes. The only option. | | Transport | Official bot REST via discord.js | The validated core bridge (`message.fetch`) | | Rate limits | Documented, per-route buckets, handled by discord.js | Undocumented; daemon pacing and backoff | | Requires the Electron client | No | Yes | See [slopbot.md](slopbot.md) and [media-scraper.md](media-scraper.md). ## Storage Runtime data lives under `media-scrape/db/`, ignored by git and configurable with `VESKSLOP_MEDIA_DB_DIR`. Each scrape is self-contained: ``` media-scrape/db/ / # - scrape.json # channel, guild, mode, state, cursor, counters index.db # SQLite metadata index media/ # downloaded bytes, only in download mode . thumbs/ # cached Discord-hosted image previews .webp ``` - The database is an index. It holds the scrape state, the messages seen, the media items, and the link between a media item and its source messages. Source channel, author, and timestamp are indexed so filters can be added later without a re-scrape. - Scrapes stay self-contained, but the API and UI **deduplicate by URL across scrapes**: one card per URL, the newest scrape wins; stats count unique URLs too. Re-scraping a channel does not duplicate anything in the grid or the counters. (Files are still stored per scrape, so the on-disk size doubles with overlapping scrapes; a shared content store is a possible later step.) - Storage uses `node:sqlite` (experimental in Node 22, prints a warning). If its API changes, `better-sqlite3` is the fallback; it needs a native build, so that switch is an owner install. - The file name in `media/` is the SHA-256 of the bytes, never the original name. The original name is metadata. - Deleting a scrape is deleting its directory. Nothing is shared between scrapes. ## Index versus download - `links` is the default. The archive stores the Discord CDN URL and the metadata. Nothing large is downloaded. - `download` fetches attachment bytes into `media/`, one file at a time, hash-addressed. Embed rows are never download targets. The Media tab prefers the local file when one exists, so the archive survives CDN URL expiry. - A custom download destination sends the bytes to `//media/` instead. Previews stay local, and embed thumbnails are copied to `//thumbnails/` so the download has a poster. Files in a custom destination are not served or linked by the Media tab; the user owns that path. - Third-party embeds are **never downloaded as media**: YouTube, Instagram, X, TikTok, and every other embed stays a link. Its thumbnail is cached (below); the video itself is never fetched. - Previews are cached in `thumbs/` after every walk, in both modes: Discord-hosted images, GIFs, and video files (the proxy renders a frame, resized to 320px WebP) plus third-party embed thumbnails (YouTube, X, TikTok, and so on). The stored bytes keep their real type (`preview_ext`), so JPEG provider thumbnails are served as `image/jpeg`, not mislabeled WebP. Embeds without a thumbnail in Discord's data are resolved through the provider's oEmbed/fxtwitter API and finally the page's `og:image`. Previews count toward the storage limit. - The grid never renders an embed's page URL as media. A direct video file without a preview shows a first-frame `