Add infinite scroll, gone/private tiles, and live-only scrape strip
This commit is contained in:
@@ -40,6 +40,9 @@ 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] 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
|
- [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
|
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
|
(`text/html` errors). Now only real video files (mp4/webm/m4v) are framed, the stream route
|
||||||
|
|||||||
+4
-2
@@ -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
|
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
|
`(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
|
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
|
polls every 4 seconds while the tab is open, lists only queued/running jobs (paused ones live in
|
||||||
known. Some channels deny the bot the search endpoint (`Missing Access`) even though history
|
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.
|
reads work, so the total is optional.
|
||||||
**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
|
||||||
|
|||||||
+1
-1
@@ -258,7 +258,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`; `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/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` |
|
||||||
|
|||||||
@@ -136,6 +136,12 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vk-media-load-more {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: center;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.vk-media-prompt {
|
.vk-media-prompt {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+37
-8
@@ -24,6 +24,8 @@
|
|||||||
authors: null,
|
authors: null,
|
||||||
errors: [],
|
errors: [],
|
||||||
loaded: false,
|
loaded: false,
|
||||||
|
loading: false,
|
||||||
|
more: false,
|
||||||
stats: null,
|
stats: null,
|
||||||
metaFields: JSON.parse(
|
metaFields: JSON.parse(
|
||||||
localStorage.getItem("vk-media-meta-fields") ??
|
localStorage.getItem("vk-media-meta-fields") ??
|
||||||
@@ -227,24 +229,29 @@
|
|||||||
renderStatus();
|
renderStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMedia(reset) {
|
const PAGE_SIZE = 60;
|
||||||
if (!state.loaded) return;
|
|
||||||
|
|
||||||
|
async function loadMedia(reset) {
|
||||||
|
if (!state.loaded || state.loading) return;
|
||||||
|
|
||||||
|
state.loading = true;
|
||||||
if (reset) {
|
if (reset) {
|
||||||
state.items = [];
|
state.items = [];
|
||||||
state.offset = 0;
|
state.offset = 0;
|
||||||
|
state.more = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await window.VK.api.get(
|
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.items = state.items.concat(items);
|
||||||
state.offset += items.length;
|
state.offset += items.length;
|
||||||
|
state.more = items.length >= PAGE_SIZE;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setStatus(`media load failed: ${error.message}`);
|
setStatus(`media load failed: ${error.message}`);
|
||||||
renderGrid();
|
} finally {
|
||||||
return;
|
state.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderGrid();
|
renderGrid();
|
||||||
@@ -281,7 +288,8 @@
|
|||||||
|
|
||||||
function renderScrapes() {
|
function renderScrapes() {
|
||||||
scrapeList.replaceChildren();
|
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) {
|
for (const scrape of active) {
|
||||||
const row = el("div", "vk-media-scrape");
|
const row = el("div", "vk-media-scrape");
|
||||||
@@ -421,7 +429,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function placeholder(media, item) {
|
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. */
|
/** 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));
|
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) {
|
function filterSelect(options, value, onChange) {
|
||||||
@@ -774,6 +797,12 @@
|
|||||||
if (state.view === "media") void loadScrapes();
|
if (state.view === "media") void loadScrapes();
|
||||||
}, 4000);
|
}, 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 ----
|
// ---- Scrape popup: server or group-chat trees ----
|
||||||
|
|
||||||
const scrapeModal = document.createElement("div");
|
const scrapeModal = document.createElement("div");
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export interface MediaItemResult {
|
|||||||
playUrl: string | null;
|
playUrl: string | null;
|
||||||
/** The remote thumbnail URL from Discord's embed data (may be an animated GIF). */
|
/** The remote thumbnail URL from Discord's embed data (may be an animated GIF). */
|
||||||
remotePreviewUrl: string | null;
|
remotePreviewUrl: string | null;
|
||||||
|
/** Why no preview exists, when a fetch failed (for example "http 404"). */
|
||||||
|
previewError: string | null;
|
||||||
localUrl: string | null;
|
localUrl: string | null;
|
||||||
thumbUrl: string | null;
|
thumbUrl: string | null;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
@@ -739,6 +741,7 @@ export class MediaService {
|
|||||||
url: row.url,
|
url: row.url,
|
||||||
playUrl: row.play_url,
|
playUrl: row.play_url,
|
||||||
remotePreviewUrl: row.preview_url,
|
remotePreviewUrl: row.preview_url,
|
||||||
|
previewError: row.preview_error,
|
||||||
localUrl:
|
localUrl:
|
||||||
row.file_path && !meta.downloadDir
|
row.file_path && !meta.downloadDir
|
||||||
? `/api/media/${meta.id}/${row.id}/file`
|
? `/api/media/${meta.id}/${row.id}/file`
|
||||||
|
|||||||
Reference in New Issue
Block a user