diff --git a/poc/public/media.js b/poc/public/media.js index 09deacb..2bc2632 100644 --- a/poc/public/media.js +++ b/poc/public/media.js @@ -412,17 +412,6 @@ return el(className ?? "figcaption", "vk-media-banner", parts.join(" ยท ")); } - /** - * 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) { - 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())); } @@ -447,36 +436,18 @@ function renderCard(item) { const figure = el("figure", "vk-media-figure"); const media = el("div", "vk-media-media"); - const source = gridSource(item); - 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 imageSource = item.localUrl || item.thumbUrl; + const fallbackSource = item.source !== "embed" ? item.url : null; + const streamPath = `/api/media/${item.scrapeId}/${item.itemId}/stream`; const animatedGif = item.kind === "gif" && item.remotePreviewUrl && /\.gif(\?|$)/i.test(item.remotePreviewUrl) ? item.remotePreviewUrl : null; - if (item.kind === "video" && videoSource && playableVideo) { - // Discord has no server-side video thumbnails; the browser shows the first frame. - const video = document.createElement("video"); - video.preload = "metadata"; - video.muted = true; - video.playsInline = true; - video.src = videoSource; - video.addEventListener( - "loadedmetadata", - () => { - if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2); - }, - { once: true } - ); - video.addEventListener("error", () => placeholder(media, item), { once: true }); - media.append(video); - } else if (source && /^(https?:|\/)/.test(source)) { + if (imageSource) { const img = document.createElement("img"); img.loading = "lazy"; - img.src = source; + img.src = imageSource; img.alt = item.kind; img.addEventListener("error", () => placeholder(media, item), { once: true }); @@ -486,10 +457,34 @@ img.src = animatedGif; }); media.addEventListener("mouseleave", () => { - img.src = source; + img.src = imageSource; }); } + media.append(img); + } else if (item.kind === "video") { + // No thumbnail: take the first frame through the same-origin stream endpoint, + // which refreshes expired URLs and avoids cross-origin blocking. + const video = document.createElement("video"); + video.preload = "metadata"; + video.muted = true; + video.playsInline = true; + video.src = streamPath; + video.addEventListener( + "loadedmetadata", + () => { + if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2); + }, + { once: true } + ); + video.addEventListener("error", () => placeholder(media, item), { once: true }); + media.append(video); + } else if (fallbackSource && /^(https?:|\/)/.test(fallbackSource)) { + const img = document.createElement("img"); + img.loading = "lazy"; + img.src = fallbackSource; + img.alt = item.kind; + img.addEventListener("error", () => placeholder(media, item), { once: true }); media.append(img); } else { placeholder(media, item); @@ -1124,7 +1119,7 @@ const streamUrl = `/api/media/${item.scrapeId}/${item.itemId}/stream`; const source = item.localUrl || item.thumbUrl || streamUrl; const embed = embedUrlFor(item); - const directVideo = item.localUrl || item.playUrl || (item.source !== "embed" ? streamUrl : null); + const directVideo = item.localUrl || (item.kind === "video" ? streamUrl : null); const canPlay = !!embed || (item.kind === "video" && !!directVideo); // A play request on something we cannot play falls back to the external page. @@ -1156,13 +1151,27 @@ { once: true } ); media.append(video); + } else if (item.kind === "video") { + // No still available: show the first frame through the stream endpoint. + const video = document.createElement("video"); + video.preload = "metadata"; + video.muted = true; + video.playsInline = true; + video.src = streamUrl; + video.addEventListener( + "loadedmetadata", + () => { + if (video.duration) video.currentTime = Math.min(0.1, video.duration / 2); + }, + { once: true } + ); + media.append(video); + media.append(viewerPlayIcon(item)); } else if (/^(https?:|\/)/.test(source)) { const img = document.createElement("img"); img.src = source; img.alt = item.kind; media.append(img); - - if (item.kind === "video") media.append(viewerPlayIcon(item)); } else { media.append(el("span", "vk-media-card-kind", item.kind.toUpperCase())); } diff --git a/poc/src/media.ts b/poc/src/media.ts index 62a6254..1067499 100644 --- a/poc/src/media.ts +++ b/poc/src/media.ts @@ -391,16 +391,20 @@ export class MediaService { const file = this.getMediaFile(scrapeId, itemId); if (file) return { kind: "file", ...file }; + const item = this.getMediaItem(scrapeId, itemId); + if (!item) return null; + + return { kind: "remote", url: item.play_url ?? item.url }; + } + + /** Re-fetch the source message for a fresh signed URL. Null when not possible. */ + async refreshMediaUrl(scrapeId: string, itemId: number): Promise { const meta = this.engine.get(scrapeId); const item = this.getMediaItem(scrapeId, itemId); if (!meta || !item) return null; + if (meta.backend !== "slopbot" || !meta.guildId || !this.bot) return null; - if (meta.backend === "slopbot" && meta.guildId && this.bot) { - const refreshed = await this.refreshUrls(meta.id, meta.channelId, item); - if (refreshed) return { kind: "remote", url: refreshed }; - } - - return { kind: "remote", url: item.play_url ?? item.url }; + return this.refreshUrls(meta.id, meta.channelId, item); } /** Re-fetch the source message and update expired URLs. Returns the fresh playable URL. */ diff --git a/poc/src/webui.ts b/poc/src/webui.ts index 0e70275..05d3810 100644 --- a/poc/src/webui.ts +++ b/poc/src/webui.ts @@ -789,9 +789,20 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle { return; } - const upstream = await fetch(source.url, { - headers: req.headers.range ? { range: String(req.headers.range) } : {} - }); + const rangeHeaders: Record = req.headers.range + ? { range: String(req.headers.range) } + : {}; + let upstream = await fetch(source.url, { headers: rangeHeaders }); + + // The signed CDN URL may have expired; re-fetch the message once and retry. + if (!upstream.ok && [403, 404, 410].includes(upstream.status)) { + const refreshed = await deps.media.refreshMediaUrl( + String(req.params.scrapeId), + Number(req.params.itemId) + ); + if (refreshed) upstream = await fetch(refreshed, { headers: rangeHeaders }); + } + if (!upstream.ok && upstream.status !== 206) { throw new HttpError(502, `upstream ${upstream.status}`); }