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:
parent
6f78f13af5
commit
97de6439b5
1 changed files with 76 additions and 50 deletions
|
|
@ -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 {
|
|
||||||
// Direct node URL — Stremio does not reliably follow Master 302/307 for video.
|
|
||||||
const session = await playback.createSession(file.id, userId);
|
|
||||||
streams.push({
|
streams.push({
|
||||||
name: playback.formatStreamLabel(file),
|
name: playback.formatStreamLabel(file),
|
||||||
title: playback.formatStreamLabel(file),
|
title: playback.formatStreamLabel(file),
|
||||||
url: session.streamUrl,
|
// Always Master URL — Master proxies from the node (works behind hairpin NAT).
|
||||||
|
url: playback.resolveStreamUrl(addonToken, file.id),
|
||||||
behaviorHints: {
|
behaviorHints: {
|
||||||
notWebReady: true,
|
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 {
|
|
||||||
const session = await playback.createSession(file.id, userId);
|
|
||||||
streams.push({
|
streams.push({
|
||||||
name: playback.formatStreamLabel(file),
|
name: playback.formatStreamLabel(file),
|
||||||
title: playback.formatStreamLabel(file),
|
title: playback.formatStreamLabel(file),
|
||||||
url: session.streamUrl,
|
url: playback.resolveStreamUrl(addonToken, file.id),
|
||||||
behaviorHints: {
|
behaviorHints: {
|
||||||
notWebReady: true,
|
notWebReady: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
|
||||||
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return streams;
|
return streams;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue