Revert stream proxy: Stremio plays directly from the media node.

Master only creates the playback session and returns the node URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 02:45:16 +02:00
parent 97de6439b5
commit 2aac72cd71

View file

@ -127,14 +127,11 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return reply.status(401).send({ error: "Invalid addon token" });
}
const streams = await getStreams(type, id, token, playback);
const streams = await getStreams(type, id, userId, playback, request.log);
return { streams };
});
/**
* Stremio opens this Master URL. We create a node session and proxy bytes from the node
* so clients never need to reach the node hostname (avoids hairpin-NAT / broken NPM).
*/
// Optional fallback if something still hits Master /play — redirect to node (no proxy).
app.route({
method: ["GET", "HEAD"],
url: "/stremio/:token/play/:mediaFileId",
@ -148,10 +145,10 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
try {
const session = await playback.createSession(mediaFileId, userId);
request.log.info(
{ mediaFileId, nodeUrl: session.streamUrl, method: request.method },
"playback proxy to node"
{ mediaFileId, streamUrl: session.streamUrl },
"playback delegate to node"
);
return proxyNodePlay(request, reply, session.streamUrl);
return reply.code(302).redirect(session.streamUrl);
} catch (err) {
const message = err instanceof Error ? err.message : "Playback unavailable";
request.log.error({ err, mediaFileId }, "playback create failed");
@ -161,55 +158,6 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
});
}
async function proxyNodePlay(
request: { method: string; headers: Record<string, unknown> },
reply: {
code: (n: number) => unknown;
header: (k: string, v: string | number) => unknown;
status: (n: number) => { send: (b?: unknown) => unknown };
send: (b?: unknown) => unknown;
},
nodeUrl: string
) {
const headers: Record<string, string> = {};
const range = request.headers.range;
if (typeof range === "string" && range) headers.Range = range;
let upstream: Response;
try {
upstream = await fetch(nodeUrl, {
method: request.method === "HEAD" ? "HEAD" : "GET",
headers,
// Long-running video streams
signal: AbortSignal.timeout(6 * 60 * 60 * 1000),
});
} catch (err) {
const message = err instanceof Error ? err.message : "node fetch failed";
return reply.status(502).send({ error: `Node unreachable: ${message}` });
}
reply.code(upstream.status);
for (const name of [
"content-type",
"content-length",
"content-range",
"accept-ranges",
"cache-control",
]) {
const value = upstream.headers.get(name);
if (value) reply.header(name, value);
}
reply.header("Access-Control-Allow-Origin", "*");
reply.header("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges");
if (request.method === "HEAD" || !upstream.body) {
return reply.send();
}
const { Readable } = await import("node:stream");
return reply.send(Readable.fromWeb(upstream.body as import("stream/web").ReadableStream));
}
function parseExtraSearch(extra: string): string | undefined {
const decoded = decodeURIComponent(extra.replace(/\.json$/, ""));
const parts = decoded.split("&");
@ -391,8 +339,9 @@ async function getSeriesMeta(id: string) {
async function getStreams(
type: string,
id: string,
addonToken: string,
playback: PlaybackService
userId: string,
playback: PlaybackService,
log?: { warn: (obj: unknown, msg?: string) => void; info?: (obj: unknown, msg?: string) => void }
) {
if (type === "movie") {
const movie = await prisma.movie.findFirst({
@ -409,15 +358,24 @@ async function getStreams(
const streams = [];
for (const file of movie.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try {
// Master only creates the session; Stremio streams directly from the node.
const session = await playback.createSession(file.id, userId);
log?.info?.(
{ mediaFileId: file.id, streamUrl: session.streamUrl },
"stream delegated to node"
);
streams.push({
name: playback.formatStreamLabel(file),
title: playback.formatStreamLabel(file),
// Always Master URL — Master proxies from the node (works behind hairpin NAT).
url: playback.resolveStreamUrl(addonToken, file.id),
url: session.streamUrl,
behaviorHints: {
notWebReady: true,
},
});
} catch (err) {
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
}
}
return streams;
}
@ -456,14 +414,23 @@ async function getStreams(
const streams = [];
for (const file of episode.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try {
const session = await playback.createSession(file.id, userId);
log?.info?.(
{ mediaFileId: file.id, streamUrl: session.streamUrl },
"stream delegated to node"
);
streams.push({
name: playback.formatStreamLabel(file),
title: playback.formatStreamLabel(file),
url: playback.resolveStreamUrl(addonToken, file.id),
url: session.streamUrl,
behaviorHints: {
notWebReady: true,
},
});
} catch (err) {
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
}
}
return streams;
}