diff --git a/apps/master-api/src/stremio/routes.ts b/apps/master-api/src/stremio/routes.ts index 9efc272..dc11135 100644 --- a/apps/master-api/src/stremio/routes.ts +++ b/apps/master-api/src/stremio/routes.ts @@ -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 }, - 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("&"); @@ -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; }