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:
parent
97de6439b5
commit
2aac72cd71
1 changed files with 43 additions and 76 deletions
|
|
@ -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;
|
||||
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),
|
||||
behaviorHints: {
|
||||
notWebReady: true,
|
||||
},
|
||||
});
|
||||
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),
|
||||
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;
|
||||
streams.push({
|
||||
name: playback.formatStreamLabel(file),
|
||||
title: playback.formatStreamLabel(file),
|
||||
url: playback.resolveStreamUrl(addonToken, file.id),
|
||||
behaviorHints: {
|
||||
notWebReady: true,
|
||||
},
|
||||
});
|
||||
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: session.streamUrl,
|
||||
behaviorHints: {
|
||||
notWebReady: true,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log?.warn({ err, mediaFileId: file.id }, "skip stream: session create failed");
|
||||
}
|
||||
}
|
||||
return streams;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue