Rework the media grid with a viewer, metadata banners, and load gate

This commit is contained in:
Fisto
2026-09-16 12:29:45 +02:00
parent fae754fba0
commit f9b4041157
8 changed files with 604 additions and 52 deletions
+6
View File
@@ -40,6 +40,12 @@ mobile draft at 390px.
- [x] C11 preview caching: Discord-hosted image/GIF previews (WebP 320px, hash-addressed, 5 MiB - [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`: cap, one attempt) run after every walk in both modes. Live run `...-20260916090722`:
`previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/thumb` (200). `previewed: 2`; thumbs served over `/api/media/<scrape>/<item>/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 - [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 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 confirm dialog has From/To dates plus a **Check Message Count** button; the walk honors
+8 -2
View File
@@ -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, - **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, 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). 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 Nothing renders until **Load media** is pressed. A **Metadata** dropdown toggles which fields
tile's tooltip shows the poster and posted time. A scrape strip shows live progress with Stop. 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 **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 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, dialog takes optional From/To dates and a **Check Message Count** button (Discord search total,
+2 -1
View File
@@ -257,12 +257,13 @@ replaceable.
| POST | `/api/audio/stop` | none | Stop speech and clear the queue | | POST | `/api/audio/stop` | none | Stop speech and clear the queue |
| POST | `/api/audio/capture` | `start` | Start or stop capture | | POST | `/api/audio/capture` | `start` | Start or stop capture |
| GET | `/api/transcript` | `limit?`, `sessionId?` | Sessions and captured segments | | 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/file` | none | The downloaded file, content-typed from its extension |
| GET | `/api/media/:scrapeId/:itemId/thumb` | none | The cached WebP preview | | 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/scrapes` | none | Every scrape with state and counters |
| GET | `/api/media/status` | none | Slopbot connection state and the scrape list | | 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/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 | | 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/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) | | 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) |
+52 -17
View File
@@ -102,6 +102,18 @@ export function uniqueScrapeId(root: string, channelId: string, now = new Date()
return id; 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 { export interface MediaRow {
id: number; id: number;
kind: MediaKind; kind: MediaKind;
@@ -369,22 +381,7 @@ export class ScrapeStore {
return row ?? null; return row ?? null;
} }
queryMedia( private mediaWhere(options: MediaFilterOptions): { where: string; values: Array<string | number> } {
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);
const conditions: string[] = []; const conditions: string[] = [];
const values: Array<string | number> = []; const values: Array<string | number> = [];
@@ -415,11 +412,49 @@ export class ScrapeStore {
const resolution = options.resolution ? resolutionCondition(options.resolution) : null; const resolution = options.resolution ? resolutionCondition(options.resolution) : null;
if (resolution) conditions.push(resolution); 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 const rows = this.db
.prepare(`SELECT * FROM media ${where} ORDER BY id DESC LIMIT ? OFFSET ?`) .prepare(`SELECT * FROM media ${where} ORDER BY id DESC LIMIT ? OFFSET ?`)
.all(...values, limit, offset); .all(...values, limit, offset);
return rows as unknown as MediaRow[]; 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
};
}
} }
+158
View File
@@ -78,6 +78,164 @@
text-decoration: none; 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 { .vk-media-card img {
width: 100%; width: 100%;
height: 100%; height: 100%;
+316 -29
View File
@@ -23,6 +23,12 @@
loadedChannels: false, loadedChannels: false,
authors: null, authors: null,
errors: [], errors: [],
loaded: false,
stats: null,
metaFields: JSON.parse(
localStorage.getItem("vk-media-meta-fields") ??
'["user","type","date","source","resolution"]'
),
filters: { filters: {
source: "", source: "",
channelId: "", channelId: "",
@@ -173,30 +179,57 @@
if (state.view === "media") renderToolbar(); 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) { async function loadMedia(reset) {
if (!state.loaded) return;
if (reset) { if (reset) {
state.items = []; state.items = [];
state.offset = 0; state.offset = 0;
} }
try { 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( 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.items = state.items.concat(items);
state.offset += items.length; state.offset += items.length;
setStatus(`${state.items.length} items loaded`);
} catch (error) { } catch (error) {
setStatus(`media load failed: ${error.message}`); setStatus(`media load failed: ${error.message}`);
}
renderGrid(); renderGrid();
return;
}
renderGrid();
void loadStats();
} }
async function startScrape(channelId, guildId, mode, downloadDir, after, before) { async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
@@ -323,32 +356,101 @@
if (state.view === "bot") renderErrors(); if (state.view === "bot") renderErrors();
} }
function renderGrid() { const META_FIELDS = [
grid.replaceChildren(); ["user", "User"],
for (const item of state.items) { ["type", "Type"],
const source = item.localUrl || item.url; ["date", "Date"],
const card = el("a", "vk-media-card"); ["source", "Source"],
card.href = source; ["resolution", "Resolution"],
card.target = "_blank"; ["length", "Length"]
card.rel = "noreferrer"; ];
const postedBy = [item.authorName, item.postedAt ? new Date(item.postedAt).toLocaleString() : null] /** "mp4 video", "webp image", "YouTube video", ... */
.filter(Boolean) function typeLabel(item) {
.join(" · "); if (item.source === "embed") return `${item.provider ?? "embed"} ${item.kind}`;
if (postedBy) card.title = postedBy;
if ((item.kind === "image" || item.kind === "gif") && /^(https?:|\/)/.test(source)) { 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"); const img = document.createElement("img");
img.loading = "lazy"; img.loading = "lazy";
img.src = item.thumbUrl || source; img.src = source;
img.alt = item.kind; img.alt = item.kind;
card.append(img); media.append(img);
} else { } else {
card.append(el("span", "vk-media-card-kind", item.kind.toUpperCase())); media.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 (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();
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) { function filterSelect(options, value, onChange) {
@@ -374,6 +476,36 @@
return input; 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() { function renderToolbar() {
toolbar.replaceChildren(); toolbar.replaceChildren();
if (state.view === "media") { if (state.view === "media") {
@@ -497,6 +629,7 @@
}) })
); );
toolbar.append(metaMenu());
toolbar.append(el("span", "vk-media-spacer")); toolbar.append(el("span", "vk-media-spacer"));
toolbar.append(button("History", openHistoryModal)); toolbar.append(button("History", openHistoryModal));
toolbar.append(button("New scrape…", openScrapeModal)); toolbar.append(button("New scrape…", openScrapeModal));
@@ -519,8 +652,10 @@
void loadScrapes().then(renderScrapes); void loadScrapes().then(renderScrapes);
void refreshErrors(); void refreshErrors();
} else { } else {
renderGrid();
void loadChannels().then(renderToolbar); void loadChannels().then(renderToolbar);
void loadAuthors().then(renderToolbar); void loadAuthors().then(renderToolbar);
void loadStats();
void loadMedia(true); void loadMedia(true);
void loadScrapes(); void loadScrapes();
} }
@@ -855,6 +990,158 @@
if (event.target === historyModal) historyModal.hidden = true; 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 = `
<div class="vk-media-modal-box vk-media-viewer-box">
<div class="vk-media-head">
<strong id="viewer-title">Preview</strong>
<span class="vk-media-spacer"></span>
<button id="viewer-x" class="btn btn-secondary vk-small-btn" type="button">X</button>
</div>
<div id="viewer-body" class="vk-media-viewer wide">
<div id="viewer-media" class="vk-media-viewer-media"></div>
<div id="viewer-meta" class="vk-media-viewer-meta"></div>
</div>
</div>
`;
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 = `
<div class="vk-media-modal-box">
<strong>Open external link?</strong>
<div id="link-target" class="vk-media-status"></div>
<div class="vk-media-status">This opens a new website in a new tab.</div>
<div class="vk-media-toolbar">
<button id="link-cancel" class="btn btn-secondary vk-small-btn" type="button">Cancel</button>
<button id="link-continue" class="btn btn-primary vk-small-btn" type="button">Continue</button>
</div>
</div>
`;
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 ---- // ---- Settings: media storage ----
function formatBytes(bytes) { function formatBytes(bytes) {
+36
View File
@@ -114,6 +114,8 @@ export interface MediaItemResult {
width: number | null; width: number | null;
height: number | null; height: number | null;
aspectRatio: number | null; aspectRatio: number | null;
contentType: string | null;
durationSecs: number | null;
url: string; url: string;
localUrl: string | null; localUrl: string | null;
thumbUrl: string | null; thumbUrl: string | null;
@@ -442,6 +444,38 @@ export class MediaService {
return count; 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. */ /** Distinct posters across every scrape, biggest first. */
listAuthors(): Array<{ id: string | null; name: string | null; count: number }> { listAuthors(): Array<{ id: string | null; name: string | null; count: number }> {
const totals = new Map<string, { id: string | null; name: string | null; count: number }>(); const totals = new Map<string, { id: string | null; name: string | null; count: number }>();
@@ -532,6 +566,8 @@ export class MediaService {
width: row.width, width: row.width,
height: row.height, height: row.height,
aspectRatio: row.aspect_ratio, aspectRatio: row.aspect_ratio,
contentType: row.content_type,
durationSecs: row.duration_secs,
url: row.url, url: row.url,
localUrl: localUrl:
row.file_path && !meta.downloadDir row.file_path && !meta.downloadDir
+23
View File
@@ -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( app.post(
"/api/media/count", "/api/media/count",
wrap(async (req, res) => { wrap(async (req, res) => {