diff --git a/CURRENT.md b/CURRENT.md index eaca4cb..b465f58 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,10 @@ 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] C12 Media tab filters and full-window layout: kind chips, source filter + (attachment/sticker/embed), source-channel filter, and the view now fills the window below the + header and call strip. API/MCP gained `source` and `channelId` on `query_media`. Smoke-tested + over `/api/media?source=` and `?channelId=`. - [~] C9 live verification. Slopbot path verified live on 2026-09-16: login Slopbot#5775, guild `177931418704281600`. Test channel links scrape: 17 messages, 4 images + 13 videos, 0 errors; download scrape fetched the 784 KB attachment. Large channel `gernal_chut`: 11 pages, 1088 diff --git a/docs/daemon.md b/docs/daemon.md index 6e7677b..134f4fe 100644 --- a/docs/daemon.md +++ b/docs/daemon.md @@ -83,7 +83,7 @@ The daemon exposes MCP over Streamable HTTP. The default address is `http://127. | `get_media_scrape` | One scrape by ID. | | `stop_media_scrape` | Stop a scrape; it pauses after the current page. | | `get_media_storage` | Usage against the limit, per-kind sizes, and the scrape list. | -| `query_media` | Media items across scrapes (`kind?`, `scrapeId?`, `limit?`, `offset?`). | +| `query_media` | Media items across scrapes (`kind?`, `source?`, `scrapeId?`, `channelId?`, `limit?`, `offset?`). | | `get_media_item` | One media item by `scrapeId` and `itemId`. | | `delete_media_scrape` | Delete a scrape and its files. Irreversible. | | `run_commands` | Run several read-only tools in one call, in order. | diff --git a/docs/media-mcp.md b/docs/media-mcp.md index cc11340..25c2ae0 100644 --- a/docs/media-mcp.md +++ b/docs/media-mcp.md @@ -23,7 +23,7 @@ interface MediaService { } ``` -`MediaQuery` (implemented): `kind`, `scrapeId`, `limit`, `offset`. Planned: `channelId`, +`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `limit`, `offset`. Planned: `guildId`, `authorId`, `after`, `before`, `text`. `StorageStatus`: `usedBytes`, `limitBytes`, `warnPercent`, `overrunPercent`, `byKind`, diff --git a/docs/media.md b/docs/media.md index d6863da..d6057f8 100644 --- a/docs/media.md +++ b/docs/media.md @@ -129,9 +129,11 @@ 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), source channel, date, and text - search. Grid with lazy loading; click opens a lightbox with the source message link, the - original URL, and a download button. A scrape-status strip shows progress with Start and Stop. +- **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. - **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. diff --git a/docs/webui.md b/docs/webui.md index a39e02f..7c98512 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -121,11 +121,14 @@ box live in Chat, not Call, and the connection stats are not chips yet. ## Media and Bot -- **Media** and **Bot** are scope buttons beside Guild and DM. They open overlays over the shell. - Mobile is out of scope for the media feature. -- Media lists scraped items with kind chips (All, Images, GIFs, Videos, Audio), a channel picker - with **Start links** / **Start download**, and the active scrapes with **Stop**. Images and GIFs - render from their Discord CDN URLs; videos and audio are link cards. +- **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. - 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). @@ -249,7 +252,7 @@ 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?`, `limit?`, `offset?` | Media items across every scrape, newest first; `localUrl` when a file was downloaded | +| 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/: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 | diff --git a/media-scrape/src/store.ts b/media-scrape/src/store.ts index 4fcab84..4b902ff 100644 --- a/media-scrape/src/store.ts +++ b/media-scrape/src/store.ts @@ -320,17 +320,27 @@ export class ScrapeStore { return row ?? null; } - queryMedia(options: { kind?: MediaKind; limit?: number; offset?: number } = {}): MediaRow[] { + queryMedia( + options: { kind?: MediaKind; source?: string; limit?: number; offset?: number } = {} + ): MediaRow[] { const limit = Math.max(1, Math.min(options.limit ?? 50, 500)); const offset = Math.max(0, options.offset ?? 0); - const statement = options.kind - ? this.db.prepare("SELECT * FROM media WHERE kind = ? ORDER BY id DESC LIMIT ? OFFSET ?") - : this.db.prepare("SELECT * FROM media ORDER BY id DESC LIMIT ? OFFSET ?"); + const conditions: string[] = []; + const values: Array = []; + if (options.kind) { + conditions.push("kind = ?"); + values.push(options.kind); + } + if (options.source) { + conditions.push("source = ?"); + values.push(options.source); + } - const rows = options.kind - ? statement.all(options.kind, limit, offset) - : statement.all(limit, offset); + const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + const rows = this.db + .prepare(`SELECT * FROM media ${where} ORDER BY id DESC LIMIT ? OFFSET ?`) + .all(...values, limit, offset); return rows as unknown as MediaRow[]; } diff --git a/media-scrape/test/run.ts b/media-scrape/test/run.ts index 2347678..b39c55c 100644 --- a/media-scrape/test/run.ts +++ b/media-scrape/test/run.ts @@ -396,6 +396,36 @@ void (async () => { assert.equal(existsSync(join(root, started.id, row.file_path)), true); }); + await test("store filters by source and kind", () => { + const dir = join(root, "store-source"); + mkdirSync(dir, { recursive: true }); + + const store = new ScrapeStore(dir); + store.insertPage([ + message("700", { + attachments: [attachment({ filename: "pic.png", contentType: "image/png" })], + embeds: [ + { + type: "video", + url: "https://www.youtube.com/watch?v=x", + title: null, + provider: "YouTube", + videoUrl: "https://www.youtube.com/embed/x", + imageUrl: null, + thumbnailUrl: null + } + ] + }) + ]); + + assert.equal(store.queryMedia({ source: "attachment" }).length, 1); + assert.equal(store.queryMedia({ source: "embed" }).length, 1); + assert.equal(store.queryMedia({ source: "sticker" }).length, 0); + assert.equal(store.queryMedia({ kind: "image", source: "attachment" }).length, 1); + assert.equal(store.queryMedia({ kind: "video", source: "attachment" }).length, 0); + store.close(); + }); + await test("preview urls are Discord-only resizes", () => { assert.equal( previewUrlFor({ url: "https://cdn.discordapp.com/attachments/1/2/pic.png", kind: "image" }), diff --git a/poc/public/media.css b/poc/public/media.css index 237d970..341a84d 100644 --- a/poc/public/media.css +++ b/poc/public/media.css @@ -2,10 +2,13 @@ .vk-media-overlay { position: fixed; - inset: 0; + left: 0; + right: 0; + top: var(--media-top, 50px); + bottom: 0; z-index: 90; display: flex; - background: color-mix(in srgb, var(--color-neutral-900) 70%, transparent); + background: var(--color-bg); } .vk-media-overlay[hidden] { @@ -16,13 +19,14 @@ display: flex; flex-direction: column; gap: 10px; - width: min(1100px, 100%); - margin: 24px auto; - padding: 14px; + width: 100%; + height: 100%; + margin: 0; + padding: 14px 18px; overflow: hidden; - border-radius: var(--radius-lg, 12px); + border-radius: 0; background: var(--color-bg); - box-shadow: inset 0 0 0 1px var(--color-neutral-800); + box-shadow: none; } .vk-media-head { diff --git a/poc/public/media.js b/poc/public/media.js index 22e3fa1..6634962 100644 --- a/poc/public/media.js +++ b/poc/public/media.js @@ -18,7 +18,8 @@ items: [], scrapes: [], channels: [], - loadedChannels: false + loadedChannels: false, + filters: { source: "", channelId: "" } }; const overlay = document.createElement("div"); @@ -63,8 +64,18 @@ return node; } + /** Start the view below the header and the call strip, filling the rest. */ + function layoutTop() { + const header = document.querySelector(".vk-header"); + const callStrip = document.querySelector("#call-strip"); + let top = header ? header.getBoundingClientRect().bottom : 0; + if (callStrip && !callStrip.hidden) top = Math.max(top, callStrip.getBoundingClientRect().bottom); + overlay.style.setProperty("--media-top", `${Math.round(top)}px`); + } + function show(view) { state.view = view; + layoutTop(); overlay.hidden = false; document.querySelector("#scope-media")?.classList.toggle("active", view === "media"); document.querySelector("#scope-bot")?.classList.toggle("active", view === "bot"); @@ -128,6 +139,7 @@ setStatus(`media status failed: ${error.message}`); } renderScrapes(); + if (state.view === "media") renderToolbar(); } async function loadMedia(reset) { @@ -137,7 +149,11 @@ } try { const kind = state.kind === "all" ? "" : `&kind=${state.kind}`; - const items = await window.VK.api.get(`/api/media?limit=60&offset=${state.offset}${kind}`); + const source = state.filters.source ? `&source=${state.filters.source}` : ""; + const channel = state.filters.channelId ? `&channelId=${state.filters.channelId}` : ""; + const items = await window.VK.api.get( + `/api/media?limit=60&offset=${state.offset}${kind}${source}${channel}` + ); state.items = state.items.concat(items); state.offset += items.length; setStatus(`${state.items.length} items loaded`); @@ -236,6 +252,50 @@ toolbar.append(button("Start links", () => void startScrape("links"))); toolbar.append(button("Start download", () => void startScrape("download"))); + + const sourceSelect = document.createElement("select"); + sourceSelect.className = "vk-chip vk-chip-off"; + for (const [value, label] of [ + ["", "Any source"], + ["attachment", "Attachments"], + ["sticker", "Stickers"], + ["embed", "Embeds"] + ]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + sourceSelect.append(option); + } + sourceSelect.value = state.filters.source; + sourceSelect.addEventListener("change", () => { + state.filters.source = sourceSelect.value; + void loadMedia(true); + }); + toolbar.append(sourceSelect); + + const channelSelect = document.createElement("select"); + channelSelect.className = "vk-chip vk-chip-off"; + const anyChannel = document.createElement("option"); + anyChannel.value = ""; + anyChannel.textContent = "Any channel"; + channelSelect.append(anyChannel); + + const seen = new Set(); + for (const scrape of state.scrapes) { + if (seen.has(scrape.channelId)) continue; + seen.add(scrape.channelId); + const option = document.createElement("option"); + option.value = scrape.channelId; + option.textContent = + state.channels.find(channel => channel.id === scrape.channelId)?.label ?? scrape.channelId; + channelSelect.append(option); + } + channelSelect.value = state.filters.channelId; + channelSelect.addEventListener("change", () => { + state.filters.channelId = channelSelect.value; + void loadMedia(true); + }); + toolbar.append(channelSelect); } else { toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md.")); } @@ -436,6 +496,17 @@ document.querySelector("#media-clean")?.addEventListener("click", openCleanModal); + window.addEventListener("resize", () => { + if (state.view) layoutTop(); + }); + + const callStrip = document.querySelector("#call-strip"); + if (callStrip) { + new MutationObserver(() => { + if (state.view) layoutTop(); + }).observe(callStrip, { attributes: true, attributeFilter: ["hidden"] }); + } + const settingsOverlay = document.querySelector("#settings-overlay"); if (settingsOverlay) { new MutationObserver(() => { diff --git a/poc/src/control-bridge.ts b/poc/src/control-bridge.ts index 0b9133d..2757e80 100644 --- a/poc/src/control-bridge.ts +++ b/poc/src/control-bridge.ts @@ -823,13 +823,18 @@ function buildServer(): McpServer { description: "Query media items across scrapes, newest scrape first.", inputSchema: { kind: z.enum(["image", "gif", "video", "audio"]).optional(), + source: z + .enum(["attachment", "sticker", "embed"]) + .optional() + .describe("Where the item came from"), scrapeId: z.string().optional().describe("Restrict to one scrape"), + channelId: z.string().optional().describe("Restrict to one source channel"), limit: z.number().int().min(1).max(500).optional(), offset: z.number().int().min(0).optional() } }, - async ({ kind, scrapeId, limit, offset }) => - jsonText(media.queryMedia({ kind, scrapeId, limit, offset })) + async ({ kind, source, scrapeId, channelId, limit, offset }) => + jsonText(media.queryMedia({ kind, source, scrapeId, channelId, limit, offset })) ); server.registerTool( diff --git a/poc/src/media.ts b/poc/src/media.ts index 7c751aa..0c0efeb 100644 --- a/poc/src/media.ts +++ b/poc/src/media.ts @@ -92,6 +92,7 @@ export interface MediaItemResult { itemId: number; channelId: string; kind: MediaKind; + source: string | null; url: string; localUrl: string | null; thumbUrl: string | null; @@ -352,7 +353,14 @@ export class MediaService { /** Read media rows across every scrape, newest scrape first. */ queryMedia( - options: { kind?: MediaKind; scrapeId?: string; limit?: number; offset?: number } = {} + options: { + kind?: MediaKind; + source?: string; + scrapeId?: string; + channelId?: string; + limit?: number; + offset?: number; + } = {} ): MediaItemResult[] { const limit = Math.max(1, Math.min(options.limit ?? 60, 500)); const offset = Math.max(0, options.offset ?? 0); @@ -362,10 +370,15 @@ export class MediaService { for (const meta of this.engine.list()) { if (meta.state === "deleted") continue; if (options.scrapeId && meta.id !== options.scrapeId) continue; + if (options.channelId && meta.channelId !== options.channelId) continue; const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id)); try { - const rows = store.queryMedia({ kind: options.kind, limit: limit + offset }); + const rows = store.queryMedia({ + kind: options.kind, + source: options.source, + limit: limit + offset + }); for (const row of rows) { if (skipped++ < offset) continue; if (results.length >= limit) break; @@ -374,6 +387,7 @@ export class MediaService { itemId: row.id, channelId: meta.channelId, kind: row.kind, + source: row.source, url: row.url, localUrl: row.file_path ? `/api/media/${meta.id}/${row.id}/file` : null, thumbUrl: row.preview_hash ? `/api/media/${meta.id}/${row.id}/thumb` : null, diff --git a/poc/src/webui.ts b/poc/src/webui.ts index d41beae..7053a8e 100644 --- a/poc/src/webui.ts +++ b/poc/src/webui.ts @@ -795,10 +795,14 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle { const kind = ["image", "gif", "video", "audio"].includes(String(req.query.kind ?? "")) ? (String(req.query.kind) as "image" | "gif" | "video" | "audio") : undefined; + const source = ["attachment", "sticker", "embed"].includes(String(req.query.source ?? "")) + ? String(req.query.source) + : undefined; + const channelId = req.query.channelId ? String(req.query.channelId) : undefined; const limit = clampLimit(req.query.limit, 60, 500); const offset = Math.max(0, Number(req.query.offset ?? 0) || 0); - res.json({ ok: true, result: deps.media.queryMedia({ kind, limit, offset }) }); + res.json({ ok: true, result: deps.media.queryMedia({ kind, source, channelId, limit, offset }) }); }) );