from flask import Flask, request, Response, send_from_directory, jsonify import os import re import requests from flask_cors import CORS import urllib.parse from requests.adapters import HTTPAdapter from urllib3.util import Retry import yt_dlp from yt_dlp.networking.impersonate import ImpersonateTarget from curl_cffi import requests as impersonate_requests import threading import io import time import hashlib from urllib.parse import urljoin # Browser to impersonate at the TLS/HTTP layer. Some origins (e.g. the # "animeidhentai" hottub channel) fingerprint clients and reset/403 anything # that isn't a real browser, so impersonation must be on by default. IMPERSONATE_TARGET = os.getenv('STREAM_IMPERSONATE', 'chrome').strip() or 'chrome' # curl_cffi sessions wrap a single libcurl handle and are not safe to share # across threads; keep one per worker thread so the Flask `threaded=True` # server can proxy concurrent segments without corrupting state. _thread_local = threading.local() def get_impersonate_session(): sess = getattr(_thread_local, 'session', None) if sess is None: sess = impersonate_requests.Session(impersonate=IMPERSONATE_TARGET) _thread_local.session = sess return sess # Stream params that have dedicated meaning and must never be treated as headers. # `referer` is mapped to a real Referer header by collect_passthrough_headers, but # `live` is purely a playback hint and must not leak upstream as a header. STREAM_RESERVED_PARAMS = {'url', 'live'} # Headers that affect the transport layer rather than the resource itself; allowing # these to be forwarded could enable request smuggling or vhost-routing abuse. STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'} # Headers curl_cffi sets coherently for the impersonated browser. Forwarding the # client's (or extractor's) own values for these would contradict the spoofed TLS # fingerprint and defeat impersonation, so they are never relayed upstream. STREAM_IMPERSONATION_MANAGED_HEADERS = { 'user-agent', 'accept', 'accept-encoding', 'accept-language', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', } # RFC 7230 token charset for header field-names. HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") # Reject control characters (CR/LF/NUL etc.) that could be used for header injection. HEADER_VALUE_BAD_CHARS_RE = re.compile(r'[\x00-\x08\x0a-\x1f\x7f]') MAX_HEADER_VALUE_LENGTH = 4096 def collect_passthrough_headers(source): """Treat any request param other than the reserved ones as an HTTP header to forward to yt-dlp/upstream. Validates names and values to prevent header injection (CRLF splitting) and disallows transport-level headers.""" headers = {} if not source: return headers for key in source: if key.lower() in STREAM_RESERVED_PARAMS: continue if key.lower() in STREAM_DISALLOWED_HEADER_NAMES: continue if not HEADER_NAME_RE.match(key): continue value = source.get(key) if value is None: continue value = str(value) if not value or len(value) > MAX_HEADER_VALUE_LENGTH: continue if HEADER_VALUE_BAD_CHARS_RE.search(value): continue header_name = 'Referer' if key.lower() == 'referer' else key headers[header_name] = value return headers # Serve frontend static files under `/static` to avoid colliding with API routes app = Flask(__name__, static_folder='../frontend', static_url_path='/static') app.url_map.strict_slashes = False # Use flask-cors for API routes CORS(app, resources={r"/api/*": {"origins": "*"}}) # Configure a requests session with retries session = requests.Session() retries = Retry(total=2, backoff_factor=0.2, status_forcelist=(500, 502, 503, 504)) adapter = HTTPAdapter(max_retries=retries) session.mount('http://', adapter) session.mount('https://', adapter) @app.route('/api/status', methods=['POST', 'GET']) def proxy_status(): if request.method == 'POST': # Safely get the json body client_data = request.get_json() or {} target_server = client_data.get('server') else: target_server = request.args.get('server') if not target_server: return jsonify({"error": "No server provided"}), 400 if target_server.endswith('/'): target_server = target_server[:-1] target_server = f"{target_server.strip()}/api/status" # Validate target URL parsed = urllib.parse.urlparse(target_server) if parsed.scheme not in ('http', 'https') or not parsed.netloc: return jsonify({"error": "Invalid target URL"}), 400 try: # Forward a small set of safe request headers safe_request_headers = {} for k in ('User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language', 'Range'): if k in request.headers: safe_request_headers[k] = request.headers[k] # Remove hop-by-hop request headers per RFC for hop in ('Connection', 'Keep-Alive', 'Proxy-Authenticate', 'Proxy-Authorization', 'TE', 'Trailers', 'Transfer-Encoding', 'Upgrade'): safe_request_headers.pop(hop, None) # Stream the GET via a session with small retry policy resp = session.get(target_server, headers=safe_request_headers, timeout=5, stream=True) hop_by_hop = { 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade' } forwarded_headers = [] for name, value in resp.headers.items(): if name.lower() in hop_by_hop: continue if name.lower() == 'content-length': # Let Flask set Content-Length if needed for the assembled response continue forwarded_headers.append((name, value)) def generate(): try: for chunk in resp.iter_content(1024 * 16): if chunk: yield chunk finally: resp.close() return Response(generate(), status=resp.status_code, headers=forwarded_headers) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/api/videos', methods=['POST']) def videos_proxy(): client_data = request.get_json() or {} target_server = client_data.get('server') client_data.pop('server', None) # Remove server from payload if not target_server: return jsonify({"error": "No server provided"}), 400 if target_server.endswith('/'): target_server = target_server[:-1] target_server = f"{target_server.strip()}/api/videos" # Validate target URL parsed = urllib.parse.urlparse(target_server) if parsed.scheme not in ('http', 'https') or not parsed.netloc: return jsonify({"error": "Invalid target URL"}), 400 try: resp = session.post(target_server, json=client_data,timeout=5) return Response(resp.content, status=resp.status_code, content_type=resp.headers.get('Content-Type', 'application/json')) except Exception as e: return jsonify({"error": str(e)}), 500 # Cache of yt-dlp metadata resolutions so the on-demand probe endpoint doesn't # re-extract the same video on every hover/scroll. Signed media URLs expire, so # entries are intentionally short-lived. RESOLVE_CACHE_TTL = 300 _resolve_cache = {} _resolve_cache_lock = threading.Lock() # Per-format fields the frontend needs to rank formats and build stream/probe # URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a # yt-dlp format dict is dropped to keep the payload small. _RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr', 'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality') # Some channels surface pages that yt-dlp can't extract because the video is # embedded in a third-party JS player iframe (e.g. the xtremestream family used # by tube.perverzija.com). The player page declares its HLS playlist URL as # `m3u8_loader_url + video_id`, so we scrape the page for the embed iframe, then # read those two variables out of the player to reconstruct the stream URL. _EMBED_IFRAME_RE = re.compile(r''']+src=["']([^"']+)''', re.I) _EMBED_LOADER_RE = re.compile(r'''m3u8_loader_url\s*=\s*[`'"]([^`'"]+)[`'"]''') _EMBED_VIDEOID_RE = re.compile(r'''video_id\s*=\s*[`'"]([^`'"]+)[`'"]''') def resolve_unsupported_embed(page_url): """Best-effort resolver for iframe-embedded JS players yt-dlp can't handle. Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose single format is the embed's HLS playlist, or None if nothing was found.""" try: sess = get_impersonate_session() page = sess.get(page_url, headers={'Referer': page_url}, timeout=15) embed_url = None for src in _EMBED_IFRAME_RE.findall(page.text): candidate = urljoin(page_url, src) if '/player/' in candidate or 'index.php?data=' in candidate: embed_url = candidate break if not embed_url: return None player = sess.get(embed_url, headers={'Referer': page_url}, timeout=15) loader = _EMBED_LOADER_RE.search(player.text) video_id = _EMBED_VIDEOID_RE.search(player.text) if not (loader and video_id): return None stream_url = loader.group(1) + video_id.group(1) parsed = urllib.parse.urlparse(embed_url) referer = f"{parsed.scheme}://{parsed.netloc}/" headers = {'Referer': referer} return { 'url': stream_url, 'is_live': False, 'http_headers': headers, 'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}], } except Exception: return None @app.route('/api/resolve', methods=['POST', 'GET']) def resolve_video(): """Resolve a page URL to its playable formats via yt-dlp and return them as JSON. The frontend calls this on demand (when a card is hovered or scrolled into view) to learn the real media URLs so it can background-probe them for direct, proxy-free playability.""" if request.method == 'POST': source = request.json or {} video_url = source.get('url') else: source = request.args video_url = request.args.get('url') if not video_url: return jsonify({"error": "No URL provided"}), 400 now = time.time() with _resolve_cache_lock: cached = _resolve_cache.get(video_url) if cached and cached[0] > now: return jsonify(cached[1]) ydl_opts = { 'quiet': True, 'no_warnings': True, 'skip_download': True, # Match /api/stream so the resolved formats reflect what playback will # actually fetch from fingerprinting origins. 'impersonate': ImpersonateTarget.from_str(IMPERSONATE_TARGET), } passthrough_headers = collect_passthrough_headers(source) if passthrough_headers: ydl_opts['http_headers'] = passthrough_headers try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(video_url, download=False) except Exception as e: # Many channels point at sites yt-dlp can't extract ("Unsupported URL"). # That's not fatal here -- the embed fallback below may still find a # stream, and otherwise we return empty formats so playback falls back to # the proxy. app.logger.info("[resolve] yt-dlp could not extract %s: %s", video_url, e) info = None # Fall back to scraping iframe-embedded JS players yt-dlp doesn't support. if not (info and (info.get('formats') or info.get('url'))): embed = resolve_unsupported_embed(video_url) if embed: info = embed formats = [] for fmt in ((info.get('formats') if info else None) or []): if not fmt.get('url'): continue formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None}) result = { 'url': info.get('url') if info else None, 'http_headers': (info.get('http_headers') if info else None) or {}, 'isLive': bool(info.get('is_live')) if info else False, 'formats': formats, } with _resolve_cache_lock: # Drop expired entries so the cache doesn't grow without bound. for key in [k for k, v in _resolve_cache.items() if v[0] <= now]: _resolve_cache.pop(key, None) _resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, result) return jsonify(result) @app.route('/api/image', methods=['GET', 'HEAD']) def image_proxy(): image_url = request.args.get('url') if not image_url: return jsonify({"error": "No URL provided"}), 400 parsed = urllib.parse.urlparse(image_url) if parsed.scheme not in ('http', 'https') or not parsed.netloc: return jsonify({"error": "Invalid target URL"}), 400 try: safe_request_headers = {} for k in ('User-Agent', 'Accept', 'Accept-Encoding', 'Accept-Language'): if k in request.headers: safe_request_headers[k] = request.headers[k] resp = session.get(image_url, headers=safe_request_headers, stream=True, timeout=15, allow_redirects=True) hop_by_hop = { 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade' } forwarded_headers = [] for name, value in resp.headers.items(): if name.lower() in hop_by_hop: continue forwarded_headers.append((name, value)) if request.method == 'HEAD': resp.close() return Response("", status=resp.status_code, headers=forwarded_headers) def generate(): try: for chunk in resp.iter_content(1024 * 16): if chunk: yield chunk finally: resp.close() return Response(generate(), status=resp.status_code, headers=forwarded_headers) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route('/') def index(): return send_from_directory(app.static_folder, 'index.html') @app.route('/favicon.ico') def favicon(): return send_from_directory(app.static_folder, 'favicon.ico') # --- Frontend asset version tracking ------------------------------------- # The client polls /api/version and, when a tracked file's content hash # changes, hot-swaps CSS in place or reloads the page. This lets a deploy # reach already-open tabs without a manual refresh. _FRONTEND_DIR = os.path.abspath(app.static_folder) _VERSION_EXTS = ('.html', '.css', '.js') _version_cache = {'mtime': None, 'payload': None} _version_lock = threading.Lock() def _scan_frontend_files(): """Map served relative paths -> absolute paths for tracked frontend files.""" files = {} for root, _dirs, names in os.walk(_FRONTEND_DIR): for name in names: if os.path.splitext(name)[1].lower() not in _VERSION_EXTS: continue path = os.path.join(root, name) rel = os.path.relpath(path, _FRONTEND_DIR).replace(os.sep, '/') files[rel] = path return files def _compute_version_payload(files): """Hash each tracked file's contents plus a combined version fingerprint.""" file_hashes = {} combined = hashlib.md5() for rel in sorted(files): try: with open(files[rel], 'rb') as fh: digest = hashlib.md5(fh.read()).hexdigest() except OSError: continue file_hashes[rel] = digest combined.update(rel.encode('utf-8')) combined.update(digest.encode('utf-8')) return {'version': combined.hexdigest(), 'files': file_hashes} @app.route('/api/version', methods=['GET']) def frontend_version(): files = _scan_frontend_files() # Use the newest mtime across tracked files as a cheap cache key so frequent # polls only re-hash contents when something on disk actually changed. try: latest_mtime = max((os.path.getmtime(p) for p in files.values()), default=0) except OSError: latest_mtime = 0 with _version_lock: if _version_cache['mtime'] != latest_mtime or _version_cache['payload'] is None: _version_cache['payload'] = _compute_version_payload(files) _version_cache['mtime'] = latest_mtime payload = _version_cache['payload'] resp = jsonify(payload) resp.headers['Cache-Control'] = 'no-store' return resp @app.route('/api/stream', methods=['POST', 'GET', 'HEAD']) def stream_video(): # Note: