diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 783f8bd..9efc272 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -127,11 +127,14 @@ export async function registerStremioRoutes(app: FastifyInstance, config: Config 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 }; }); - // 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({ method: ["GET", "HEAD"], 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" }); } - 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 { const session = await playback.createSession(mediaFileId, userId); request.log.info( - { mediaFileId, streamUrl: session.streamUrl }, - "playback redirect to node" + { mediaFileId, nodeUrl: session.streamUrl, method: request.method }, + "playback proxy to node" ); - return reply.code(302).redirect(session.streamUrl); + return proxyNodePlay(request, reply, session.streamUrl); } catch (err) { const message = err instanceof Error ? err.message : "Playback unavailable"; 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 }, + 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 = {}; + 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("&"); @@ -354,9 +391,8 @@ async function getSeriesMeta(id: string) { async function getStreams( type: string, id: string, - userId: string, - playback: PlaybackService, - log?: { warn: (obj: unknown, msg?: string) => void } + addonToken: string, + playback: PlaybackService ) { if (type === "movie") { const movie = await prisma.movie.findFirst({ @@ -373,20 +409,15 @@ async function getStreams( const streams = []; for (const file of movie.mediaFiles) { 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({ - 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"); - } + 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, + }, + }); } return streams; } @@ -425,19 +456,14 @@ 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); - 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"); - } + streams.push({ + name: playback.formatStreamLabel(file), + title: playback.formatStreamLabel(file), + url: playback.resolveStreamUrl(addonToken, file.id), + behaviorHints: { + notWebReady: true, + }, + }); } return streams; }