new version

This commit is contained in:
UrloMythus
2026-04-15 19:23:14 +02:00
parent 5120b19d0b
commit 8134936d59
135 changed files with 3013 additions and 1589 deletions
+34 -8
View File
@@ -1,11 +1,3 @@
from .proxy import proxy_router
from .extractor import extractor_router
from .speedtest import speedtest_router
from .playlist_builder import playlist_builder_router
from .xtream import xtream_root_router
from .acestream import acestream_router
from .telegram import telegram_router
__all__ = [
"proxy_router",
"extractor_router",
@@ -15,3 +7,37 @@ __all__ = [
"acestream_router",
"telegram_router",
]
def __getattr__(name: str):
# Lazy import routers so importing a single route module does not
# pull in optional integrations (telegram/acestream/transcode) at startup.
if name == "proxy_router":
from .proxy import proxy_router
return proxy_router
if name == "extractor_router":
from .extractor import extractor_router
return extractor_router
if name == "speedtest_router":
from .speedtest import speedtest_router
return speedtest_router
if name == "playlist_builder_router":
from .playlist_builder import playlist_builder_router
return playlist_builder_router
if name == "xtream_root_router":
from .xtream import xtream_root_router
return xtream_root_router
if name == "acestream_router":
from .acestream import acestream_router
return acestream_router
if name == "telegram_router":
from .telegram import telegram_router
return telegram_router
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+24 -4
View File
@@ -9,7 +9,8 @@ Provides endpoints for proxying acestream content:
import asyncio
import logging
from typing import Annotated
from functools import lru_cache
from typing import Annotated, TYPE_CHECKING
from urllib.parse import urlencode, urljoin, urlparse
import aiohttp
@@ -17,8 +18,6 @@ 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,
@@ -34,6 +33,22 @@ 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):
"""
@@ -46,7 +61,7 @@ class AcestreamM3U8Processor(M3U8Processor):
def __init__(
self,
request: Request,
session: AcestreamSession,
session: "AcestreamSession",
key_url: str = None,
force_playlist_proxy: bool = True,
key_only_proxy: bool = False,
@@ -140,6 +155,7 @@ async def acestream_hls_manifest(
"""
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")
@@ -278,6 +294,7 @@ async def acestream_segment_proxy(
"""
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
@@ -368,6 +385,7 @@ async def acestream_ts_stream(
"""
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")
@@ -438,6 +456,7 @@ async def acestream_ts_stream(
# 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,
@@ -509,6 +528,7 @@ async def acestream_status(
"""
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)
+203
View File
@@ -0,0 +1,203 @@
"""
EPG Proxy — XMLTV/EPG pass-through with caching.
Supports Channels DVR, Plex, Emby, and any XMLTV-compatible EPG client.
Usage:
GET /proxy/epg?d=<epg_url>&api_password=<key>
With custom headers for protected sources:
GET /proxy/epg?d=<url>&h_Authorization=Bearer+<token>&api_password=<key>
With cache TTL override:
GET /proxy/epg?d=<url>&cache_ttl=7200&api_password=<key>
"""
import hashlib
import logging
import time
from typing import Optional
import aiohttp
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import Response
from mediaflow_proxy.configs import settings
from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
from mediaflow_proxy.utils.http_client import create_aiohttp_session
logger = logging.getLogger(__name__)
epg_router = APIRouter()
# In-memory EPG cache: {cache_key: (content_bytes, content_type, fetch_timestamp)}
# EPG data rarely changes; default TTL is 1 hour.
_epg_cache: dict[str, tuple[bytes, str, float]] = {}
def _get_cached_epg(cache_key: str, ttl: int) -> Optional[tuple[bytes, str]]:
"""Return (content, content_type) from cache if the entry exists and has not expired."""
entry = _epg_cache.get(cache_key)
if entry is None:
return None
content, content_type, ts = entry
if time.monotonic() - ts < ttl:
return content, content_type
# Expired — evict lazily
del _epg_cache[cache_key]
return None
def _set_cached_epg(cache_key: str, content: bytes, content_type: str) -> None:
_epg_cache[cache_key] = (content, content_type, time.monotonic())
def _build_cache_key(destination: str, request_headers: dict[str, str]) -> str:
"""
Incorporate auth-bearing headers into the cache key so that different
credentials don't serve each other's cached EPG data.
"""
if not request_headers:
return destination
header_hash = hashlib.md5(str(sorted(request_headers.items())).encode()).hexdigest()[:8]
return f"{destination}|{header_hash}"
@epg_router.get("/epg")
@epg_router.head("/epg")
async def epg_proxy(
request: Request,
destination: str = Query(
...,
alias="d",
description="URL of the XMLTV/EPG source. Supports plain URLs and base64-encoded URLs.",
),
cache_ttl: Optional[int] = Query(
None,
description=(
"Cache lifetime in seconds. 0 disables caching. Defaults to the EPG_CACHE_TTL setting (3600 s = 1 h)."
),
),
):
"""
Proxy EPG / XMLTV data from any upstream source with optional caching.
**Channels DVR setup:** enter this URL as your custom EPG source:
http://<proxy-host>:<port>/proxy/epg?d=<epg_url>&api_password=<key>
**Protected EPG sources** — pass authentication via `h_` header params:
?d=<url>&h_Authorization=Bearer+<token>&api_password=<key>
Base64-encoded destination URLs are automatically decoded.
Returns the XMLTV XML with `Content-Type: application/xml`.
"""
# Resolve base64-encoded destination URLs
destination = process_potential_base64_url(destination)
if not destination.startswith(("http://", "https://")):
raise HTTPException(
status_code=400,
detail="Destination must be an http:// or https:// URL.",
)
# Collect upstream request headers from h_<name> query params
request_headers: dict[str, str] = {
key[2:]: value for key, value in request.query_params.items() if key.startswith("h_")
}
# Effective TTL — per-request override or global config
effective_ttl: int = cache_ttl if cache_ttl is not None else settings.epg_cache_ttl
cache_key = _build_cache_key(destination, request_headers)
# --- Cache read -------------------------------------------------------
if effective_ttl > 0:
cached = _get_cached_epg(cache_key, effective_ttl)
if cached is not None:
content, content_type = cached
logger.debug("[epg_proxy] Cache HIT: %s", destination)
if request.method == "HEAD":
return Response(
status_code=200,
headers={
"Content-Type": content_type,
"Content-Length": str(len(content)),
"X-EPG-Cache": "HIT",
"Cache-Control": f"public, max-age={effective_ttl}",
},
)
return Response(
content=content,
media_type=content_type,
headers={
"X-EPG-Cache": "HIT",
"Cache-Control": f"public, max-age={effective_ttl}",
},
)
# --- Upstream fetch ---------------------------------------------------
logger.info("[epg_proxy] Fetching EPG from: %s", destination)
async with create_aiohttp_session(destination, timeout=120) as (session, proxy_url):
try:
async with session.get(
destination,
headers=request_headers,
proxy=proxy_url,
allow_redirects=True,
) as response:
response.raise_for_status()
content = await response.read()
content_type = response.headers.get("content-type", "application/xml; charset=utf-8")
# Normalise to XML content type if upstream returns something unexpected
if not any(t in content_type.lower() for t in ("xml", "text")):
content_type = "application/xml; charset=utf-8"
if effective_ttl > 0:
_set_cached_epg(cache_key, content, content_type)
logger.info(
"[epg_proxy] Cached %d bytes from %s (TTL=%ds)",
len(content),
destination,
effective_ttl,
)
if request.method == "HEAD":
return Response(
status_code=200,
headers={
"Content-Type": content_type,
"Content-Length": str(len(content)),
"X-EPG-Cache": "MISS",
"Cache-Control": f"public, max-age={effective_ttl}",
},
)
return Response(
content=content,
media_type=content_type,
headers={
"X-EPG-Cache": "MISS",
"Cache-Control": f"public, max-age={effective_ttl}",
},
)
except aiohttp.ClientResponseError as e:
logger.warning("[epg_proxy] Upstream HTTP %s for %s", e.status, destination)
raise HTTPException(
status_code=e.status,
detail=f"Upstream EPG error: HTTP {e.status}",
)
except aiohttp.ClientConnectorError as e:
logger.error("[epg_proxy] Cannot connect to %s: %s", destination, e)
raise HTTPException(
status_code=502,
detail=f"Cannot connect to EPG source: {e}",
)
except TimeoutError:
logger.error("[epg_proxy] Timeout fetching %s", destination)
raise HTTPException(status_code=504, detail="EPG source timed out")
except aiohttp.ClientError as e:
logger.error("[epg_proxy] Fetch error for %s: %s", destination, e)
raise HTTPException(status_code=502, detail=f"EPG fetch failed: {e}")
+24 -1
View File
@@ -179,9 +179,32 @@ async def _extract_url_impl(
if "no_proxy" in request.query_params:
response["query_params"]["no_proxy"] = request.query_params.get("no_proxy")
# Some extractors return force_playlist_proxy as top-level metadata for internal
# manifest processing. Redirect URL encoding expects it in query params.
if response.pop("force_playlist_proxy", False):
response["query_params"]["force_playlist_proxy"] = "1"
if extractor_params.redirect_stream:
encode_args = {
key: response[key]
for key in (
"mediaflow_proxy_url",
"endpoint",
"destination_url",
"query_params",
"request_headers",
"propagate_response_headers",
"remove_response_headers",
"encryption_handler",
"expiration",
"ip",
"filename",
"stream_transformer",
)
if key in response
}
stream_url = encode_mediaflow_proxy_url(
**response,
**encode_args,
response_headers=proxy_headers.response,
)
return RedirectResponse(url=stream_url, status_code=302)
+34 -206
View File
@@ -1,10 +1,9 @@
import asyncio
import logging
import re
from functools import lru_cache
from typing import Annotated
from urllib.parse import quote, unquote
import aiohttp
from fastapi import Request, Depends, APIRouter, Query, HTTPException, Response
from fastapi.datastructures import QueryParams
@@ -28,32 +27,40 @@ from mediaflow_proxy.schemas import (
)
from mediaflow_proxy.utils.base64_utils import process_potential_base64_url
from mediaflow_proxy.utils.extractor_helpers import (
check_and_extract_dlhd_stream,
check_and_extract_sportsonline_stream,
)
from mediaflow_proxy.utils.hls_prebuffer import hls_prebuffer
from mediaflow_proxy.utils.hls_utils import parse_hls_playlist, find_stream_by_resolution
from mediaflow_proxy.utils.http_utils import (
get_proxy_headers,
ProxyRequestHeaders,
apply_header_manipulation,
)
from mediaflow_proxy.utils.http_client import create_aiohttp_session
from mediaflow_proxy.utils.m3u8_processor import M3U8Processor
from mediaflow_proxy.utils.stream_transformers import apply_transformer_to_bytes
from mediaflow_proxy.remuxer.media_source import HTTPMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
logger = logging.getLogger(__name__)
proxy_router = APIRouter()
@lru_cache(maxsize=1)
def _load_transcode_components():
from mediaflow_proxy.remuxer.media_source import HTTPMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
return (
HTTPMediaSource,
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
def sanitize_url(url: str) -> str:
"""
Sanitize URL to fix common encoding issues and handle base64 encoded URLs.
@@ -168,52 +175,6 @@ async def hls_manifest_proxy(
# Sanitize destination URL to fix common encoding issues
hls_params.destination = sanitize_url(hls_params.destination)
# Check if this is a retry after 403 error (dlhd_retry parameter)
force_refresh = request.query_params.get("dlhd_retry") == "1"
# Check if destination contains DLHD pattern and extract stream directly
dlhd_result = await check_and_extract_dlhd_stream(
request, hls_params.destination, proxy_headers, force_refresh=force_refresh
)
dlhd_original_url = None
if dlhd_result:
# Store original DLHD URL for cache invalidation on 403 errors
dlhd_original_url = hls_params.destination
# Update destination and headers with extracted stream data
hls_params.destination = dlhd_result["destination_url"]
extracted_headers = dlhd_result.get("request_headers", {})
proxy_headers.request.update(extracted_headers)
# Check if extractor wants key-only proxy (DLHD uses hls_key_proxy endpoint)
if dlhd_result.get("mediaflow_endpoint") == "hls_key_proxy":
hls_params.key_only_proxy = True
# Check if extractor wants to force playlist proxy (needed for .css disguised m3u8)
if dlhd_result.get("force_playlist_proxy"):
hls_params.force_playlist_proxy = True
# Also add headers to query params so they propagate to key/segment requests
# This is necessary because M3U8Processor encodes headers as h_* query params
query_dict = dict(request.query_params)
for header_name, header_value in extracted_headers.items():
# Add header with h_ prefix to query params
query_dict[f"h_{header_name}"] = header_value
# Add DLHD original URL to track for cache invalidation
if dlhd_original_url:
query_dict["dlhd_original"] = dlhd_original_url
# Add DLHD key params if present (for dynamic key header computation)
if dlhd_result.get("dlhd_channel_salt"):
query_dict["dlhd_salt"] = dlhd_result["dlhd_channel_salt"]
if dlhd_result.get("dlhd_auth_token"):
query_dict["dlhd_token"] = dlhd_result["dlhd_auth_token"]
if dlhd_result.get("dlhd_iframe_url"):
query_dict["dlhd_iframe"] = dlhd_result["dlhd_iframe_url"]
# Remove retry flag from subsequent requests
query_dict.pop("dlhd_retry", None)
# Update request query params
request._query_params = QueryParams(query_dict)
# Check if destination contains Sportsonline pattern and extract stream directly
sportsonline_result = await check_and_extract_sportsonline_stream(request, hls_params.destination, proxy_headers)
if sportsonline_result:
@@ -231,124 +192,9 @@ async def hls_manifest_proxy(
for header_name, header_value in extracted_headers.items():
# Add header with h_ prefix to query params
query_dict[f"h_{header_name}"] = header_value
# Remove retry flag from subsequent requests
query_dict.pop("dlhd_retry", None)
# Update request query params
request._query_params = QueryParams(query_dict)
# Wrap the handler to catch 403 errors and retry with cache invalidation
try:
result = await _handle_hls_with_dlhd_retry(request, hls_params, proxy_headers, dlhd_original_url)
return result
except HTTPException:
raise
except Exception as e:
logger.exception(f"Unexpected error in hls_manifest_proxy: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def _handle_hls_with_dlhd_retry(
request: Request, hls_params: HLSManifestParams, proxy_headers: ProxyRequestHeaders, dlhd_original_url: str | None
):
"""
Handle HLS request with automatic retry on 403 errors for DLHD streams.
"""
# Check if resolution selection is needed (either max_res or specific resolution)
if hls_params.max_res or hls_params.resolution:
async with create_aiohttp_session(hls_params.destination) as (session, proxy_url):
try:
response = await session.get(
hls_params.destination,
headers=proxy_headers.request,
proxy=proxy_url,
)
response.raise_for_status()
playlist_content = await response.text()
except aiohttp.ClientResponseError as e:
raise HTTPException(
status_code=502,
detail=f"Failed to fetch HLS manifest from origin: {e.status}",
) from e
except asyncio.TimeoutError as e:
raise HTTPException(
status_code=504,
detail=f"Timeout while fetching HLS manifest: {e}",
) from e
except aiohttp.ClientError as e:
raise HTTPException(status_code=502, detail=f"Network error fetching HLS manifest: {e}") from e
streams = parse_hls_playlist(playlist_content, base_url=hls_params.destination)
if not streams:
raise HTTPException(status_code=404, detail="No streams found in the manifest.")
# Select stream based on resolution parameter or max_res
if hls_params.resolution:
selected_stream = find_stream_by_resolution(streams, hls_params.resolution)
if not selected_stream:
raise HTTPException(
status_code=404, detail=f"No suitable stream found for resolution {hls_params.resolution}."
)
else:
# max_res: select highest resolution
selected_stream = max(
streams,
key=lambda s: s.get("resolution", (0, 0))[0] * s.get("resolution", (0, 0))[1],
)
if selected_stream.get("resolution", (0, 0)) == (0, 0):
logger.warning(
"Selected stream has resolution (0, 0); resolution parsing may have failed or not be available in the manifest."
)
# Rebuild the manifest preserving master-level directives
# but removing non-selected variant blocks
lines = playlist_content.splitlines()
selected_variant_index = streams.index(selected_stream)
variant_index = -1
new_manifest_lines = []
i = 0
while i < len(lines):
line = lines[i]
if line.startswith("#EXT-X-STREAM-INF"):
variant_index += 1
next_line = ""
if i + 1 < len(lines) and not lines[i + 1].startswith("#"):
next_line = lines[i + 1]
# Only keep the selected variant
if variant_index == selected_variant_index:
new_manifest_lines.append(line)
if next_line:
new_manifest_lines.append(next_line)
# Skip variant block (stream-inf + optional url)
i += 2 if next_line else 1
continue
# Preserve all other lines (master directives, media tags, etc.)
new_manifest_lines.append(line)
i += 1
new_manifest = "\n".join(new_manifest_lines)
# Parse skip segments (already returns list of dicts with 'start' and 'end' keys)
skip_segments_list = hls_params.get_skip_segments()
# Process the new manifest to proxy all URLs within it
processor = M3U8Processor(
request,
hls_params.key_url,
hls_params.force_playlist_proxy,
hls_params.key_only_proxy,
hls_params.no_proxy,
skip_segments_list,
hls_params.start_offset,
)
processed_manifest = await processor.process_m3u8(new_manifest, base_url=hls_params.destination)
return Response(content=processed_manifest, media_type="application/vnd.apple.mpegurl")
return await handle_hls_stream_proxy(request, hls_params, proxy_headers, hls_params.transformer)
@@ -463,10 +309,14 @@ async def hls_segment_proxy(
response_headers = apply_header_manipulation(base_headers, proxy_headers)
return Response(content=segment_data, media_type=mime_type, headers=response_headers)
# get_or_download returned None (timeout or error) - fall through to streaming
logger.warning(f"[hls_segment_proxy] Prebuffer timeout, using direct streaming: {segment_url}")
# get_or_download returned None (timeout or error) - fall through to direct fetch
logger.warning(f"[hls_segment_proxy] Prebuffer timeout, using direct fetch: {segment_url}")
# Fallback to direct streaming
# Fallback to direct streaming.
# Override the response Content-Type so that CDN-served MPEG-TS segments
# are not interpreted as a non-video format.
if mime_type != "application/octet-stream":
proxy_headers.response["content-type"] = mime_type
return await handle_stream_request("GET", segment_url, proxy_headers, transformer)
@@ -499,6 +349,7 @@ async def transcode_hls_playlist(
"""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, _, handle_transcode_hls_playlist, _ = _load_transcode_components()
destination = sanitize_url(destination)
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
await source.resolve_file_size()
@@ -540,6 +391,7 @@ async def transcode_hls_init(
"""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, handle_transcode_hls_init, _, _ = _load_transcode_components()
destination = sanitize_url(destination)
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
await source.resolve_file_size()
@@ -572,6 +424,7 @@ async def transcode_hls_segment(
"""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, _, _, handle_transcode_hls_segment = _load_transcode_components()
destination = sanitize_url(destination)
source = HTTPMediaSource(url=destination, headers=dict(proxy_headers.request))
await source.resolve_file_size()
@@ -660,39 +513,11 @@ async def proxy_stream_endpoint(
# Sanitize destination URL to fix common encoding issues
destination = sanitize_url(destination)
# Check if this is a DLHD key URL request with key params in query
dlhd_salt = request.query_params.get("dlhd_salt")
dlhd_token = request.query_params.get("dlhd_token")
if dlhd_salt and "/key/" in destination:
# This is a DLHD key URL - compute dynamic headers via executor to avoid blocking
from mediaflow_proxy.extractors.dlhd import compute_key_headers
key_headers = await asyncio.to_thread(compute_key_headers, destination, dlhd_salt)
if key_headers:
ts, nonce, key_path, fingerprint = key_headers
proxy_headers.request.update(
{
"X-Key-Timestamp": str(ts),
"X-Key-Nonce": str(nonce),
"X-Fingerprint": fingerprint,
"X-Key-Path": key_path,
}
)
if dlhd_token:
proxy_headers.request["Authorization"] = f"Bearer {dlhd_token}"
logger.info(f"[proxy_stream] Computed DLHD key headers for: {destination}")
# Check if destination contains DLHD pattern and extract stream directly
dlhd_result = await check_and_extract_dlhd_stream(request, destination, proxy_headers)
if dlhd_result:
# Update destination and headers with extracted stream data
destination = dlhd_result["destination_url"]
proxy_headers.request.update(dlhd_result.get("request_headers", {}))
# Handle transcode mode — transcode uses time-based seeking, not byte ranges
if transcode:
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, handle_transcode, _, _, _ = _load_transcode_components()
transcode_headers = dict(proxy_headers.request)
transcode_headers.pop("range", None)
transcode_headers.pop("if-range", None)
@@ -708,6 +533,9 @@ async def proxy_stream_endpoint(
if "range" not in proxy_headers.request:
proxy_headers.request["range"] = "bytes=0-"
# Mark that this range was auto-added (not from client)
# This is used in handlers.py to decide whether to convert 206->200
proxy_headers.auto_added_range = True
if filename:
# If a filename is provided (not a segment), set it in the headers using RFC 6266 format
+229 -91
View File
@@ -12,37 +12,75 @@ import asyncio
import logging
import re
import secrets
from typing import Annotated, Optional
from urllib.parse import quote
from functools import lru_cache
from typing import Annotated, Optional, TYPE_CHECKING
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel
from telethon import TelegramClient
from telethon.sessions import StringSession
from mediaflow_proxy.configs import settings
from mediaflow_proxy.remuxer.media_source import TelegramMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
from mediaflow_proxy.utils.http_utils import (
EnhancedStreamingResponse,
ProxyRequestHeaders,
apply_header_manipulation,
get_proxy_headers,
)
from mediaflow_proxy.utils.telegram import (
TelegramMediaRef,
parse_telegram_url,
telegram_manager,
)
logger = logging.getLogger(__name__)
telegram_router = APIRouter()
if TYPE_CHECKING:
from mediaflow_proxy.utils.telegram import TelegramMediaRef
def _telegram_utils():
from mediaflow_proxy.utils.telegram import TelegramMediaRef, parse_telegram_url, telegram_manager
return TelegramMediaRef, parse_telegram_url, telegram_manager
@lru_cache(maxsize=1)
def _load_transcode_handlers():
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
return (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
def _content_disposition_inline(filename: str) -> str:
"""
Build a Content-Disposition header value that is always latin-1 safe.
Starlette/FastAPI requires header values to be latin-1 encodable. Telegram filenames
may contain unicode (e.g. Cyrillic), so we use RFC 6266 `filename*` when needed.
"""
# Sanitize newlines and carriage returns
sanitized = (filename or "").strip().replace("\n", " ").replace("\r", " ")
if not sanitized:
return "inline"
try:
# Try if the filename is latin-1 encodable
sanitized.encode("latin-1")
# For the filename= parameter, we must escape backslashes and double quotes
escaped = sanitized.replace("\\", "\\\\").replace('"', '\\"')
return f'inline; filename="{escaped}"'
except UnicodeEncodeError:
# For filename*, use percent-encoding with the original (unescaped) sanitized name
encoded = quote(sanitized, encoding="utf-8", safe="")
return f"inline; filename*=UTF-8''{encoded}"
def get_content_type(mime_type: str, file_name: Optional[str] = None) -> str:
"""Determine content type from mime type or filename."""
@@ -115,6 +153,87 @@ def parse_range_header(range_header: Optional[str], file_size: int) -> tuple[int
return start, end
def _parse_chat_id_value(chat_id: str) -> int | str:
"""Parse chat_id as integer when possible; otherwise keep username form."""
try:
return int(chat_id)
except ValueError:
return chat_id
def _build_telegram_ref_from_params(
TelegramMediaRef,
parse_telegram_url,
*,
telegram_url: str | None,
chat_id: str | None,
message_id: int | None,
file_id: str | None,
document_id: int | None,
file_size: int | None,
require_file_size_for_file_id: bool,
):
"""
Build a TelegramMediaRef with route-level priority:
URL -> chat_id+message_id -> chat_id+document_id -> chat_id+file_id -> file_id.
"""
if telegram_url:
return parse_telegram_url(telegram_url)
if chat_id and message_id is not None:
return TelegramMediaRef(
chat_id=_parse_chat_id_value(chat_id),
message_id=message_id,
file_id=file_id,
document_id=document_id,
)
if chat_id and document_id is not None:
return TelegramMediaRef(chat_id=_parse_chat_id_value(chat_id), document_id=document_id)
if chat_id and file_id:
return TelegramMediaRef(chat_id=_parse_chat_id_value(chat_id), file_id=file_id)
if file_id:
if require_file_size_for_file_id and not file_size:
raise HTTPException(
status_code=400,
detail="file_size parameter is required when using file_id. "
"The file_id doesn't contain size information needed for range requests.",
)
return TelegramMediaRef(file_id=file_id)
raise HTTPException(
status_code=400,
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', "
"'chat_id' + 'document_id', or 'file_id' (+ 'file_size' for stream/transcode)",
)
async def _resolve_media_info_with_file_id_fallback(telegram_manager, TelegramMediaRef, ref, file_size: int | None):
"""
Resolve media info and keep compatibility with direct file_id mode.
If chat-scoped resolution (chat_id + message_id/document_id/file_id) fails to locate
a message, and file_id is available, fall back to direct file_id resolution.
"""
try:
return ref, await telegram_manager.get_media_info(ref, file_size=file_size)
except Exception as e:
error_name = type(e).__name__
can_fallback_to_file_id = (
ref.file_id is not None
and ref.chat_id is not None
and error_name in {"TelegramDocumentNotFoundError", "TelegramMessageNotFoundError"}
)
if not can_fallback_to_file_id:
raise
fallback_ref = TelegramMediaRef(file_id=ref.file_id)
media_info = await telegram_manager.get_media_info(fallback_ref, file_size=file_size)
return fallback_ref, media_info
@telegram_router.head("/telegram/stream")
@telegram_router.get("/telegram/stream")
@telegram_router.head("/telegram/stream/{filename:path}")
@@ -126,6 +245,7 @@ async def telegram_stream(
url: Optional[str] = Query(None, description="Alias for 'd' parameter"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID (use with message_id)"),
message_id: Optional[int] = Query(None, description="Message ID (use with chat_id)"),
document_id: Optional[int] = Query(None, description="Document ID (use with chat_id)"),
file_id: Optional[str] = Query(None, description="Bot API file_id (requires file_size parameter)"),
file_size: Optional[int] = Query(None, description="File size in bytes (required for file_id streaming)"),
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4 (EAC3/AC3->AAC)"),
@@ -138,6 +258,7 @@ async def telegram_stream(
Supports:
- t.me links: https://t.me/channel/123, https://t.me/c/123456789/456
- chat_id + message_id: Direct reference by IDs (e.g., chat_id=-100123456&message_id=789)
- chat_id + document_id: Resolve by scanning recent messages in the chat
- file_id + file_size: Direct streaming by Bot API file_id (requires file_size)
When transcode=true, the media is remuxed to fragmented MP4 with
@@ -155,6 +276,7 @@ async def telegram_stream(
url: Alias for 'd' parameter
chat_id: Chat/Channel ID (numeric or username)
message_id: Message ID within the chat
document_id: Telegram document ID within the chat
file_id: Bot API file_id (requires file_size parameter)
file_size: File size in bytes (required for file_id streaming)
transcode: Transcode to browser-compatible format (EAC3/AC3->AAC)
@@ -165,41 +287,28 @@ async def telegram_stream(
"""
if not settings.enable_telegram:
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
TelegramMediaRef, parse_telegram_url, telegram_manager = _telegram_utils()
# Get the URL from either parameter
telegram_url = d or url
# Determine which input method was used
if not telegram_url and not file_id and not (chat_id and message_id):
raise HTTPException(
status_code=400,
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' + 'file_size' parameters",
try:
ref = _build_telegram_ref_from_params(
TelegramMediaRef,
parse_telegram_url,
telegram_url=telegram_url,
chat_id=chat_id,
message_id=message_id,
file_id=file_id,
document_id=document_id,
file_size=file_size,
require_file_size_for_file_id=True,
)
try:
# Parse the reference based on input type
if telegram_url:
ref = parse_telegram_url(telegram_url)
elif chat_id and message_id:
# Direct chat_id + message_id
# Try to parse chat_id as int, otherwise treat as username
try:
parsed_chat_id: int | str = int(chat_id)
except ValueError:
parsed_chat_id = chat_id # Username
ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id)
else:
# file_id mode
if not file_size:
raise HTTPException(
status_code=400,
detail="file_size parameter is required when using file_id. "
"The file_id doesn't contain size information needed for range requests.",
)
ref = TelegramMediaRef(file_id=file_id)
# Get media info (pass file_size for file_id mode)
media_info = await telegram_manager.get_media_info(ref, file_size=file_size)
ref, media_info = await _resolve_media_info_with_file_id_fallback(
telegram_manager, TelegramMediaRef, ref, file_size
)
actual_file_size = media_info.file_size
mime_type = media_info.mime_type
media_filename = filename or media_info.file_name
@@ -235,7 +344,7 @@ async def telegram_stream(
"access-control-allow-origin": "*",
}
if media_filename:
headers["content-disposition"] = f'inline; filename="{media_filename}"'
headers["content-disposition"] = _content_disposition_inline(media_filename)
return Response(headers=headers)
# Build response headers
@@ -253,7 +362,7 @@ async def telegram_stream(
base_headers["content-range"] = f"bytes {start}-{end}/{actual_file_size}"
if media_filename:
base_headers["content-disposition"] = f'inline; filename="{media_filename}"'
base_headers["content-disposition"] = _content_disposition_inline(media_filename)
response_headers = apply_header_manipulation(base_headers, proxy_headers)
@@ -326,6 +435,10 @@ async def telegram_stream(
)
elif error_name == "MessageIdInvalidError":
raise HTTPException(status_code=404, detail="Message not found in the specified chat.")
elif error_name == "TelegramMessageNotFoundError":
raise HTTPException(status_code=404, detail=str(e))
elif error_name == "TelegramDocumentNotFoundError":
raise HTTPException(status_code=404, detail=str(e))
elif error_name == "AuthKeyError":
raise HTTPException(
status_code=401, detail="Telegram session is invalid. Please regenerate the session string."
@@ -359,7 +472,7 @@ async def telegram_stream(
async def _handle_transcode(
request: Request,
ref: TelegramMediaRef,
ref: "TelegramMediaRef",
file_size: int,
start_time: float | None = None,
file_name: str = "",
@@ -371,6 +484,7 @@ async def _handle_transcode(
passes it to the source-agnostic transcode handler which handles
cue probing, seeking, and pipeline selection.
"""
handle_transcode, _, _, _ = _load_transcode_handlers()
source = TelegramMediaSource(ref, file_size, file_name=file_name)
return await handle_transcode(request, source, start_time=start_time)
@@ -385,6 +499,7 @@ async def _resolve_telegram_source(
url: str | None = None,
chat_id: str | None = None,
message_id: int | None = None,
document_id: int | None = None,
file_id: str | None = None,
file_size: int | None = None,
filename: str | None = None,
@@ -403,39 +518,31 @@ async def _resolve_telegram_source(
DC connections per request is wasteful.
"""
if not settings.enable_telegram:
from fastapi import HTTPException
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
TelegramMediaRef, parse_telegram_url, telegram_manager = _telegram_utils()
telegram_url = d or url
if not telegram_url and not file_id and not (chat_id and message_id):
from fastapi import HTTPException
ref = _build_telegram_ref_from_params(
TelegramMediaRef,
parse_telegram_url,
telegram_url=telegram_url,
chat_id=chat_id,
message_id=message_id,
file_id=file_id,
document_id=document_id,
file_size=file_size,
require_file_size_for_file_id=True,
)
raise HTTPException(
status_code=400,
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' + 'file_size'",
try:
ref, media_info = await _resolve_media_info_with_file_id_fallback(
telegram_manager, TelegramMediaRef, ref, file_size
)
if telegram_url:
ref = parse_telegram_url(telegram_url)
elif chat_id and message_id:
try:
parsed_chat_id: int | str = int(chat_id)
except ValueError:
parsed_chat_id = chat_id
ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id)
else:
if not file_size:
from fastapi import HTTPException
raise HTTPException(
status_code=400,
detail="file_size is required when using file_id",
)
ref = TelegramMediaRef(file_id=file_id)
media_info = await telegram_manager.get_media_info(ref, file_size=file_size)
except Exception as e:
if type(e).__name__ in {"TelegramDocumentNotFoundError", "TelegramMessageNotFoundError"}:
raise HTTPException(status_code=404, detail=str(e))
raise
actual_file_size = media_info.file_size
media_filename = filename or media_info.file_name
@@ -455,6 +562,7 @@ async def telegram_transcode_hls_playlist(
url: Optional[str] = Query(None, description="Alias for 'd'"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
message_id: Optional[int] = Query(None, description="Message ID"),
document_id: Optional[int] = Query(None, description="Document ID"),
file_id: Optional[str] = Query(None, description="Bot API file_id"),
file_size: Optional[int] = Query(None, description="File size in bytes"),
filename: Optional[str] = Query(None, description="Optional filename"),
@@ -462,11 +570,13 @@ async def telegram_transcode_hls_playlist(
"""Generate an HLS VOD M3U8 playlist for a Telegram media file."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
_, _, handle_transcode_hls_playlist, _ = _load_transcode_handlers()
source = await _resolve_telegram_source(
d,
url,
chat_id,
message_id,
document_id,
file_id,
file_size,
filename,
@@ -497,6 +607,7 @@ async def telegram_transcode_hls_init(
url: Optional[str] = Query(None, description="Alias for 'd'"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
message_id: Optional[int] = Query(None, description="Message ID"),
document_id: Optional[int] = Query(None, description="Document ID"),
file_id: Optional[str] = Query(None, description="Bot API file_id"),
file_size: Optional[int] = Query(None, description="File size in bytes"),
filename: Optional[str] = Query(None, description="Optional filename"),
@@ -504,11 +615,13 @@ async def telegram_transcode_hls_init(
"""Serve the fMP4 init segment for a Telegram media file."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
_, handle_transcode_hls_init, _, _ = _load_transcode_handlers()
source = await _resolve_telegram_source(
d,
url,
chat_id,
message_id,
document_id,
file_id,
file_size,
filename,
@@ -527,6 +640,7 @@ async def telegram_transcode_hls_segment(
url: Optional[str] = Query(None, description="Alias for 'd'"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID"),
message_id: Optional[int] = Query(None, description="Message ID"),
document_id: Optional[int] = Query(None, description="Document ID"),
file_id: Optional[str] = Query(None, description="Bot API file_id"),
file_size: Optional[int] = Query(None, description="File size in bytes"),
filename: Optional[str] = Query(None, description="Optional filename"),
@@ -534,11 +648,13 @@ async def telegram_transcode_hls_segment(
"""Serve a single HLS fMP4 media segment for a Telegram media file."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
_, _, _, handle_transcode_hls_segment = _load_transcode_handlers()
source = await _resolve_telegram_source(
d,
url,
chat_id,
message_id,
document_id,
file_id,
file_size,
filename,
@@ -585,7 +701,18 @@ def _build_telegram_hls_resolved_params(
# Carry over non-identifying params from the original request
# (api_password, filename, etc.)
_skip_keys = {"d", "url", "chat_id", "message_id", "file_id", "file_size", "seg", "start_ms", "end_ms"}
_skip_keys = {
"d",
"url",
"chat_id",
"message_id",
"document_id",
"file_id",
"file_size",
"seg",
"start_ms",
"end_ms",
}
for key in request.query_params:
if key not in _skip_keys:
params[key] = request.query_params[key]
@@ -595,6 +722,9 @@ def _build_telegram_hls_resolved_params(
if ref.chat_id is not None and ref.message_id is not None:
params["chat_id"] = str(ref.chat_id)
params["message_id"] = str(ref.message_id)
elif ref.chat_id is not None and ref.document_id is not None:
params["chat_id"] = str(ref.chat_id)
params["document_id"] = str(ref.document_id)
elif ref.file_id:
params["file_id"] = ref.file_id
# Always include file_size -- it prevents unnecessary lookups
@@ -609,6 +739,7 @@ async def telegram_info(
url: Optional[str] = Query(None, description="Alias for 'd' parameter"),
chat_id: Optional[str] = Query(None, description="Chat/Channel ID (use with message_id)"),
message_id: Optional[int] = Query(None, description="Message ID (use with chat_id)"),
document_id: Optional[int] = Query(None, description="Document ID (use with chat_id)"),
file_id: Optional[str] = Query(None, description="Bot API file_id"),
file_size: Optional[int] = Query(None, description="File size in bytes (optional for file_id)"),
):
@@ -620,6 +751,7 @@ async def telegram_info(
url: Alias for 'd' parameter
chat_id: Chat/Channel ID (numeric or username)
message_id: Message ID within the chat
document_id: Telegram document ID within the chat
file_id: Bot API file_id
file_size: File size in bytes (optional, will be 0 if not provided for file_id)
@@ -628,28 +760,26 @@ async def telegram_info(
"""
if not settings.enable_telegram:
raise HTTPException(status_code=503, detail="Telegram proxy support is disabled")
TelegramMediaRef, parse_telegram_url, telegram_manager = _telegram_utils()
telegram_url = d or url
if not telegram_url and not file_id and not (chat_id and message_id):
raise HTTPException(
status_code=400,
detail="Provide either 'd' (t.me URL), 'chat_id' + 'message_id', or 'file_id' parameter",
try:
ref = _build_telegram_ref_from_params(
TelegramMediaRef,
parse_telegram_url,
telegram_url=telegram_url,
chat_id=chat_id,
message_id=message_id,
file_id=file_id,
document_id=document_id,
file_size=file_size,
require_file_size_for_file_id=False,
)
try:
if telegram_url:
ref = parse_telegram_url(telegram_url)
elif chat_id and message_id:
try:
parsed_chat_id: int | str = int(chat_id)
except ValueError:
parsed_chat_id = chat_id
ref = TelegramMediaRef(chat_id=parsed_chat_id, message_id=message_id)
else:
ref = TelegramMediaRef(file_id=file_id)
media_info = await telegram_manager.get_media_info(ref, file_size=file_size)
ref, media_info = await _resolve_media_info_with_file_id_fallback(
telegram_manager, TelegramMediaRef, ref, file_size
)
return {
"file_id": media_info.file_id,
@@ -673,6 +803,10 @@ async def telegram_info(
)
elif error_name == "MessageIdInvalidError":
raise HTTPException(status_code=404, detail="Message not found in the specified chat.")
elif error_name == "TelegramMessageNotFoundError":
raise HTTPException(status_code=404, detail=str(e))
elif error_name == "TelegramDocumentNotFoundError":
raise HTTPException(status_code=404, detail=str(e))
elif error_name == "FileReferenceExpiredError":
raise HTTPException(
status_code=410,
@@ -718,6 +852,7 @@ async def telegram_status():
}
# Check if client is connected
_, _, telegram_manager = _telegram_utils()
if telegram_manager.is_initialized:
return {
"enabled": True,
@@ -783,6 +918,9 @@ async def session_start(request: SessionStartRequest):
session_id = secrets.token_urlsafe(16)
try:
from telethon import TelegramClient
from telethon.sessions import StringSession
client = TelegramClient(StringSession(), request.api_id, request.api_hash)
await client.connect()
+24 -7
View File
@@ -22,6 +22,7 @@ Configuration:
import base64
import logging
import re
from functools import lru_cache
from typing import Annotated
from urllib.parse import urljoin, urlencode, urlparse
@@ -31,13 +32,6 @@ from fastapi import APIRouter, Request, Depends, Query, Response, HTTPException
from mediaflow_proxy.configs import settings
from mediaflow_proxy.handlers import proxy_stream
from mediaflow_proxy.remuxer.media_source import HTTPMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
from mediaflow_proxy.utils.base64_utils import decode_base64_url
from mediaflow_proxy.utils.http_utils import ProxyRequestHeaders, get_proxy_headers
from mediaflow_proxy.utils.http_client import create_aiohttp_session
@@ -46,10 +40,30 @@ logger = logging.getLogger(__name__)
xtream_root_router = APIRouter()
@lru_cache(maxsize=1)
def _load_transcode_components():
from mediaflow_proxy.remuxer.media_source import HTTPMediaSource
from mediaflow_proxy.remuxer.transcode_handler import (
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
return (
HTTPMediaSource,
handle_transcode,
handle_transcode_hls_init,
handle_transcode_hls_playlist,
handle_transcode_hls_segment,
)
async def _handle_xtream_transcode(request, upstream_url: str, proxy_headers, start_time: float | None):
"""Shared transcode handler for Xtream stream endpoints."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, handle_transcode, _, _, _ = _load_transcode_components()
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
await source.resolve_file_size()
return await handle_transcode(request, source, start_time=start_time)
@@ -59,6 +73,7 @@ async def _handle_xtream_hls_playlist(request, upstream_url: str, proxy_headers)
"""Generate HLS VOD playlist for an Xtream stream."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, _, handle_transcode_hls_playlist, _ = _load_transcode_components()
from urllib.parse import quote
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
@@ -88,6 +103,7 @@ async def _handle_xtream_hls_init(request, upstream_url: str, proxy_headers):
"""Serve fMP4 init segment for an Xtream stream."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, handle_transcode_hls_init, _, _ = _load_transcode_components()
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
await source.resolve_file_size()
return await handle_transcode_hls_init(request, source)
@@ -104,6 +120,7 @@ async def _handle_xtream_hls_segment(
"""Serve a single HLS fMP4 segment for an Xtream stream."""
if not settings.enable_transcode:
raise HTTPException(status_code=503, detail="Transcoding support is disabled")
HTTPMediaSource, _, _, _, handle_transcode_hls_segment = _load_transcode_components()
source = HTTPMediaSource(url=upstream_url, headers=dict(proxy_headers.request))
await source.resolve_file_size()
return await handle_transcode_hls_segment(