Proxy Stremio playback through Master to avoid node hairpin NAT.

Clients only talk to master.vonas.nl; Master fetches the node stream server-side and pipes Range responses.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jos Vooges | STH 2026-08-25 02:44:30 +02:00
parent 6f78f13af5
commit 97de6439b5

View file

@ -127,11 +127,14 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
const streams = await getStreams(type, id, userId, playback, request.log); const streams = await getStreams(type, id, token, playback);
return { streams }; return { streams };
}); });
// Fallback play URL (direct node redirect) — stream.json prefers eager node URLs. /**
* 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).
*/
app.route({ app.route({
method: ["GET", "HEAD"], method: ["GET", "HEAD"],
url: "/stremio/:token/play/:mediaFileId", url: "/stremio/:token/play/:mediaFileId",
@ -142,28 +145,13 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config
return reply.status(401).send({ error: "Invalid addon token" }); return reply.status(401).send({ error: "Invalid addon token" });
} }
if (request.method === "HEAD") {
const mediaFile = await prisma.mediaFile.findUnique({
where: { id: mediaFileId },
include: { node: true },
});
if (
!mediaFile?.available ||
mediaFile.node.revoked ||
mediaFile.node.status !== "ONLINE"
) {
return reply.status(404).send();
}
return reply.status(200).send();
}
try { try {
const session = await playback.createSession(mediaFileId, userId); const session = await playback.createSession(mediaFileId, userId);
request.log.info( request.log.info(
{ mediaFileId, streamUrl: session.streamUrl }, { mediaFileId, nodeUrl: session.streamUrl, method: request.method },
"playback redirect to node" "playback proxy to node"
); );
return reply.code(302).redirect(session.streamUrl); return proxyNodePlay(request, reply, session.streamUrl);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "Playback unavailable"; const message = err instanceof Error ? err.message : "Playback unavailable";
request.log.error({ err, mediaFileId }, "playback create failed"); request.log.error({ err, mediaFileId }, "playback create failed");
@ -173,6 +161,55 @@ 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 { function parseExtraSearch(extra: string): string | undefined {
const decoded = decodeURIComponent(extra.replace(/\.json$/, "")); const decoded = decodeURIComponent(extra.replace(/\.json$/, ""));
const parts = decoded.split("&"); const parts = decoded.split("&");
@ -354,9 +391,8 @@ async function getSeriesMeta(id: string) {
async function getStreams( async function getStreams(
type: string, type: string,
id: string, id: string,
userId: string, addonToken: string,
playback: PlaybackService, playback: PlaybackService
log?: { warn: (obj: unknown, msg?: string) => void }
) { ) {
if (type === "movie") { if (type === "movie") {
const movie = await prisma.movie.findFirst({ const movie = await prisma.movie.findFirst({
@ -373,20 +409,15 @@ async function getStreams(
const streams = []; const streams = [];
for (const file of movie.mediaFiles) { for (const file of movie.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue; if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try { streams.push({
// Direct node URL — Stremio does not reliably follow Master 302/307 for video. name: playback.formatStreamLabel(file),
const session = await playback.createSession(file.id, userId); title: playback.formatStreamLabel(file),
streams.push({ // Always Master URL — Master proxies from the node (works behind hairpin NAT).
name: playback.formatStreamLabel(file), url: playback.resolveStreamUrl(addonToken, file.id),
title: playback.formatStreamLabel(file), behaviorHints: {
url: session.streamUrl, notWebReady: true,
behaviorHints: { },
notWebReady: true, });
},
});
} catch (err) {
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
}
} }
return streams; return streams;
} }
@ -425,19 +456,14 @@ async function getStreams(
const streams = []; const streams = [];
for (const file of episode.mediaFiles) { for (const file of episode.mediaFiles) {
if (file.node.status !== "ONLINE" || file.node.revoked) continue; if (file.node.status !== "ONLINE" || file.node.revoked) continue;
try { streams.push({
const session = await playback.createSession(file.id, userId); name: playback.formatStreamLabel(file),
streams.push({ title: playback.formatStreamLabel(file),
name: playback.formatStreamLabel(file), url: playback.resolveStreamUrl(addonToken, file.id),
title: playback.formatStreamLabel(file), behaviorHints: {
url: session.streamUrl, notWebReady: true,
behaviorHints: { },
notWebReady: true, });
},
});
} catch (err) {
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
}
} }
return streams; return streams;
} }