Add app.py
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user