#!/usr/bin/env python3 """Mado: a small web front end for ani-cli. Search, episode lists and stream links all come from ani-cli itself. The app drives it non-interactively with two stand-ins placed first on its PATH: * fzf / rofi / dmenu: records the menu ani-cli would show (search results, episode list) and then aborts like a cancelled pick; * mpv (via ANI_CLI_PLAYER): ani-cli calls it exactly as it would call mpv, with the referrer, subtitle file, title and stream link, and Mado records those arguments instead of playing anything. Streams are served through /proxy so the browser can play them: the proxy adds the Referer header the hosts demand and rewrites HLS playlists. """ import os import re import secrets import shutil import signal import sqlite3 import subprocess import tempfile import threading import time from functools import wraps from urllib.parse import urljoin import requests from flask import Flask, Response, abort, g, jsonify, request, send_from_directory, session from itsdangerous import BadSignature, URLSafeTimedSerializer from requests.adapters import HTTPAdapter from werkzeug.security import check_password_hash, generate_password_hash BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.environ.get("ANIWEB_DATA_DIR", os.path.join(BASE_DIR, "data")) ANI_CLI = os.environ.get("ANI_CLI_BIN", "ani-cli") MAX_JOBS = int(os.environ.get("ANIWEB_MAX_JOBS", "4")) STREAM_TTL = 8 * 3600 os.makedirs(DATA_DIR, exist_ok=True) DB_PATH = os.path.join(DATA_DIR, "mado.db") ANI_STATE_DIR = os.path.join(DATA_DIR, "ani-cli-state") os.makedirs(ANI_STATE_DIR, exist_ok=True) # -------------------------------------------------------------------------- # ani-cli constants (read from the installed script so they follow upgrades) # -------------------------------------------------------------------------- def _ani_cli_agent(): agent = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36") path = shutil.which(ANI_CLI) if path: try: with open(path, "r", errors="ignore") as fh: m = re.search(r'^agent="([^"]+)"', fh.read(500_000), re.M) if m: agent = m.group(1) except OSError: pass return agent UA = _ani_cli_agent() def _shim_dir(): # ani-cli expands $player_function unquoted and matches it against *mpv*, # *flatpak*mpv* ..., so the path must have no spaces and no "flatpak". preferred = os.path.join(DATA_DIR, "shim") if re.search(r"\s|flatpak", preferred): return tempfile.mkdtemp(prefix="mado-shim-") return preferred SHIM_DIR = _shim_dir() PLAYER_PATH = os.path.join(SHIM_DIR, "mado-mpv") # -------------------------------------------------------------------------- # Flask app, secrets, database # -------------------------------------------------------------------------- def _load_secret(): path = os.path.join(DATA_DIR, "secret.key") try: fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "w") as fh: fh.write(secrets.token_hex(32)) except FileExistsError: pass with open(path) as fh: return fh.read().strip() SECRET = _load_secret() app = Flask(__name__, static_folder=os.path.join(BASE_DIR, "static"), static_url_path="/static") app.config.update( SECRET_KEY=SECRET, SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=os.environ.get("ANIWEB_HTTPS") == "1", PERMANENT_SESSION_LIFETIME=90 * 24 * 3600, MAX_CONTENT_LENGTH=64 * 1024, ) stream_signer = URLSafeTimedSerializer(SECRET, salt="mado-stream") SCHEMA = """ CREATE TABLE IF NOT EXISTS users( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE COLLATE NOCASE, pw_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS searches( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, query TEXT NOT NULL, qkey TEXT NOT NULL, mode TEXT NOT NULL, searched_at INTEGER NOT NULL, UNIQUE(user_id, qkey, mode) ); CREATE TABLE IF NOT EXISTS progress( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, show_key TEXT NOT NULL, mode TEXT NOT NULL, title TEXT NOT NULL, query TEXT NOT NULL, idx INTEGER NOT NULL, total INTEGER NOT NULL DEFAULT 0, episode TEXT NOT NULL, position REAL NOT NULL DEFAULT 0, duration REAL NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, PRIMARY KEY(user_id, show_key, mode) ); """ def init_db(): conn = sqlite3.connect(DB_PATH) conn.execute("PRAGMA journal_mode=WAL") conn.executescript(SCHEMA) conn.commit() conn.close() init_db() def db(): if "db" not in g: g.db = sqlite3.connect(DB_PATH, timeout=15) g.db.row_factory = sqlite3.Row g.db.execute("PRAGMA foreign_keys=ON") return g.db @app.teardown_appcontext def close_db(_exc): conn = g.pop("db", None) if conn is not None: conn.close() # -------------------------------------------------------------------------- # Small helpers # -------------------------------------------------------------------------- def fail(message, status=400): return jsonify(error=message), status class AniError(Exception): def __init__(self, message, status=502): super().__init__(message) self.message = message self.status = status @app.errorhandler(AniError) def handle_ani_error(exc): return fail(exc.message, exc.status) @app.after_request def no_store_api(resp): if request.path.startswith("/api/"): resp.headers["Cache-Control"] = "no-store" return resp class TTLCache: def __init__(self, ttl, maxsize=500): self.ttl, self.maxsize = ttl, maxsize self._data = {} self._lock = threading.Lock() def get(self, key): with self._lock: hit = self._data.get(key) if hit and hit[0] > time.time(): return hit[1] self._data.pop(key, None) return None def set(self, key, value): with self._lock: if len(self._data) >= self.maxsize: now = time.time() for k in [k for k, v in self._data.items() if v[0] <= now] or list(self._data)[:50]: self._data.pop(k, None) self._data[key] = (time.time() + self.ttl, value) SEARCH_CACHE = TTLCache(600) EPISODE_CACHE = TTLCache(600) PLAY_CACHE = TTLCache(1200) _key_locks = {} _key_locks_guard = threading.Lock() def _key_lock(key): with _key_locks_guard: if len(_key_locks) > 2000: _key_locks.clear() return _key_locks.setdefault(key, threading.Lock()) def cached_call(cache, key, fn): """Return cache[key], computing it once even if several requests race.""" hit = cache.get(key) if hit is not None: return hit with _key_lock((id(cache), key)): hit = cache.get(key) if hit is not None: return hit value = fn() cache.set(key, value) return value # -------------------------------------------------------------------------- # Driving ani-cli # -------------------------------------------------------------------------- MENU_SHIM = """#!/bin/sh # Stand-in for fzf / rofi / dmenu. Records the menu ani-cli wants to show, then # aborts the way a cancelled pick would. (Written by Mado, safe to delete.) prompt="" while [ $# -gt 0 ]; do case "$1" in --prompt|-p) prompt="$2"; shift ;; esac shift done cat > "$ANIWEB_MENU" printf '%s' "$prompt" > "$ANIWEB_MENU.prompt" exit 130 """ PLAYER_SHIM = """#!/bin/sh # Stand-in for mpv. ani-cli calls it the way it would call mpv; Mado only # records the arguments (referrer, subtitle file, title, stream link). : > "$ANIWEB_PLAYER" for arg in "$@"; do printf '%s\\n' "$arg" >> "$ANIWEB_PLAYER"; done exit 0 """ ANSI_RE = re.compile(r"\x1b(?:\[[0-9;?]*[ -/]*[@-~]|[78=>]|\([A-Z0-9])") JOBS = threading.BoundedSemaphore(MAX_JOBS) FALLBACK_ERROR = "ani-cli couldn't finish that request. If it keeps happening, update it with `ani-cli -U`." def strip_ansi(text): return ANSI_RE.sub("", text).replace("\r", "\n") def ensure_shims(): os.makedirs(SHIM_DIR, exist_ok=True) wanted = {name: MENU_SHIM for name in ("fzf", "rofi", "dmenu")} wanted["mado-mpv"] = PLAYER_SHIM for name, content in wanted.items(): path = os.path.join(SHIM_DIR, name) try: if open(path).read() == content and os.access(path, os.X_OK): continue except OSError: pass with open(path, "w") as fh: fh.write(content) os.chmod(path, 0o755) class Run: def __init__(self, out, err, menu, prompt, player): self.out, self.err, self.menu, self.prompt, self.player = out, err, menu, prompt, player def _read(path): try: with open(path, errors="replace") as fh: return fh.read() except OSError: return "" def run_ani_cli(args, mode, timeout): ensure_shims() files = [] for prefix in ("menu-", "player-"): fd, path = tempfile.mkstemp(prefix=prefix, dir=DATA_DIR) os.close(fd) files.append(path) menu_file, player_file = files env = os.environ.copy() env.update( PATH=SHIM_DIR + os.pathsep + env.get("PATH", ""), ANI_CLI_PLAYER=PLAYER_PATH, ANI_CLI_MODE=mode, ANI_CLI_HIST_DIR=ANI_STATE_DIR, ANI_CLI_LOG="0", ANIWEB_MENU=menu_file, ANIWEB_PLAYER=player_file, TERM="xterm", ) try: with JOBS: try: proc = subprocess.Popen( [ANI_CLI, "--no-detach", "--exit-after-play", *args], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, start_new_session=True, ) except FileNotFoundError: raise AniError("ani-cli isn't installed on the server. Install it and restart Mado.", 500) try: out, err = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGKILL) proc.communicate() raise AniError("ani-cli took too long to answer. Try again in a moment.", 504) player = [ln for ln in _read(player_file).splitlines() if ln] return Run( strip_ansi(out.decode("utf-8", "replace")), strip_ansi(err.decode("utf-8", "replace")), _read(menu_file), _read(menu_file + ".prompt"), player, ) finally: for path in (menu_file, menu_file + ".prompt", player_file): try: os.remove(path) except OSError: pass def last_line(text): lines = [ln.strip() for ln in text.splitlines() if ln.strip()] return lines[-1] if lines else "" def sentence(text): text = text.strip() return text if text.endswith((".", "!", ")")) else text + "." def clean_query(raw): """Make a user query safe to hand to ani-cli. ani-cli treats any word starting with "-" as an option (-U self-updates, -D wipes its history), so strip leading dashes, quotes, backslashes and control characters. """ text = re.sub(r'["\\\x00-\x1f]', " ", str(raw or "")) words = [w.lstrip("-") for w in text.split()] words = [w for w in words if w][:12] query = " ".join(words)[:100].strip() return query, query.split() RESULT_RE = re.compile(r"^\s*(\d+)\s+(.+?)\s*$") def parse_results(menu): """The search menu shows one 'N title' line per result.""" results = [] for line in menu.splitlines(): m = RESULT_RE.match(line) if m: # "(N episodes)" changes while a show airs; keep it out of the stable key title = re.sub(r"\s*\(\d+\s+episodes?\)\s*$", "", m.group(2)) or m.group(2) results.append({"index": int(m.group(1)), "title": title, "show": title.lower(), "guessed": False}) return results def episode_sort_key(ep): try: return (0, float(ep), ep) except ValueError: return (1, 0.0, ep) def menu_episodes(menu): seen = dict.fromkeys(ln.strip() for ln in menu.splitlines() if ln.strip()) return sorted(seen, key=episode_sort_key) def single_result(query, title=None): # With exactly one hit ani-cli skips the result menu, so the title is unknown # until something plays; keep the key stable and fix the display title later. return {"index": 1, "title": title or query, "show": "~" + query.lower(), "guessed": title is None} def parse_player(args): """Turn the arguments ani-cli gave its (stand-in) mpv into a stream description.""" if not args or not args[-1].startswith("http"): return None info = {"url": args[-1], "referer": "", "sub": "", "title": "", "episode": ""} for arg in args[:-1]: if arg.startswith("--referrer="): info["referer"] = arg.split("=", 1)[1] elif arg.startswith("--sub-file="): info["sub"] = arg.split("=", 1)[1] elif arg.startswith("--force-media-title="): media = arg.split("=", 1)[1] m = re.match(r"^(.*) Episode (\S+)$", media) info["title"], info["episode"] = (m.group(1), m.group(2)) if m else (media, "") return info QUALITIES = ("best", "1080p", "720p", "480p", "360p") def ani_search(query, words, mode): def work(): r = run_ani_cli(words, mode, timeout=45) prompt = r.prompt.strip().lower() if r.menu and prompt.startswith("select anime"): results = parse_results(r.menu) if results: return results elif r.menu and prompt.startswith("select episode"): # exactly one hit: ani-cli went straight to the episode menu episodes = menu_episodes(r.menu) result = single_result(query) EPISODE_CACHE.set((mode, result["show"], 1), episodes) return [result] else: info = parse_player(r.player) if info: # one hit with one episode: ani-cli went straight to "playing" it result = single_result(query, info["title"] or None) ep = info["episode"] or "1" EPISODE_CACHE.set((mode, result["show"], 1), [ep]) PLAY_CACHE.set((mode, result["show"], 1, ep, "best"), info) return [result] err = last_line(r.err) if not err or re.search(r"no results", err, re.I): raise AniError("No results found. Try another spelling or the original title.", 404) app.logger.warning("ani-cli search failed: %s", r.err[-500:]) raise AniError(sentence(err), 502) return cached_call(SEARCH_CACHE, (mode, query.lower()), work) def ani_episodes(words, mode, entry): def work(): r = run_ani_cli(["-S", str(entry["index"]), *words], mode, timeout=45) prompt = r.prompt.strip().lower() if r.menu and prompt.startswith("select episode"): episodes = menu_episodes(r.menu) if episodes: return episodes else: info = parse_player(r.player) if info: # a one-episode title: ani-cli went straight to "playing" it ep = info["episode"] or "1" PLAY_CACHE.set((mode, entry["show"], entry["index"], ep, "best"), info) return [ep] app.logger.warning("ani-cli episode list failed: %s", r.err[-500:]) err = last_line(r.err) raise AniError(sentence(err) if err else FALLBACK_ERROR, 502) return cached_call(EPISODE_CACHE, (mode, entry["show"], entry["index"]), work) def ani_play(words, mode, entry, episode, quality): def work(): r = run_ani_cli( ["-S", str(entry["index"]), "-e", episode, "-q", quality, *words], mode, timeout=90 ) info = parse_player(r.player) if info: return info err = last_line(r.err) app.logger.warning("ani-cli play failed: %s", r.err[-500:]) if re.search(r"not released|no valid|no sources|invalid episode", err, re.I): raise AniError(sentence(err), 404) raise AniError(sentence(err) if err else FALLBACK_ERROR, 502) return cached_call(PLAY_CACHE, (mode, entry["show"], entry["index"], episode, quality), work) def pick_result(query, words, mode, show_key, hint): """Find the search result the client means, even if the order has shifted.""" results = ani_search(query, words, mode) by_index = {r["index"]: r for r in results} if not show_key: return by_index.get(hint, results[0]) if hint in by_index and by_index[hint]["show"] == show_key: return by_index[hint] for r in results: if r["show"] == show_key: return r raise AniError("That title no longer shows up for this search. Search for it again.", 404) def proxied(url, referers, ctype=None): payload = {"u": url, "r": referers} if ctype: payload["c"] = ctype return "/proxy/" + stream_signer.dumps(payload) # -------------------------------------------------------------------------- # Accounts # -------------------------------------------------------------------------- USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$") EPISODE_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z._-]{0,15}$") _login_failures = {} def current_user(): return session.get("uid") def login_required(fn): @wraps(fn) def wrapper(*args, **kwargs): if not current_user(): return fail("Sign in to continue.", 401) return fn(*args, **kwargs) return wrapper def body(): return request.get_json(force=True, silent=True) or {} @app.get("/api/me") def me(): uid = current_user() if not uid: return jsonify(user=None) row = db().execute("SELECT username FROM users WHERE id=?", (uid,)).fetchone() if not row: session.clear() return jsonify(user=None) return jsonify(user=row["username"]) @app.post("/api/register") def register(): data = body() username = str(data.get("username", "")).strip() password = str(data.get("password", "")) if not USERNAME_RE.match(username): return fail("Usernames are 3 to 32 characters: letters, numbers, dots, dashes or underscores.") if len(password) < 6: return fail("Passwords need at least 6 characters.") try: cur = db().execute( "INSERT INTO users(username, pw_hash, created_at) VALUES(?,?,?)", (username, generate_password_hash(password), int(time.time())), ) db().commit() except sqlite3.IntegrityError: return fail("That username is taken.", 409) session.clear() session["uid"] = cur.lastrowid session.permanent = True return jsonify(user=username) @app.post("/api/login") def login(): ip = request.remote_addr or "?" now = time.time() recent = [t for t in _login_failures.get(ip, []) if now - t < 600] _login_failures[ip] = recent if len(recent) >= 10: return fail("Too many failed attempts. Wait a few minutes and try again.", 429) data = body() row = db().execute( "SELECT id, username, pw_hash FROM users WHERE username=?", (str(data.get("username", "")).strip(),) ).fetchone() if not row or not check_password_hash(row["pw_hash"], str(data.get("password", ""))): recent.append(now) return fail("Wrong username or password.", 401) session.clear() session["uid"] = row["id"] session.permanent = True return jsonify(user=row["username"]) @app.post("/api/logout") def logout(): session.clear() return jsonify(user=None) # -------------------------------------------------------------------------- # Search, episodes, playback # -------------------------------------------------------------------------- def get_mode(): return "dub" if request.values.get("mode") == "dub" else "sub" def get_hint(): try: return max(1, min(200, int(request.values.get("index", 1)))) except ValueError: return 1 def missing_tools(): """Programs ani-cli needs (besides the menu and player, which Mado stands in for).""" missing = [] if not shutil.which(ANI_CLI): missing.append("ani-cli") for tool in ("curl", "sed", "grep", "od"): if not shutil.which(tool): missing.append(tool) if not any(shutil.which(b) for b in ("base64", "openssl")): missing.append("base64") return missing @app.get("/api/status") def status(): missing = missing_tools() return jsonify(ok=not missing, missing=missing) @app.get("/api/search") @login_required def search(): query, words = clean_query(request.args.get("q")) if not query: return fail("Type an anime name to search.") mode = get_mode() results = ani_search(query, words, mode) uid = current_user() now = int(time.time()) conn = db() conn.execute( "INSERT INTO searches(user_id, query, qkey, mode, searched_at) VALUES(?,?,?,?,?) " "ON CONFLICT(user_id, qkey, mode) DO UPDATE SET query=excluded.query, searched_at=excluded.searched_at", (uid, query, query.lower(), mode, now), ) conn.execute( "DELETE FROM searches WHERE user_id=? AND id NOT IN " "(SELECT id FROM searches WHERE user_id=? ORDER BY searched_at DESC LIMIT 100)", (uid, uid), ) conn.commit() watched = { r["show_key"]: r["episode"] for r in conn.execute("SELECT show_key, episode FROM progress WHERE user_id=? AND mode=?", (uid, mode)) } out = [dict(r, last_episode=watched.get(r["show"])) for r in results] return jsonify(query=query, mode=mode, results=out) @app.get("/api/episodes") @login_required def episodes(): query, words = clean_query(request.args.get("q")) if not query: return fail("Missing search text.") mode = get_mode() entry = pick_result(query, words, mode, request.args.get("show", ""), get_hint()) eps = ani_episodes(words, mode, entry) row = db().execute( "SELECT episode, position, duration FROM progress WHERE user_id=? AND show_key=? AND mode=?", (current_user(), entry["show"], mode), ).fetchone() return jsonify(entry=entry, episodes=eps, last=dict(row) if row else None) @app.post("/api/play") @login_required def play(): data = body() query, words = clean_query(data.get("q")) episode = str(data.get("episode", "")).strip() if not query or not EPISODE_RE.match(episode): return fail("Pick an episode to play.") mode = "dub" if data.get("mode") == "dub" else "sub" try: hint = max(1, min(200, int(data.get("index", 1)))) except (TypeError, ValueError): hint = 1 quality = data.get("quality") if data.get("quality") in QUALITIES else "best" entry = pick_result(query, words, mode, str(data.get("show", "")), hint) link = ani_play(words, mode, entry, episode, quality) # the stream host wants the embed site as referer (ani-cli hands it to mpv the same way) referers = [link["referer"]] if link["referer"] else [""] return jsonify( src=proxied(link["url"], referers), kind="hls" if ".m3u8" in link["url"].lower() else "file", subtitle=proxied(link["sub"], referers, "text/vtt; charset=utf-8") if link["sub"] else None, title=link["title"] or entry["title"], episode=episode, index=entry["index"], ) # -------------------------------------------------------------------------- # History # -------------------------------------------------------------------------- @app.post("/api/progress") @login_required def save_progress(): d = body() show = str(d.get("show", ""))[:200] title = str(d.get("title", ""))[:200] episode = str(d.get("episode", "")).strip() query, _ = clean_query(d.get("q")) mode = "dub" if d.get("mode") == "dub" else "sub" if not show or not title or not query or not EPISODE_RE.match(episode): return fail("Incomplete progress update.") try: idx = max(1, min(200, int(d.get("index", 1)))) total = max(0, min(10000, int(d.get("total", 0)))) position = max(0.0, float(d.get("position", 0))) duration = max(0.0, float(d.get("duration", 0))) except (TypeError, ValueError): return fail("Incomplete progress update.") db().execute( "INSERT INTO progress(user_id, show_key, mode, title, query, idx, total, episode, position, duration, updated_at) " "VALUES(?,?,?,?,?,?,?,?,?,?,?) " "ON CONFLICT(user_id, show_key, mode) DO UPDATE SET title=excluded.title, " "query=excluded.query, idx=excluded.idx, total=excluded.total, episode=excluded.episode, " "position=excluded.position, duration=excluded.duration, updated_at=excluded.updated_at", (current_user(), show, mode, title, query, idx, total, episode, position, duration, int(time.time())), ) db().commit() return "", 204 @app.get("/api/history") @login_required def history(): uid = current_user() conn = db() searches = [ {"id": r["id"], "query": r["query"], "mode": r["mode"]} for r in conn.execute( "SELECT id, query, mode FROM searches WHERE user_id=? ORDER BY searched_at DESC LIMIT 30", (uid,) ) ] watching = [ {"show": r["show_key"], "mode": r["mode"], "title": r["title"], "query": r["query"], "index": r["idx"], "total": r["total"], "episode": r["episode"], "position": r["position"], "duration": r["duration"]} for r in conn.execute( "SELECT * FROM progress WHERE user_id=? ORDER BY updated_at DESC LIMIT 20", (uid,) ) ] return jsonify(searches=searches, watching=watching) @app.delete("/api/history/searches") @login_required def clear_searches(): db().execute("DELETE FROM searches WHERE user_id=?", (current_user(),)) db().commit() return "", 204 @app.delete("/api/history/searches/") @login_required def delete_search(search_id): db().execute("DELETE FROM searches WHERE id=? AND user_id=?", (search_id, current_user())) db().commit() return "", 204 @app.post("/api/history/watching/remove") @login_required def remove_watching(): d = body() mode = "dub" if d.get("mode") == "dub" else "sub" db().execute( "DELETE FROM progress WHERE user_id=? AND show_key=? AND mode=?", (current_user(), str(d.get("show", "")), mode), ) db().commit() return "", 204 # -------------------------------------------------------------------------- # Stream proxy: adds the Referer the hosts require and rewrites HLS playlists # -------------------------------------------------------------------------- HTTP = requests.Session() HTTP.mount("https://", HTTPAdapter(pool_connections=16, pool_maxsize=64)) HTTP.mount("http://", HTTPAdapter(pool_connections=16, pool_maxsize=64)) PASS_HEADERS = ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified") URI_ATTR = re.compile(r'URI="([^"]+)"') def open_upstream(url, referers, range_header): for i, ref in enumerate(referers): headers = {"User-Agent": UA, "Accept": "*/*", "Accept-Encoding": "identity"} if ref: headers["Referer"] = ref if range_header: headers["Range"] = range_header try: resp = HTTP.get(url, headers=headers, stream=True, timeout=(10, 30)) except requests.RequestException: continue if resp.status_code in (401, 403) and i < len(referers) - 1: resp.close() continue return resp, ref abort(502) def rewrite_playlist(text, base_url, referers): out = [] for line in text.splitlines(): stripped = line.strip() if not stripped: out.append(line) elif stripped.startswith("#"): out.append( URI_ATTR.sub(lambda m: 'URI="%s"' % proxied(urljoin(base_url, m.group(1)), referers), line) ) else: out.append(proxied(urljoin(base_url, stripped), referers)) return "\n".join(out) + "\n" @app.get("/proxy/") def proxy(token): try: data = stream_signer.loads(token, max_age=STREAM_TTL) except BadSignature: abort(403) url, referers = data["u"], data.get("r") or [""] resp, used = open_upstream(url, referers, request.headers.get("Range")) ctype = resp.headers.get("Content-Type", "") path = resp.url.split("?")[0].lower() if ".m3u8" in path or "mpegurl" in ctype.lower(): raw = resp.content[:8_000_000] resp.close() text = raw.decode("utf-8", "replace") if text.lstrip().startswith("#EXTM3U"): ordered = [used] + [r for r in referers if r != used] return Response( rewrite_playlist(text, resp.url, ordered), mimetype="application/vnd.apple.mpegurl", headers={"Cache-Control": "no-store"}, ) return Response(raw, status=resp.status_code, mimetype=ctype or "text/plain") headers = {h: resp.headers[h] for h in PASS_HEADERS if h in resp.headers} if headers.get("Content-Type", "").lower() in ("", "application/octet-stream", "binary/octet-stream") \ and path.endswith(".mp4"): headers["Content-Type"] = "video/mp4" if data.get("c"): headers["Content-Type"] = data["c"] headers["Cache-Control"] = "private, max-age=600" def stream(): try: for chunk in resp.iter_content(64 * 1024): if chunk: yield chunk finally: resp.close() return Response(stream(), status=resp.status_code, headers=headers) # -------------------------------------------------------------------------- # Pages and entry point # -------------------------------------------------------------------------- @app.get("/") def index(): return send_from_directory(app.static_folder, "index.html") def main(): for tool in missing_tools(): print(f"[mado] warning: {tool} not found on PATH") host = os.environ.get("HOST", "127.0.0.1") port = int(os.environ.get("PORT", "8000")) try: from waitress import serve except ImportError: print(f"[mado] http://{host}:{port} (Flask dev server; `pip install waitress` for a sturdier one)") app.run(host=host, port=port, threaded=True) else: print(f"[mado] http://{host}:{port}") serve(app, host=host, port=port, threads=16) if __name__ == "__main__": main()