From 7150a92dd5636da6d68fbf0a11806ffa1b620d4d Mon Sep 17 00:00:00 2001 From: Vicente Bearth Date: Sat, 10 Oct 2026 11:50:31 +0200 Subject: [PATCH] Add app.py --- app.py | 929 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 929 insertions(+) create mode 100644 app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..642d54e --- /dev/null +++ b/app.py @@ -0,0 +1,929 @@ +#!/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() \ No newline at end of file