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
|
- [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
@@ -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
@@ -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
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
+319
-32
@@ -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();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderGrid();
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function renderGrid() {
|
||||||
grid.replaceChildren();
|
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]
|
if (!state.loaded) {
|
||||||
.filter(Boolean)
|
const prompt = el("div", "vk-media-prompt");
|
||||||
.join(" · ");
|
prompt.append(el("div", "vk-media-status", "Use the filters above to filter media."));
|
||||||
if (postedBy) card.title = postedBy;
|
const load = el("button", "btn btn-primary vk-media-load", "Load media");
|
||||||
|
load.type = "button";
|
||||||
if ((item.kind === "image" || item.kind === "gif") && /^(https?:|\/)/.test(source)) {
|
load.addEventListener("click", () => {
|
||||||
const img = document.createElement("img");
|
state.loaded = true;
|
||||||
img.loading = "lazy";
|
void loadMedia(true);
|
||||||
img.src = item.thumbUrl || source;
|
});
|
||||||
img.alt = item.kind;
|
prompt.append(load);
|
||||||
card.append(img);
|
grid.append(prompt);
|
||||||
} else {
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user