Add per-kind stats, GIF hover playback, and static gif previews
This commit is contained in:
@@ -40,6 +40,13 @@ 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] C22 Media grid polish: per-kind status row
|
||||||
|
(`(Images/Videos) | Downloaded I/V | Cached I/V | Loaded I/V`), GIF tiles with a translucent
|
||||||
|
GIF label that play the animated original on hover (cached previews are static ffmpeg frames),
|
||||||
|
Tenor oEmbed preview resolution, and first-frame/inline playback for embeds that carry
|
||||||
|
Discord's `videoUrl` (Tenor/gifv now classify as video with `play_url`). `remotePreviewUrl`
|
||||||
|
exposes the animated source to the UI. Live: stats per kind correct; Tenor embed stored with
|
||||||
|
`play_url`.
|
||||||
- [x] C21 Playback of non-downloaded and expired media. `play_url` stores the embed's direct
|
- [x] C21 Playback of non-downloaded and expired media. `play_url` stores the embed's direct
|
||||||
video (gifv/Tenor mp4); a new `/api/media/<scrape>/<item>/stream` route serves the local file
|
video (gifv/Tenor mp4); a new `/api/media/<scrape>/<item>/stream` route serves the local file
|
||||||
or re-fetches the source message through slopbot to refresh expired signed CDN URLs and streams
|
or re-fetches the source message through slopbot to refresh expired signed CDN URLs and streams
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ Previews run after every walk:
|
|||||||
- Embeds without a thumbnail in Discord's data (fxtwitter, vxReddit, ...) are resolved first:
|
- Embeds without a thumbnail in Discord's data (fxtwitter, vxReddit, ...) are resolved first:
|
||||||
provider oEmbed/fxtwitter API, then the page's `og:image`/`twitter:image` (512 KiB read cap,
|
provider oEmbed/fxtwitter API, then the page's `og:image`/`twitter:image` (512 KiB read cap,
|
||||||
10 s timeout, one attempt).
|
10 s timeout, one attempt).
|
||||||
|
- Animated GIF previews are stored as a static first frame (`ffmpeg`, 480px WebP); the original
|
||||||
|
animated GIF URL stays in `preview_url` for hover playback. Tenor embeds without a thumbnail are
|
||||||
|
resolved through Tenor's oEmbed.
|
||||||
- One attempt per row, no retries; a failure marks `preview_error` and never fails the scrape.
|
- One attempt per row, no retries; a failure marks `preview_error` and never fails the scrape.
|
||||||
- Stored by hash in `thumbs/<sha256>.<ext>`, where the extension follows the response type
|
- Stored by hash in `thumbs/<sha256>.<ext>`, where the extension follows the response type
|
||||||
(`preview_ext`, default webp); `preview_hash` and `preview_bytes` land on the row, and the
|
(`preview_ext`, default webp); `preview_hash` and `preview_bytes` land on the row, and the
|
||||||
|
|||||||
+4
-3
@@ -159,9 +159,10 @@ that deletion is irreversible. The delete button is disabled for a 5 second coun
|
|||||||
for a fresh signed URL, so expired CDN links still play. Embed `videoUrl`s (gifv/Tenor mp4s) are
|
for a fresh signed URL, so expired CDN links still play. Embed `videoUrl`s (gifv/Tenor mp4s) are
|
||||||
stored as `play_url` and used directly. The full link is a separate button that opens the
|
stored as `play_url` and used directly. The full link is a separate button that opens the
|
||||||
original page with the warning. Length comes from Discord's attachment duration and, for
|
original page with the warning. Length comes from Discord's attachment duration and, for
|
||||||
downloads, an ffprobe probe. The status row reads
|
downloads, an ffprobe probe. GIF tiles carry a translucent **GIF** label top-left; the cached
|
||||||
`(Images/Videos) | Downloaded A/B | Cached C/B (excl Downl) | Loaded L/B`, where B is the total
|
preview is a static frame and the animated GIF plays while hovering. The status row reads
|
||||||
for the active filters. A scrape strip shows live progress with Stop.
|
`(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.
|
||||||
**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,
|
||||||
|
|||||||
+1
-1
@@ -265,7 +265,7 @@ replaceable.
|
|||||||
| 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/stats` | same filters as `/api/media` | Counts per kind (`image`, `gif`, `video`, `audio`), each with `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) |
|
||||||
|
|||||||
@@ -312,6 +312,38 @@ export async function videoPoster(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Static first-frame WebP from an animated GIF. Null when ffmpeg is missing or fails. */
|
||||||
|
export async function gifPoster(
|
||||||
|
inputPath: string,
|
||||||
|
outDir: string,
|
||||||
|
ffmpeg = process.env.VESKSLOP_FFMPEG ?? "ffmpeg"
|
||||||
|
): Promise<{ hash: string; bytes: number } | null> {
|
||||||
|
const temp = join(outDir, `.gif-${Date.now()}-${Math.random().toString(16).slice(2)}.webp`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync(
|
||||||
|
ffmpeg,
|
||||||
|
["-v", "error", "-y", "-i", inputPath, "-frames:v", "1", "-vf", "scale=480:-1", temp],
|
||||||
|
{ timeout: 30_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const buffer = readFileSync(temp);
|
||||||
|
if (!buffer.length) return null;
|
||||||
|
|
||||||
|
const hash = createHash("sha256").update(buffer).digest("hex");
|
||||||
|
renameSync(temp, join(outDir, `${hash}.webp`));
|
||||||
|
return { hash, bytes: buffer.length };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
rmSync(temp, { force: true });
|
||||||
|
} catch {
|
||||||
|
// already renamed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The gap before the next file, in milliseconds. */
|
/** The gap before the next file, in milliseconds. */
|
||||||
export function downloadGapMs(options: DownloadOptions = {}): number {
|
export function downloadGapMs(options: DownloadOptions = {}): number {
|
||||||
const min = options.minGapMs ?? 1000;
|
const min = options.minGapMs ?? 1000;
|
||||||
|
|||||||
@@ -98,6 +98,11 @@ export async function resolveEmbedPreview(
|
|||||||
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
|
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (host === "tenor.com") {
|
||||||
|
const data = await getJson(`https://tenor.com/oembed?url=${encodeURIComponent(media.url)}`, options);
|
||||||
|
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
|
||||||
|
}
|
||||||
|
|
||||||
if (host === "fxtwitter.com" || host === "vxtwitter.com" || host === "twitter.com" || host === "x.com") {
|
if (host === "fxtwitter.com" || host === "vxtwitter.com" || host === "twitter.com" || host === "x.com") {
|
||||||
const match = /\/status\/(\d+)/.exec(parsed.pathname);
|
const match = /\/status\/(\d+)/.exec(parsed.pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
|
|||||||
+20
-10
@@ -3,12 +3,13 @@
|
|||||||
* Rules: docs/media-scraper.md.
|
* Rules: docs/media-scraper.md.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { copyFileSync, mkdirSync } from "node:fs";
|
import { copyFileSync, mkdirSync, rmSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import {
|
import {
|
||||||
downloadGapMs,
|
downloadGapMs,
|
||||||
downloadOne,
|
downloadOne,
|
||||||
downloadPreview,
|
downloadPreview,
|
||||||
|
gifPoster,
|
||||||
probeDimensions,
|
probeDimensions,
|
||||||
videoPoster,
|
videoPoster,
|
||||||
type DownloadOptions
|
type DownloadOptions
|
||||||
@@ -378,21 +379,30 @@ export class ScrapeEngine {
|
|||||||
const outcome = await downloadPreview(previewRow, join(dir, "thumbs"), options);
|
const outcome = await downloadPreview(previewRow, join(dir, "thumbs"), options);
|
||||||
|
|
||||||
if (outcome.state === "downloaded" && outcome.hash) {
|
if (outcome.state === "downloaded" && outcome.hash) {
|
||||||
const ext = outcome.ext ?? "webp";
|
let hash = outcome.hash;
|
||||||
store.markPreview(row.id, { hash: outcome.hash, bytes: outcome.bytes ?? 0, ext });
|
let bytes = outcome.bytes ?? 0;
|
||||||
|
let ext = outcome.ext ?? "webp";
|
||||||
|
|
||||||
|
// Animated GIFs are kept static in the grid; hovering plays the original.
|
||||||
|
if (ext === "gif") {
|
||||||
|
const poster = await gifPoster(join(dir, "thumbs", `${hash}.gif`), join(dir, "thumbs"));
|
||||||
|
if (poster) {
|
||||||
|
rmSync(join(dir, "thumbs", `${hash}.gif`), { force: true });
|
||||||
|
hash = poster.hash;
|
||||||
|
bytes = poster.bytes;
|
||||||
|
ext = "webp";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.markPreview(row.id, { hash, bytes, ext });
|
||||||
meta.previewed = (meta.previewed ?? 0) + 1;
|
meta.previewed = (meta.previewed ?? 0) + 1;
|
||||||
|
|
||||||
// Embed thumbnails also land in the custom destination, so the download has a
|
// Embed thumbnails also land in the custom destination, so the download has a
|
||||||
// viewable poster for embeds while the media tab keeps its local copy.
|
// viewable poster for embeds while the media tab keeps its local copy.
|
||||||
if (options.destinationDir && row.source === "embed") {
|
if (options.destinationDir && row.source === "embed") {
|
||||||
const target = join(
|
const target = join(options.destinationDir, meta.id, "thumbnails", `${hash}.${ext}`);
|
||||||
options.destinationDir,
|
|
||||||
meta.id,
|
|
||||||
"thumbnails",
|
|
||||||
`${outcome.hash}.${ext}`
|
|
||||||
);
|
|
||||||
mkdirSync(dirname(target), { recursive: true });
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
copyFileSync(join(dir, "thumbs", `${outcome.hash}.${ext}`), target);
|
copyFileSync(join(dir, "thumbs", `${hash}.${ext}`), target);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
store.markPreviewError(row.id, outcome.error ?? outcome.state);
|
store.markPreviewError(row.id, outcome.error ?? outcome.state);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export {
|
|||||||
downloadGapMs,
|
downloadGapMs,
|
||||||
downloadOne,
|
downloadOne,
|
||||||
downloadPreview,
|
downloadPreview,
|
||||||
|
gifPoster,
|
||||||
previewUrlFor,
|
previewUrlFor,
|
||||||
probeDimensions,
|
probeDimensions,
|
||||||
videoPoster,
|
videoPoster,
|
||||||
|
|||||||
@@ -476,34 +476,4 @@ export class ScrapeStore {
|
|||||||
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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { createHash } from "node:crypto";
|
|||||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { downloadOne, downloadPreview, previewUrlFor, videoPoster } from "../src/download.js";
|
import { downloadOne, downloadPreview, gifPoster, previewUrlFor, videoPoster } from "../src/download.js";
|
||||||
import { ogImageFromHtml, resolveEmbedPreview } from "../src/embed-preview.js";
|
import { ogImageFromHtml, resolveEmbedPreview } from "../src/embed-preview.js";
|
||||||
import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js";
|
import { ScrapeEngine, type FetchPage, type ScrapeBackend } from "../src/engine.js";
|
||||||
import { extractMedia } from "../src/extract.js";
|
import { extractMedia } from "../src/extract.js";
|
||||||
@@ -734,6 +734,11 @@ void (async () => {
|
|||||||
status: 200
|
status: 200
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (url.includes("tenor.com/oembed")) {
|
||||||
|
return new Response(JSON.stringify({ thumbnail_url: "https://media.tenor.com/ok.png" }), {
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
}
|
||||||
if (url.includes("api.fxtwitter.com")) {
|
if (url.includes("api.fxtwitter.com")) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ tweet: { media: { videos: [{ thumbnail_url: "https://pbs.twimg.com/ok.jpg" }] } } }),
|
JSON.stringify({ tweet: { media: { videos: [{ thumbnail_url: "https://pbs.twimg.com/ok.jpg" }] } } }),
|
||||||
@@ -763,6 +768,13 @@ void (async () => {
|
|||||||
),
|
),
|
||||||
"https://pbs.twimg.com/ok.jpg"
|
"https://pbs.twimg.com/ok.jpg"
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
await resolveEmbedPreview(
|
||||||
|
{ url: "https://tenor.com/view/abc-123", source: "embed", preview_url: null },
|
||||||
|
{ fetchImpl }
|
||||||
|
),
|
||||||
|
"https://media.tenor.com/ok.png"
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await resolveEmbedPreview(
|
await resolveEmbedPreview(
|
||||||
{ url: "https://vxreddit.com/r/x/comments/1", source: "embed", preview_url: null },
|
{ url: "https://vxreddit.com/r/x/comments/1", source: "embed", preview_url: null },
|
||||||
@@ -823,6 +835,32 @@ void (async () => {
|
|||||||
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
|
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test("gifPoster freezes an animated gif", async () => {
|
||||||
|
const dir = join(root, "gif-test");
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const gifPath = join(dir, "anim.gif");
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync("ffmpeg", [
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-y",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"testsrc=duration=1:size=64x64:rate=2",
|
||||||
|
gifPath
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
console.log("ok - gifPoster skipped (no ffmpeg)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const poster = await gifPoster(gifPath, dir);
|
||||||
|
assert.ok(poster?.hash);
|
||||||
|
assert.equal(existsSync(join(dir, `${poster?.hash}.webp`)), true);
|
||||||
|
});
|
||||||
|
|
||||||
await test("snowflake helpers round-trip dates", () => {
|
await test("snowflake helpers round-trip dates", () => {
|
||||||
const id = snowflakeFromIso("2026-09-16T00:00:00.000Z");
|
const id = snowflakeFromIso("2026-09-16T00:00:00.000Z");
|
||||||
assert.ok(id);
|
assert.ok(id);
|
||||||
|
|||||||
@@ -114,6 +114,17 @@
|
|||||||
color: var(--color-neutral-400);
|
color: var(--color-neutral-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vk-media-gif-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
left: 6px;
|
||||||
|
font: 600 10px/1 ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
text-shadow: 0 0 3px rgba(0, 0, 0, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.vk-media-play {
|
.vk-media-play {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px;
|
top: 4px;
|
||||||
|
|||||||
+45
-6
@@ -191,11 +191,31 @@
|
|||||||
return `${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`;
|
return `${kind}${source}${channel}${author}${aspect}${resolution}${after}${before}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function kindTotals(field) {
|
||||||
|
const stats = state.stats ?? {};
|
||||||
|
return {
|
||||||
|
images: (stats.image?.[field] ?? 0) + (stats.gif?.[field] ?? 0),
|
||||||
|
videos: stats.video?.[field] ?? 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLoaded() {
|
||||||
|
let images = 0;
|
||||||
|
let videos = 0;
|
||||||
|
for (const item of state.items) {
|
||||||
|
if (item.kind === "video") videos++;
|
||||||
|
else if (item.kind === "image" || item.kind === "gif") images++;
|
||||||
|
}
|
||||||
|
return { images, videos };
|
||||||
|
}
|
||||||
|
|
||||||
function renderStatus() {
|
function renderStatus() {
|
||||||
const stats = state.stats ?? { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 };
|
const downloaded = kindTotals("downloaded");
|
||||||
|
const cached = kindTotals("cached");
|
||||||
|
const loaded = countLoaded();
|
||||||
setStatus(
|
setStatus(
|
||||||
`(Images/Videos) | Downloaded ${stats.downloaded}/${stats.total} | ` +
|
`(Images/Videos) | Downloaded ${downloaded.images}/${downloaded.videos} | ` +
|
||||||
`Cached ${stats.cached}/${stats.total} (excl Downl) | Loaded ${state.items.length}/${stats.total}`
|
`Cached ${cached.images}/${cached.videos} | Loaded ${loaded.images}/${loaded.videos}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,15 +448,22 @@
|
|||||||
const figure = el("figure", "vk-media-figure");
|
const figure = el("figure", "vk-media-figure");
|
||||||
const media = el("div", "vk-media-media");
|
const media = el("div", "vk-media-media");
|
||||||
const source = gridSource(item);
|
const source = gridSource(item);
|
||||||
const playable = /\.(mp4|webm|m4v)(\?|$)/i.test(item.url ?? "");
|
const videoSource = item.localUrl || item.playUrl || (item.source !== "embed" ? item.url : null);
|
||||||
|
const playableVideo =
|
||||||
|
!!videoSource &&
|
||||||
|
(/\.(mp4|webm|m4v)(\?|$)/i.test(videoSource) || videoSource === item.playUrl);
|
||||||
|
const animatedGif =
|
||||||
|
item.kind === "gif" && item.remotePreviewUrl && /\.gif(\?|$)/i.test(item.remotePreviewUrl)
|
||||||
|
? item.remotePreviewUrl
|
||||||
|
: null;
|
||||||
|
|
||||||
if (item.kind === "video" && source && item.source !== "embed" && playable) {
|
if (item.kind === "video" && videoSource && playableVideo) {
|
||||||
// Discord has no server-side video thumbnails; the browser shows the first frame.
|
// Discord has no server-side video thumbnails; the browser shows the first frame.
|
||||||
const video = document.createElement("video");
|
const video = document.createElement("video");
|
||||||
video.preload = "metadata";
|
video.preload = "metadata";
|
||||||
video.muted = true;
|
video.muted = true;
|
||||||
video.playsInline = true;
|
video.playsInline = true;
|
||||||
video.src = source;
|
video.src = videoSource;
|
||||||
video.addEventListener(
|
video.addEventListener(
|
||||||
"loadedmetadata",
|
"loadedmetadata",
|
||||||
() => {
|
() => {
|
||||||
@@ -452,11 +479,23 @@
|
|||||||
img.src = source;
|
img.src = source;
|
||||||
img.alt = item.kind;
|
img.alt = item.kind;
|
||||||
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
||||||
|
|
||||||
|
// GIFs stay static in the grid and play while hovered.
|
||||||
|
if (animatedGif) {
|
||||||
|
media.addEventListener("mouseenter", () => {
|
||||||
|
img.src = animatedGif;
|
||||||
|
});
|
||||||
|
media.addEventListener("mouseleave", () => {
|
||||||
|
img.src = source;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
media.append(img);
|
media.append(img);
|
||||||
} else {
|
} else {
|
||||||
placeholder(media, item);
|
placeholder(media, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.kind === "gif") media.append(el("span", "vk-media-gif-label", "GIF"));
|
||||||
if (item.kind === "video") media.append(playIcon(item));
|
if (item.kind === "video") media.append(playIcon(item));
|
||||||
|
|
||||||
media.addEventListener("click", () => openViewer(item, false));
|
media.addEventListener("click", () => openViewer(item, false));
|
||||||
|
|||||||
+21
-6
@@ -118,6 +118,8 @@ export interface MediaItemResult {
|
|||||||
durationSecs: number | null;
|
durationSecs: number | null;
|
||||||
url: string;
|
url: string;
|
||||||
playUrl: string | null;
|
playUrl: string | null;
|
||||||
|
/** The remote thumbnail URL from Discord's embed data (may be an animated GIF). */
|
||||||
|
remotePreviewUrl: string | null;
|
||||||
localUrl: string | null;
|
localUrl: string | null;
|
||||||
thumbUrl: string | null;
|
thumbUrl: string | null;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
@@ -539,9 +541,9 @@ export class MediaService {
|
|||||||
resolution?: string;
|
resolution?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
} = {}): { total: number; downloaded: number; cached: number; notDownloaded: number } {
|
} = {}): Record<MediaKind, { total: number; downloaded: number; cached: number; notDownloaded: number }> {
|
||||||
// Deduplicate by URL across scrapes, newest scrape wins, like queryMedia.
|
// Deduplicate by URL across scrapes, newest scrape wins, like queryMedia.
|
||||||
const byUrl = new Map<string, { downloaded: boolean; cached: boolean }>();
|
const byUrl = new Map<string, { kind: MediaKind; downloaded: boolean; cached: boolean }>();
|
||||||
|
|
||||||
for (const meta of this.engine.list()) {
|
for (const meta of this.engine.list()) {
|
||||||
if (meta.state === "deleted") continue;
|
if (meta.state === "deleted") continue;
|
||||||
@@ -559,6 +561,7 @@ export class MediaService {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (byUrl.has(row.url)) continue;
|
if (byUrl.has(row.url)) continue;
|
||||||
byUrl.set(row.url, {
|
byUrl.set(row.url, {
|
||||||
|
kind: row.kind,
|
||||||
downloaded: row.downloaded === 1,
|
downloaded: row.downloaded === 1,
|
||||||
cached: row.preview_hash !== null
|
cached: row.preview_hash !== null
|
||||||
});
|
});
|
||||||
@@ -571,13 +574,24 @@ export class MediaService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = { total: byUrl.size, downloaded: 0, cached: 0, notDownloaded: 0 };
|
const totals: Record<
|
||||||
|
MediaKind,
|
||||||
|
{ total: number; downloaded: number; cached: number; notDownloaded: number }
|
||||||
|
> = {
|
||||||
|
image: { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 },
|
||||||
|
gif: { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 },
|
||||||
|
video: { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 },
|
||||||
|
audio: { total: 0, downloaded: 0, cached: 0, notDownloaded: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
for (const entry of byUrl.values()) {
|
for (const entry of byUrl.values()) {
|
||||||
|
const bucket = totals[entry.kind];
|
||||||
|
bucket.total++;
|
||||||
if (entry.downloaded) {
|
if (entry.downloaded) {
|
||||||
totals.downloaded++;
|
bucket.downloaded++;
|
||||||
} else {
|
} else {
|
||||||
totals.notDownloaded++;
|
bucket.notDownloaded++;
|
||||||
if (entry.cached) totals.cached++;
|
if (entry.cached) bucket.cached++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,6 +708,7 @@ export class MediaService {
|
|||||||
durationSecs: row.duration_secs,
|
durationSecs: row.duration_secs,
|
||||||
url: row.url,
|
url: row.url,
|
||||||
playUrl: row.play_url,
|
playUrl: row.play_url,
|
||||||
|
remotePreviewUrl: row.preview_url,
|
||||||
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