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 queue 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: they can't be shared by two # requests at once, but reusing one *across* requests is what keeps the upstream # connection alive, and with it the TLS handshake we already paid for. A video # arrives as dozens of range requests (and an HLS stream as one request per # segment), so a handshake per request is the difference between a stall and a # seek. # # This used to be a thread-local, which never actually hit: the development # server gives every connection a brand-new thread, so each request found empty # thread-local storage and built a session from scratch. Sessions live in a # shared pool instead -- checked out for the duration of one request, returned # when its response is closed (which, for a streamed body, is when the last byte # has been sent). LIFO so the hottest connection is the one handed out next. try: _SESSION_POOL_SIZE = max(1, int(os.getenv('STREAM_SESSION_POOL', '') or 8)) except ValueError: _SESSION_POOL_SIZE = 8 _session_pool = queue.LifoQueue(maxsize=_SESSION_POOL_SIZE) def _borrow_session(): """A session nobody else is using: from the pool, or a fresh one.""" try: return _session_pool.get_nowait() except queue.Empty: return impersonate_requests.Session(impersonate=IMPERSONATE_TARGET) def _return_session(sess): """Hand a session back. Beyond the pool's size the extras are closed, so a burst of concurrency doesn't leave idle connections open forever.""" try: _session_pool.put_nowait(sess) except queue.Full: try: sess.close() except Exception: pass def _discard_session(sess): """Drop a session that raised, rather than pooling a possibly-poisoned handle.""" try: sess.close() except Exception: pass def _release_when_closed(resp, sess): """Return `sess` to the pool once `resp` is closed. Every caller either closes the response outright or streams it through a generator that closes in a `finally`, so this is where a request's exclusive hold on a session ends. Idempotent: a double close must not put the same session in the pool twice.""" original_close = resp.close released = False def close(): nonlocal released try: original_close() finally: if not released: released = True _return_session(sess) resp.close = close return resp def _is_tls_verify_error(err): message = str(err).lower() return 'certificate' in message or 'curl: (60)' in message or 'ssl: ' in message # Hosts already proven to fail certificate verification. A video is fetched in # many range requests, so remembering the host keeps us from paying for a # doomed TLS handshake on every one of them. _tls_unverified_hosts = set() def impersonate_get(url, **kwargs): """Upstream GET that survives an origin with a broken certificate. Some media hosts serve expired certs (heavyfetish's stNN CDN, for one), which a browser refuses outright -- part of why this proxy exists. The viewer's connection to *us* stays verified either way, so rather than failing the stream we retry once with verification off, and say so in the log. Set STREAM_TLS_VERIFY_ONLY=1 to keep the hard failure instead.""" host = urllib.parse.urlparse(url).netloc sess = _borrow_session() if host in _tls_unverified_hosts: try: return _release_when_closed(sess.get(url, verify=False, **kwargs), sess) except Exception: _discard_session(sess) raise try: return _release_when_closed(sess.get(url, **kwargs), sess) except Exception as err: strict = os.getenv('STREAM_TLS_VERIFY_ONLY', '').strip().lower() in ('1', 'true', 'yes', 'on') if strict or not _is_tls_verify_error(err): _discard_session(sess) raise app.logger.warning("[stream] TLS verification failed for %s (%s); retrying unverified", url, err) _tls_unverified_hosts.add(host) try: return _release_when_closed(sess.get(url, verify=False, **kwargs), sess) except Exception: _discard_session(sess) raise # 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', } # `Content-Range: bytes 0-0/12345` -> the total size of the resource. A '*' # total (an origin that won't say) deliberately doesn't match, so the length is # then simply left out rather than guessed at. _CONTENT_RANGE_TOTAL_RE = re.compile(r'^\s*bytes\s+\d+-\d+/(\d+)\s*$', re.I) # 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. # `protocol` is what yt-dlp calls the delivery method ('https', 'm3u8_native', # 'http_dash_segments', ...). Passing it on saves the player a HEAD round trip # against the proxy -- and with it a whole upstream connection -- for URLs whose # extension doesn't say what they are, which is most signed CDN links. _RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr', 'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality', 'protocol', 'format_note') # 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.""" # Both fetches are small and fully buffered, so this holds one pooled session # for the whole scrape rather than going through impersonate_get (whose # release is tied to closing a streamed response). sess = _borrow_session() try: 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: _discard_session(sess) sess = None return None finally: if sess is not None: _return_session(sess) @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 # Captures the path *relative to the frontend dir* (the manifest's key), since # the served URL carries an extra `static/` prefix. _ASSET_REF_RE = re.compile(r'(src|href)="static/((?:js|css)/[^"?#]+)"') @app.route('/') def index(): """Serve index.html with each local asset URL stamped with its content hash. index.html itself is always revalidated, but the assets it names are not under our control once a CDN or a phone has them: Cloudflare rewrites our `no-cache` to `max-age=14400`, and an iOS home-screen app will happily run four-hour-old JavaScript. A content hash in the query gives every deploy new URLs, which no cache can satisfy from an old copy -- so a reload always lands on the build that's actually deployed.""" hashes = _version_payload().get('files', {}) def stamp(match): attr, rel = match.group(1), match.group(2) digest = hashes.get(rel) return f'{attr}="static/{rel}?v={digest}"' if digest else match.group(0) try: with open(os.path.join(_FRONTEND_DIR, 'index.html'), encoding='utf-8') as fh: html = _ASSET_REF_RE.sub(stamp, fh.read()) except OSError: return send_from_directory(app.static_folder, 'index.html') resp = Response(html, mimetype='text/html') resp.headers['Cache-Control'] = 'no-cache, must-revalidate' return resp @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} def _version_payload(): 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 return _version_cache['payload'] @app.route('/api/version', methods=['GET']) def frontend_version(): resp = jsonify(_version_payload()) resp.headers['Cache-Control'] = 'no-store' return resp @app.route('/api/stream', methods=['POST', 'GET', 'HEAD']) def stream_video(): # Note: