mirror of
https://github.com/UrloMythus/UnHided.git
synced 2026-06-10 09:10:23 +00:00
new version
This commit is contained in:
@@ -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}")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user