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

1164 lines
46 KiB
Python

"""
Xtream Codes (XC) API Proxy Routes.
This module provides a stateless pass-through proxy for Xtream Codes API,
allowing users to use MediaFlow as an intermediary with any XC-compatible IPTV player.
All streams (live, VOD, series, catch-up/timeshift) are proxied without storing any data.
Configuration:
Configure your IPTV player with:
- Server: http://your-mediaflow-server:8888
- Username: {base64_upstream}:{actual_username}:{api_password}
- Password: your_xc_password
Where:
- base64_upstream: Base64-encoded upstream XC server URL
- actual_username: Your actual XC username
- api_password: Your MediaFlow API password (if configured)
The api_password part can be omitted if MediaFlow doesn't require authentication.
"""
import base64
import logging
import re
from functools import lru_cache
from typing import Annotated
from urllib.parse import urljoin, urlencode, urlparse
from fastapi.responses import RedirectResponse
import aiohttp
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.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
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)
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))
await source.resolve_file_size()
# Build URLs using the generic proxy transcode endpoints with upstream URL
encoded_url = quote(upstream_url, safe="")
base_params = f"d={encoded_url}"
original = request.query_params
if "api_password" in original:
base_params += f"&api_password={quote(original['api_password'], safe='')}"
init_url = f"/proxy/transcode/init.mp4?{base_params}"
segment_url_template = (
f"/proxy/transcode/segment.m4s?{base_params}&seg={{seg}}&start_ms={{start_ms}}&end_ms={{end_ms}}"
)
return await handle_transcode_hls_playlist(
request,
source,
init_url=init_url,
segment_url_template=segment_url_template,
)
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)
async def _handle_xtream_hls_segment(
request,
upstream_url: str,
proxy_headers,
start_ms: float,
end_ms: float,
seg: int | None = None,
):
"""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(
request, source, start_time_ms=start_ms, end_time_ms=end_ms, segment_number=seg
)
def decode_upstream_url(upstream_encoded: str) -> str:
"""
Decode the base64-encoded upstream XC server URL.
Args:
upstream_encoded: Base64-encoded upstream server URL.
Returns:
The decoded upstream server URL.
Raises:
HTTPException: If the URL cannot be decoded.
"""
decoded = decode_base64_url(upstream_encoded)
if not decoded:
raise HTTPException(
status_code=400,
detail="Invalid upstream server URL encoding. Must be base64-encoded.",
)
# Ensure the URL has a trailing slash for proper URL joining
if not decoded.endswith("/"):
decoded += "/"
return decoded
def decode_base64_username(encoded: str) -> str | None:
"""
Try to decode a base64-encoded username string.
Args:
encoded: The potentially base64-encoded string.
Returns:
The decoded string if successful, None otherwise.
"""
try:
# Handle URL-safe base64 encoding (replace - with + and _ with /)
url_safe_encoded = encoded.replace("-", "+").replace("_", "/")
# Add padding if necessary
missing_padding = len(url_safe_encoded) % 4
if missing_padding:
url_safe_encoded += "=" * (4 - missing_padding)
# Decode the base64 string
decoded_bytes = base64.b64decode(url_safe_encoded)
decoded = decoded_bytes.decode("utf-8")
# Check if it looks like our format (contains colons and starts with http)
if ":" in decoded:
return decoded
return None
except (base64.binascii.Error, UnicodeDecodeError, ValueError):
return None
def parse_username_with_upstream(username: str) -> tuple[str, str, str | None]:
"""
Parse username that contains encoded upstream URL and optional API password.
Supports two formats:
1. Base64-encoded format (NEW - recommended for IPTV apps):
Username is base64({upstream_url}:{actual_username}:{api_password})
Or base64({upstream_url}:{actual_username})
2. Legacy colon-separated format:
{base64_upstream}:{actual_username}:{api_password}
Or {base64_upstream}:{actual_username}
Args:
username: The username field which contains upstream URL and optionally API password.
Returns:
Tuple of (upstream_base_url, actual_username, api_password or None).
Raises:
HTTPException: If format is invalid.
"""
# First, try to decode the entire username as base64
# This is the new format where the whole string is base64-encoded
decoded_username = decode_base64_username(username)
if decoded_username:
# Successfully decoded base64, now parse the decoded string
parts = decoded_username.split(":")
logger.debug(f"Decoded base64 username, found {len(parts)} parts")
# The decoded format is: {upstream_url}:{actual_username}:{api_password}
# or {upstream_url}:{actual_username}
# Note: upstream_url contains "://" so we need to handle that
# Find the protocol separator
if "://" not in decoded_username:
raise HTTPException(
status_code=400,
detail="Invalid username format. Decoded base64 doesn't contain valid upstream URL.",
)
# Split on :// first to get protocol
proto_split = decoded_username.split("://", 1)
if len(proto_split) != 2:
raise HTTPException(
status_code=400,
detail="Invalid username format. Could not parse upstream URL protocol.",
)
protocol = proto_split[0]
rest = proto_split[1]
# Now split the rest by colons
rest_parts = rest.split(":")
if len(rest_parts) == 2:
# Format: protocol://host:actual_username (no api_password, no port in URL)
host, actual_username = rest_parts
upstream_url = f"{protocol}://{host}"
api_password = None
elif len(rest_parts) == 3:
# Could be:
# - protocol://host:port:actual_username (no api_password)
# - protocol://host:actual_username:api_password (no port in URL)
# We need to determine which case by checking if the second part looks like a port
if rest_parts[1].isdigit() and len(rest_parts[1]) <= 5:
# Looks like a port: protocol://host:port:actual_username
host, port, actual_username = rest_parts
upstream_url = f"{protocol}://{host}:{port}"
api_password = None
else:
# No port: protocol://host:actual_username:api_password
host, actual_username, api_password = rest_parts
upstream_url = f"{protocol}://{host}"
api_password = api_password if api_password else None
elif len(rest_parts) == 4:
# Format: protocol://host:port:actual_username:api_password
host, port, actual_username, api_password = rest_parts
upstream_url = f"{protocol}://{host}:{port}"
api_password = api_password if api_password else None
else:
raise HTTPException(
status_code=400,
detail="Invalid username format. Could not parse base64-decoded username.",
)
# Ensure trailing slash for URL joining
if not upstream_url.endswith("/"):
upstream_url += "/"
logger.info(f"Parsed base64 username: upstream={upstream_url}, user={actual_username}")
return upstream_url, actual_username, api_password
# Legacy format: {base64_upstream}:{actual_username}:{api_password}
if ":" not in username:
raise HTTPException(
status_code=400,
detail="Invalid username format. Expected base64-encoded username or legacy format: {base64_upstream}:{actual_username}:{api_password}",
)
parts = username.split(":")
if len(parts) == 2:
# Format: {base64_upstream}:{actual_username}
upstream_encoded, actual_username = parts
api_password = None
elif len(parts) == 3:
# Format: {base64_upstream}:{actual_username}:{api_password}
upstream_encoded, actual_username, api_password = parts
api_password = api_password if api_password else None
else:
raise HTTPException(
status_code=400,
detail="Invalid username format. Expected base64-encoded username or legacy format: {base64_upstream}:{actual_username}:{api_password}",
)
upstream_base = decode_upstream_url(upstream_encoded)
logger.info(f"Parsed legacy username: upstream={upstream_base}, user={actual_username}")
return upstream_base, actual_username, api_password
def verify_xc_api_password(api_password: str | None):
"""
Verify the API password for XC endpoints.
Args:
api_password: The API password from the username field.
Raises:
HTTPException: If API password is required but not provided or invalid.
"""
# If no API password is configured on the server, allow access
if not settings.api_password:
return
# If API password is required but not provided
if not api_password:
raise HTTPException(
status_code=403,
detail="API password required. Username format: {base64_upstream}:{actual_username}:{api_password}",
)
# Verify the password matches
if api_password != settings.api_password:
raise HTTPException(
status_code=403,
detail="Invalid API password",
)
def get_mediaflow_base_url(request: Request) -> str:
"""
Get the MediaFlow base URL for URL rewriting.
Args:
request: The incoming FastAPI request.
Returns:
The MediaFlow base URL.
"""
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
return f"{scheme}://{host}"
def encode_username_for_rewrite(upstream_base: str, actual_username: str, api_password: str | None) -> str:
"""
Create a base64-encoded username token for URL rewriting.
Args:
upstream_base: The upstream XC server base URL.
actual_username: The actual XC username.
api_password: The MediaFlow API password (if any).
Returns:
A base64-encoded username string.
"""
# Remove trailing slash from upstream for cleaner encoding
upstream_clean = upstream_base.rstrip("/")
# Build the combined string
if api_password:
combined = f"{upstream_clean}:{actual_username}:{api_password}"
else:
combined = f"{upstream_clean}:{actual_username}"
# Base64 encode (URL-safe)
encoded = base64.urlsafe_b64encode(combined.encode()).decode().rstrip("=")
return encoded
def rewrite_urls_for_api(
content: str,
upstream_base: str,
mediaflow_base: str,
actual_username: str,
api_password: str | None,
) -> str:
"""
Rewrite stream URLs in API responses to route through MediaFlow.
This function replaces the upstream username in stream URLs with a base64-encoded
token containing upstream URL + username + api_password, so MediaFlow can properly
route the requests.
Args:
content: The API response content.
upstream_base: The upstream XC server base URL.
mediaflow_base: The MediaFlow base URL.
actual_username: The actual XC username (to be replaced in URLs).
api_password: The MediaFlow API password (if any).
Returns:
The content with rewritten URLs.
"""
# Parse the upstream URL to get the origin for replacement
parsed = urlparse(upstream_base)
upstream_origin = f"{parsed.scheme}://{parsed.netloc}"
# Create the encoded username token for MediaFlow
encoded_username = encode_username_for_rewrite(upstream_base, actual_username, api_password)
# Pattern to match stream URLs with username in path
# Matches: http(s)://host(:port)/path/{username}/password/...
# We need to replace {username} with {encoded_username}
# First, handle the common XC stream URL patterns where username appears in the path
# Pattern: /{prefix}/{username}/{password}/ where prefix is live, movie, series, etc.
# or /{username}/{password}/ for short format
# Escape special regex characters in the origin and username
escaped_origin = re.escape(upstream_origin)
escaped_username = re.escape(actual_username)
# Pattern for URLs like: https://upstream/live/{username}/{password}/...
# or https://upstream/{username}/{password}/...
# We want to replace the upstream origin AND the username in one go
def replace_stream_url(match):
"""Replace upstream origin with mediaflow and username with encoded token."""
full_url = match.group(0)
# Replace the upstream origin with mediaflow base
new_url = full_url.replace(upstream_origin, mediaflow_base, 1)
# Replace the username in the path with encoded username
# The username appears after a / and before another /
new_url = re.sub(
r"(/(live|movie|series|timeshift|hlsr|hls)?/)" + escaped_username + r"/",
r"\1" + encoded_username + "/",
new_url,
)
# Also handle short format: /{username}/{password}/
new_url = re.sub(
r"^(" + re.escape(mediaflow_base) + ")/" + escaped_username + r"/([^/]+/\d+\.)",
r"\1/" + encoded_username + r"/\2",
new_url,
)
return new_url
# Find and replace all URLs that contain the upstream origin
# Match URLs that start with the upstream origin and contain the username
url_pattern = escaped_origin + r'[^"\s\\]*' + escaped_username + r'[^"\s\\]*'
content = re.sub(url_pattern, replace_stream_url, content)
# Handle escaped URLs in JSON (where / is escaped as \/)
escaped_upstream_json = upstream_origin.replace("/", "\\/")
escaped_mediaflow_json = mediaflow_base.replace("/", "\\/")
escaped_username_json = actual_username.replace("/", "\\/")
def replace_escaped_stream_url(match):
"""Replace escaped upstream origin with mediaflow and username with encoded token."""
full_url = match.group(0)
new_url = full_url.replace(escaped_upstream_json, escaped_mediaflow_json, 1)
# Replace username (handling escaped slashes)
new_url = re.sub(
r"(\\/(?:live|movie|series|timeshift|hlsr|hls)?\\/)" + re.escape(escaped_username_json) + r"\\/",
r"\1" + encoded_username + "\\/",
new_url,
)
# Short format
new_url = re.sub(
r"^("
+ re.escape(escaped_mediaflow_json)
+ ")\\/"
+ re.escape(escaped_username_json)
+ r"\\/([^\\/]+\\/\d+\.)",
r"\1\\/" + encoded_username + r"\\/\2",
new_url,
)
return new_url
escaped_url_pattern = re.escape(escaped_upstream_json) + r'[^"\s]*' + re.escape(escaped_username_json) + r'[^"\s]*'
content = re.sub(escaped_url_pattern, replace_escaped_stream_url, content)
# Also do a simple domain replacement for any remaining URLs that don't have username in path
# (like server_info URLs)
content = content.replace(upstream_origin, mediaflow_base)
content = content.replace(escaped_upstream_json, escaped_mediaflow_json)
# Also replace hostname-only version (without port) if the upstream has a non-standard port
# This handles cases where server_info.url doesn't include the port
if parsed.port and parsed.port not in (80, 443):
upstream_host_only = f"{parsed.scheme}://{parsed.hostname}"
escaped_host_only_json = upstream_host_only.replace("/", "\\/")
content = content.replace(upstream_host_only, mediaflow_base)
content = content.replace(escaped_host_only_json, escaped_mediaflow_json)
# IMPORTANT: Rewrite user_info.username in the response
# Some IPTV players (like Tivimate) use the username from the response for subsequent API calls
# So we need to replace the actual username with the encoded username in user_info
# Pattern: "username":"actual_username" -> "username":"encoded_username"
content = re.sub(
r'"username"\s*:\s*"' + escaped_username + r'"',
f'"username":"{encoded_username}"',
content,
)
return content
async def forward_api_request(
upstream_url: str,
request: Request,
upstream_base: str,
actual_username: str,
api_password: str | None,
) -> Response:
"""
Forward an API request to upstream XC server.
Args:
upstream_url: The full upstream URL.
request: The incoming FastAPI request.
upstream_base: The decoded upstream base URL.
actual_username: The actual XC username (for URL rewriting).
api_password: The MediaFlow API password (for URL rewriting).
Returns:
The response from upstream with URLs rewritten.
"""
mediaflow_base = get_mediaflow_base_url(request)
async with create_aiohttp_session(upstream_url) as (session, proxy_url):
try:
async with session.get(upstream_url, proxy=proxy_url, allow_redirects=True) as response:
response.raise_for_status()
content = await response.text()
content_type = response.headers.get("content-type", "application/json")
# Rewrite URLs in JSON responses
if "json" in content_type.lower():
content = rewrite_urls_for_api(
content, upstream_base, mediaflow_base, actual_username, api_password
)
return Response(
content=content,
status_code=response.status,
media_type=content_type,
)
except aiohttp.ClientResponseError as e:
logger.error(f"Upstream XC API error: {e.status}")
raise HTTPException(
status_code=e.status,
detail=f"Upstream XC server error: {e.status}",
)
except aiohttp.ClientError as e:
logger.error(f"Failed to connect to upstream XC server: {e}")
raise HTTPException(
status_code=502,
detail=f"Failed to connect to upstream XC server: {str(e)}",
)
# =============================================================================
# XC API Endpoints
# =============================================================================
@xtream_root_router.get("/player_api.php")
async def player_api(
request: Request,
username: str = Query(..., description="Format: {base64_upstream}:{actual_username}:{api_password}"),
password: str = Query(..., description="XC password"),
action: str = Query(None, description="API action"),
):
"""
Player API endpoint for IPTV player compatibility.
Handles all XC API actions including authentication, categories, streams, and EPG.
Args:
request: The incoming FastAPI request.
username: Combined upstream URL, username, and API password.
password: XC password.
action: The API action to perform.
Returns:
The API response with stream URLs rewritten.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
# Build query params for upstream (with actual username)
query_params = {"username": actual_username, "password": password}
if action:
query_params["action"] = action
# Add any other query params except our special ones
for k, v in request.query_params.items():
if k not in ("username", "password", "action", "api_password"):
query_params[k] = v
upstream_url = f"{upstream_base}player_api.php?{urlencode(query_params)}"
logger.info(f"XC player_api.php: action={action}, upstream={upstream_base}, user={actual_username}")
return await forward_api_request(upstream_url, request, upstream_base, actual_username, api_password)
@xtream_root_router.get("/xmltv.php")
async def xmltv_api(
request: Request,
username: str = Query(..., description="Format: {base64_upstream}:{actual_username}:{api_password}"),
password: str = Query(..., description="XC password"),
):
"""
XMLTV/EPG endpoint for electronic program guide data.
Args:
request: The incoming FastAPI request.
username: Combined upstream URL, username, and API password.
password: XC password.
Returns:
The EPG XML data from upstream.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
# Build query params for upstream
query_params = {"username": actual_username, "password": password}
for k, v in request.query_params.items():
if k not in ("username", "password", "api_password"):
query_params[k] = v
upstream_url = f"{upstream_base}xmltv.php?{urlencode(query_params)}"
logger.info(f"XC xmltv.php: upstream={upstream_base}")
async with create_aiohttp_session(upstream_url, timeout=60) as (session, proxy_url):
try:
async with session.get(upstream_url, proxy=proxy_url, allow_redirects=True) as response:
response.raise_for_status()
return Response(
content=await response.read(),
status_code=response.status,
media_type=response.headers.get("content-type", "application/xml"),
)
except aiohttp.ClientResponseError as e:
raise HTTPException(status_code=e.status, detail=f"Upstream error: {e.status}")
except aiohttp.ClientError as e:
raise HTTPException(status_code=502, detail=f"Failed to connect: {str(e)}")
@xtream_root_router.get("/get.php")
async def get_playlist(
request: Request,
username: str = Query(..., description="Format: base64({upstream}:{actual_username}:{api_password})"),
password: str = Query(..., description="XC password"),
type: str = Query("m3u_plus", description="Playlist type (m3u, m3u_plus)"),
output: str = Query("ts", description="Output format (ts, m3u8)"),
):
"""
M3U playlist generation endpoint (XC API v1).
Redirects to /proxy/hls/manifest.m3u8 which handles M3U URL rewriting.
Args:
request: The incoming FastAPI request.
username: Combined upstream URL, username, and API password.
password: XC password.
type: Playlist type (m3u, m3u_plus).
output: Output stream format (ts, m3u8).
Returns:
Redirect to HLS proxy with upstream get.php URL.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
# Build query params for upstream get.php
query_params = {"username": actual_username, "password": password, "type": type, "output": output}
for k, v in request.query_params.items():
if k not in ("username", "password", "type", "output", "api_password"):
query_params[k] = v
upstream_url = f"{upstream_base}get.php?{urlencode(query_params)}"
logger.info(f"XC get.php: type={type}, output={output}, upstream={upstream_base}, user={actual_username}")
# Redirect to HLS proxy which handles M3U URL rewriting
mediaflow_base = get_mediaflow_base_url(request)
hls_params = {"d": upstream_url}
if api_password:
hls_params["api_password"] = api_password
redirect_url = f"{mediaflow_base}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
@xtream_root_router.get("/panel_api.php")
async def panel_api(
request: Request,
username: str = Query(..., description="Format: {base64_upstream}:{actual_username}:{api_password}"),
password: str = Query(..., description="XC password"),
):
"""
Panel API endpoint (alternative API used by some XC implementations).
Args:
request: The incoming FastAPI request.
username: Combined upstream URL, username, and API password.
password: XC password.
Returns:
The API response with stream URLs rewritten.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
query_params = {"username": actual_username, "password": password}
for k, v in request.query_params.items():
if k not in ("username", "password", "api_password"):
query_params[k] = v
upstream_url = f"{upstream_base}panel_api.php?{urlencode(query_params)}"
logger.info(f"XC panel_api.php: upstream={upstream_base}")
return await forward_api_request(upstream_url, request, upstream_base, actual_username, api_password)
# =============================================================================
# Stream Proxy Endpoints
# =============================================================================
@xtream_root_router.head("/live/{username}/{password}/{stream_id}.{ext}")
@xtream_root_router.get("/live/{username}/{password}/{stream_id}.{ext}")
async def live_stream(
username: str,
password: str,
stream_id: str,
ext: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
):
"""
Live stream endpoint.
Username format: {base64_upstream}:{actual_username}:{api_password}
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"live/{actual_username}/{password}/{stream_id}.{ext}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC live stream: {stream_path}")
# For m3u8, redirect to HLS proxy
if ext in ("m3u8", "m3u"):
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
hls_params = {"d": upstream_url}
if api_password:
hls_params["api_password"] = api_password
redirect_url = f"{scheme}://{host}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
return await proxy_stream(request.method, upstream_url, proxy_headers)
@xtream_root_router.head("/movie/{username}/{password}/{stream_id}.{ext}")
@xtream_root_router.get("/movie/{username}/{password}/{stream_id}.{ext}")
async def movie_stream(
username: str,
password: str,
stream_id: str,
ext: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"),
hls: bool = Query(False, description="Generate HLS VOD playlist for transcode (seekable)"),
hls_init: bool = Query(False, description="Serve fMP4 init segment"),
seg: int | None = Query(None, description="HLS segment number (informational)"),
start_ms: float | None = Query(None, description="HLS segment start time in milliseconds"),
end_ms: float | None = Query(None, description="HLS segment end time in milliseconds"),
start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"),
):
"""
VOD/movie stream endpoint.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"movie/{actual_username}/{password}/{stream_id}.{ext}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC movie stream: {stream_path}")
if hls:
return await _handle_xtream_hls_playlist(request, upstream_url, proxy_headers)
if hls_init:
return await _handle_xtream_hls_init(request, upstream_url, proxy_headers)
if (start_ms is None) != (end_ms is None):
raise HTTPException(status_code=400, detail="Both start_ms and end_ms are required for segment requests")
if start_ms is not None and end_ms is not None:
return await _handle_xtream_hls_segment(request, upstream_url, proxy_headers, start_ms, end_ms, seg)
if transcode:
return await _handle_xtream_transcode(request, upstream_url, proxy_headers, start)
return await proxy_stream(request.method, upstream_url, proxy_headers)
@xtream_root_router.head("/series/{username}/{password}/{stream_id}.{ext}")
@xtream_root_router.get("/series/{username}/{password}/{stream_id}.{ext}")
async def series_stream(
username: str,
password: str,
stream_id: str,
ext: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"),
hls: bool = Query(False, description="Generate HLS VOD playlist for transcode (seekable)"),
hls_init: bool = Query(False, description="Serve fMP4 init segment"),
seg: int | None = Query(None, description="HLS segment number (informational)"),
start_ms: float | None = Query(None, description="HLS segment start time in milliseconds"),
end_ms: float | None = Query(None, description="HLS segment end time in milliseconds"),
start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"),
):
"""
Series/episode stream endpoint.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"series/{actual_username}/{password}/{stream_id}.{ext}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC series stream: {stream_path}")
if hls:
return await _handle_xtream_hls_playlist(request, upstream_url, proxy_headers)
if hls_init:
return await _handle_xtream_hls_init(request, upstream_url, proxy_headers)
if (start_ms is None) != (end_ms is None):
raise HTTPException(status_code=400, detail="Both start_ms and end_ms are required for segment requests")
if start_ms is not None and end_ms is not None:
return await _handle_xtream_hls_segment(request, upstream_url, proxy_headers, start_ms, end_ms, seg)
if transcode:
return await _handle_xtream_transcode(request, upstream_url, proxy_headers, start)
return await proxy_stream(request.method, upstream_url, proxy_headers)
@xtream_root_router.head("/timeshift/{username}/{password}/{duration}/{start}/{stream_id}.{ext}")
@xtream_root_router.get("/timeshift/{username}/{password}/{duration}/{start}/{stream_id}.{ext}")
async def timeshift_stream(
username: str,
password: str,
duration: str,
start: str,
stream_id: str,
ext: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"),
seek: float | None = Query(None, description="Seek start time in seconds (transcode mode)"),
):
"""
Timeshift/catch-up stream endpoint.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"timeshift/{actual_username}/{password}/{duration}/{start}/{stream_id}.{ext}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC timeshift stream: {stream_path}")
if transcode:
return await _handle_xtream_transcode(request, upstream_url, proxy_headers, seek)
return await proxy_stream(request.method, upstream_url, proxy_headers)
@xtream_root_router.head("/streaming/timeshift.php")
@xtream_root_router.get("/streaming/timeshift.php")
async def timeshift_php(
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
username: str = Query(..., description="Format: {base64_upstream}:{actual_username}:{api_password}"),
password: str = Query(..., description="XC password"),
stream: str = Query(..., description="Stream ID"),
start: str = Query(..., description="Start time"),
duration: str = Query(None, description="Duration in minutes"),
):
"""
Timeshift.php catch-up endpoint (alternative format).
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
# Build query params for upstream
query_params = {"username": actual_username, "password": password, "stream": stream, "start": start}
if duration:
query_params["duration"] = duration
for k, v in request.query_params.items():
if k not in ("username", "password", "stream", "start", "duration", "api_password"):
query_params[k] = v
upstream_url = f"{upstream_base}streaming/timeshift.php?{urlencode(query_params)}"
logger.info(f"XC timeshift.php: stream={stream}, start={start}")
return await proxy_stream(request.method, upstream_url, proxy_headers)
@xtream_root_router.head("/hlsr/{token}/{username}/{password}/{channel_id}/{start}/{end}/index.m3u8")
@xtream_root_router.get("/hlsr/{token}/{username}/{password}/{channel_id}/{start}/{end}/index.m3u8")
async def hlsr_catchup(
token: str,
username: str,
password: str,
channel_id: str,
start: str,
end: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
):
"""
HLSR catch-up stream endpoint.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"hlsr/{token}/{actual_username}/{password}/{channel_id}/{start}/{end}/index.m3u8"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC HLSR catch-up: channel={channel_id}")
# Redirect to HLS proxy for proper m3u8 handling
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
hls_params = {"d": upstream_url}
if api_password:
hls_params["api_password"] = api_password
redirect_url = f"{scheme}://{host}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
@xtream_root_router.head("/hls/{token}/{stream_id}.m3u8")
@xtream_root_router.get("/hls/{token}/{stream_id}.m3u8")
async def hls_stream(
token: str,
stream_id: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
):
"""
HLS stream endpoint with token authentication.
Note: This endpoint doesn't use the username format since it's token-based.
The api_password should be passed as a query parameter if required.
"""
# For token-based HLS, check api_password from query params
api_password = request.query_params.get("api_password")
if settings.api_password and api_password != settings.api_password:
raise HTTPException(status_code=403, detail="Invalid API password")
# Get upstream from query params (must be base64-encoded)
upstream_encoded = request.query_params.get("upstream")
if not upstream_encoded:
raise HTTPException(status_code=400, detail="Missing 'upstream' query parameter")
upstream_base = decode_upstream_url(upstream_encoded)
stream_path = f"hls/{token}/{stream_id}.m3u8"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC HLS stream: {stream_path}")
# Redirect to HLS proxy
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
hls_params = {"d": upstream_url}
if api_password:
hls_params["api_password"] = api_password
redirect_url = f"{scheme}://{host}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
@xtream_root_router.head("/{username}/{password}/{stream_id}.{ext}")
@xtream_root_router.get("/{username}/{password}/{stream_id}.{ext}")
async def live_stream_short(
username: str,
password: str,
stream_id: str,
ext: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
):
"""
Short format live stream endpoint (without /live/ prefix).
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"{actual_username}/{password}/{stream_id}.{ext}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC short live stream: {stream_path}")
# For m3u8, redirect to HLS proxy
if ext in ("m3u8", "m3u"):
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host", request.headers.get("host", request.url.netloc))
hls_params = {"d": upstream_url}
if api_password:
hls_params["api_password"] = api_password
redirect_url = f"{scheme}://{host}/proxy/hls/manifest.m3u8?{urlencode(hls_params)}"
return RedirectResponse(url=redirect_url, status_code=302)
return await proxy_stream(request.method, upstream_url, proxy_headers)
# =============================================================================
# Stream Endpoints WITHOUT Extension (for players like IMPlayer)
# These handle URLs like /{username}/{password}/{stream_id} without .ts/.m3u8
# =============================================================================
@xtream_root_router.head("/live/{username}/{password}/{stream_id}")
@xtream_root_router.get("/live/{username}/{password}/{stream_id}")
async def live_stream_no_ext(
username: str,
password: str,
stream_id: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
):
"""
Live stream endpoint without extension (defaults to .ts).
Some players like IMPlayer don't include the extension in stream URLs.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
# Default to .ts format when no extension provided
stream_path = f"live/{actual_username}/{password}/{stream_id}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC live stream (no ext): {stream_path}")
return await proxy_stream(request.method, upstream_url, proxy_headers)
@xtream_root_router.head("/movie/{username}/{password}/{stream_id}")
@xtream_root_router.get("/movie/{username}/{password}/{stream_id}")
async def movie_stream_no_ext(
username: str,
password: str,
stream_id: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"),
hls: bool = Query(False, description="Generate HLS VOD playlist for transcode (seekable)"),
hls_init: bool = Query(False, description="Serve fMP4 init segment"),
seg: int | None = Query(None, description="HLS segment number (informational)"),
start_ms: float | None = Query(None, description="HLS segment start time in milliseconds"),
end_ms: float | None = Query(None, description="HLS segment end time in milliseconds"),
start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"),
):
"""
Movie stream endpoint without extension.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"movie/{actual_username}/{password}/{stream_id}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC movie stream (no ext): {stream_path}")
if hls:
return await _handle_xtream_hls_playlist(request, upstream_url, proxy_headers)
if hls_init:
return await _handle_xtream_hls_init(request, upstream_url, proxy_headers)
if (start_ms is None) != (end_ms is None):
raise HTTPException(status_code=400, detail="Both start_ms and end_ms are required for segment requests")
if start_ms is not None and end_ms is not None:
return await _handle_xtream_hls_segment(request, upstream_url, proxy_headers, start_ms, end_ms, seg)
if transcode:
return await _handle_xtream_transcode(request, upstream_url, proxy_headers, start)
return await proxy_stream(request.method, upstream_url, proxy_headers)
@xtream_root_router.head("/series/{username}/{password}/{stream_id}")
@xtream_root_router.get("/series/{username}/{password}/{stream_id}")
async def series_stream_no_ext(
username: str,
password: str,
stream_id: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
transcode: bool = Query(False, description="Transcode to browser-compatible fMP4"),
hls: bool = Query(False, description="Generate HLS VOD playlist for transcode (seekable)"),
hls_init: bool = Query(False, description="Serve fMP4 init segment"),
seg: int | None = Query(None, description="HLS segment number (informational)"),
start_ms: float | None = Query(None, description="HLS segment start time in milliseconds"),
end_ms: float | None = Query(None, description="HLS segment end time in milliseconds"),
start: float | None = Query(None, description="Seek start time in seconds (transcode mode)"),
):
"""
Series stream endpoint without extension.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"series/{actual_username}/{password}/{stream_id}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC series stream (no ext): {stream_path}")
if hls:
return await _handle_xtream_hls_playlist(request, upstream_url, proxy_headers)
if hls_init:
return await _handle_xtream_hls_init(request, upstream_url, proxy_headers)
if (start_ms is None) != (end_ms is None):
raise HTTPException(status_code=400, detail="Both start_ms and end_ms are required for segment requests")
if start_ms is not None and end_ms is not None:
return await _handle_xtream_hls_segment(request, upstream_url, proxy_headers, start_ms, end_ms, seg)
if transcode:
return await _handle_xtream_transcode(request, upstream_url, proxy_headers, start)
return await proxy_stream(request.method, upstream_url, proxy_headers)
@xtream_root_router.head("/{username}/{password}/{stream_id}")
@xtream_root_router.get("/{username}/{password}/{stream_id}")
async def live_stream_short_no_ext(
username: str,
password: str,
stream_id: str,
request: Request,
proxy_headers: Annotated[ProxyRequestHeaders, Depends(get_proxy_headers)],
):
"""
Short format live stream endpoint without extension (without /live/ prefix).
Some players like IMPlayer use this format without extension.
"""
upstream_base, actual_username, api_password = parse_username_with_upstream(username)
verify_xc_api_password(api_password)
stream_path = f"{actual_username}/{password}/{stream_id}"
upstream_url = urljoin(upstream_base, stream_path)
logger.info(f"XC short live stream (no ext): {stream_path}")
return await proxy_stream(request.method, upstream_url, proxy_headers)