legco_ai_assistant/backend/app/services/hls_proxy.py

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": "*"},
)