mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-04-11 11:50:51 +00:00
update
This commit is contained in:
540
mediaflow_proxy/routes/acestream.py
Normal file
540
mediaflow_proxy/routes/acestream.py
Normal file
@@ -0,0 +1,540 @@
|
||||
"""
|
||||
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 typing import Annotated
|
||||
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.remuxer.transcode_pipeline import stream_transcode_universal
|
||||
from mediaflow_proxy.utils.acestream import acestream_manager, AcestreamSession
|
||||
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()
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
# 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")
|
||||
|
||||
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).
|
||||
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")
|
||||
|
||||
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()
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user