Add the advanced search drawer, adaptive paging, and zoom
This commit is contained in:
@@ -40,6 +40,14 @@ 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] C37 Advanced search drawer, adaptive paging, and zoom. Filters moved into an **Advanced
|
||||||
|
search** drawer with Apply/Reset (no auto-update): filetype chips, source kind, origin
|
||||||
|
(server/DM), multi-select People, and an Advanced section (aspect, resolution, channel, dates,
|
||||||
|
**cachedOnly**). A **Card size** slider scales tiles from 100 px (2/3 of current) to one image
|
||||||
|
per row; page size = full rows about 1.5 screens tall and follows zoom/resize. Infinite scroll
|
||||||
|
preloads the next page's JSON and previews. Backend gained `authorIds`, `origin`, `cachedOnly`
|
||||||
|
on `/api/media`, `/api/media/stats`, and MCP `query_media`. Verified: `origin=dm` and
|
||||||
|
`cachedOnly=1` return the expected rows.
|
||||||
- [x] C36 Daemon restart, preview diagnostics, browse-time caching, and rate-limit pauses.
|
- [x] C36 Daemon restart, preview diagnostics, browse-time caching, and rate-limit pauses.
|
||||||
Settings → Server has a **Restart daemon** button (`POST /api/daemon/restart`: re-execs the
|
Settings → Server has a **Restart daemon** button (`POST /api/daemon/restart`: re-execs the
|
||||||
daemon, keeps the client running). The preview pass logs every failure with scrape/item/kind/
|
daemon, keeps the client running). The preview pass logs every failure with scrape/item/kind/
|
||||||
|
|||||||
+4
-4
@@ -23,10 +23,10 @@ interface MediaService {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `authorId`, `aspect`,
|
`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `authorIds`, `origin`
|
||||||
`resolution`, `after`, `before`, `limit`, `offset`. The `aspect` and `resolution` values are the
|
(`guild`/`dm`), `cachedOnly`, `aspect`, `resolution`, `after`, `before`, `limit`, `offset`. The
|
||||||
preset keys from `filter-presets.ts`; `after`/`before` are ISO times on `posted_at`. Planned:
|
`aspect` and `resolution` values are the preset keys from `filter-presets.ts`; `after`/`before` are
|
||||||
`guildId`, `text`.
|
ISO times on `posted_at`. Planned: `guildId`, `text`.
|
||||||
|
|
||||||
`StorageStatus`: `usedBytes`, `limitBytes`, `warnPercent`, `overrunPercent`, `byKind`,
|
`StorageStatus`: `usedBytes`, `limitBytes`, `warnPercent`, `overrunPercent`, `byKind`,
|
||||||
`scrapes: [{ id, bytes, state, createdAt }]`.
|
`scrapes: [{ id, bytes, state, createdAt }]`.
|
||||||
|
|||||||
+12
-5
@@ -126,10 +126,17 @@ box live in Chat, not Call, and the connection stats are not chips yet.
|
|||||||
is visible, stay on screen). Settings slides over the media view, and dialogs that Settings
|
is visible, stay on screen). Settings slides over the media view, and dialogs that Settings
|
||||||
opens (for example **Clean media…**) render above Settings. Mobile is out of scope for the media
|
opens (for example **Clean media…**) render above Settings. Mobile is out of scope for the media
|
||||||
feature.
|
feature.
|
||||||
- Media lists scraped items with kind chips (All, Images, GIFs, Videos, Audio) and filters for
|
- Media lists scraped items. Filters live in an **Advanced search** drawer and do not apply until
|
||||||
source (any, attachments, stickers, embeds), source channel, poster, aspect ratio, resolution,
|
**Apply filters** is pressed: filetype chips, source kind (attachments/stickers/embeds), origin
|
||||||
and a posted-date range (from/to). **New scrape…** opens a popup with **Server** and **Group
|
(server channels or DMs), a multi-select **People** list, and an **Advanced** section with aspect
|
||||||
chat** tabs: servers expand into their text channels, each channel (and each group chat) has
|
ratio, resolution, source channel, posted-date range, and **Show only cached previews**.
|
||||||
|
**Reset** clears every filter and the zoom, unloads the grid, and brings back the initial
|
||||||
|
Load media prompt.
|
||||||
|
- A **Card size** slider scales tiles from two-thirds of the old size up to one image per row; the
|
||||||
|
page size follows the layout — full rows about 1.5 screens tall — and the grid infinite-scrolls,
|
||||||
|
preloading the next page's JSON and preview images just before they are needed.
|
||||||
|
**New scrape…** opens a popup with **Server** and **Group chat**
|
||||||
|
tabs: servers expand into their text channels, each channel (and each group chat) has
|
||||||
**Links** and **Download** buttons, and group chats always use the puppet. **History** lists
|
**Links** and **Download** buttons, and group chats always use the puppet. **History** lists
|
||||||
each scrape with start time, duration, pages, messages, media counts, bytes, files, and previews.
|
each scrape with start time, duration, pages, messages, media counts, bytes, files, and previews.
|
||||||
The scrape strip shows live progress with a pulse and **Stop**. Images and GIFs render from the
|
The scrape strip shows live progress with a pulse and **Stop**. Images and GIFs render from the
|
||||||
@@ -258,7 +265,7 @@ 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`, `contentType`, `durationSecs`, `previewError`; `localUrl`/`thumbUrl` when a file or preview exists |
|
| GET | `/api/media` | `kind?`, `source?`, `channelId?`, `authorId?` (comma list), `origin?` (`guild`/`dm`), `cachedOnly?`, `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`, `previewError`, `animUrl`; `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 preview (type from `preview_ext`) |
|
| GET | `/api/media/:scrapeId/:itemId/thumb` | none | The cached preview (type from `preview_ext`) |
|
||||||
| GET | `/api/media/:scrapeId/:itemId/stream` | none | Playback/full-view source: the local file, or the remote URL refreshed from the source message. Supports `Range`. Non-media upstream responses (provider pages) return `415` |
|
| GET | `/api/media/:scrapeId/:itemId/stream` | none | Playback/full-view source: the local file, or the remote URL refreshed from the source message. Supports `Range`. Non-media upstream responses (provider pages) return `415` |
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export interface MediaFilterOptions {
|
|||||||
kind?: MediaKind;
|
kind?: MediaKind;
|
||||||
source?: string;
|
source?: string;
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
|
authorIds?: string[];
|
||||||
|
cachedOnly?: boolean;
|
||||||
aspect?: string;
|
aspect?: string;
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
@@ -484,6 +486,13 @@ export class ScrapeStore {
|
|||||||
conditions.push("author_id = ?");
|
conditions.push("author_id = ?");
|
||||||
values.push(options.authorId);
|
values.push(options.authorId);
|
||||||
}
|
}
|
||||||
|
if (options.authorIds?.length) {
|
||||||
|
conditions.push(`author_id IN (${options.authorIds.map(() => "?").join(", ")})`);
|
||||||
|
values.push(...options.authorIds);
|
||||||
|
}
|
||||||
|
if (options.cachedOnly) {
|
||||||
|
conditions.push("preview_hash IS NOT NULL");
|
||||||
|
}
|
||||||
if (options.after) {
|
if (options.after) {
|
||||||
conditions.push("posted_at >= ?");
|
conditions.push("posted_at >= ?");
|
||||||
values.push(options.after);
|
values.push(options.after);
|
||||||
|
|||||||
@@ -136,6 +136,42 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vk-media-drawer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 46vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
background: var(--color-neutral-900);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--color-neutral-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vk-media-filter-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vk-media-people {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
max-height: 150px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vk-media-advanced summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font: 400 11px/1.4 ui-monospace, Menlo, monospace;
|
||||||
|
color: var(--color-neutral-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vk-media-zoom {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.vk-media-load-more {
|
.vk-media-load-more {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
justify-self: center;
|
justify-self: center;
|
||||||
|
|||||||
+361
-144
@@ -31,15 +31,11 @@
|
|||||||
localStorage.getItem("vk-media-meta-fields") ??
|
localStorage.getItem("vk-media-meta-fields") ??
|
||||||
'["user","type","date","source","resolution"]'
|
'["user","type","date","source","resolution"]'
|
||||||
),
|
),
|
||||||
filters: {
|
filters: defaultFilters(),
|
||||||
source: "",
|
zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50),
|
||||||
channelId: "",
|
draft: null,
|
||||||
authorId: "",
|
prefetched: null,
|
||||||
aspect: "",
|
pageSize: 60
|
||||||
resolution: "",
|
|
||||||
after: "",
|
|
||||||
before: ""
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const overlay = document.createElement("div");
|
const overlay = document.createElement("div");
|
||||||
@@ -66,6 +62,10 @@
|
|||||||
const scrapeList = overlay.querySelector("#media-scrapes");
|
const scrapeList = overlay.querySelector("#media-scrapes");
|
||||||
const grid = overlay.querySelector("#media-grid");
|
const grid = overlay.querySelector("#media-grid");
|
||||||
|
|
||||||
|
const filterDrawer = el("div", "vk-media-drawer");
|
||||||
|
filterDrawer.hidden = true;
|
||||||
|
toolbar.after(filterDrawer);
|
||||||
|
|
||||||
function setStatus(text) {
|
function setStatus(text) {
|
||||||
statusLine.textContent = text;
|
statusLine.textContent = text;
|
||||||
}
|
}
|
||||||
@@ -102,6 +102,8 @@
|
|||||||
document.querySelector("#scope-guild")?.classList.remove("active");
|
document.querySelector("#scope-guild")?.classList.remove("active");
|
||||||
document.querySelector("#scope-dm")?.classList.remove("active");
|
document.querySelector("#scope-dm")?.classList.remove("active");
|
||||||
|
|
||||||
|
if (view === "media") applyZoom();
|
||||||
|
|
||||||
if (view === "bot") {
|
if (view === "bot") {
|
||||||
localStorage.setItem("vk-media-errors-seen", String(errorCount));
|
localStorage.setItem("vk-media-errors-seen", String(errorCount));
|
||||||
updateErrorBadge();
|
updateErrorBadge();
|
||||||
@@ -188,12 +190,16 @@
|
|||||||
const kind = state.kind === "all" ? "" : `kind=${state.kind}&`;
|
const kind = state.kind === "all" ? "" : `kind=${state.kind}&`;
|
||||||
const source = state.filters.source ? `source=${state.filters.source}&` : "";
|
const source = state.filters.source ? `source=${state.filters.source}&` : "";
|
||||||
const channel = state.filters.channelId ? `channelId=${state.filters.channelId}&` : "";
|
const channel = state.filters.channelId ? `channelId=${state.filters.channelId}&` : "";
|
||||||
const author = state.filters.authorId ? `authorId=${state.filters.authorId}&` : "";
|
const authors = state.filters.authorIds.length
|
||||||
|
? `authorId=${state.filters.authorIds.join(",")}&`
|
||||||
|
: "";
|
||||||
|
const origin = state.filters.origin ? `origin=${state.filters.origin}&` : "";
|
||||||
|
const cached = state.filters.cachedOnly ? "cachedOnly=1&" : "";
|
||||||
const aspect = state.filters.aspect ? `aspect=${state.filters.aspect}&` : "";
|
const aspect = state.filters.aspect ? `aspect=${state.filters.aspect}&` : "";
|
||||||
const resolution = state.filters.resolution ? `resolution=${state.filters.resolution}&` : "";
|
const resolution = state.filters.resolution ? `resolution=${state.filters.resolution}&` : "";
|
||||||
const after = state.filters.after ? `after=${state.filters.after}T00:00:00.000Z&` : "";
|
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 before = state.filters.before ? `before=${state.filters.before}T23:59:59.999Z&` : "";
|
||||||
return `${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`;
|
return `${kind}${source}${channel}${authors}${origin}${cached}${aspect}${resolution}${after}${before}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function kindTotals(field) {
|
function kindTotals(field) {
|
||||||
@@ -233,8 +239,6 @@
|
|||||||
renderStatus();
|
renderStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 60;
|
|
||||||
|
|
||||||
async function loadMedia(reset) {
|
async function loadMedia(reset) {
|
||||||
if (!state.loaded || state.loading) return;
|
if (!state.loaded || state.loading) return;
|
||||||
|
|
||||||
@@ -243,15 +247,16 @@
|
|||||||
state.items = [];
|
state.items = [];
|
||||||
state.offset = 0;
|
state.offset = 0;
|
||||||
state.more = false;
|
state.more = false;
|
||||||
|
state.prefetched = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await window.VK.api.get(
|
const items = await window.VK.api.get(
|
||||||
`/api/media?limit=${PAGE_SIZE}&offset=${state.offset}&${filterQuery()}`
|
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
||||||
);
|
);
|
||||||
state.items = state.items.concat(items);
|
state.items = state.items.concat(items);
|
||||||
state.offset += items.length;
|
state.offset += items.length;
|
||||||
state.more = items.length >= PAGE_SIZE;
|
state.more = items.length >= state.pageSize;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`media load failed: ${error.message}`);
|
setStatus(`media load failed: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -260,6 +265,32 @@
|
|||||||
|
|
||||||
renderGrid();
|
renderGrid();
|
||||||
void loadStats();
|
void loadStats();
|
||||||
|
void prefetchNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Warm the next page's JSON and preview images before the user reaches them. */
|
||||||
|
async function prefetchNext() {
|
||||||
|
if (!state.loaded || !state.more || state.prefetched || state.loading) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await window.VK.api.get(
|
||||||
|
`/api/media?limit=${state.pageSize}&offset=${state.offset}&${filterQuery()}`
|
||||||
|
);
|
||||||
|
if (!items.length) {
|
||||||
|
state.more = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.prefetched = items;
|
||||||
|
for (const item of items) {
|
||||||
|
const src = item.thumbUrl || item.localUrl;
|
||||||
|
if (!src) continue;
|
||||||
|
const image = new Image();
|
||||||
|
image.src = src;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Preloading is best effort; the scroll handler falls back to a request.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
|
async function startScrape(channelId, guildId, mode, downloadDir, after, before) {
|
||||||
@@ -593,134 +624,304 @@
|
|||||||
|
|
||||||
function renderToolbar() {
|
function renderToolbar() {
|
||||||
toolbar.replaceChildren();
|
toolbar.replaceChildren();
|
||||||
if (state.view === "media") {
|
if (state.view !== "media") {
|
||||||
for (const kind of KINDS) {
|
|
||||||
const chip = el("button", `vk-chip ${state.kind === kind ? "vk-chip-on" : "vk-chip-off"}`, KIND_LABELS[kind]);
|
|
||||||
chip.type = "button";
|
|
||||||
chip.addEventListener("click", () => {
|
|
||||||
state.kind = kind;
|
|
||||||
void loadMedia(true);
|
|
||||||
renderToolbar();
|
|
||||||
});
|
|
||||||
toolbar.append(chip);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const sourceSelect = document.createElement("select");
|
|
||||||
sourceSelect.className = "vk-chip vk-chip-off";
|
|
||||||
for (const [value, label] of [
|
|
||||||
["", "Any source"],
|
|
||||||
["attachment", "Attachments"],
|
|
||||||
["sticker", "Stickers"],
|
|
||||||
["embed", "Embeds"]
|
|
||||||
]) {
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = value;
|
|
||||||
option.textContent = label;
|
|
||||||
sourceSelect.append(option);
|
|
||||||
}
|
|
||||||
sourceSelect.value = state.filters.source;
|
|
||||||
sourceSelect.addEventListener("change", () => {
|
|
||||||
state.filters.source = sourceSelect.value;
|
|
||||||
void loadMedia(true);
|
|
||||||
});
|
|
||||||
toolbar.append(sourceSelect);
|
|
||||||
|
|
||||||
const channelSelect = document.createElement("select");
|
|
||||||
channelSelect.className = "vk-chip vk-chip-off";
|
|
||||||
const anyChannel = document.createElement("option");
|
|
||||||
anyChannel.value = "";
|
|
||||||
anyChannel.textContent = "Any channel";
|
|
||||||
channelSelect.append(anyChannel);
|
|
||||||
|
|
||||||
const seen = new Set();
|
|
||||||
for (const scrape of state.scrapes) {
|
|
||||||
if (seen.has(scrape.channelId)) continue;
|
|
||||||
seen.add(scrape.channelId);
|
|
||||||
const option = document.createElement("option");
|
|
||||||
option.value = scrape.channelId;
|
|
||||||
option.textContent =
|
|
||||||
state.channels.find(channel => channel.id === scrape.channelId)?.label ?? scrape.channelId;
|
|
||||||
channelSelect.append(option);
|
|
||||||
}
|
|
||||||
channelSelect.value = state.filters.channelId;
|
|
||||||
channelSelect.addEventListener("change", () => {
|
|
||||||
state.filters.channelId = channelSelect.value;
|
|
||||||
void loadMedia(true);
|
|
||||||
});
|
|
||||||
toolbar.append(channelSelect);
|
|
||||||
|
|
||||||
const authorOptions = [["", "Any user"]].concat(
|
|
||||||
(state.authors ?? [])
|
|
||||||
.filter(author => author.id)
|
|
||||||
.map(author => [author.id, `${author.name} (${author.count})`])
|
|
||||||
);
|
|
||||||
toolbar.append(
|
|
||||||
filterSelect(authorOptions, state.filters.authorId, value => {
|
|
||||||
state.filters.authorId = value;
|
|
||||||
void loadMedia(true);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
toolbar.append(
|
|
||||||
filterSelect(
|
|
||||||
[
|
|
||||||
["", "Any ratio"],
|
|
||||||
["vertical", "Vertical"],
|
|
||||||
["square", "1:1"],
|
|
||||||
["wide43", "4:3"],
|
|
||||||
["wide169", "16:9"],
|
|
||||||
["ultrawide", "21:9"],
|
|
||||||
["other", "Other"]
|
|
||||||
],
|
|
||||||
state.filters.aspect,
|
|
||||||
value => {
|
|
||||||
state.filters.aspect = value;
|
|
||||||
void loadMedia(true);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
toolbar.append(
|
|
||||||
filterSelect(
|
|
||||||
[
|
|
||||||
["", "Any quality"],
|
|
||||||
["sd", "SD (<=480p)"],
|
|
||||||
["hd", "HD (720p)"],
|
|
||||||
["fhd", "FHD (1080p)"],
|
|
||||||
["qhd", "QHD (1440p)"],
|
|
||||||
["uhd", "4K+"],
|
|
||||||
["unknown", "Unknown"]
|
|
||||||
],
|
|
||||||
state.filters.resolution,
|
|
||||||
value => {
|
|
||||||
state.filters.resolution = value;
|
|
||||||
void loadMedia(true);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
toolbar.append(
|
|
||||||
el("span", "vk-media-status", "From"),
|
|
||||||
dateInput(state.filters.after, value => {
|
|
||||||
state.filters.after = value;
|
|
||||||
void loadMedia(true);
|
|
||||||
}),
|
|
||||||
el("span", "vk-media-status", "To"),
|
|
||||||
dateInput(state.filters.before, value => {
|
|
||||||
state.filters.before = value;
|
|
||||||
void loadMedia(true);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
toolbar.append(metaMenu());
|
|
||||||
toolbar.append(el("span", "vk-media-spacer"));
|
|
||||||
toolbar.append(button("History", openHistoryModal));
|
|
||||||
toolbar.append(button("New scrape…", openScrapeModal));
|
|
||||||
} else {
|
|
||||||
toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md."));
|
toolbar.append(el("span", null, "Slopbot is the dedicated bot core. Setup: docs/slopbot.md."));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toolbar.append(button("Advanced search", toggleFilterDrawer));
|
||||||
|
toolbar.append(metaMenu());
|
||||||
|
toolbar.append(el("span", "vk-media-spacer"));
|
||||||
|
toolbar.append(button("History", openHistoryModal));
|
||||||
|
toolbar.append(button("New scrape…", openScrapeModal));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Advanced search drawer ----
|
||||||
|
|
||||||
|
function defaultFilters() {
|
||||||
|
return {
|
||||||
|
source: "",
|
||||||
|
channelId: "",
|
||||||
|
authorIds: [],
|
||||||
|
origin: "",
|
||||||
|
cachedOnly: false,
|
||||||
|
aspect: "",
|
||||||
|
resolution: "",
|
||||||
|
after: "",
|
||||||
|
before: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFilterDrawer() {
|
||||||
|
filterDrawer.hidden = !filterDrawer.hidden;
|
||||||
|
if (!filterDrawer.hidden) {
|
||||||
|
state.draft = {
|
||||||
|
kind: state.kind,
|
||||||
|
...state.filters,
|
||||||
|
authorIds: [...state.filters.authorIds]
|
||||||
|
};
|
||||||
|
renderFilterDrawer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFilterDrawer() {
|
||||||
|
filterDrawer.replaceChildren();
|
||||||
|
const draft = state.draft;
|
||||||
|
|
||||||
|
const section = (label) => {
|
||||||
|
const wrap = el("div", "vk-media-filter-section");
|
||||||
|
wrap.append(el("div", "vk-media-status", label));
|
||||||
|
return wrap;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filetype
|
||||||
|
const kinds = section("Filetype");
|
||||||
|
const chips = el("div", "vk-media-toolbar");
|
||||||
|
for (const kind of KINDS) {
|
||||||
|
const chip = el(
|
||||||
|
"button",
|
||||||
|
`vk-chip ${draft.kind === kind ? "vk-chip-on" : "vk-chip-off"}`,
|
||||||
|
KIND_LABELS[kind]
|
||||||
|
);
|
||||||
|
chip.type = "button";
|
||||||
|
chip.addEventListener("click", () => {
|
||||||
|
draft.kind = kind;
|
||||||
|
renderFilterDrawer();
|
||||||
|
});
|
||||||
|
chips.append(chip);
|
||||||
|
}
|
||||||
|
kinds.append(chips);
|
||||||
|
filterDrawer.append(kinds);
|
||||||
|
|
||||||
|
// Source kind
|
||||||
|
const sourceSection = section("Source kind");
|
||||||
|
sourceSection.append(
|
||||||
|
filterSelect(
|
||||||
|
[
|
||||||
|
["", "Any"],
|
||||||
|
["attachment", "Attachments"],
|
||||||
|
["sticker", "Stickers"],
|
||||||
|
["embed", "Embeds"]
|
||||||
|
],
|
||||||
|
draft.source,
|
||||||
|
value => {
|
||||||
|
draft.source = value;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
filterDrawer.append(sourceSection);
|
||||||
|
|
||||||
|
// Server or DM
|
||||||
|
const originSection = section("Source");
|
||||||
|
originSection.append(
|
||||||
|
filterSelect(
|
||||||
|
[
|
||||||
|
["", "Anywhere"],
|
||||||
|
["guild", "Server channels"],
|
||||||
|
["dm", "DMs and group chats"]
|
||||||
|
],
|
||||||
|
draft.origin,
|
||||||
|
value => {
|
||||||
|
draft.origin = value;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
filterDrawer.append(originSection);
|
||||||
|
|
||||||
|
// People (multi-select)
|
||||||
|
const peopleSection = section("People");
|
||||||
|
const people = el("div", "vk-media-people");
|
||||||
|
for (const author of state.authors ?? []) {
|
||||||
|
if (!author.id) continue;
|
||||||
|
const row = el("label", "vk-media-check");
|
||||||
|
const box = document.createElement("input");
|
||||||
|
box.type = "checkbox";
|
||||||
|
box.checked = draft.authorIds.includes(author.id);
|
||||||
|
box.addEventListener("change", () => {
|
||||||
|
draft.authorIds = box.checked
|
||||||
|
? [...new Set([...draft.authorIds, author.id])]
|
||||||
|
: draft.authorIds.filter(id => id !== author.id);
|
||||||
|
});
|
||||||
|
row.append(box, document.createTextNode(` ${author.name} (${author.count})`));
|
||||||
|
people.append(row);
|
||||||
|
}
|
||||||
|
if (!people.childElementCount) people.append(el("span", "vk-media-status", "No posters indexed yet."));
|
||||||
|
peopleSection.append(people);
|
||||||
|
filterDrawer.append(peopleSection);
|
||||||
|
|
||||||
|
// Advanced
|
||||||
|
const advanced = document.createElement("details");
|
||||||
|
advanced.className = "vk-media-advanced";
|
||||||
|
const summary = document.createElement("summary");
|
||||||
|
summary.textContent = "Advanced";
|
||||||
|
advanced.append(summary);
|
||||||
|
|
||||||
|
const advancedBody = el("div", "vk-media-filter-section");
|
||||||
|
advancedBody.append(
|
||||||
|
filterSelect(
|
||||||
|
[
|
||||||
|
["", "Any ratio"],
|
||||||
|
["vertical", "Vertical"],
|
||||||
|
["square", "1:1"],
|
||||||
|
["wide43", "4:3"],
|
||||||
|
["wide169", "16:9"],
|
||||||
|
["ultrawide", "21:9"],
|
||||||
|
["other", "Other"]
|
||||||
|
],
|
||||||
|
draft.aspect,
|
||||||
|
value => {
|
||||||
|
draft.aspect = value;
|
||||||
|
}
|
||||||
|
),
|
||||||
|
filterSelect(
|
||||||
|
[
|
||||||
|
["", "Any quality"],
|
||||||
|
["sd", "SD (<=480p)"],
|
||||||
|
["hd", "HD (720p)"],
|
||||||
|
["fhd", "FHD (1080p)"],
|
||||||
|
["qhd", "QHD (1440p)"],
|
||||||
|
["uhd", "4K+"],
|
||||||
|
["unknown", "Unknown"]
|
||||||
|
],
|
||||||
|
draft.resolution,
|
||||||
|
value => {
|
||||||
|
draft.resolution = value;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const channelSelect = document.createElement("select");
|
||||||
|
channelSelect.className = "vk-chip vk-chip-off";
|
||||||
|
const anyChannel = document.createElement("option");
|
||||||
|
anyChannel.value = "";
|
||||||
|
anyChannel.textContent = "Any channel";
|
||||||
|
channelSelect.append(anyChannel);
|
||||||
|
const seen = new Set();
|
||||||
|
for (const scrape of state.scrapes) {
|
||||||
|
if (seen.has(scrape.channelId)) continue;
|
||||||
|
seen.add(scrape.channelId);
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = scrape.channelId;
|
||||||
|
option.textContent =
|
||||||
|
state.channels.find(channel => channel.id === scrape.channelId)?.label ?? scrape.channelId;
|
||||||
|
channelSelect.append(option);
|
||||||
|
}
|
||||||
|
channelSelect.value = draft.channelId;
|
||||||
|
channelSelect.addEventListener("change", () => {
|
||||||
|
draft.channelId = channelSelect.value;
|
||||||
|
});
|
||||||
|
advancedBody.append(channelSelect);
|
||||||
|
|
||||||
|
advancedBody.append(
|
||||||
|
el("span", "vk-media-status", "From"),
|
||||||
|
dateInput(draft.after, value => {
|
||||||
|
draft.after = value;
|
||||||
|
}),
|
||||||
|
el("span", "vk-media-status", "To"),
|
||||||
|
dateInput(draft.before, value => {
|
||||||
|
draft.before = value;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const cachedRow = el("label", "vk-media-check");
|
||||||
|
const cachedBox = document.createElement("input");
|
||||||
|
cachedBox.type = "checkbox";
|
||||||
|
cachedBox.checked = draft.cachedOnly;
|
||||||
|
cachedBox.addEventListener("change", () => {
|
||||||
|
draft.cachedOnly = cachedBox.checked;
|
||||||
|
});
|
||||||
|
cachedRow.append(cachedBox, document.createTextNode(" Show only cached previews"));
|
||||||
|
advancedBody.append(cachedRow);
|
||||||
|
|
||||||
|
advanced.append(advancedBody);
|
||||||
|
filterDrawer.append(advanced);
|
||||||
|
|
||||||
|
// Zoom
|
||||||
|
const zoomSection = section("Card size");
|
||||||
|
const zoom = document.createElement("input");
|
||||||
|
zoom.type = "range";
|
||||||
|
zoom.min = "0";
|
||||||
|
zoom.max = "100";
|
||||||
|
zoom.value = String(state.zoom);
|
||||||
|
zoom.className = "vk-media-zoom";
|
||||||
|
zoom.addEventListener("input", () => {
|
||||||
|
state.zoom = Number(zoom.value);
|
||||||
|
localStorage.setItem("vk-media-zoom", String(state.zoom));
|
||||||
|
applyZoom();
|
||||||
|
});
|
||||||
|
zoomSection.append(zoom);
|
||||||
|
filterDrawer.append(zoomSection);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const actions = el("div", "vk-media-toolbar");
|
||||||
|
actions.append(
|
||||||
|
button("Apply filters", () => {
|
||||||
|
state.kind = draft.kind;
|
||||||
|
state.filters = {
|
||||||
|
source: draft.source,
|
||||||
|
channelId: draft.channelId,
|
||||||
|
authorIds: [...draft.authorIds],
|
||||||
|
origin: draft.origin,
|
||||||
|
cachedOnly: draft.cachedOnly,
|
||||||
|
aspect: draft.aspect,
|
||||||
|
resolution: draft.resolution,
|
||||||
|
after: draft.after,
|
||||||
|
before: draft.before
|
||||||
|
};
|
||||||
|
filterDrawer.hidden = true;
|
||||||
|
if (state.loaded) {
|
||||||
|
void loadMedia(true);
|
||||||
|
} else {
|
||||||
|
void loadStats();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
button("Reset", () => {
|
||||||
|
resetMediaView();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
filterDrawer.append(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card width from the zoom slider: 2/3 current, current, up to a full row. */
|
||||||
|
function cardWidth() {
|
||||||
|
const gridWidth = grid.clientWidth || 900;
|
||||||
|
const zoom = Math.max(0, Math.min(100, state.zoom));
|
||||||
|
if (zoom <= 50) return Math.round(100 + (zoom / 50) * 50);
|
||||||
|
return Math.round(150 + ((zoom - 50) / 50) * Math.max(0, gridWidth - 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Items per page: full rows, about 1.5 screens tall. */
|
||||||
|
function computePageSize() {
|
||||||
|
const width = cardWidth();
|
||||||
|
const columns = Math.max(1, Math.floor(((grid.clientWidth || 900) + 8) / (width + 8)));
|
||||||
|
const rowHeight = width * 1.16 + 30;
|
||||||
|
const rows = Math.max(1, Math.floor(((grid.clientHeight || 600) + 8) / (rowHeight + 8)));
|
||||||
|
return Math.max(columns, columns * Math.max(1, Math.ceil(rows * 1.5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyZoom() {
|
||||||
|
const width = cardWidth();
|
||||||
|
const gridWidth = grid.clientWidth || 900;
|
||||||
|
grid.style.gridTemplateColumns =
|
||||||
|
width >= gridWidth - 24 ? "1fr" : `repeat(auto-fill, minmax(${width}px, 1fr))`;
|
||||||
|
state.pageSize = computePageSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMediaView() {
|
||||||
|
state.kind = "all";
|
||||||
|
state.filters = defaultFilters();
|
||||||
|
state.zoom = 50;
|
||||||
|
localStorage.setItem("vk-media-zoom", "50");
|
||||||
|
state.loaded = false;
|
||||||
|
state.loading = false;
|
||||||
|
state.more = false;
|
||||||
|
state.prefetched = null;
|
||||||
|
state.items = [];
|
||||||
|
state.offset = 0;
|
||||||
|
filterDrawer.hidden = true;
|
||||||
|
applyZoom();
|
||||||
|
renderGrid();
|
||||||
|
void loadStats();
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -776,8 +977,22 @@
|
|||||||
|
|
||||||
// Infinite scroll: fetch the next page before the grid bottom is reached.
|
// Infinite scroll: fetch the next page before the grid bottom is reached.
|
||||||
grid.addEventListener("scroll", () => {
|
grid.addEventListener("scroll", () => {
|
||||||
if (state.loading || !state.more) return;
|
if (state.loading || !state.loaded) return;
|
||||||
if (grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 240) void loadMedia(false);
|
if (grid.scrollTop + grid.clientHeight < grid.scrollHeight - 240) return;
|
||||||
|
|
||||||
|
if (state.prefetched) {
|
||||||
|
const items = state.prefetched;
|
||||||
|
state.prefetched = null;
|
||||||
|
state.items = state.items.concat(items);
|
||||||
|
state.offset += items.length;
|
||||||
|
state.more = items.length >= state.pageSize;
|
||||||
|
renderGrid();
|
||||||
|
void loadStats();
|
||||||
|
void prefetchNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.more) void loadMedia(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Scrape popup: server or group-chat trees ----
|
// ---- Scrape popup: server or group-chat trees ----
|
||||||
@@ -1460,7 +1675,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", () => {
|
window.addEventListener("resize", () => {
|
||||||
if (state.view) layoutTop();
|
if (!state.view) return;
|
||||||
|
layoutTop();
|
||||||
|
if (state.view === "media") applyZoom();
|
||||||
});
|
});
|
||||||
|
|
||||||
const callStrip = document.querySelector("#call-strip");
|
const callStrip = document.querySelector("#call-strip");
|
||||||
|
|||||||
@@ -880,7 +880,9 @@ function buildServer(): McpServer {
|
|||||||
.describe("Where the item came from"),
|
.describe("Where the item came from"),
|
||||||
scrapeId: z.string().optional().describe("Restrict to one scrape"),
|
scrapeId: z.string().optional().describe("Restrict to one scrape"),
|
||||||
channelId: z.string().optional().describe("Restrict to one source channel"),
|
channelId: z.string().optional().describe("Restrict to one source channel"),
|
||||||
authorId: z.string().optional().describe("Restrict to one poster"),
|
authorIds: z.array(z.string()).optional().describe("Restrict to these posters"),
|
||||||
|
origin: z.enum(["dm", "guild"]).optional().describe("Server channels or DMs"),
|
||||||
|
cachedOnly: z.boolean().optional().describe("Only items with a cached preview"),
|
||||||
aspect: z
|
aspect: z
|
||||||
.enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"])
|
.enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -895,14 +897,30 @@ function buildServer(): McpServer {
|
|||||||
offset: z.number().int().min(0).optional()
|
offset: z.number().int().min(0).optional()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async ({ kind, source, scrapeId, channelId, authorId, aspect, resolution, after, before, limit, offset }) =>
|
async ({
|
||||||
|
kind,
|
||||||
|
source,
|
||||||
|
scrapeId,
|
||||||
|
channelId,
|
||||||
|
authorIds,
|
||||||
|
origin,
|
||||||
|
cachedOnly,
|
||||||
|
aspect,
|
||||||
|
resolution,
|
||||||
|
after,
|
||||||
|
before,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}) =>
|
||||||
jsonText(
|
jsonText(
|
||||||
media.queryMedia({
|
media.queryMedia({
|
||||||
kind,
|
kind,
|
||||||
source,
|
source,
|
||||||
scrapeId,
|
scrapeId,
|
||||||
channelId,
|
channelId,
|
||||||
authorId,
|
authorIds,
|
||||||
|
origin,
|
||||||
|
cachedOnly,
|
||||||
aspect,
|
aspect,
|
||||||
resolution,
|
resolution,
|
||||||
after,
|
after,
|
||||||
|
|||||||
@@ -635,6 +635,9 @@ export class MediaService {
|
|||||||
source?: string;
|
source?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
|
authorIds?: string[];
|
||||||
|
origin?: "dm" | "guild";
|
||||||
|
cachedOnly?: boolean;
|
||||||
aspect?: string;
|
aspect?: string;
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
@@ -751,6 +754,9 @@ export class MediaService {
|
|||||||
scrapeId?: string;
|
scrapeId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
|
authorIds?: string[];
|
||||||
|
origin?: "dm" | "guild";
|
||||||
|
cachedOnly?: boolean;
|
||||||
aspect?: string;
|
aspect?: string;
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
@@ -762,6 +768,8 @@ export class MediaService {
|
|||||||
if (meta.state === "deleted") continue;
|
if (meta.state === "deleted") continue;
|
||||||
if (options.scrapeId && meta.id !== options.scrapeId) continue;
|
if (options.scrapeId && meta.id !== options.scrapeId) continue;
|
||||||
if (options.channelId && meta.channelId !== options.channelId) continue;
|
if (options.channelId && meta.channelId !== options.channelId) continue;
|
||||||
|
if (options.origin === "dm" && meta.guildId) continue;
|
||||||
|
if (options.origin === "guild" && !meta.guildId) continue;
|
||||||
|
|
||||||
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
|
const store = new ScrapeStore(scrapeDir(this.rootDir, meta.id));
|
||||||
try {
|
try {
|
||||||
@@ -772,6 +780,8 @@ export class MediaService {
|
|||||||
kind: options.kind,
|
kind: options.kind,
|
||||||
source: options.source,
|
source: options.source,
|
||||||
authorId: options.authorId,
|
authorId: options.authorId,
|
||||||
|
authorIds: options.authorIds,
|
||||||
|
cachedOnly: options.cachedOnly,
|
||||||
aspect: options.aspect,
|
aspect: options.aspect,
|
||||||
resolution: options.resolution,
|
resolution: options.resolution,
|
||||||
after: options.after,
|
after: options.after,
|
||||||
@@ -820,6 +830,9 @@ export class MediaService {
|
|||||||
scrapeId?: string;
|
scrapeId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
authorId?: string;
|
authorId?: string;
|
||||||
|
authorIds?: string[];
|
||||||
|
origin?: "dm" | "guild";
|
||||||
|
cachedOnly?: boolean;
|
||||||
aspect?: string;
|
aspect?: string;
|
||||||
resolution?: string;
|
resolution?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
|
|||||||
+28
-3
@@ -882,7 +882,17 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
|||||||
? String(req.query.source)
|
? String(req.query.source)
|
||||||
: undefined;
|
: undefined;
|
||||||
const channelId = req.query.channelId ? String(req.query.channelId) : undefined;
|
const channelId = req.query.channelId ? String(req.query.channelId) : undefined;
|
||||||
const authorId = req.query.authorId ? String(req.query.authorId) : undefined;
|
const authorIds = req.query.authorId
|
||||||
|
? String(req.query.authorId)
|
||||||
|
.split(",")
|
||||||
|
.map(value => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const origin =
|
||||||
|
req.query.origin === "dm" || req.query.origin === "guild"
|
||||||
|
? (String(req.query.origin) as "dm" | "guild")
|
||||||
|
: undefined;
|
||||||
|
const cachedOnly = req.query.cachedOnly === "1" || req.query.cachedOnly === "true";
|
||||||
const aspect = req.query.aspect ? String(req.query.aspect) : undefined;
|
const aspect = req.query.aspect ? String(req.query.aspect) : undefined;
|
||||||
const resolution = req.query.resolution ? String(req.query.resolution) : undefined;
|
const resolution = req.query.resolution ? String(req.query.resolution) : undefined;
|
||||||
const after = req.query.after ? String(req.query.after) : undefined;
|
const after = req.query.after ? String(req.query.after) : undefined;
|
||||||
@@ -896,7 +906,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
|||||||
kind,
|
kind,
|
||||||
source,
|
source,
|
||||||
channelId,
|
channelId,
|
||||||
authorId,
|
authorIds,
|
||||||
|
origin,
|
||||||
|
cachedOnly,
|
||||||
aspect,
|
aspect,
|
||||||
resolution,
|
resolution,
|
||||||
after,
|
after,
|
||||||
@@ -922,13 +934,26 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
|||||||
? (String(req.query.kind) as "image" | "gif" | "video" | "audio")
|
? (String(req.query.kind) as "image" | "gif" | "video" | "audio")
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const authorIds = req.query.authorId
|
||||||
|
? String(req.query.authorId)
|
||||||
|
.split(",")
|
||||||
|
.map(value => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: undefined;
|
||||||
|
const origin =
|
||||||
|
req.query.origin === "dm" || req.query.origin === "guild"
|
||||||
|
? (String(req.query.origin) as "dm" | "guild")
|
||||||
|
: undefined;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
result: deps.media.stats({
|
result: deps.media.stats({
|
||||||
kind,
|
kind,
|
||||||
source: req.query.source ? String(req.query.source) : undefined,
|
source: req.query.source ? String(req.query.source) : undefined,
|
||||||
channelId: req.query.channelId ? String(req.query.channelId) : undefined,
|
channelId: req.query.channelId ? String(req.query.channelId) : undefined,
|
||||||
authorId: req.query.authorId ? String(req.query.authorId) : undefined,
|
authorIds,
|
||||||
|
origin,
|
||||||
|
cachedOnly: req.query.cachedOnly === "1" || req.query.cachedOnly === "true",
|
||||||
aspect: req.query.aspect ? String(req.query.aspect) : undefined,
|
aspect: req.query.aspect ? String(req.query.aspect) : undefined,
|
||||||
resolution: req.query.resolution ? String(req.query.resolution) : undefined,
|
resolution: req.query.resolution ? String(req.query.resolution) : undefined,
|
||||||
after: req.query.after ? String(req.query.after) : undefined,
|
after: req.query.after ? String(req.query.after) : undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user