Stream refreshed media for playback and view full
This commit is contained in:
+29
-9
@@ -1080,13 +1080,15 @@
|
||||
body.className = `vk-media-viewer ${vertical ? "vertical" : "wide"}`;
|
||||
media.replaceChildren();
|
||||
|
||||
const source = item.localUrl || item.thumbUrl || item.url;
|
||||
const direct = item.localUrl || (item.source !== "embed" ? item.url : null);
|
||||
const playableFile = !!direct && /\.(mp4|webm|m4v)(\?|$)/i.test(direct);
|
||||
// The stream endpoint serves the local file or a refreshed remote URL, so signed
|
||||
// CDN links that expired no longer break playback.
|
||||
const streamUrl = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
|
||||
const source = item.localUrl || item.thumbUrl || streamUrl;
|
||||
const embed = embedUrlFor(item);
|
||||
const canPlay = !!embed || playableFile;
|
||||
const directVideo = item.localUrl || item.playUrl || (item.source !== "embed" ? streamUrl : null);
|
||||
const canPlay = !!embed || (item.kind === "video" && !!directVideo);
|
||||
|
||||
// A play request on something we cannot embed falls back to the external page.
|
||||
// A play request on something we cannot play falls back to the external page.
|
||||
if (play && !canPlay) openLinkWarning(item.url);
|
||||
|
||||
if (play && embed) {
|
||||
@@ -1095,11 +1097,25 @@
|
||||
frame.allow = "autoplay; fullscreen; picture-in-picture";
|
||||
frame.allowFullscreen = true;
|
||||
media.append(frame);
|
||||
} else if (play && playableFile && direct) {
|
||||
} else if (play && directVideo) {
|
||||
const video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.autoplay = true;
|
||||
video.src = direct;
|
||||
video.src = directVideo;
|
||||
video.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
media.replaceChildren(
|
||||
el("div", "vk-media-status", "This format is not supported in the browser.")
|
||||
);
|
||||
media.append(
|
||||
button("Open in new tab", () =>
|
||||
window.open(directVideo, "_blank", "noreferrer")
|
||||
)
|
||||
);
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
media.append(video);
|
||||
} else if (/^(https?:|\/)/.test(source)) {
|
||||
const img = document.createElement("img");
|
||||
@@ -1127,8 +1143,12 @@
|
||||
link.addEventListener("click", () => openLinkWarning(item.url));
|
||||
meta.append(link);
|
||||
|
||||
// "View full" opens the embedded media (player, local file, or preview) in a new tab.
|
||||
const fullUrl = embedUrlFor(item) ?? item.localUrl ?? item.thumbUrl ?? item.url;
|
||||
// "View full" opens the embedded media (player, local file, or a refreshed stream).
|
||||
const streamPath = `/api/media/${item.scrapeId}/${item.itemId}/stream`;
|
||||
const fullUrl =
|
||||
embedUrlFor(item) ??
|
||||
item.localUrl ??
|
||||
(item.kind === "video" ? streamPath : item.playUrl ?? item.thumbUrl ?? streamPath);
|
||||
meta.append(button("View full", () => window.open(fullUrl, "_blank", "noreferrer")));
|
||||
|
||||
viewerModal.hidden = false;
|
||||
|
||||
@@ -117,6 +117,7 @@ export interface MediaItemResult {
|
||||
contentType: string | null;
|
||||
durationSecs: number | null;
|
||||
url: string;
|
||||
playUrl: string | null;
|
||||
localUrl: string | null;
|
||||
thumbUrl: string | null;
|
||||
provider: string | null;
|
||||
@@ -373,6 +374,89 @@ export class MediaService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A servable media source: the local file, or a remote URL refreshed from the source
|
||||
* message (signed CDN links expire). Null when nothing can be resolved.
|
||||
*/
|
||||
async resolveMediaSource(
|
||||
scrapeId: string,
|
||||
itemId: number
|
||||
): Promise<
|
||||
| { kind: "file"; path: string; contentType: string }
|
||||
| { kind: "remote"; url: string }
|
||||
| null
|
||||
> {
|
||||
const file = this.getMediaFile(scrapeId, itemId);
|
||||
if (file) return { kind: "file", ...file };
|
||||
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Re-fetch the source message and update expired URLs. Returns the fresh playable URL. */
|
||||
private async refreshUrls(
|
||||
scrapeId: string,
|
||||
channelId: string,
|
||||
item: MediaRow
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const store = new ScrapeStore(scrapeDir(this.rootDir, scrapeId));
|
||||
let messageId: string | null = null;
|
||||
try {
|
||||
messageId = store.sourceMessageId(item.id);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
if (!messageId || !this.bot) return null;
|
||||
|
||||
const channel = await this.bot.channels.fetch(channelId);
|
||||
if (!channel?.isTextBased()) return null;
|
||||
const message = await channel.messages.fetch(messageId);
|
||||
|
||||
const path = (url: string) => url.split("?")[0];
|
||||
const attachment = [...message.attachments.values()].find(
|
||||
candidate => path(candidate.url) === path(item.url)
|
||||
);
|
||||
if (attachment) {
|
||||
const update = new ScrapeStore(scrapeDir(this.rootDir, scrapeId));
|
||||
try {
|
||||
update.updateUrls(item.id, { url: attachment.url });
|
||||
} finally {
|
||||
update.close();
|
||||
}
|
||||
return attachment.url;
|
||||
}
|
||||
|
||||
const embed = message.embeds.find(
|
||||
candidate =>
|
||||
(item.play_url && candidate.video?.url && path(candidate.video.url) === path(item.play_url)) ||
|
||||
(candidate.video?.url && path(candidate.url ?? "") === path(item.url))
|
||||
);
|
||||
if (embed?.video?.url) {
|
||||
const update = new ScrapeStore(scrapeDir(this.rootDir, scrapeId));
|
||||
try {
|
||||
update.updateUrls(item.id, { playUrl: embed.video.url, url: embed.url ?? item.url });
|
||||
} finally {
|
||||
update.close();
|
||||
}
|
||||
return embed.video.url;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.deps.log?.(`refresh failed for ${scrapeId}:${item.id}: ${String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Absolute path and content type for a downloaded file, or null. */
|
||||
getMediaFile(scrapeId: string, itemId: number): { path: string; contentType: string } | null {
|
||||
// A custom destination is the user's filesystem, outside the archive: not served.
|
||||
@@ -609,6 +693,7 @@ export class MediaService {
|
||||
contentType: row.content_type,
|
||||
durationSecs: row.duration_secs,
|
||||
url: row.url,
|
||||
playUrl: row.play_url,
|
||||
localUrl:
|
||||
row.file_path && !meta.downloadDir
|
||||
? `/api/media/${meta.id}/${row.id}/file`
|
||||
|
||||
+41
-3
@@ -8,7 +8,8 @@
|
||||
*/
|
||||
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { Readable } from "node:stream";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { dirname } from "node:path";
|
||||
@@ -758,7 +759,7 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
"/api/media/:scrapeId/:itemId/thumb",
|
||||
wrap(async (req, res) => {
|
||||
const file = deps.media.getMediaThumb(String(req.params.scrapeId), Number(req.params.itemId));
|
||||
if (!file) throw new HttpError(404, "thumb not found");
|
||||
if (!file || !existsSync(file.path)) throw new HttpError(404, "thumb not found");
|
||||
res.type(file.contentType).sendFile(file.path);
|
||||
})
|
||||
);
|
||||
@@ -767,11 +768,48 @@ export function startWebUi(deps: WebUiDeps): WebUiHandle {
|
||||
"/api/media/:scrapeId/:itemId/file",
|
||||
wrap(async (req, res) => {
|
||||
const file = deps.media.getMediaFile(String(req.params.scrapeId), Number(req.params.itemId));
|
||||
if (!file) throw new HttpError(404, "file not found");
|
||||
if (!file || !existsSync(file.path)) throw new HttpError(404, "file not found");
|
||||
res.type(file.contentType).sendFile(file.path);
|
||||
})
|
||||
);
|
||||
|
||||
// Playback and full-view source: local file when downloaded, otherwise a refreshed remote URL.
|
||||
app.get(
|
||||
"/api/media/:scrapeId/:itemId/stream",
|
||||
wrap(async (req, res) => {
|
||||
const source = await deps.media.resolveMediaSource(
|
||||
String(req.params.scrapeId),
|
||||
Number(req.params.itemId)
|
||||
);
|
||||
if (!source) throw new HttpError(404, "media source not found");
|
||||
|
||||
if (source.kind === "file") {
|
||||
if (!existsSync(source.path)) throw new HttpError(404, "file not found");
|
||||
res.type(source.contentType).sendFile(source.path);
|
||||
return;
|
||||
}
|
||||
|
||||
const upstream = await fetch(source.url, {
|
||||
headers: req.headers.range ? { range: String(req.headers.range) } : {}
|
||||
});
|
||||
if (!upstream.ok && upstream.status !== 206) {
|
||||
throw new HttpError(502, `upstream ${upstream.status}`);
|
||||
}
|
||||
|
||||
res.status(upstream.status);
|
||||
for (const header of ["content-type", "content-length", "accept-ranges", "content-range"]) {
|
||||
const value = upstream.headers.get(header);
|
||||
if (value) res.setHeader(header, value);
|
||||
}
|
||||
|
||||
if (!upstream.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
Readable.fromWeb(upstream.body as any).pipe(res);
|
||||
})
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/media/storage",
|
||||
wrap(async (_req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user