Resolve external embed previews and keep their real MIME types
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] C19 External embed previews and MIME fixes. Embeds with no thumbnail in Discord's data are
|
||||||
|
resolved via provider oEmbed/fxtwitter and an `og:image` fallback; preview bytes keep their
|
||||||
|
real extension (`preview_ext`), so JPEG thumbnails serve as `image/jpeg` instead of mislabeled
|
||||||
|
WebP (the browser "Content-Type image/webp is not supported" errors). The grid no longer uses
|
||||||
|
embed page URLs as media, and first-frame `<video>` is limited to playable direct files
|
||||||
|
(mp4/webm/m4v) with an on-error placeholder. Live: fxtwitter/vxReddit/TikTok previews cached;
|
||||||
|
7 jpg + 4 webp thumbs served with the right types.
|
||||||
- [x] C18 Video previews and the status row. Uploaded videos have no Discord thumbnails: the
|
- [x] C18 Video previews and the status row. Uploaded videos have no Discord thumbnails: the
|
||||||
grid renders a first-frame `<video>` for them, and downloads generate a poster frame with
|
grid renders a first-frame `<video>` for them, and downloads generate a poster frame with
|
||||||
ffmpeg into `thumbs/` (live: both video attachments in the test channel got posters). The
|
ffmpeg into `thumbs/` (live: both video attachments in the test channel got posters). The
|
||||||
|
|||||||
@@ -120,9 +120,13 @@ Previews run after every walk:
|
|||||||
- In both modes, embed rows also get their stored `preview_url` (YouTube, X, TikTok thumbnails,
|
- In both modes, embed rows also get their stored `preview_url` (YouTube, X, TikTok thumbnails,
|
||||||
and so on) cached. The embed's full media URL is never fetched; embed rows are excluded from
|
and so on) cached. The embed's full media URL is never fetched; embed rows are excluded from
|
||||||
`pendingDownloads`.
|
`pendingDownloads`.
|
||||||
|
- 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,
|
||||||
|
10 s timeout, one attempt).
|
||||||
- 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>.webp`; `preview_hash` and `preview_bytes` land on the row, and
|
- Stored by hash in `thumbs/<sha256>.<ext>`, where the extension follows the response type
|
||||||
the counter is `previewed`. Previews count toward the storage limit.
|
(`preview_ext`, default webp); `preview_hash` and `preview_bytes` land on the row, and the
|
||||||
|
counter is `previewed`. Previews count toward the storage limit.
|
||||||
- The job stays `running` through the download and preview passes, so a client never sees `done`
|
- The job stays `running` through the download and preview passes, so a client never sees `done`
|
||||||
on a half-archived scrape.
|
on a half-archived scrape.
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -86,9 +86,15 @@ media-scrape/db/
|
|||||||
owns that path.
|
owns that path.
|
||||||
- Third-party embeds are **never downloaded as media**: YouTube, Instagram, X, TikTok, and every
|
- Third-party embeds are **never downloaded as media**: YouTube, Instagram, X, TikTok, and every
|
||||||
other embed stays a link. Its thumbnail is cached (below); the video itself is never fetched.
|
other embed stays a link. Its thumbnail is cached (below); the video itself is never fetched.
|
||||||
- Previews are cached small (WebP, 320px) in `thumbs/` after every walk, in both modes: Discord
|
- Previews are cached in `thumbs/` after every walk, in both modes: Discord-hosted images and GIFs
|
||||||
hosted images and GIFs, and third-party embed thumbnails (YouTube, X, TikTok, and so on), so the
|
(resized to 320px WebP) and third-party embed thumbnails (YouTube, X, TikTok, and so on). The
|
||||||
grid does not depend on expiring CDN links. Previews count toward the storage limit.
|
stored bytes keep their real type (`preview_ext`), so JPEG provider thumbnails are served as
|
||||||
|
`image/jpeg`, not mislabeled WebP. Embeds without a thumbnail in Discord's data are resolved
|
||||||
|
through the provider's oEmbed/fxtwitter API and finally the page's `og:image`. Previews count
|
||||||
|
toward the storage limit.
|
||||||
|
- The grid never renders an embed's page URL as media. A direct video file without a preview shows
|
||||||
|
a first-frame `<video>` only for playable containers (mp4, webm, m4v); anything else shows a
|
||||||
|
labeled placeholder, and a failed image falls back to that placeholder.
|
||||||
- Every item records who posted it, when, and its dimensions: `author_id`, `author_name`,
|
- Every item records who posted it, when, and its dimensions: `author_id`, `author_name`,
|
||||||
`posted_at`, `width`, `height`, and `aspect_ratio`. In `download` mode a missing resolution is
|
`posted_at`, `width`, `height`, and `aspect_ratio`. In `download` mode a missing resolution is
|
||||||
filled in with `ffprobe` after the file lands.
|
filled in with `ffprobe` after the file lands.
|
||||||
|
|||||||
@@ -59,9 +59,25 @@ export interface DownloadOutcome {
|
|||||||
path?: string;
|
path?: string;
|
||||||
hash?: string;
|
hash?: string;
|
||||||
bytes?: number;
|
bytes?: number;
|
||||||
|
/** File extension of the stored bytes, when known. */
|
||||||
|
ext?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PREVIEW_EXTENSIONS: Record<string, string> = {
|
||||||
|
"image/webp": "webp",
|
||||||
|
"image/png": "png",
|
||||||
|
"image/jpeg": "jpg",
|
||||||
|
"image/gif": "gif",
|
||||||
|
"image/avif": "avif"
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Extension for preview bytes from the response type, defaulting to webp. */
|
||||||
|
export function previewExtension(contentType: string | null): string {
|
||||||
|
const type = (contentType ?? "").split(";")[0].trim().toLowerCase();
|
||||||
|
return PREVIEW_EXTENSIONS[type] ?? "webp";
|
||||||
|
}
|
||||||
|
|
||||||
/** 4xx that will not improve on retry: the signed URL expired or the file is gone. */
|
/** 4xx that will not improve on retry: the signed URL expired or the file is gone. */
|
||||||
const SKIP_STATUS = new Set([400, 401, 403, 404, 410]);
|
const SKIP_STATUS = new Set([400, 401, 403, 404, 410]);
|
||||||
|
|
||||||
@@ -214,11 +230,13 @@ export async function downloadPreview(
|
|||||||
return { id: media.id, state: "skipped", error: "too large" };
|
return { id: media.id, state: "skipped", error: "too large" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the real type: provider thumbnails are often JPEG or PNG, not WebP.
|
||||||
|
const ext = previewExtension(response.headers.get("content-type"));
|
||||||
const hash = createHash("sha256").update(buffer).digest("hex");
|
const hash = createHash("sha256").update(buffer).digest("hex");
|
||||||
const file = `${hash}.webp`;
|
const file = `${hash}.${ext}`;
|
||||||
writeFileSync(join(dir, file), buffer);
|
writeFileSync(join(dir, file), buffer);
|
||||||
|
|
||||||
return { id: media.id, state: "downloaded", path: file, hash, bytes: buffer.byteLength };
|
return { id: media.id, state: "downloaded", path: file, hash, bytes: buffer.byteLength, ext };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
id: media.id,
|
id: media.id,
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* Resolve a preview image for embeds that carry none in Discord's data.
|
||||||
|
* Provider oEmbed/API first, then the page's og:image / twitter:image.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DownloadOptions } from "./download.js";
|
||||||
|
import type { MediaRow } from "./store.js";
|
||||||
|
|
||||||
|
const USER_AGENT = "veskslop-media/0.1";
|
||||||
|
const MAX_HTML_BYTES = 512 * 1024;
|
||||||
|
const TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
function fetchOf(options: DownloadOptions): typeof fetch {
|
||||||
|
return options.fetchImpl ?? fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJson(url: string, options: DownloadOptions): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetchOf(options)(url, {
|
||||||
|
headers: { "user-agent": USER_AGENT, accept: "application/json" },
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getText(url: string, options: DownloadOptions): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetchOf(options)(url, {
|
||||||
|
headers: { "user-agent": USER_AGENT, accept: "text/html" },
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS)
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return (await response.text()).slice(0, MAX_HTML_BYTES);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First og:image or twitter:image in the page head, either attribute order. */
|
||||||
|
export function ogImageFromHtml(html: string): string | null {
|
||||||
|
for (const pattern of [
|
||||||
|
/<meta[^>]+(?:property|name)=["']og:image["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']og:image["']/i,
|
||||||
|
/<meta[^>]+(?:property|name)=["']twitter:image["'][^>]+content=["']([^"']+)["']/i,
|
||||||
|
/<meta[^>]+content=["']([^"']+)["'][^>]+(?:property|name)=["']twitter:image["']/i
|
||||||
|
]) {
|
||||||
|
const match = pattern.exec(html);
|
||||||
|
if (match?.[1]) return match[1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview URL for an embed. Uses the stored thumbnail when present, otherwise asks the
|
||||||
|
* provider (oEmbed, fxtwitter) and finally falls back to the page's og:image.
|
||||||
|
*/
|
||||||
|
export async function resolveEmbedPreview(
|
||||||
|
media: Pick<MediaRow, "url" | "source" | "preview_url">,
|
||||||
|
options: DownloadOptions = {}
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (media.source !== "embed") return null;
|
||||||
|
if (media.preview_url) return media.preview_url;
|
||||||
|
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(media.url);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = parsed.hostname.replace(/^www\./, "").toLowerCase();
|
||||||
|
|
||||||
|
if (host === "youtube.com" || host === "m.youtube.com" || host === "youtu.be") {
|
||||||
|
const data = await getJson(
|
||||||
|
`https://www.youtube.com/oembed?url=${encodeURIComponent(media.url)}&format=json`,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === "vimeo.com") {
|
||||||
|
const data = await getJson(
|
||||||
|
`https://vimeo.com/api/oembed.json?url=${encodeURIComponent(media.url)}`,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
if (typeof data?.thumbnail_url === "string") return data.thumbnail_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host === "tiktok.com") {
|
||||||
|
const data = await getJson(
|
||||||
|
`https://www.tiktok.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") {
|
||||||
|
const match = /\/status\/(\d+)/.exec(parsed.pathname);
|
||||||
|
if (match) {
|
||||||
|
const data = await getJson(`https://api.fxtwitter.com/status/${match[1]}`, options);
|
||||||
|
const video = data?.tweet?.media?.videos?.[0]?.thumbnail_url;
|
||||||
|
const photo = data?.tweet?.media?.photos?.[0]?.url;
|
||||||
|
if (typeof video === "string") return video;
|
||||||
|
if (typeof photo === "string") return photo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await getText(media.url, options);
|
||||||
|
return html ? ogImageFromHtml(html) : null;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
videoPoster,
|
videoPoster,
|
||||||
type DownloadOptions
|
type DownloadOptions
|
||||||
} from "./download.js";
|
} from "./download.js";
|
||||||
|
import { resolveEmbedPreview } from "./embed-preview.js";
|
||||||
import {
|
import {
|
||||||
createScrapeDirs,
|
createScrapeDirs,
|
||||||
listScrapes,
|
listScrapes,
|
||||||
@@ -358,18 +359,38 @@ export class ScrapeEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const outcome = await downloadPreview(row, join(dir, "thumbs"), options);
|
// Some providers (fxtwitter, vxReddit, ...) ship no thumbnail in Discord's data;
|
||||||
|
// resolve one from the provider or the page before caching it.
|
||||||
|
let previewRow = row;
|
||||||
|
if (row.source === "embed" && !row.preview_url) {
|
||||||
|
const resolved = await resolveEmbedPreview(row, options);
|
||||||
|
if (!resolved) {
|
||||||
|
store.markPreviewError(row.id, "no preview source");
|
||||||
|
this.persist(dir, meta);
|
||||||
|
await this.sleep(downloadGapMs(options));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
previewRow = { ...row, preview_url: resolved };
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await downloadPreview(previewRow, join(dir, "thumbs"), options);
|
||||||
|
|
||||||
if (outcome.state === "downloaded" && outcome.hash) {
|
if (outcome.state === "downloaded" && outcome.hash) {
|
||||||
store.markPreview(row.id, { hash: outcome.hash, bytes: outcome.bytes ?? 0 });
|
const ext = outcome.ext ?? "webp";
|
||||||
|
store.markPreview(row.id, { hash: outcome.hash, bytes: outcome.bytes ?? 0, 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(options.destinationDir, meta.id, "thumbnails", `${outcome.hash}.webp`);
|
const target = join(
|
||||||
|
options.destinationDir,
|
||||||
|
meta.id,
|
||||||
|
"thumbnails",
|
||||||
|
`${outcome.hash}.${ext}`
|
||||||
|
);
|
||||||
mkdirSync(dirname(target), { recursive: true });
|
mkdirSync(dirname(target), { recursive: true });
|
||||||
copyFileSync(join(dir, "thumbs", `${outcome.hash}.webp`), target);
|
copyFileSync(join(dir, "thumbs", `${outcome.hash}.${ext}`), target);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
store.markPreviewError(row.id, outcome.error ?? outcome.state);
|
store.markPreviewError(row.id, outcome.error ?? outcome.state);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { extractMedia, type ExtractedMedia } from "./extract.js";
|
export { extractMedia, type ExtractedMedia } from "./extract.js";
|
||||||
|
export { ogImageFromHtml, resolveEmbedPreview } from "./embed-preview.js";
|
||||||
export { isNearLimit, planRetention, type RetentionPlan, type RetentionScrape } from "./retention.js";
|
export { isNearLimit, planRetention, type RetentionPlan, type RetentionScrape } from "./retention.js";
|
||||||
export { isoFromSnowflake, snowflakeFromIso } from "./snowflake.js";
|
export { isoFromSnowflake, snowflakeFromIso } from "./snowflake.js";
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export interface MediaRow {
|
|||||||
preview_url: string | null;
|
preview_url: string | null;
|
||||||
file_hash: string | null;
|
file_hash: string | null;
|
||||||
preview_hash: string | null;
|
preview_hash: string | null;
|
||||||
|
preview_ext: string | null;
|
||||||
file_bytes: number | null;
|
file_bytes: number | null;
|
||||||
preview_bytes: number | null;
|
preview_bytes: number | null;
|
||||||
file_path: string | null;
|
file_path: string | null;
|
||||||
@@ -169,6 +170,7 @@ CREATE TABLE IF NOT EXISTS media (
|
|||||||
preview_url TEXT,
|
preview_url TEXT,
|
||||||
file_hash TEXT,
|
file_hash TEXT,
|
||||||
preview_hash TEXT,
|
preview_hash TEXT,
|
||||||
|
preview_ext TEXT,
|
||||||
file_bytes INTEGER,
|
file_bytes INTEGER,
|
||||||
preview_bytes INTEGER,
|
preview_bytes INTEGER,
|
||||||
file_path TEXT,
|
file_path TEXT,
|
||||||
@@ -212,6 +214,7 @@ export class ScrapeStore {
|
|||||||
if (!names.has("file_path")) this.db.exec("ALTER TABLE media ADD COLUMN file_path TEXT");
|
if (!names.has("file_path")) this.db.exec("ALTER TABLE media ADD COLUMN file_path TEXT");
|
||||||
if (!names.has("download_error")) this.db.exec("ALTER TABLE media ADD COLUMN download_error TEXT");
|
if (!names.has("download_error")) this.db.exec("ALTER TABLE media ADD COLUMN download_error TEXT");
|
||||||
if (!names.has("preview_error")) this.db.exec("ALTER TABLE media ADD COLUMN preview_error TEXT");
|
if (!names.has("preview_error")) this.db.exec("ALTER TABLE media ADD COLUMN preview_error TEXT");
|
||||||
|
if (!names.has("preview_ext")) this.db.exec("ALTER TABLE media ADD COLUMN preview_ext TEXT");
|
||||||
if (!names.has("aspect_ratio")) this.db.exec("ALTER TABLE media ADD COLUMN aspect_ratio REAL");
|
if (!names.has("aspect_ratio")) this.db.exec("ALTER TABLE media ADD COLUMN aspect_ratio REAL");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,10 +350,12 @@ export class ScrapeStore {
|
|||||||
.all(limit) as unknown as MediaRow[];
|
.all(limit) as unknown as MediaRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
markPreview(id: number, preview: { hash: string; bytes: number }): void {
|
markPreview(id: number, preview: { hash: string; bytes: number; ext?: string }): void {
|
||||||
this.db
|
this.db
|
||||||
.prepare("UPDATE media SET preview_hash = ?, preview_bytes = ?, preview_error = NULL WHERE id = ?")
|
.prepare(
|
||||||
.run(preview.hash, preview.bytes, id);
|
"UPDATE media SET preview_hash = ?, preview_bytes = ?, preview_ext = ?, preview_error = NULL WHERE id = ?"
|
||||||
|
)
|
||||||
|
.run(preview.hash, preview.bytes, preview.ext ?? "webp", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
markPreviewError(id: number, error: string): void {
|
markPreviewError(id: number, error: string): void {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ 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, previewUrlFor, videoPoster } from "../src/download.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";
|
||||||
import { PuppetBackend } from "../src/puppet-backend.js";
|
import { PuppetBackend } from "../src/puppet-backend.js";
|
||||||
@@ -549,6 +550,17 @@ void (async () => {
|
|||||||
assert.equal(outcome.state, "downloaded");
|
assert.equal(outcome.state, "downloaded");
|
||||||
assert.equal(outcome.path, `${outcome.hash}.webp`);
|
assert.equal(outcome.path, `${outcome.hash}.webp`);
|
||||||
assert.equal(existsSync(join(dir, `${outcome.hash}.webp`)), true);
|
assert.equal(existsSync(join(dir, `${outcome.hash}.webp`)), true);
|
||||||
|
|
||||||
|
const jpeg = await downloadPreview(
|
||||||
|
{ id: 10, url: "https://cdn.discordapp.com/attachments/1/2/pic.png", kind: "image", source: "attachment", preview_url: null },
|
||||||
|
dir,
|
||||||
|
{
|
||||||
|
fetchImpl: async () =>
|
||||||
|
new Response("jpeg-bytes", { status: 200, headers: { "content-type": "image/jpeg" } })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(jpeg.path, `${jpeg.hash}.jpg`);
|
||||||
|
assert.equal(existsSync(join(dir, `${jpeg.hash}.jpg`)), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
await test("embed thumbnails download only when enabled", async () => {
|
await test("embed thumbnails download only when enabled", async () => {
|
||||||
@@ -635,6 +647,7 @@ void (async () => {
|
|||||||
const row = store.queryMedia({ kind: "image" })[0];
|
const row = store.queryMedia({ kind: "image" })[0];
|
||||||
assert.ok(row.preview_hash);
|
assert.ok(row.preview_hash);
|
||||||
assert.ok((row.preview_bytes ?? 0) > 0);
|
assert.ok((row.preview_bytes ?? 0) > 0);
|
||||||
|
assert.equal(row.preview_ext, "webp");
|
||||||
store.close();
|
store.close();
|
||||||
assert.equal(existsSync(join(root, started.id, "thumbs", `${row.preview_hash}.webp`)), true);
|
assert.equal(existsSync(join(root, started.id, "thumbs", `${row.preview_hash}.webp`)), true);
|
||||||
});
|
});
|
||||||
@@ -704,14 +717,82 @@ void (async () => {
|
|||||||
|
|
||||||
const embed = store.queryMedia({ kind: "video" })[0];
|
const embed = store.queryMedia({ kind: "video" })[0];
|
||||||
assert.ok(embed.preview_hash);
|
assert.ok(embed.preview_hash);
|
||||||
assert.equal(
|
const previewFile = `${embed.preview_hash}.${embed.preview_ext ?? "webp"}`;
|
||||||
existsSync(join(dest, started.id, "thumbnails", `${embed.preview_hash}.webp`)),
|
assert.equal(existsSync(join(dest, started.id, "thumbnails", previewFile)), true);
|
||||||
true
|
assert.equal(existsSync(join(root, started.id, "thumbs", previewFile)), true);
|
||||||
);
|
|
||||||
assert.equal(existsSync(join(root, started.id, "thumbs", `${embed.preview_hash}.webp`)), true);
|
|
||||||
store.close();
|
store.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await test("embed previews resolve from providers and og:image", async () => {
|
||||||
|
const fetchImpl = async (input: any) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("youtube.com/oembed")) {
|
||||||
|
return new Response(JSON.stringify({ thumbnail_url: "https://i.ytimg.com/ok.jpg" }), {
|
||||||
|
status: 200
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("api.fxtwitter.com")) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ tweet: { media: { videos: [{ thumbnail_url: "https://pbs.twimg.com/ok.jpg" }] } } }),
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (url.includes("vxreddit.com")) {
|
||||||
|
return new Response(
|
||||||
|
'<html><head><meta property="og:image" content="https://i.redd.it/ok.jpg"></head></html>',
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response("no", { status: 404 });
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await resolveEmbedPreview(
|
||||||
|
{ url: "https://youtu.be/abc", source: "embed", preview_url: null },
|
||||||
|
{ fetchImpl }
|
||||||
|
),
|
||||||
|
"https://i.ytimg.com/ok.jpg"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await resolveEmbedPreview(
|
||||||
|
{ url: "https://fxtwitter.com/u/status/12345", source: "embed", preview_url: null },
|
||||||
|
{ fetchImpl }
|
||||||
|
),
|
||||||
|
"https://pbs.twimg.com/ok.jpg"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await resolveEmbedPreview(
|
||||||
|
{ url: "https://vxreddit.com/r/x/comments/1", source: "embed", preview_url: null },
|
||||||
|
{ fetchImpl }
|
||||||
|
),
|
||||||
|
"https://i.redd.it/ok.jpg"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await resolveEmbedPreview(
|
||||||
|
{ url: "https://x.com/u/status/1", source: "embed", preview_url: "https://stored/ok.jpg" },
|
||||||
|
{ fetchImpl }
|
||||||
|
),
|
||||||
|
"https://stored/ok.jpg"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await resolveEmbedPreview(
|
||||||
|
{ url: "https://cdn.discordapp.com/x.png", source: "attachment", preview_url: null },
|
||||||
|
{ fetchImpl }
|
||||||
|
),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
ogImageFromHtml('<meta content="https://a/1.jpg" property="og:image">'),
|
||||||
|
"https://a/1.jpg"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
ogImageFromHtml('<meta name="twitter:image" content="https://a/2.jpg">'),
|
||||||
|
"https://a/2.jpg"
|
||||||
|
);
|
||||||
|
assert.equal(ogImageFromHtml("<html></html>"), null);
|
||||||
|
});
|
||||||
|
|
||||||
await test("videoPoster extracts a cached frame", async () => {
|
await test("videoPoster extracts a cached frame", async () => {
|
||||||
const dir = join(root, "poster-test");
|
const dir = join(root, "poster-test");
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
|
|||||||
+34
-20
@@ -392,24 +392,38 @@
|
|||||||
return el(className ?? "figcaption", "vk-media-banner", parts.join(" · "));
|
return el(className ?? "figcaption", "vk-media-banner", parts.join(" · "));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Grid thumbnail source: cached preview, then the local file, then the original link. */
|
/**
|
||||||
|
* Grid thumbnail source: cached preview, then the local file.
|
||||||
|
* Attachment URLs are direct files; embed URLs are pages, never rendered as media.
|
||||||
|
*/
|
||||||
function gridSource(item) {
|
function gridSource(item) {
|
||||||
return item.thumbUrl || item.localUrl || item.url;
|
if (item.thumbUrl) return item.thumbUrl;
|
||||||
|
if (item.localUrl) return item.localUrl;
|
||||||
|
if (item.source !== "embed") return item.url;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholder(media, item) {
|
||||||
|
media.replaceChildren(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function playIcon(item) {
|
||||||
|
const play = el("span", "vk-media-play", "▶");
|
||||||
|
play.title = "Play";
|
||||||
|
play.addEventListener("click", event => {
|
||||||
|
event.stopPropagation();
|
||||||
|
openViewer(item, true);
|
||||||
|
});
|
||||||
|
return play;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(item) {
|
function renderCard(item) {
|
||||||
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 visual = item.kind === "image" || item.kind === "gif" || item.kind === "video";
|
const playable = /\.(mp4|webm|m4v)(\?|$)/i.test(item.url ?? "");
|
||||||
|
|
||||||
if (visual && /^(https?:|\/)/.test(source) && item.kind !== "video") {
|
if (item.kind === "video" && source && item.source !== "embed" && playable) {
|
||||||
const img = document.createElement("img");
|
|
||||||
img.loading = "lazy";
|
|
||||||
img.src = source;
|
|
||||||
img.alt = item.kind;
|
|
||||||
media.append(img);
|
|
||||||
} else if (item.kind === "video" && /^(https?:|\/)/.test(source)) {
|
|
||||||
// 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";
|
||||||
@@ -423,20 +437,20 @@
|
|||||||
},
|
},
|
||||||
{ once: true }
|
{ once: true }
|
||||||
);
|
);
|
||||||
|
video.addEventListener("error", () => placeholder(media, item), { once: true });
|
||||||
media.append(video);
|
media.append(video);
|
||||||
|
} else if (source && /^(https?:|\/)/.test(source)) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.src = source;
|
||||||
|
img.alt = item.kind;
|
||||||
|
img.addEventListener("error", () => placeholder(media, item), { once: true });
|
||||||
|
media.append(img);
|
||||||
} else {
|
} else {
|
||||||
media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase()));
|
placeholder(media, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === "video") {
|
if (item.kind === "video") media.append(playIcon(item));
|
||||||
const play = el("span", "vk-media-play", "▶");
|
|
||||||
play.title = "Play";
|
|
||||||
play.addEventListener("click", event => {
|
|
||||||
event.stopPropagation();
|
|
||||||
openViewer(item, true);
|
|
||||||
});
|
|
||||||
media.append(play);
|
|
||||||
}
|
|
||||||
|
|
||||||
media.addEventListener("click", () => openViewer(item, false));
|
media.addEventListener("click", () => openViewer(item, false));
|
||||||
figure.append(media);
|
figure.append(media);
|
||||||
|
|||||||
+3
-2
@@ -394,9 +394,10 @@ export class MediaService {
|
|||||||
const item = this.getMediaItem(scrapeId, itemId);
|
const item = this.getMediaItem(scrapeId, itemId);
|
||||||
if (!item?.preview_hash) return null;
|
if (!item?.preview_hash) return null;
|
||||||
|
|
||||||
|
const extension = item.preview_ext ?? "webp";
|
||||||
return {
|
return {
|
||||||
path: join(this.rootDir, scrapeId, "thumbs", `${item.preview_hash}.webp`),
|
path: join(this.rootDir, scrapeId, "thumbs", `${item.preview_hash}.${extension}`),
|
||||||
contentType: "image/webp"
|
contentType: FILE_CONTENT_TYPES[extension] ?? "application/octet-stream"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user