Stream refreshed media for playback and view full

This commit is contained in:
Fisto
2026-09-16 13:18:55 +02:00
parent d3f9d20190
commit 612b2504dc
9 changed files with 207 additions and 18 deletions
+85
View File
@@ -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
View File
@@ -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) => {