76 lines
2.7 KiB
Python
76 lines
2.7 KiB
Python
"""HLS manifest proxy service (Phase 3.3).
|
|
|
|
Rewrites HLS manifests and proxies .ts segments so the browser treats
|
|
them as same-origin, enabling Web Audio API access to the audio track.
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
from typing import AsyncGenerator
|
|
from urllib.parse import quote, urljoin
|
|
|
|
import httpx
|
|
from fastapi.responses import StreamingResponse
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class HLSProxyService:
|
|
"""Streams and rewrites HLS manifests; proxies .ts segments with zero re-encoding."""
|
|
|
|
async def rewrite_manifest(self, upstream_url: str, upstream: httpx.Response) -> AsyncGenerator[str, None]:
|
|
base_url = upstream_url
|
|
async for line in upstream.aiter_lines():
|
|
rewritten = self._rewrite_line(line, base_url)
|
|
yield rewritten + "\n"
|
|
|
|
def _rewrite_line(self, line: str, base_url: str) -> str:
|
|
stripped = line.rstrip("\r\n")
|
|
|
|
if not stripped:
|
|
return stripped
|
|
|
|
if stripped.startswith("#"):
|
|
if stripped.startswith("#EXT-X-KEY:") and 'URI="' in stripped:
|
|
return self._rewrite_key_uri(stripped, base_url)
|
|
return stripped
|
|
|
|
if "://" in stripped:
|
|
absolute_uri = stripped
|
|
else:
|
|
absolute_uri = urljoin(base_url, stripped)
|
|
|
|
return self._build_proxy_url_for_uri(absolute_uri)
|
|
|
|
def _rewrite_key_uri(self, line: str, base_url: str) -> str:
|
|
match = re.match(r'(#EXT-X-KEY:.*URI=")(.+?)(".*)', line)
|
|
if not match:
|
|
return line
|
|
prefix, uri, suffix = match.group(1), match.group(2), match.group(3)
|
|
if "://" in uri:
|
|
absolute_uri = uri
|
|
else:
|
|
absolute_uri = urljoin(base_url, uri)
|
|
proxy_uri = self._build_proxy_url_for_uri(absolute_uri)
|
|
return f"{prefix}{proxy_uri}{suffix}"
|
|
|
|
def _resolve_url(self, uri: str, base_url: str) -> str:
|
|
return urljoin(base_url, uri)
|
|
|
|
def _build_proxy_url_for_uri(self, absolute_uri: str) -> str:
|
|
encoded = quote(absolute_uri, safe="")
|
|
if absolute_uri.endswith(".m3u8"):
|
|
return f"/api/v1/youtube/proxy/manifest.m3u8?url={encoded}"
|
|
return f"/api/v1/youtube/proxy/segment.ts?url={encoded}"
|
|
|
|
async def proxy_segment(self, upstream_url: str) -> StreamingResponse:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
req = client.build_request("GET", upstream_url)
|
|
upstream = await client.send(req, stream=True)
|
|
return StreamingResponse(
|
|
upstream.aiter_bytes(),
|
|
status_code=upstream.status_code,
|
|
media_type="video/mp2t",
|
|
headers={"access-control-allow-origin": "*"},
|
|
)
|