Rework the media grid with a viewer, metadata banners, and load gate
This commit is contained in:
@@ -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/<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
|
||||
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
|
||||
|
||||
+8
-2
@@ -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,
|
||||
|
||||
+2
-1
@@ -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) |
|
||||
|
||||
+52
-17
@@ -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<string | number> } {
|
||||
const conditions: string[] = [];
|
||||
const values: Array<string | number> = [];
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
+316
-29
@@ -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();
|
||||
}
|
||||
|
||||
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 META_FIELDS = [
|
||||
["user", "User"],
|
||||
["type", "Type"],
|
||||
["date", "Date"],
|
||||
["source", "Source"],
|
||||
["resolution", "Resolution"],
|
||||
["length", "Length"]
|
||||
];
|
||||
|
||||
const postedBy = [item.authorName, item.postedAt ? new Date(item.postedAt).toLocaleString() : null]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
if (postedBy) card.title = postedBy;
|
||||
/** "mp4 video", "webp image", "YouTube video", ... */
|
||||
function typeLabel(item) {
|
||||
if (item.source === "embed") return `${item.provider ?? "embed"} ${item.kind}`;
|
||||
|
||||
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");
|
||||
img.loading = "lazy";
|
||||
img.src = item.thumbUrl || source;
|
||||
img.src = source;
|
||||
img.alt = item.kind;
|
||||
card.append(img);
|
||||
media.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));
|
||||
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||
}
|
||||
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) {
|
||||
@@ -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 = `
|
||||
<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 ----
|
||||
|
||||
function formatBytes(bytes) {
|
||||
|
||||
@@ -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<string, { id: string | null; name: string | null; count: number }>();
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user