Fetch the current 881903 live stream .m3u8 URL or play it directly. Includes a small web server for sharing /live/* endpoints.
- Bun
- ffplay (for
--play)
bun installPlain URL:
bun run ./src/get-stream-url.tsJSON output:
bun run ./src/get-stream-url.ts --jsonPlay stream (ffplay):
bun run ./src/get-stream-url.ts --playHelp:
bun run ./src/get-stream-url.ts --helpStart the server:
bun run serverEndpoints:
GET /Home page that fetches current stream URLs.GET /live/903Redirects to the current.m3u8for channel 903.GET /live/881Redirects to the current.m3u8for channel 881.GET /live/903?format=jsonReturns JSON{ channel, url, cached, fetchedAtMs, expiresAtMs }.GET /live/881?format=jsonReturns JSON{ channel, url, cached, fetchedAtMs, expiresAtMs }.
Caching:
- Stream URLs are cached for 10 minutes in-memory.
This project includes a Vercel-ready serverless function under api/.
Vercel endpoints:
GET /Home page (rewritten to/api).GET /live/903Redirects to the current.m3u8for channel 903.GET /live/881Redirects to the current.m3u8for channel 881.GET /live/903?format=jsonReturns JSON{ channel, url, cached, fetchedAtMs, expiresAtMs }.GET /live/881?format=jsonReturns JSON{ channel, url, cached, fetchedAtMs, expiresAtMs }.
Direct API endpoints:
GET /apiHome page.GET /api/live/903Redirects to the current.m3u8for channel 903.GET /api/live/881Redirects to the current.m3u8for channel 881.GET /api/live/903?format=jsonReturns JSON{ channel, url, cached, fetchedAtMs, expiresAtMs }.GET /api/live/881?format=jsonReturns JSON{ channel, url, cached, fetchedAtMs, expiresAtMs }.
Notes:
- Uses
playwright-core+@sparticuz/chromiumfor Vercel serverless compatibility. vercel.jsonsetsmaxDurationto 60s to reduce timeouts.
Last investigated: 2026-06-27. Read this first when resuming.
- Local CLI: works fully. Fetcher and player are the same machine/IP, so the IP-bound stream token is valid.
- Vercel deployment: mechanically healthy but cannot serve a playable URL to remote users. The stream URL it returns only works from Vercel's own IP (proven below).
- The only way to give a remote mobile/desktop browser a playable URL is a stream proxy on a stable-IP host (e.g. Fly.io). Pure client-side (browser-only) is blocked by the site's hardening.
The .m3u8 URL carries a token ?...&t=<unixts>.1.<signature>. That token is cryptographically bound to the IP address that fetched it.
Verified with a controlled cross-IP probe (a temporary ?probe=<url> endpoint, since removed):
| Token captured by | Fetched from local Mac | Fetched from Vercel (HK) |
|---|---|---|
| Local Mac | ✅ 200 | ❌ 403 |
| Vercel (HK) | ❌ 403 | ✅ 200 |
Same URL, fetched seconds apart (no expiry involved). The only variable that flips 200↔403 is the requester's IP. Cookies are not required (same-IP fetches with no cookies return 200). This is anti-hotlinking by design.
Implication: the "fetch a URL server-side, hand it to the client" model is impossible for this site. Whoever fetches the URL is the only IP that can play it.
Idea: have the user's own browser mint the token (then fetcher = player = user's IP). Blocked by three independent walls on 881903.com, all measured:
| Wall | Measurement | Effect |
|---|---|---|
playlist.js is Referer-gated |
Referer: https://www.881903.com → 200; empty / Google / any other origin → 403 |
Your page can only send Referer = your own origin (or empty). Browsers cannot forge a cross-origin Referer, so your page can never fetch the token-minting script. |
.m3u8/chunks CORS-locked |
Access-Control-Allow-Origin: https://www.881903.com |
hls.js (needed on Android/Chrome) uses fetch → blocked from your origin. (iOS native <video>/<audio> could bypass CORS, but still needs the URL, which is gated above.) |
Live page X-Frame-Options: DENY |
header present | Cannot embed their player in an <iframe> either. |
playlist.js is also eval(atob(...))-obfuscated, so extraction is fragile even if fetchable.
The blocker is the browser same-origin / Referer security model, not IP. It would only be possible via a native app / custom WebView (can set the Referer header) or a browser extension/userscript (elevated privileges; thin support on mobile).
- Page:
https://www.881903.com/live/{903,881}→ containsliveJsUrl. liveJsUrl:https://playlist.881903.com/web/v4/{903,881}hd/playlist.js?t=&n1=&n2=&z=(obfuscated; mints the m3u8 URL, bound to the fetcher IP; Referer-gated).- Master playlist:
https://<edge>.881903.com/edge-ts/{903,881}hd/playlist.m3u8?r=&ri=&t=<token>→ referenceschunks.m3u8?sessionId=.... - Stream is 64 kbps audio (
CODECS="mp4a.40.2"), so proxy bandwidth is trivial (~29 MB per listener-hour). - Geo matters: from a US IP the site serves a tokenless
edge-aacURL (needs CloudFrontKey-Pair-Idcookies, not playable standalone); from Hong Kong it serves theedge-tsURL with thet=token. Always fetch from an Asia/HK region.
Deploy to a host with a stable IP (Fly.io / Railway / Render / VPS) in the hkg region:
- One process bootstraps the session via browser and proxies the HLS (playlist + chunks, rewriting URLs) from that same IP.
- Serve a small player page from your own origin so you control CORS →
hls.jsworks on Android, native HLS on iOS. The listener only ever touches your domain. - This is the only no-install, any-mobile-browser path.
- Why not Vercel: Vercel egress IP is not stable across invocations (browser-bootstrap invocation vs each chunk-proxy invocation can differ) → segment fetches would 403. A stable single IP is required.
- Fly.io cost estimate (2026-06-27): no free tier, credit card required, pay-as-you-go, no minimum.
shared-cpu-1x512 MB = $3.19/mo (1 GB = $5.70/mo) if 24/7; with auto-stop/start it tracks usage (~$1/mo for personal use). HK egress $0.04/GB → ~0.12¢ per listener-hour (bandwidth is rounding error). Inbound is free.
networkidle→domcontentloadedinget-stream-url.tsandstream-service.ts. A live-stream page never goes network-idle, sowaitUntil: "networkidle"always hit the 30sgototimeout — this was the root cause of both the CLI breakage and the "Vercel timeouts."@sparticuz/chromiumon Vercel:getBrowser()instream-service.tsbranches —BROWSERLESS_WS_ENDPOINT→ CDP connect;process.env.VERCEL→ sparticuz launch; else local Playwright Chromium.ETXTBSYconcurrency fix: concurrent invocations raced on the shared/tmp/chromiumextraction. Added a module-level serialized launch + retry-on-ETXTBSY.hkg1region pin invercel.json(see geo note above).- Hobby-plan limits: function memory capped at 2048 MB (was 3008); Bun server needs
idleTimeout: 60; fetch timeouts bumped (waitForResponse30s, APIwithTimeout45s) to fit the 60s function budget.
- Browserless.io (remote browser over WebSocket): HTTP
/json/versionwas reachable, but every WebSocketconnectattempt timed out at the handshake. Abandoned in favor of@sparticuz/chromium. (getBrowser()still honorsBROWSERLESS_WS_ENDPOINTif ever set.)
Type-check the project:
bun run lint:ts