Files
UnHided/mediaflow_proxy/routes/acestream.py
T
UrloMythus 8134936d59 new version
2026-04-15 19:23:14 +02:00

561 lines
21 KiB
Python

"""
Acestream proxy routes.
Provides endpoints for proxying acestream content:
- /proxy/acestream/manifest.m3u8 - HLS manifest proxy (primary, leverages existing HLS infrastructure)
- /proxy/acestream/stream - MPEG-TS stream proxy with fan-out to multiple clients
- /proxy/acestream/segment.ts - Segment proxy for HLS mode
"""
import asyncio
import logging
from functools import lru_cache
from typing import Annotated, TYPE_CHECKING
from urllib.parse import urlencode, urljoin, urlparse
import aiohttp
from fastapi import APIRouter, Query, Request, HTTPException, Response, Depends
from starlette.background import BackgroundTask
from mediaflow_proxy.configs import settings
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.http_utils import (
get_original_scheme,
get_proxy_headers,
ProxyRequestHeaders,
EnhancedStreamingResponse,
apply_header_manipulation,
create_streamer,
)
from mediaflow_proxy.utils.m3u8_processor import M3U8Processor
from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
logger = logging.getLogger(__name__)
acestream_router = APIRouter()
if TYPE_CHECKING:
from mediaflow_proxy.utils.acestream import AcestreamSession
def _get_acestream_manager():
from mediaflow_proxy.utils.acestream import acestream_manager
return acestream_manager
@lru_cache(maxsize=1)
def _load_transcode_pipeline():
from mediaflow_proxy.remuxer.transcode_pipeline import stream_transcode_universal
return stream_transcode_universal
class AcestreamM3U8Processor(M3U8Processor):
"""
M3U8 processor specialized for Acestream.
Rewrites segment URLs to go through the acestream segment proxy endpoint
while preserving session information.
"""
def __init__(
self,
request: Request,
session: "AcestreamSession",
key_url: str = None,
force_playlist_proxy: bool = True,
key_only_proxy: bool = False,
no_proxy: bool = False,
):
super().__init__(
request=request,
key_url=key_url,
force_playlist_proxy=force_playlist_proxy,
key_only_proxy=key_only_proxy,
no_proxy=no_proxy,
)
self.session = session
async def proxy_content_url(self, url: str, base_url: str) -> str:
"""
Override to route acestream segments through the acestream segment endpoint.
This ensures segments use /proxy/acestream/segment.ts instead of /proxy/hls/segment.ts
"""
full_url = urljoin(base_url, url)
# If no_proxy is enabled, return the direct URL
if self.no_proxy:
return full_url
# Check if this is a playlist URL (use standard proxy for playlists)
parsed = urlparse(full_url)
is_playlist = parsed.path.endswith((".m3u", ".m3u8", ".m3u_plus"))
if is_playlist:
# Use standard playlist proxy
return await super().proxy_content_url(url, base_url)
# For segments, route through acestream segment endpoint
query_params = {
"d": full_url,
}
# Preserve the original id/infohash parameter from the request
if "id" in self.request.query_params:
query_params["id"] = self.request.query_params["id"]
else:
query_params["infohash"] = self.session.infohash
# Include api_password and headers from the original request
for key, value in self.request.query_params.items():
if key == "api_password" or key.startswith("h_"):
query_params[key] = value
# Determine the segment extension
path = parsed.path.lower()
if path.endswith(".ts"):
ext = "ts"
elif path.endswith(".m4s"):
ext = "m4s"
elif path.endswith(".mp4"):
ext = "mp4"
else:
ext = "ts"
# Build acestream segment proxy URL
base_proxy_url = str(
self.request.url_for("acestream_segment_proxy", ext=ext).replace(scheme=get_original_scheme(self.request))
)
return f"{base_proxy_url}?{urlencode(query_params)}"
@acestream_router.head("/acestream/manifest.m3u8")
@acestream_router.get("/acestream/manifest.m3u8")
async def acestream_hls_manifest(
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
infohash: str = Query(None, description="Acestream infohash"),
id: str = Query(None, description="Acestream content ID (alternative to infohash)"),
):
"""
Proxy Acestream HLS manifest.
Creates or reuses an acestream session and proxies the HLS manifest,
rewriting segment URLs to go through mediaflow.
Args:
request: The incoming HTTP request.
proxy_headers: Headers for proxy requests.
infohash: The acestream infohash.
id: Alternative content ID.
Returns:
Processed HLS manifest with proxied segment URLs.
"""
if not settings.enable_acestream:
raise HTTPException(status_code=503, detail="Acestream support is disabled")
acestream_manager = _get_acestream_manager()
if not infohash and not id:
raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required")
content_id = id
if not infohash:
infohash = content_id # Use content_id as the key if no infohash
max_retries = 2
last_error = None
for attempt in range(max_retries):
try:
# Get or create acestream session (don't increment client count for manifest requests)
session = await acestream_manager.get_or_create_session(infohash, content_id, increment_client=False)
if not session.playback_url:
raise HTTPException(status_code=502, detail="Failed to get playback URL from acestream")
logger.info(f"[acestream_hls_manifest] Using playback URL: {session.playback_url}")
# Fetch the manifest from acestream with extended timeout for buffering
async with create_aiohttp_session(session.playback_url, timeout=120) as (http_session, proxy_url):
response = await http_session.get(
session.playback_url,
headers=proxy_headers.request,
proxy=proxy_url,
)
response.raise_for_status()
manifest_content = await response.text()
break # Success, exit retry loop
except asyncio.TimeoutError:
last_error = "Timeout fetching manifest"
if attempt < max_retries - 1:
logger.warning(f"[acestream_hls_manifest] Timeout fetching manifest, retrying: {infohash[:16]}...")
await asyncio.sleep(1) # Brief delay before retry
continue
logger.error(f"[acestream_hls_manifest] Timeout after {max_retries} attempts")
raise HTTPException(status_code=504, detail="Timeout fetching manifest from acestream")
except aiohttp.ClientResponseError as e:
last_error = e
# If we get 403, the session is stale - invalidate and retry
if e.status == 403 and attempt < max_retries - 1:
logger.warning(
f"[acestream_hls_manifest] Session stale (403), invalidating and retrying: {infohash[:16]}..."
)
await acestream_manager.invalidate_session(infohash)
continue # Retry with fresh session
logger.error(f"[acestream_hls_manifest] HTTP error fetching manifest: {e}")
raise HTTPException(status_code=e.status, detail=f"Failed to fetch manifest: {e}")
except aiohttp.ClientError as e:
last_error = e
logger.error(f"[acestream_hls_manifest] Client error fetching manifest: {e}")
raise HTTPException(status_code=502, detail=f"Failed to fetch manifest: {e}")
else:
# Exhausted retries
logger.error(f"[acestream_hls_manifest] Failed after {max_retries} attempts: {last_error}")
raise HTTPException(status_code=502, detail=f"Failed to fetch manifest after retries: {last_error}")
try:
# Process the manifest to rewrite URLs
processor = AcestreamM3U8Processor(
request=request,
session=session,
force_playlist_proxy=True,
)
processed_manifest = await processor.process_m3u8(manifest_content, base_url=session.playback_url)
# Register with HLS prebuffer for segment caching
if settings.enable_hls_prebuffer:
segment_urls = processor._extract_segment_urls_from_content(manifest_content, session.playback_url)
if segment_urls:
await hls_prebuffer.register_playlist(
playlist_url=session.playback_url,
segment_urls=segment_urls,
headers=proxy_headers.request,
)
base_headers = {
"content-type": "application/vnd.apple.mpegurl",
"cache-control": "no-cache, no-store, must-revalidate",
"access-control-allow-origin": "*",
}
response_headers = apply_header_manipulation(base_headers, proxy_headers, include_propagate=False)
return Response(
content=processed_manifest, media_type="application/vnd.apple.mpegurl", headers=response_headers
)
except HTTPException:
raise
except Exception as e:
logger.exception(f"[acestream_hls_manifest] Error: {e}")
raise HTTPException(status_code=500, detail=f"Internal error: {e}")
# Map file extensions to MIME types for segments
SEGMENT_MIME_TYPES = {
"ts": "video/mp2t",
"m4s": "video/mp4",
"mp4": "video/mp4",
"m4a": "audio/mp4",
"aac": "audio/aac",
}
@acestream_router.get("/acestream/segment.{ext}")
async def acestream_segment_proxy(
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
ext: str,
d: str = Query(..., description="Segment URL"),
infohash: str = Query(None, description="Acestream session infohash"),
id: str = Query(None, description="Acestream content ID (alternative to infohash)"),
):
"""
Proxy Acestream HLS segments.
Uses the HLS prebuffer for segment caching if enabled.
Args:
request: The incoming HTTP request.
proxy_headers: Headers for proxy requests.
ext: Segment file extension.
d: The segment URL to proxy.
infohash: The acestream session infohash (for tracking).
id: Alternative content ID.
Returns:
Proxied segment content.
"""
if not settings.enable_acestream:
raise HTTPException(status_code=503, detail="Acestream support is disabled")
acestream_manager = _get_acestream_manager()
# Use id or infohash for session lookup
session_key = id or infohash
if not session_key:
raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required")
segment_url = d
mime_type = SEGMENT_MIME_TYPES.get(ext.lower(), "application/octet-stream")
logger.debug(f"[acestream_segment_proxy] Request for: {segment_url}")
# Touch the session to keep it alive - use touch_segment() to indicate active playback
session = acestream_manager.get_session(session_key)
if session:
session.touch_segment()
logger.debug(f"[acestream_segment_proxy] Touched session: {session_key[:16]}...")
# Use HLS prebuffer if enabled
if settings.enable_hls_prebuffer:
await hls_prebuffer.request_segment(segment_url)
segment_data = await hls_prebuffer.get_or_download(segment_url, proxy_headers.request)
if segment_data:
logger.info(f"[acestream_segment_proxy] Serving from prebuffer ({len(segment_data)} bytes)")
base_headers = {
"content-type": mime_type,
"cache-control": "public, max-age=3600",
"access-control-allow-origin": "*",
}
response_headers = apply_header_manipulation(base_headers, proxy_headers)
return Response(content=segment_data, media_type=mime_type, headers=response_headers)
logger.warning("[acestream_segment_proxy] Prebuffer miss, using direct streaming")
# Fallback to direct streaming
streamer = await create_streamer(segment_url)
try:
await streamer.create_streaming_response(segment_url, proxy_headers.request)
base_headers = {
"content-type": mime_type,
"cache-control": "public, max-age=3600",
"access-control-allow-origin": "*",
}
response_headers = apply_header_manipulation(base_headers, proxy_headers)
return EnhancedStreamingResponse(
streamer.stream_content(),
status_code=streamer.response.status if streamer.response else 200,
headers=response_headers,
background=BackgroundTask(streamer.close),
)
except Exception as e:
await streamer.close()
logger.error(f"[acestream_segment_proxy] Error streaming segment: {e}")
raise HTTPException(status_code=502, detail=f"Failed to stream segment: {e}")
@acestream_router.head("/acestream/stream")
@acestream_router.get("/acestream/stream")
async def acestream_ts_stream(
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
infohash: str = Query(None, description="Acestream infohash"),
id: str = Query(None, description="Acestream content ID (alternative to infohash)"),
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"),
start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"),
):
"""
Proxy Acestream MPEG-TS stream with fan-out to multiple clients.
Creates or reuses an acestream session and streams MPEG-TS content.
Multiple clients can share the same upstream connection.
When transcode=true, the MPEG-TS stream is transcoded on-the-fly to
browser-compatible fMP4 (H.264 + AAC).
Args:
request: The incoming HTTP request.
proxy_headers: Headers for proxy requests.
infohash: The acestream infohash.
id: Alternative content ID.
transcode: Transcode to browser-compatible format.
start: Seek start time in seconds (transcode mode).
Returns:
MPEG-TS stream (or fMP4 if transcode=true).
"""
if not settings.enable_acestream:
raise HTTPException(status_code=503, detail="Acestream support is disabled")
acestream_manager = _get_acestream_manager()
if not infohash and not id:
raise HTTPException(status_code=400, detail="Either 'infohash' or 'id' parameter is required")
content_id = id
if not infohash:
infohash = content_id
try:
# Get or create acestream session
# For MPEG-TS, we need to use getstream endpoint
base_url = f"http://{settings.acestream_host}:{settings.acestream_port}"
session = await acestream_manager.get_or_create_session(infohash, content_id)
if not session.playback_url:
raise HTTPException(status_code=502, detail="Failed to get playback URL from acestream")
# For MPEG-TS streaming, we need to convert HLS playback URL to getstream
# Acestream uses different parameter names:
# - 'id' for content IDs
# - 'infohash' for magnet link hashes (40-char hex)
if content_id:
ts_url = f"{base_url}/ace/getstream?id={content_id}&pid={session.pid}"
else:
ts_url = f"{base_url}/ace/getstream?infohash={infohash}&pid={session.pid}"
logger.info(f"[acestream_ts_stream] Streaming from: {ts_url}")
if transcode:
if not settings.enable_transcode:
await acestream_manager.release_session(infohash)
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
# Acestream provides a live MPEG-TS stream that does NOT support
# HTTP Range requests and is not seekable. Use an ffmpeg subprocess
# to remux video (passthrough) and transcode audio (AC3→AAC) to
# fragmented MP4. The subprocess approach isolates native FFmpeg
# crashes from the Python server process.
if request.method == "HEAD":
await acestream_manager.release_session(infohash)
return Response(
status_code=200,
headers={
"access-control-allow-origin": "*",
"cache-control": "no-cache, no-store",
"content-type": "video/mp4",
"content-disposition": "inline",
},
)
async def _acestream_ts_source():
"""Single-connection async byte generator for the live TS stream."""
try:
async with create_aiohttp_session(ts_url) as (session, proxy_url):
async with session.get(
ts_url,
proxy=proxy_url,
allow_redirects=True,
) as resp:
resp.raise_for_status()
async for chunk in resp.content.iter_any():
yield chunk
except asyncio.CancelledError:
logger.debug("[acestream_ts_stream] Transcode source cancelled")
except GeneratorExit:
logger.debug("[acestream_ts_stream] Transcode source closed")
# Use our custom PyAV pipeline with forced video re-encoding
# (live MPEG-TS sources often have corrupt H.264 bitstreams
# that browsers reject; re-encoding produces a clean stream).
stream_transcode_universal = _load_transcode_pipeline()
content = stream_transcode_universal(
_acestream_ts_source(),
force_video_reencode=True,
)
async def release_transcode_session():
await acestream_manager.release_session(infohash)
return EnhancedStreamingResponse(
content=content,
media_type="video/mp4",
headers={
"access-control-allow-origin": "*",
"cache-control": "no-cache, no-store",
"content-disposition": "inline",
},
background=BackgroundTask(release_transcode_session),
)
streamer = await create_streamer(ts_url)
try:
await streamer.create_streaming_response(ts_url, proxy_headers.request)
base_headers = {
"content-type": "video/mp2t",
"transfer-encoding": "chunked",
"cache-control": "no-cache, no-store, must-revalidate",
"access-control-allow-origin": "*",
}
response_headers = apply_header_manipulation(base_headers, proxy_headers)
async def release_on_complete():
"""Release session when streaming completes."""
await streamer.close()
await acestream_manager.release_session(infohash)
return EnhancedStreamingResponse(
streamer.stream_content(),
status_code=streamer.response.status if streamer.response else 200,
headers=response_headers,
background=BackgroundTask(release_on_complete),
)
except Exception:
await streamer.close()
await acestream_manager.release_session(infohash)
raise
except HTTPException:
raise
except Exception as e:
logger.exception(f"[acestream_ts_stream] Error: {e}")
await acestream_manager.release_session(infohash)
raise HTTPException(status_code=500, detail=f"Internal error: {e}")
@acestream_router.get("/acestream/status")
async def acestream_status(
infohash: str = Query(None, description="Acestream infohash to check"),
):
"""
Get acestream session status.
Args:
infohash: Optional infohash to check specific session.
Returns:
Session status information.
"""
if not settings.enable_acestream:
raise HTTPException(status_code=503, detail="Acestream support is disabled")
acestream_manager = _get_acestream_manager()
if infohash:
session = acestream_manager.get_session(infohash)
if session:
return {
"status": "active",
"infohash": session.infohash,
"client_count": session.client_count,
"is_live": session.is_live,
"created_at": session.created_at,
"last_access": session.last_access,
}
else:
return {"status": "not_found", "infohash": infohash}
# Return all active sessions
sessions = acestream_manager.get_active_sessions()
return {
"enabled": settings.enable_acestream,
"active_sessions": len(sessions),
"sessions": [
{
"infohash": s.infohash,
"client_count": s.client_count,
"is_live": s.is_live,
}
for s in sessions.values()
],
}