102 lines
3.5 KiB
Python
102 lines
3.5 KiB
Python
import re
|
|
from urllib.parse import quote, urljoin
|
|
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import Response
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.session import get_db
|
|
from app.services.tv_catalog import get_tv_catalog_page
|
|
from app.services.tv_streams import get_public_tv_payload, is_allowed_tv_proxy_url
|
|
|
|
router = APIRouter()
|
|
|
|
_HLS_URI_ATTRIBUTE_RE = re.compile(r'URI="([^"]+)"')
|
|
|
|
|
|
def _proxied_tv_url(url: str) -> str:
|
|
return f"/api/v1/tv/proxy?url={quote(url, safe='')}"
|
|
|
|
|
|
def _rewrite_hls_uri_attributes(line: str, *, base_url: str) -> str:
|
|
def replace(match: re.Match[str]) -> str:
|
|
uri = match.group(1)
|
|
absolute_url = urljoin(base_url, uri)
|
|
return f'URI="{_proxied_tv_url(absolute_url)}"'
|
|
|
|
return _HLS_URI_ATTRIBUTE_RE.sub(replace, line)
|
|
|
|
|
|
def _should_strip_hls_metadata_line(line: str) -> bool:
|
|
normalized = line.strip().upper()
|
|
return normalized.startswith("#EXT-X-MEDIA:") and "TYPE=SUBTITLES" in normalized
|
|
|
|
|
|
@router.get("/streams")
|
|
async def list_public_tv_streams(
|
|
offset: int = Query(0, ge=0),
|
|
limit: int = Query(50, ge=1, le=100),
|
|
q: str = Query("", max_length=200),
|
|
selected_id: str | None = Query(None, max_length=200),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
return await get_tv_catalog_page(db, offset=offset, limit=limit, q=q, selected_id=selected_id)
|
|
|
|
|
|
@router.get("/proxy")
|
|
async def proxy_tv_stream(
|
|
url: str = Query(..., description="Upstream TV stream or manifest URL"),
|
|
db: AsyncSession = Depends(get_db),
|
|
):
|
|
payload = await get_public_tv_payload(db)
|
|
if not is_allowed_tv_proxy_url(url, payload.get("sources", [])):
|
|
raise HTTPException(status_code=403, detail="TV proxy target is not allowed")
|
|
|
|
try:
|
|
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0) as client:
|
|
upstream = await client.get(
|
|
url,
|
|
headers={
|
|
"User-Agent": "Mozilla/5.0",
|
|
"Referer": "https://tv.cctv.com/live/cctv4/",
|
|
},
|
|
)
|
|
upstream.raise_for_status()
|
|
except httpx.HTTPError as exc:
|
|
raise HTTPException(status_code=502, detail=f"Failed to fetch TV stream: {exc}") from exc
|
|
|
|
content_type = upstream.headers.get("content-type", "application/octet-stream")
|
|
raw_content = upstream.content
|
|
response_url = str(upstream.url)
|
|
is_manifest = (
|
|
response_url.endswith(".m3u8")
|
|
or "mpegurl" in content_type.lower()
|
|
or raw_content.lstrip().startswith(b"#EXTM3U")
|
|
)
|
|
|
|
headers = {"Cache-Control": "no-store"}
|
|
|
|
if is_manifest:
|
|
manifest_text = upstream.text
|
|
rewritten_lines: list[str] = []
|
|
for line in manifest_text.splitlines():
|
|
stripped = line.strip()
|
|
if not stripped:
|
|
rewritten_lines.append(line)
|
|
continue
|
|
if stripped.startswith("#"):
|
|
if _should_strip_hls_metadata_line(line):
|
|
continue
|
|
rewritten_lines.append(_rewrite_hls_uri_attributes(line, base_url=response_url))
|
|
continue
|
|
absolute_url = urljoin(response_url, stripped)
|
|
rewritten_lines.append(_proxied_tv_url(absolute_url))
|
|
return Response(
|
|
content="\n".join(rewritten_lines),
|
|
media_type="application/vnd.apple.mpegurl",
|
|
headers=headers,
|
|
)
|
|
|
|
return Response(content=raw_content, media_type=content_type, headers=headers)
|