From f9b404115741be4a57a4f8eedb6a380afeea45e8 Mon Sep 17 00:00:00 2001 From: Fisto Date: Wed, 16 Sep 2026 12:29:45 +0200 Subject: [PATCH] Rework the media grid with a viewer, metadata banners, and load gate --- CURRENT.md | 6 + docs/media.md | 10 +- docs/webui.md | 3 +- media-scrape/src/store.ts | 69 ++++++-- poc/public/media.css | 158 +++++++++++++++++ poc/public/media.js | 351 ++++++++++++++++++++++++++++++++++---- poc/src/media.ts | 36 ++++ poc/src/webui.ts | 23 +++ 8 files changed, 604 insertions(+), 52 deletions(-) diff --git a/CURRENT.md b/CURRENT.md index e3195c0..7431fd9 100644 --- a/CURRENT.md +++ b/CURRENT.md @@ -40,6 +40,12 @@ 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] C17 Media grid and viewer. Embed videos now render their cached thumbnail with a play + icon; a Metadata dropdown picks banner fields (User, Type, Date, Source, Resolution, Length); + nothing shows until Load media; the status row reads `downloaded A/B | cached C/D (excludes + downloaded) | N loaded` from `/api/media/stats`; clicking a tile opens a viewer with + wide-below / vertical-right metadata, full link with an external-site warning, View full, and + an embedded player for YouTube/Vimeo/Twitch/Streamable when possible. - [x] C16 Message counting and date-bounded scrapes. Discord search (`total_results`, one guild request, slopbot only) powers `POST /api/media/count` and MCP `count_media_messages`; the confirm dialog has From/To dates plus a **Check Message Count** button; the walk honors diff --git a/docs/media.md b/docs/media.md index 6e7281c..da6d6de 100644 --- a/docs/media.md +++ b/docs/media.md @@ -136,8 +136,14 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun - **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. + Nothing renders until **Load media** is pressed. A **Metadata** dropdown toggles which fields + show in the small banner under each tile (User, Type, Date, Source, Resolution, Length). Video + tiles carry a translucent play icon top-right. Clicking a tile opens a viewer: wide media shows + the metadata banner below, vertical media shows it to the right; the banner has the full link + (with an external-site warning) and **View full**, and the play action embeds a YouTube, Vimeo, + Twitch, or Streamable player when possible. The status row reads + `downloaded A/B (images/videos) | cached C/D (excludes downloaded) | N loaded`. 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 6d3d1b7..554d6e3 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -257,12 +257,13 @@ 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?`, `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` | `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`, `contentType`, `durationSecs`; `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/stats` | same filters as `/api/media` | Counts: `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/store.ts b/media-scrape/src/store.ts index c4799ca..cda336b 100644 --- a/media-scrape/src/store.ts +++ b/media-scrape/src/store.ts @@ -102,6 +102,18 @@ export function uniqueScrapeId(root: string, channelId: string, now = new Date() return id; } +export interface MediaFilterOptions { + kind?: MediaKind; + source?: string; + authorId?: string; + aspect?: string; + resolution?: string; + after?: string; + before?: string; + limit?: number; + offset?: number; +} + export interface MediaRow { id: number; kind: MediaKind; @@ -369,22 +381,7 @@ export class ScrapeStore { return row ?? null; } - queryMedia( - options: { - kind?: MediaKind; - source?: string; - authorId?: string; - aspect?: string; - resolution?: string; - after?: string; - before?: 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); - + private mediaWhere(options: MediaFilterOptions): { where: string; values: Array } { const conditions: string[] = []; const values: Array = []; @@ -415,11 +412,49 @@ export class ScrapeStore { const resolution = options.resolution ? resolutionCondition(options.resolution) : null; if (resolution) conditions.push(resolution); - const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""; + return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", values }; + } + + queryMedia(options: MediaFilterOptions = {}): MediaRow[] { + const limit = Math.max(1, Math.min(options.limit ?? 50, 500)); + const offset = Math.max(0, options.offset ?? 0); + const { where, values } = this.mediaWhere(options); + 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[]; } + + /** 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/poc/public/media.css b/poc/public/media.css index d01a059..e208ae4 100644 --- a/poc/public/media.css +++ b/poc/public/media.css @@ -78,6 +78,164 @@ text-decoration: none; } +.vk-media-figure { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + min-width: 0; +} + +.vk-media-media { + position: relative; + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + overflow: hidden; + border-radius: var(--radius-sm, 6px); + background: var(--color-neutral-900); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); + cursor: pointer; +} + +.vk-media-media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.vk-media-banner { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 400 10px/1.3 ui-monospace, Menlo, monospace; + color: var(--color-neutral-400); +} + +.vk-media-play { + position: absolute; + top: 4px; + right: 6px; + font-size: 20px; + line-height: 1; + color: rgba(255, 255, 255, 0.72); + text-shadow: 0 0 3px rgba(0, 0, 0, 0.8); + cursor: pointer; +} + +.vk-media-prompt { + grid-column: 1 / -1; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 40px 0; +} + +.vk-media-load { + padding: 10px 22px; + font-size: 13px; +} + +.vk-media-meta { + position: relative; + display: inline-flex; +} + +.vk-media-meta-menu { + position: absolute; + top: 140%; + left: 0; + z-index: 5; + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: var(--radius-sm, 6px); + background: var(--color-bg); + box-shadow: inset 0 0 0 1px var(--color-neutral-800); +} + +.vk-media-viewer-box { + width: min(1000px, 94vw); + height: min(86vh, 860px); +} + +.vk-media-viewer { + display: flex; + flex: 1; + gap: 12px; + min-height: 0; +} + +.vk-media-viewer.wide { + flex-direction: column; +} + +.vk-media-viewer.vertical { + flex-direction: row; +} + +.vk-media-viewer-media { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 0; +} + +.vk-media-viewer-media img, +.vk-media-viewer-media video { + max-width: 100%; + max-height: 100%; + border-radius: var(--radius-sm, 6px); +} + +.vk-media-viewer-media iframe { + width: min(880px, 100%); + aspect-ratio: 16 / 9; + border: 0; + border-radius: var(--radius-sm, 6px); +} + +.vk-media-viewer.vertical .vk-media-viewer-media { + max-width: 45%; +} + +.vk-media-viewer-meta { + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + font: 400 11px/1.5 ui-monospace, Menlo, monospace; + color: var(--color-neutral-300); +} + +.vk-media-viewer.vertical .vk-media-viewer-meta { + flex: 1; +} + +.vk-media-meta-key { + color: var(--color-neutral-500); +} + +.vk-media-link { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + padding: 0; + border: none; + background: none; + color: var(--color-accent-400, #7aa2ff); + font: inherit; + text-align: left; + cursor: pointer; +} + .vk-media-card img { width: 100%; height: 100%; diff --git a/poc/public/media.js b/poc/public/media.js index 1373301..556b79c 100644 --- a/poc/public/media.js +++ b/poc/public/media.js @@ -23,6 +23,12 @@ loadedChannels: false, authors: null, errors: [], + loaded: false, + stats: null, + metaFields: JSON.parse( + localStorage.getItem("vk-media-meta-fields") ?? + '["user","type","date","source","resolution"]' + ), filters: { source: "", channelId: "", @@ -173,30 +179,57 @@ if (state.view === "media") renderToolbar(); } + function filterQuery() { + const kind = state.kind === "all" ? "" : `kind=${state.kind}&`; + const source = state.filters.source ? `source=${state.filters.source}&` : ""; + const channel = state.filters.channelId ? `channelId=${state.filters.channelId}&` : ""; + const author = state.filters.authorId ? `authorId=${state.filters.authorId}&` : ""; + const aspect = state.filters.aspect ? `aspect=${state.filters.aspect}&` : ""; + const resolution = state.filters.resolution ? `resolution=${state.filters.resolution}&` : ""; + const after = state.filters.after ? `after=${state.filters.after}T00:00:00.000Z&` : ""; + const before = state.filters.before ? `before=${state.filters.before}T23:59:59.999Z&` : ""; + return `${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`; + } + + function renderStatus() { + const stats = state.stats ?? { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 }; + setStatus( + `downloaded ${stats.downloaded}/${stats.total} (images/videos) | ` + + `cached ${stats.cached}/${stats.notDownloaded} (excludes downloaded) | ${state.items.length} loaded` + ); + } + + async function loadStats() { + try { + state.stats = await window.VK.api.get(`/api/media/stats?${filterQuery()}`); + } catch { + state.stats = null; + } + renderStatus(); + } + async function loadMedia(reset) { + if (!state.loaded) return; + if (reset) { state.items = []; state.offset = 0; } + try { - const kind = state.kind === "all" ? "" : `&kind=${state.kind}`; - const source = state.filters.source ? `&source=${state.filters.source}` : ""; - const channel = state.filters.channelId ? `&channelId=${state.filters.channelId}` : ""; - const author = state.filters.authorId ? `&authorId=${state.filters.authorId}` : ""; - const aspect = state.filters.aspect ? `&aspect=${state.filters.aspect}` : ""; - const resolution = state.filters.resolution ? `&resolution=${state.filters.resolution}` : ""; - const after = state.filters.after ? `&after=${state.filters.after}T00:00:00.000Z` : ""; - const before = state.filters.before ? `&before=${state.filters.before}T23:59:59.999Z` : ""; const items = await window.VK.api.get( - `/api/media?limit=60&offset=${state.offset}${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}` + `/api/media?limit=60&offset=${state.offset}&${filterQuery()}` ); state.items = state.items.concat(items); state.offset += items.length; - setStatus(`${state.items.length} items loaded`); } catch (error) { setStatus(`media load failed: ${error.message}`); + renderGrid(); + return; } + renderGrid(); + void loadStats(); } async function startScrape(channelId, guildId, mode, downloadDir, after, before) { @@ -323,32 +356,101 @@ if (state.view === "bot") renderErrors(); } + const META_FIELDS = [ + ["user", "User"], + ["type", "Type"], + ["date", "Date"], + ["source", "Source"], + ["resolution", "Resolution"], + ["length", "Length"] + ]; + + /** "mp4 video", "webp image", "YouTube video", ... */ + function typeLabel(item) { + if (item.source === "embed") return `${item.provider ?? "embed"} ${item.kind}`; + + const match = /\.([a-z0-9]{2,5})$/i.exec((item.url ?? "").split("?")[0]); + const extension = (match?.[1] ?? "").toLowerCase(); + return extension ? `${extension} ${item.kind}` : item.kind; + } + + function metaValues(item) { + return { + user: item.authorName ?? "unknown", + type: typeLabel(item), + date: item.postedAt ? new Date(item.postedAt).toLocaleString() : "—", + source: item.provider ?? "discord", + resolution: item.width && item.height ? `${item.width}x${item.height}` : "—", + length: item.durationSecs ? `${item.durationSecs.toFixed(1)}s` : "—" + }; + } + + function metaBanner(item, fields, className) { + const values = metaValues(item); + const parts = fields.filter(field => field in values).map(field => values[field]); + if (!parts.length) return null; + return el(className ?? "figcaption", "vk-media-banner", parts.join(" · ")); + } + + /** Grid thumbnail source: cached preview, then the local file, then the original link. */ + function gridSource(item) { + return item.thumbUrl || item.localUrl || item.url; + } + + function renderCard(item) { + const figure = el("figure", "vk-media-figure"); + const media = el("div", "vk-media-media"); + const source = gridSource(item); + const visual = item.kind === "image" || item.kind === "gif" || item.kind === "video"; + + if (visual && /^(https?:|\/)/.test(source)) { + const img = document.createElement("img"); + img.loading = "lazy"; + img.src = source; + img.alt = item.kind; + media.append(img); + } else { + media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase())); + } + + if (item.kind === "video") { + const play = el("span", "vk-media-play", "▶"); + play.title = "Play"; + play.addEventListener("click", event => { + event.stopPropagation(); + openViewer(item, true); + }); + media.append(play); + } + + media.addEventListener("click", () => openViewer(item, false)); + figure.append(media); + + const banner = metaBanner(item, state.metaFields); + if (banner) figure.append(banner); + + return figure; + } + function renderGrid() { grid.replaceChildren(); - for (const item of state.items) { - const source = item.localUrl || item.url; - const card = el("a", "vk-media-card"); - card.href = source; - card.target = "_blank"; - card.rel = "noreferrer"; - const postedBy = [item.authorName, item.postedAt ? new Date(item.postedAt).toLocaleString() : null] - .filter(Boolean) - .join(" · "); - if (postedBy) card.title = postedBy; - - if ((item.kind === "image" || item.kind === "gif") && /^(https?:|\/)/.test(source)) { - const img = document.createElement("img"); - img.loading = "lazy"; - img.src = item.thumbUrl || source; - img.alt = item.kind; - card.append(img); - } else { - card.append(el("span", "vk-media-card-kind", item.kind.toUpperCase())); - card.append(el("span", "vk-media-card-name", item.provider || new URL(item.url).hostname)); - } - grid.append(card); + if (!state.loaded) { + const prompt = el("div", "vk-media-prompt"); + prompt.append(el("div", "vk-media-status", "Use the filters above to filter media.")); + const load = el("button", "btn btn-primary vk-media-load", "Load media"); + load.type = "button"; + load.addEventListener("click", () => { + state.loaded = true; + void loadMedia(true); + }); + prompt.append(load); + grid.append(prompt); + return; } + + for (const item of state.items) grid.append(renderCard(item)); + if (!state.items.length) grid.append(el("div", "vk-media-status", "No media matches the filters.")); } function filterSelect(options, value, onChange) { @@ -374,6 +476,36 @@ return input; } + /** Dropdown with the metadata-field checkboxes. */ + function metaMenu() { + const wrap = el("span", "vk-media-meta"); + const panel = el("div", "vk-media-meta-menu"); + panel.hidden = true; + + const toggle = button("Metadata", () => { + panel.hidden = !panel.hidden; + }); + + for (const [key, label] of META_FIELDS) { + const row = el("label", "vk-media-check"); + const box = document.createElement("input"); + box.type = "checkbox"; + box.checked = state.metaFields.includes(key); + box.addEventListener("change", () => { + state.metaFields = box.checked + ? [...new Set([...state.metaFields, key])] + : state.metaFields.filter(field => field !== key); + localStorage.setItem("vk-media-meta-fields", JSON.stringify(state.metaFields)); + renderGrid(); + }); + row.append(box, document.createTextNode(` ${label}`)); + panel.append(row); + } + + wrap.append(toggle, panel); + return wrap; + } + function renderToolbar() { toolbar.replaceChildren(); if (state.view === "media") { @@ -497,6 +629,7 @@ }) ); + toolbar.append(metaMenu()); toolbar.append(el("span", "vk-media-spacer")); toolbar.append(button("History", openHistoryModal)); toolbar.append(button("New scrape…", openScrapeModal)); @@ -519,8 +652,10 @@ void loadScrapes().then(renderScrapes); void refreshErrors(); } else { + renderGrid(); void loadChannels().then(renderToolbar); void loadAuthors().then(renderToolbar); + void loadStats(); void loadMedia(true); void loadScrapes(); } @@ -855,6 +990,158 @@ if (event.target === historyModal) historyModal.hidden = true; }); + // ---- Media viewer ---- + + function embedUrlFor(item) { + if (item.source !== "embed") return null; + + try { + const url = new URL(item.url); + const host = url.hostname.replace(/^www\./, ""); + + if (host === "youtube.com" || host === "m.youtube.com") { + const id = url.searchParams.get("v"); + if (id) return `https://www.youtube.com/embed/${id}`; + } + if (host === "youtu.be") return `https://www.youtube.com/embed${url.pathname}`; + if (host === "vimeo.com") return `https://player.vimeo.com/video${url.pathname}`; + if (host === "clips.twitch.tv") { + return `https://player.twitch.tv/?clip=${url.pathname.slice(1)}&parent=${location.hostname}`; + } + if (host === "streamable.com") return `https://streamable.com/e${url.pathname}`; + } catch { + // not embeddable + } + + return null; + } + + const viewerModal = document.createElement("div"); + viewerModal.className = "vk-media-modal"; + viewerModal.hidden = true; + viewerModal.innerHTML = ` +
+
+ Preview + + +
+
+
+
+
+
+ `; + document.body.append(viewerModal); + + function openViewer(item, play) { + const body = viewerModal.querySelector("#viewer-body"); + const media = viewerModal.querySelector("#viewer-media"); + const meta = viewerModal.querySelector("#viewer-meta"); + const vertical = item.aspectRatio != null && item.aspectRatio < 1; + + viewerModal.querySelector("#viewer-title").textContent = typeLabel(item); + body.className = `vk-media-viewer ${vertical ? "vertical" : "wide"}`; + media.replaceChildren(); + + const source = item.localUrl || item.thumbUrl || item.url; + const embed = play ? embedUrlFor(item) : null; + + if (embed) { + const frame = document.createElement("iframe"); + frame.src = embed; + frame.allow = "autoplay; fullscreen; picture-in-picture"; + frame.allowFullscreen = true; + media.append(frame); + } else if (item.kind === "video" && /^(https?:|\/)/.test(source)) { + if (play) { + const video = document.createElement("video"); + video.controls = true; + video.autoplay = true; + video.src = source; + media.append(video); + } else { + const img = document.createElement("img"); + img.src = source; + img.alt = item.kind; + media.append(img); + media.append(button("Play", () => openViewer(item, true))); + } + } else if (/^(https?:|\/)/.test(source)) { + const img = document.createElement("img"); + img.src = source; + img.alt = item.kind; + media.append(img); + } else { + media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase())); + } + + meta.replaceChildren(); + const values = metaValues(item); + for (const [key, label] of META_FIELDS) { + const line = el("div", "vk-media-meta-line"); + line.append(el("span", "vk-media-meta-key", `${label}: `)); + line.append(el("span", null, values[key])); + meta.append(line); + } + + const link = el("button", "vk-media-link", item.url); + link.type = "button"; + link.title = "Open the original link"; + link.addEventListener("click", () => openLinkWarning(item.url)); + meta.append(link); + + meta.append( + button("View full", () => window.open(item.localUrl || item.url, "_blank", "noreferrer")) + ); + + viewerModal.hidden = false; + } + + viewerModal.querySelector("#viewer-x").addEventListener("click", () => { + viewerModal.hidden = true; + }); + viewerModal.addEventListener("click", event => { + if (event.target === viewerModal) viewerModal.hidden = true; + }); + + // ---- External link warning ---- + + const linkModal = document.createElement("div"); + linkModal.className = "vk-media-modal"; + linkModal.hidden = true; + linkModal.innerHTML = ` +
+ Open external link? + +
This opens a new website in a new tab.
+
+ + +
+
+ `; + document.body.append(linkModal); + + let pendingLink = null; + + function openLinkWarning(url) { + pendingLink = url; + linkModal.querySelector("#link-target").textContent = url; + linkModal.hidden = false; + } + + linkModal.querySelector("#link-cancel").addEventListener("click", () => { + linkModal.hidden = true; + }); + linkModal.querySelector("#link-continue").addEventListener("click", () => { + if (pendingLink) window.open(pendingLink, "_blank", "noreferrer"); + linkModal.hidden = true; + }); + linkModal.addEventListener("click", event => { + if (event.target === linkModal) linkModal.hidden = true; + }); + // ---- Settings: media storage ---- function formatBytes(bytes) { diff --git a/poc/src/media.ts b/poc/src/media.ts index 2c235bf..ca2b1b2 100644 --- a/poc/src/media.ts +++ b/poc/src/media.ts @@ -114,6 +114,8 @@ export interface MediaItemResult { width: number | null; height: number | null; aspectRatio: number | null; + contentType: string | null; + durationSecs: number | null; url: string; localUrl: string | null; thumbUrl: string | null; @@ -442,6 +444,38 @@ export class MediaService { return count; } + /** Item counts for the status row, under the same filters as queryMedia. */ + stats(options: { + kind?: MediaKind; + source?: string; + channelId?: string; + authorId?: string; + aspect?: string; + resolution?: string; + after?: string; + before?: string; + } = {}): { total: number; downloaded: number; cached: number; notDownloaded: number } { + const totals = { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 }; + + for (const meta of this.engine.list()) { + if (meta.state === "deleted") continue; + if (options.channelId && meta.channelId !== options.channelId) continue; + + const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id)); + try { + const stats = store.stats(options); + totals.total += stats.total; + totals.downloaded += stats.downloaded; + totals.cached += stats.cached; + totals.notDownloaded += stats.notDownloaded; + } finally { + store.close(); + } + } + + return totals; + } + /** Distinct posters across every scrape, biggest first. */ listAuthors(): Array<{ id: string | null; name: string | null; count: number }> { const totals = new Map(); @@ -532,6 +566,8 @@ export class MediaService { width: row.width, height: row.height, aspectRatio: row.aspect_ratio, + contentType: row.content_type, + durationSecs: row.duration_secs, url: row.url, localUrl: row.file_path && !meta.downloadDir diff --git a/poc/src/webui.ts b/poc/src/webui.ts index b57db0d..844fcf0 100644 --- a/poc/src/webui.ts +++ b/poc/src/webui.ts @@ -832,6 +832,29 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle { }) ); + app.get( + "/api/media/stats", + wrap(async (req, res) => { + const kind = ["image", "gif", "video", "audio"].includes(String(req.query.kind ?? "")) + ? (String(req.query.kind) as "image" | "gif" | "video" | "audio") + : undefined; + + res.json({ + ok: true, + result: deps.media.stats({ + kind, + source: req.query.source ? String(req.query.source) : undefined, + channelId: req.query.channelId ? String(req.query.channelId) : undefined, + authorId: req.query.authorId ? String(req.query.authorId) : undefined, + aspect: req.query.aspect ? String(req.query.aspect) : undefined, + resolution: req.query.resolution ? String(req.query.resolution) : undefined, + after: req.query.after ? String(req.query.after) : undefined, + before: req.query.before ? String(req.query.before) : undefined + }) + }); + }) + ); + app.post( "/api/media/count", wrap(async (req, res) => {