Manveer04 commited on
Commit
7186fe6
·
verified ·
1 Parent(s): d64ac28

Rename app.py to server.js

Browse files
Files changed (2) hide show
  1. app.py +0 -330
  2. server.js +73 -0
app.py DELETED
@@ -1,330 +0,0 @@
1
- import os
2
- import time
3
- import asyncio
4
- from typing import Optional, Tuple, Dict, Any, Union, List
5
-
6
- from fastapi import FastAPI, Response, Request, Header
7
- from fastapi.middleware.cors import CORSMiddleware
8
- from starlette.responses import StreamingResponse
9
-
10
- import httpx
11
- from playwright.async_api import async_playwright, TimeoutError as PTE, Page, Frame
12
-
13
- # =========================
14
- # Config (env-overridable)
15
- # =========================
16
- TARGET = os.getenv(
17
- "CASTER_WIDGET_URL",
18
- "https://widgets.cloud.caster.fm/player/?token=41a9830e-3fa4-4aef-a0cb-226600fb2946&frameId=2x5fz&theme=dark&color=6C2BDD",
19
- )
20
- MATCH_PREFIX = os.getenv(
21
- "STREAM_MATCH_PREFIX",
22
- "https://sapircast.caster.fm:10445/VCDm1?token=",
23
- )
24
-
25
- PRE_CLICK_WAIT_MS = int(os.getenv("PRE_CLICK_WAIT_MS", "2000"))
26
- MATCH_TIMEOUT_MS = int(os.getenv("MATCH_TIMEOUT_MS", "30000"))
27
- NAV_TIMEOUT_MS = int(os.getenv("NAV_TIMEOUT_MS", "30000"))
28
- CLICK_VISIBLE_TIMEOUT_MS = int(os.getenv("CLICK_VISIBLE_TIMEOUT_MS", "2000"))
29
- RETRIES = int(os.getenv("RETRIES", "5"))
30
-
31
- # caching / perf
32
- CACHE_TTL_SEC = int(os.getenv("CACHE_TTL_SEC", "90"))
33
- EXTRACTION_TIMEOUT_MS = int(os.getenv("EXTRACTION_TIMEOUT_MS", "18000"))
34
-
35
- # chromium flags (HF-friendly)
36
- CHROMIUM_ARGS = [
37
- "--autoplay-policy=no-user-gesture-required",
38
- "--no-sandbox",
39
- "--disable-dev-shm-usage",
40
- ]
41
-
42
- app = FastAPI(title="Caster Stream URL Extractor (cached)")
43
-
44
- app.add_middleware(
45
- CORSMiddleware,
46
- allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]
47
- )
48
-
49
- # =========================
50
- # Globals
51
- # =========================
52
- P = None # playwright instance
53
- BROWSER = None # chromium browser
54
- CONTEXT = None # shared context
55
-
56
- CACHE: Dict[str, Any] = {"url": None, "ts": 0.0}
57
- CACHE_LOCK = asyncio.Lock()
58
-
59
- # =========================
60
- # Utils
61
- # =========================
62
- def _mk_trace() -> Dict[str, Any]:
63
- return {"steps": [], "requests": [], "responses": [], "frames": [], "errors": []}
64
-
65
- async def _record_frames(page: Page, trace: Dict[str, Any]) -> None:
66
- try:
67
- trace["frames"] = [{"name": fr.name, "url": fr.url, "is_main": fr == page.main_frame} for fr in page.frames]
68
- except Exception as e:
69
- trace["errors"].append(f"frame-list-error: {e!r}")
70
-
71
- async def _click_selectors_in(page_or_frame: Union[Page, Frame], sels: List[str], trace: Dict[str, Any]) -> bool:
72
- async def try_click(sel: str) -> bool:
73
- try:
74
- loc = page_or_frame.locator(sel).first
75
- await loc.wait_for(state="visible", timeout=CLICK_VISIBLE_TIMEOUT_MS)
76
- await loc.click(force=True)
77
- trace["steps"].append(f"clicked: {sel}")
78
- return True
79
- except Exception as e:
80
- trace["steps"].append(f"click-miss: {sel} ({e.__class__.__name__})")
81
- return False
82
- for s in sels:
83
- if await try_click(s):
84
- return True
85
- return False
86
-
87
- async def _any_audio_playing(target: Union[Page, Frame]) -> bool:
88
- try:
89
- h = await target.wait_for_function(
90
- """
91
- () => {
92
- const els = [document.getElementById('playerAudioElement'), ...document.querySelectorAll('audio')].filter(Boolean);
93
- for (const a of els) { if (!a.paused && a.readyState >= 2) return true; }
94
- return false;
95
- }
96
- """,
97
- timeout=8000,
98
- )
99
- return bool(await h.json_value())
100
- except Exception:
101
- return False
102
-
103
- # =========================
104
- # Lifecycle
105
- # =========================
106
- @app.on_event("startup")
107
- async def _startup():
108
- global P, BROWSER, CONTEXT
109
- if P is None:
110
- P = await async_playwright().start()
111
- if BROWSER is None:
112
- BROWSER = await P.chromium.launch(headless=True, args=CHROMIUM_ARGS)
113
- if CONTEXT is None:
114
- CONTEXT = await BROWSER.new_context()
115
-
116
- @app.on_event("shutdown")
117
- async def _shutdown():
118
- global P, BROWSER, CONTEXT
119
- try:
120
- if CONTEXT:
121
- await CONTEXT.close()
122
- if BROWSER:
123
- await BROWSER.close()
124
- finally:
125
- CONTEXT = None
126
- BROWSER = None
127
- if P:
128
- await P.stop()
129
-
130
- # =========================
131
- # Core extraction (one run)
132
- # =========================
133
- async def _extract_once(strict_prefix: str, debug: bool = False) -> Tuple[Optional[str], Dict[str, Any]]:
134
- trace: Dict[str, Any] = _mk_trace()
135
- latest_by_req: Optional[str] = None
136
- latest_by_resp: Optional[str] = None
137
-
138
- def on_request(url: str):
139
- nonlocal latest_by_req
140
- trace["requests"].append(url)
141
- if url.startswith(strict_prefix):
142
- latest_by_req = url
143
-
144
- async def on_response(r):
145
- try:
146
- url = r.url
147
- status = r.status
148
- ctype = (r.headers or {}).get("content-type")
149
- trace["responses"].append({"url": url, "status": status, "content_type": ctype})
150
- nonlocal latest_by_resp
151
- if url.startswith(strict_prefix) and status < 400 and (ctype is None or ctype.lower().startswith("audio/")):
152
- latest_by_resp = url
153
- except Exception as e:
154
- trace["errors"].append(f"resp-handler: {e!r}")
155
-
156
- async def runner():
157
- page = await CONTEXT.new_page()
158
- CONTEXT.on("request", lambda r: on_request(r.url))
159
- CONTEXT.on("response", lambda r: asyncio.create_task(on_response(r)))
160
-
161
- # Navigate (resilient)
162
- last_err = None
163
- for i in range(RETRIES):
164
- try:
165
- await page.goto(TARGET, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
166
- trace["steps"].append("goto-domcontentloaded")
167
- break
168
- except Exception as e:
169
- last_err = e
170
- trace["steps"].append(f"goto-retry-{i+1}: {e.__class__.__name__}")
171
- await asyncio.sleep(0.8 + i * 0.5)
172
- else:
173
- await page.close()
174
- trace["errors"].append(f"goto-failed: {last_err!r}")
175
- return None
176
-
177
- await _record_frames(page, trace)
178
-
179
- # Click play
180
- await page.wait_for_timeout(PRE_CLICK_WAIT_MS)
181
- selectors = [
182
- ".play button.control-btn",
183
- 'button:has-text("PLAY")',
184
- '[onclick*="playerAction"]',
185
- 'button[id*="play"]',
186
- ]
187
- clicked = await _click_selectors_in(page, selectors, trace)
188
- if not clicked:
189
- for fr in page.frames:
190
- if await _click_selectors_in(fr, selectors, trace):
191
- clicked = True
192
- break
193
-
194
- # Wait for some network settling
195
- try:
196
- await page.wait_for_load_state("networkidle", timeout=5000)
197
- trace["steps"].append("after-click-networkidle")
198
- except Exception:
199
- trace["steps"].append("after-click-no-networkidle")
200
-
201
- # Wait until audio is actually playing (main or frames)
202
- playing = await _any_audio_playing(page)
203
- if not playing:
204
- for fr in page.frames:
205
- if await _any_audio_playing(fr):
206
- playing = True
207
- break
208
-
209
- # Debounce to capture the latest stream URL after play init
210
- await page.wait_for_timeout(1000)
211
-
212
- url = latest_by_resp or latest_by_req
213
- await page.close()
214
- return url
215
-
216
- # Hard timeout guard
217
- try:
218
- url = await asyncio.wait_for(runner(), timeout=EXTRACTION_TIMEOUT_MS / 1000)
219
- except asyncio.TimeoutError:
220
- trace["errors"].append("extraction-timeout")
221
- url = None
222
-
223
- return url, trace
224
-
225
- # =========================
226
- # Cache helpers
227
- # =========================
228
- async def _get_cached_url(strict_prefix: str, force: bool = False) -> Optional[str]:
229
- now = time.time()
230
- if not force and CACHE["url"] and (now - CACHE["ts"] < CACHE_TTL_SEC):
231
- return CACHE["url"]
232
-
233
- async with CACHE_LOCK:
234
- # Double-check after acquiring lock
235
- now = time.time()
236
- if not force and CACHE["url"] and (now - CACHE["ts"] < CACHE_TTL_SEC):
237
- return CACHE["url"]
238
-
239
- url, _ = await _extract_once(strict_prefix, debug=False)
240
- if url:
241
- CACHE["url"] = url
242
- CACHE["ts"] = time.time()
243
- return CACHE["url"]
244
-
245
- def _wants_true(req: Request, key: str) -> bool:
246
- v = (req.query_params.get(key) or "").lower()
247
- return v in ("1", "true", "yes")
248
-
249
- # =========================
250
- # Routes
251
- # =========================
252
- @app.get("/getplayerurl")
253
- async def get_player_url(request: Request) -> Response:
254
- debug = _wants_true(request, "debug")
255
- force = _wants_true(request, "force")
256
- prefix = request.query_params.get("prefix") or MATCH_PREFIX
257
-
258
- # 👇 Capture client headers
259
- client_ua = request.headers.get("user-agent", "Mozilla/5.0")
260
- client_ref = request.headers.get("referer", TARGET)
261
- client_ip = request.client.host
262
-
263
- global CONTEXT
264
- CONTEXT = await BROWSER.new_context(extra_http_headers={
265
- "User-Agent": client_ua,
266
- "X-Forwarded-For": client_ip, # 👈 Some servers read this
267
- "Referer": client_ref
268
- })
269
-
270
- if debug:
271
- url, trace = await _extract_once(prefix, debug=True)
272
- from json import dumps
273
- return Response(
274
- content=dumps({"ok": bool(url), "url": url, "match_prefix": prefix, **trace}, ensure_ascii=False, indent=2),
275
- media_type="application/json"
276
- )
277
-
278
- url = await _get_cached_url(prefix, force=force)
279
- if not url:
280
- return Response(status_code=204)
281
- return Response(content=url.strip() + "\n", media_type="text/plain")
282
-
283
-
284
- @app.get("/stream")
285
- async def stream(range: str | None = Header(default=None)) -> Response:
286
- # Fast path: cached stream URL
287
- url = await _get_cached_url(MATCH_PREFIX, force=False)
288
- if not url:
289
- # Warm cache if empty/stale
290
- url = await _get_cached_url(MATCH_PREFIX, force=True)
291
- if not url:
292
- return Response(status_code=204)
293
-
294
- headers = {
295
- "Referer": TARGET, # some hosts require referer/origin
296
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
297
- "(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
298
- "Accept": "*/*",
299
- }
300
- if range:
301
- headers["Range"] = range
302
-
303
- async with httpx.AsyncClient(follow_redirects=True, timeout=None) as client:
304
- r = await client.get(url, headers=headers)
305
- # If token expired, refresh once & retry
306
- if r.status_code >= 400:
307
- url = await _get_cached_url(MATCH_PREFIX, force=True)
308
- if not url:
309
- return Response(status_code=502, content="upstream unavailable\n")
310
- r = await client.get(url, headers=headers)
311
-
312
- out_headers = {
313
- "Content-Type": r.headers.get("content-type", "audio/mpeg"),
314
- "Accept-Ranges": r.headers.get("accept-ranges", "bytes"),
315
- }
316
- if "content-length" in r.headers:
317
- out_headers["Content-Length"] = r.headers["content-length"]
318
-
319
- return StreamingResponse(r.aiter_raw(), status_code=r.status_code, headers=out_headers)
320
-
321
- @app.get("/")
322
- async def root() -> dict:
323
- return {
324
- "ok": True,
325
- "endpoint": "/getplayerurl",
326
- "proxy": "/stream",
327
- "target": TARGET,
328
- "match_prefix": MATCH_PREFIX,
329
- "cache_ttl_sec": CACHE_TTL_SEC,
330
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
server.js ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ICY→HTTP shim for browsers (works on HF Spaces)
2
+ // Listens on process.env.PORT and 0.0.0.0 (required by HF)
3
+ const http = require("http");
4
+ const net = require("net");
5
+
6
+ const PORT = process.env.PORT || 7860;
7
+ const HOST = "0.0.0.0";
8
+
9
+ // Configure upstream via env (can set in HF Space Settings → Variables)
10
+ const UPSTREAM_HOST = process.env.STREAM_HOST || "37.59.40.90";
11
+ const UPSTREAM_PORT = Number(process.env.STREAM_PORT || 18624);
12
+ const UPSTREAM_PATH = process.env.STREAM_PATH || "/"; // try "/;" if needed
13
+
14
+ const server = http.createServer((req, res) => {
15
+ if (req.url !== "/stream") {
16
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
17
+ return res.end(`<h3>OK</h3><p>Use <code>/stream</code> for audio.</p>`);
18
+ }
19
+
20
+ const sock = net.createConnection(UPSTREAM_PORT, UPSTREAM_HOST, () => {
21
+ const reqLines = [
22
+ `GET ${UPSTREAM_PATH} HTTP/1.0`,
23
+ `Host: ${UPSTREAM_HOST}:${UPSTREAM_PORT}`,
24
+ `Icy-MetaData: 0`,
25
+ `User-Agent: Browser-Audio-Proxy`,
26
+ `Accept: */*`,
27
+ ``,
28
+ ``,
29
+ ];
30
+ sock.write(reqLines.join("\r\n"));
31
+ });
32
+
33
+ let headerBuf = Buffer.alloc(0);
34
+ let sentHeaders = false;
35
+
36
+ sock.on("data", chunk => {
37
+ if (!sentHeaders) {
38
+ headerBuf = Buffer.concat([headerBuf, chunk]);
39
+ const i = headerBuf.indexOf("\r\n\r\n");
40
+ if (i === -1) return;
41
+
42
+ const head = headerBuf.slice(0, i).toString("utf8");
43
+ const body = headerBuf.slice(i + 4);
44
+
45
+ const ctMatch = head.match(/content-type\s*:\s*([^\r\n]+)/i);
46
+ const contentType = (ctMatch && ctMatch[1].trim()) || "audio/mpeg";
47
+
48
+ res.writeHead(200, {
49
+ "Content-Type": contentType,
50
+ "Cache-Control": "no-store",
51
+ "X-Content-Type-Options": "nosniff",
52
+ "Accept-Ranges": "none",
53
+ "Connection": "close",
54
+ "Access-Control-Allow-Origin": "*", // helpful if you embed elsewhere
55
+ });
56
+
57
+ if (body.length) res.write(body);
58
+ sentHeaders = true;
59
+ return;
60
+ }
61
+ res.write(chunk);
62
+ });
63
+
64
+ sock.on("end", () => res.end());
65
+ sock.on("error", () => {
66
+ if (!res.headersSent) res.writeHead(502, { "Content-Type": "text/plain" });
67
+ res.end("Upstream error");
68
+ });
69
+ });
70
+
71
+ server.listen(PORT, HOST, () =>
72
+ console.log(`Proxy listening on http://${HOST}:${PORT}/stream`)
73
+ );