Add the advanced search drawer, adaptive paging, and zoom

This commit is contained in:
Fisto
2026-09-17 07:57:26 +02:00
parent dea1724eb3
commit 5319a028e2
9 changed files with 492 additions and 159 deletions
+8
View File
@@ -40,6 +40,14 @@ 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] 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.
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/
+4 -4
View File
@@ -23,10 +23,10 @@ interface MediaService {
}
```
`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `authorId`, `aspect`,
`resolution`, `after`, `before`, `limit`, `offset`. The `aspect` and `resolution` values are the
preset keys from `filter-presets.ts`; `after`/`before` are ISO times on `posted_at`. Planned:
`guildId`, `text`.
`MediaQuery` (implemented): `kind`, `source`, `scrapeId`, `channelId`, `authorIds`, `origin`
(`guild`/`dm`), `cachedOnly`, `aspect`, `resolution`, `after`, `before`, `limit`, `offset`. The
`aspect` and `resolution` values are the preset keys from `filter-presets.ts`; `after`/`before` are
ISO times on `posted_at`. Planned: `guildId`, `text`.
`StorageStatus`: `usedBytes`, `limitBytes`, `warnPercent`, `overrunPercent`, `byKind`,
`scrapes: [{ id, bytes, state, createdAt }]`.
+12 -5
View File
@@ -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
opens (for example **Clean media…**) render above Settings. Mobile is out of scope for the media
feature.
- Media lists scraped items with kind chips (All, Images, GIFs, Videos, Audio) and filters for
source (any, attachments, stickers, embeds), source channel, poster, aspect ratio, resolution,
and a posted-date range (from/to). **New scrape…** opens a popup with **Server** and **Group
chat** tabs: servers expand into their text channels, each channel (and each group chat) has
- Media lists scraped items. Filters live in an **Advanced search** drawer and do not apply until
**Apply filters** is pressed: filetype chips, source kind (attachments/stickers/embeds), origin
(server channels or DMs), a multi-select **People** list, and an **Advanced** section with aspect
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
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
@@ -258,7 +265,7 @@ 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`, `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/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` |
+9
View File
@@ -106,6 +106,8 @@ export interface MediaFilterOptions {
kind?: MediaKind;
source?: string;
authorId?: string;
authorIds?: string[];
cachedOnly?: boolean;
aspect?: string;
resolution?: string;
after?: string;
@@ -484,6 +486,13 @@ export class ScrapeStore {
conditions.push("author_id = ?");
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) {
conditions.push("posted_at >= ?");
values.push(options.after);
+36
View File
@@ -136,6 +136,42 @@
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 {
grid-column: 1 / -1;
justify-self: center;
+361 -144
View File
@@ -31,15 +31,11 @@
localStorage.getItem("vk-media-meta-fields") ??
'["user","type","date","source","resolution"]'
),
filters: {
source: "",
channelId: "",
authorId: "",
aspect: "",
resolution: "",
after: "",
before: ""
}
filters: defaultFilters(),
zoom: Number(localStorage.getItem("vk-media-zoom") ?? 50),
draft: null,
prefetched: null,
pageSize: 60
};
const overlay = document.createElement("div");
@@ -66,6 +62,10 @@
const scrapeList = overlay.querySelector("#media-scrapes");
const grid = overlay.querySelector("#media-grid");
const filterDrawer = el("div", "vk-media-drawer");
filterDrawer.hidden = true;
toolbar.after(filterDrawer);
function setStatus(text) {
statusLine.textContent = text;
}
@@ -102,6 +102,8 @@
document.querySelector("#scope-guild")?.classList.remove("active");
document.querySelector("#scope-dm")?.classList.remove("active");
if (view === "media") applyZoom();
if (view === "bot") {
localStorage.setItem("vk-media-errors-seen", String(errorCount));
updateErrorBadge();
@@ -188,12 +190,16 @@
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 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 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}`;
return `${kind}${source}${channel}${authors}${origin}${cached}${aspect}${resolution}${after}${before}`;
}
function kindTotals(field) {
@@ -233,8 +239,6 @@
renderStatus();
}
const PAGE_SIZE = 60;
async function loadMedia(reset) {
if (!state.loaded || state.loading) return;
@@ -243,15 +247,16 @@
state.items = [];
state.offset = 0;
state.more = false;
state.prefetched = null;
}
try {
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.offset += items.length;
state.more = items.length >= PAGE_SIZE;
state.more = items.length >= state.pageSize;
} catch (error) {
setStatus(`media load failed: ${error.message}`);
} finally {
@@ -260,6 +265,32 @@
renderGrid();
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) {
@@ -593,134 +624,304 @@
function renderToolbar() {
toolbar.replaceChildren();
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 {
if (state.view !== "media") {
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() {
@@ -776,8 +977,22 @@
// Infinite scroll: fetch the next page before the grid bottom is reached.
grid.addEventListener("scroll", () => {
if (state.loading || !state.more) return;
if (grid.scrollTop + grid.clientHeight >= grid.scrollHeight - 240) void loadMedia(false);
if (state.loading || !state.loaded) return;
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 ----
@@ -1460,7 +1675,9 @@
});
window.addEventListener("resize", () => {
if (state.view) layoutTop();
if (!state.view) return;
layoutTop();
if (state.view === "media") applyZoom();
});
const callStrip = document.querySelector("#call-strip");
+21 -3
View File
@@ -880,7 +880,9 @@ function buildServer(): McpServer {
.describe("Where the item came from"),
scrapeId: z.string().optional().describe("Restrict to one scrape"),
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
.enum(["vertical", "square", "wide43", "wide169", "ultrawide", "other"])
.optional()
@@ -895,14 +897,30 @@ function buildServer(): McpServer {
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(
media.queryMedia({
kind,
source,
scrapeId,
channelId,
authorId,
authorIds,
origin,
cachedOnly,
aspect,
resolution,
after,
+13
View File
@@ -635,6 +635,9 @@ export class MediaService {
source?: string;
channelId?: string;
authorId?: string;
authorIds?: string[];
origin?: "dm" | "guild";
cachedOnly?: boolean;
aspect?: string;
resolution?: string;
after?: string;
@@ -751,6 +754,9 @@ export class MediaService {
scrapeId?: string;
channelId?: string;
authorId?: string;
authorIds?: string[];
origin?: "dm" | "guild";
cachedOnly?: boolean;
aspect?: string;
resolution?: string;
after?: string;
@@ -762,6 +768,8 @@ export class MediaService {
if (meta.state === "deleted") continue;
if (options.scrapeId && meta.id !== options.scrapeId) 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));
try {
@@ -772,6 +780,8 @@ export class MediaService {
kind: options.kind,
source: options.source,
authorId: options.authorId,
authorIds: options.authorIds,
cachedOnly: options.cachedOnly,
aspect: options.aspect,
resolution: options.resolution,
after: options.after,
@@ -820,6 +830,9 @@ export class MediaService {
scrapeId?: string;
channelId?: string;
authorId?: string;
authorIds?: string[];
origin?: "dm" | "guild";
cachedOnly?: boolean;
aspect?: string;
resolution?: string;
after?: string;
+28 -3
View File
@@ -882,7 +882,17 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
? String(req.query.source)
: 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 resolution = req.query.resolution ? String(req.query.resolution) : undefined;
const after = req.query.after ? String(req.query.after) : undefined;
@@ -896,7 +906,9 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
kind,
source,
channelId,
authorId,
authorIds,
origin,
cachedOnly,
aspect,
resolution,
after,
@@ -922,13 +934,26 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
? (String(req.query.kind) as "image" | "gif" | "video" | "audio")
: 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({
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,
authorIds,
origin,
cachedOnly: req.query.cachedOnly === "1" || req.query.cachedOnly === "true",
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,