Files
UnHided/mediaflow_proxy/routes/xtream.py
UrloMythus cfc6bbabc9 update
2026-02-19 20:15:03 +01:00

1147 lines
45 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 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.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
logger = logging.getLogger(__name__)
xtream_root_router = APIRouter()
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")
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")
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")
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")
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)