Add app.py

This commit is contained in:
2026-10-10 11:50:31 +02:00
commit 7150a92dd5
+929
View File
@@ -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/<int:search_id>")
@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/<token>")
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()