Add infinite scroll, gone/private tiles, and live-only scrape strip

This commit is contained in:
Fisto
2026-09-16 14:09:55 +02:00
parent d522681553
commit 3da0da0ca7
6 changed files with 54 additions and 11 deletions
+3
View File
@@ -40,6 +40,9 @@ 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] C28 Media grid usability. Infinite scroll (240 px threshold) with a Load more fallback,
`previewError` exposed so tiles show PRIVATE (403) or GONE (404/410) with the reason in the
tooltip, and the scrape strip lists only queued/running jobs — paused scrapes move to History.
- [x] C27 Non-media stream guard. Rows awaiting backfill fell back to first-frame `<video>` with
a provider page URL (YouTube embed, redirectors), and the stream route piped the HTML through
(`text/html` errors). Now only real video files (mp4/webm/m4v) are framed, the stream route
+4 -2
View File
@@ -167,8 +167,10 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
preview is a static frame and the animated GIF plays while hovering. The status row reads
`(Images/Videos) | Downloaded I/V | Cached I/V | Loaded I/V` — image counts include GIFs, and
each slot is the count for the active filters. A scrape strip shows live progress with Stop; it
polls every 4 seconds while the tab is open and shows `X/Yp (~Z%)` when the search total is
known. Some channels deny the bot the search endpoint (`Missing Access`) even though history
polls every 4 seconds while the tab is open, lists only queued/running jobs (paused ones live in
History), and shows `X/Yp (~Z%)` when the search total is known. The grid scrolls infinitely
(a **Load more** button appears as a fallback). Media whose source answers `403` shows a
**PRIVATE** tile and `404`/`410` shows **GONE**, with the reason in the tooltip. Some channels deny the bot the search endpoint (`Missing Access`) even though history
reads work, so the total is optional.
**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
+1 -1
View File
@@ -258,7 +258,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`; `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`, `previewError`; `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` |
+6
View File
@@ -136,6 +136,12 @@
cursor: pointer;
}
.vk-media-load-more {
grid-column: 1 / -1;
justify-self: center;
margin: 10px 0;
}
.vk-media-prompt {
grid-column: 1 / -1;
display: flex;
+37 -8
View File
@@ -24,6 +24,8 @@
authors: null,
errors: [],
loaded: false,
loading: false,
more: false,
stats: null,
metaFields: JSON.parse(
localStorage.getItem("vk-media-meta-fields") ??
@@ -227,24 +229,29 @@
renderStatus();
}
async function loadMedia(reset) {
if (!state.loaded) return;
const PAGE_SIZE = 60;
async function loadMedia(reset) {
if (!state.loaded || state.loading) return;
state.loading = true;
if (reset) {
state.items = [];
state.offset = 0;
state.more = false;
}
try {
const items = await window.VK.api.get(
`/api/media?limit=60&offset=${state.offset}&${filterQuery()}`
`/api/media?limit=${PAGE_SIZE}&offset=${state.offset}&${filterQuery()}`
);
state.items = state.items.concat(items);
state.offset += items.length;
state.more = items.length >= PAGE_SIZE;
} catch (error) {
setStatus(`media load failed: ${error.message}`);
renderGrid();
return;
} finally {
state.loading = false;
}
renderGrid();
@@ -281,7 +288,8 @@
function renderScrapes() {
scrapeList.replaceChildren();
const active = state.scrapes.filter(scrape => ["queued", "running", "paused"].includes(scrape.state));
// Only live jobs: a paused scrape is not running and belongs in History, not the strip.
const active = state.scrapes.filter(scrape => ["queued", "running"].includes(scrape.state));
for (const scrape of active) {
const row = el("div", "vk-media-scrape");
@@ -421,7 +429,14 @@
}
function placeholder(media, item) {
media.replaceChildren(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
const error = item.previewError ?? "";
let label = item.kind.toUpperCase();
if (/\b403\b/.test(error)) label = "PRIVATE";
else if (/\b(404|410)\b/.test(error)) label = "GONE";
const node = el("span", "vk-media-card-kind", label);
if (error) node.title = error;
media.replaceChildren(node);
}
/** Only real video files can be framed; embed pages (YouTube etc.) never are. */
@@ -535,7 +550,15 @@
}
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."));
if (!state.items.length) {
grid.append(el("div", "vk-media-status", "No media matches the filters."));
} else if (state.more) {
const more = el("button", "btn btn-secondary vk-media-load-more", "Load more");
more.type = "button";
more.addEventListener("click", () => void loadMedia(false));
grid.append(more);
}
}
function filterSelect(options, value, onChange) {
@@ -774,6 +797,12 @@
if (state.view === "media") void loadScrapes();
}, 4000);
// 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);
});
// ---- Scrape popup: server or group-chat trees ----
const scrapeModal = document.createElement("div");
+3
View File
@@ -121,6 +121,8 @@ export interface MediaItemResult {
playUrl: string | null;
/** The remote thumbnail URL from Discord's embed data (may be an animated GIF). */
remotePreviewUrl: string | null;
/** Why no preview exists, when a fetch failed (for example "http 404"). */
previewError: string | null;
localUrl: string | null;
thumbUrl: string | null;
provider: string | null;
@@ -739,6 +741,7 @@ export class MediaService {
url: row.url,
playUrl: row.play_url,
remotePreviewUrl: row.preview_url,
previewError: row.preview_error,
localUrl:
row.file_path && !meta.downloadDir
? `/api/media/${meta.id}/${row.id}/file`