miruro / app /api /stream /route.ts
mwask's picture
Upload 45 files
3e8e34c verified
Raw
History Blame Contribute Delete
4.95 kB
import { NextRequest, NextResponse } from "next/server";
import {
verify,
mintStreamToken,
mintDashBase,
StreamClaim,
} from "@/lib/security/tokens";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const UA =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
// Rewrite every URI in an HLS manifest so child playlists, keys and segments
// also flow back through this proxy (the real hosts are never exposed).
function rewriteManifest(
text: string,
baseUrl: string,
origin: string,
ref?: string
): string {
const base = new URL(baseUrl);
const wrap = (u: string) => {
let abs: string;
try {
abs = new URL(u, base).toString();
} catch {
return u;
}
return `${origin}/api/stream?t=${encodeURIComponent(
mintStreamToken(abs, ref)
)}`;
};
return text
.split("\n")
.map((line) => {
const t = line.trim();
if (!t) return line;
if (t.startsWith("#")) {
// rewrite URI="..." attributes (EXT-X-KEY / MAP / MEDIA / I-FRAME)
return line.replace(/URI="([^"]+)"/g, (_m, u) => `URI="${wrap(u)}"`);
}
return wrap(t); // bare segment / sub-playlist line
})
.join("\n");
}
// Rewrite a DASH .mpd so its <BaseURL> points at the segment proxy. The real
// host is BaseURL[0]; "x-bc.interkh.com" placeholder bases are dropped.
function rewriteMpd(
xml: string,
mpdUrl: string,
origin: string,
ref?: string
): string {
const bases = [...xml.matchAll(/<BaseURL>([^<]+)<\/BaseURL>/g)].map((m) =>
m[1].trim()
);
let real = bases.find((u) => !u.includes("x-bc")) || bases[0];
if (!real) {
// no explicit BaseURL — derive from the manifest directory
real = mpdUrl.split("?")[0].replace(/[^/]*$/, "");
} else {
real = new URL(real, mpdUrl).toString();
}
if (!real.endsWith("/")) real += "/";
const proxied = `${origin}/api/dseg/${mintDashBase(real, ref)}/`;
let first = true;
let out = xml.replace(/<BaseURL>[^<]*<\/BaseURL>/g, () =>
first ? ((first = false), `<BaseURL>${proxied}</BaseURL>`) : ""
);
// if the manifest had no BaseURL at all, inject one after <MPD ...>
if (first) {
out = out.replace(/(<MPD\b[^>]*>)/, `$1<BaseURL>${proxied}</BaseURL>`);
}
return out;
}
export async function GET(req: NextRequest) {
const { searchParams, origin } = new URL(req.url);
const claim = verify<StreamClaim>(searchParams.get("t"));
if (!claim || claim.k !== "stream") {
return new NextResponse("forbidden", { status: 403 });
}
const target = claim.url;
const upstreamHeaders: Record<string, string> = { "User-Agent": UA };
if (claim.ref) {
upstreamHeaders["Referer"] = claim.ref;
upstreamHeaders["Origin"] = new URL(claim.ref).origin;
}
const range = req.headers.get("range");
if (range) upstreamHeaders["Range"] = range;
let upstream: Response;
try {
upstream = await fetch(target, {
headers: upstreamHeaders,
redirect: "follow",
cache: "no-store", // never serve a stale manifest (segment tokens rotate)
});
} catch {
return new NextResponse("bad gateway", { status: 502 });
}
const ct = (upstream.headers.get("content-type") || "").toLowerCase();
const path0 = target.split("?")[0].toLowerCase();
const isManifest = path0.endsWith(".m3u8") || ct.includes("mpegurl");
const isMpd = path0.endsWith(".mpd") || ct.includes("dash+xml");
if (isMpd) {
const xml = await upstream.text();
const finalUrl = (upstream as any).url || target;
const body = rewriteMpd(xml, finalUrl, origin, claim.ref);
return new NextResponse(body, {
status: upstream.status,
headers: {
"Content-Type": "application/dash+xml",
"Cache-Control": "no-store",
},
});
}
if (isManifest) {
const text = await upstream.text();
// upstream.url reflects any redirects, giving the correct base for relatives
const finalUrl = (upstream as any).url || target;
const body = rewriteManifest(text, finalUrl, origin, claim.ref);
return new NextResponse(body, {
status: upstream.status,
headers: {
"Content-Type": "application/vnd.apple.mpegurl",
"Cache-Control": "no-store",
},
});
}
// Binary passthrough for segments / mp4 (preserve range/seek headers)
const headers = new Headers();
for (const h of [
"content-type",
"content-length",
"accept-ranges",
"content-range",
"cache-control",
]) {
const v = upstream.headers.get(h);
if (v) headers.set(h, v);
}
if (!headers.has("cache-control")) headers.set("Cache-Control", "no-store");
return new NextResponse(upstream.body, {
status: upstream.status,
headers,
});
}