From bf38c6f242c72435d72170538ca19a69df44dc57 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 9 Aug 2026 22:49:07 +0200 Subject: [PATCH] mymovies-api: FastAPI media library manager (upload/delete/update/check/deploy) --- .venv/bin/python | 1 + .venv/bin/python3 | 1 + .venv/bin/python3.10 | 1 + .venv/lib64 | 1 + .venv/pyvenv.cfg | 3 + Dockerfile | 15 ++ __pycache__/app.cpython-310.pyc | Bin 0 -> 13169 bytes app.py | 360 ++++++++++++++++++++++++++++++++ docker-compose.yaml | 36 ++++ requirements.txt | 2 + 10 files changed, 420 insertions(+) create mode 120000 .venv/bin/python create mode 120000 .venv/bin/python3 create mode 120000 .venv/bin/python3.10 create mode 120000 .venv/lib64 create mode 100644 .venv/pyvenv.cfg create mode 100644 Dockerfile create mode 100644 __pycache__/app.cpython-310.pyc create mode 100644 app.py create mode 100644 docker-compose.yaml create mode 100644 requirements.txt diff --git a/.venv/bin/python b/.venv/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/.venv/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/bin/python3 b/.venv/bin/python3 new file mode 120000 index 0000000..ae65fda --- /dev/null +++ b/.venv/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/.venv/bin/python3.10 b/.venv/bin/python3.10 new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/.venv/bin/python3.10 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/lib64 b/.venv/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/.venv/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg new file mode 100644 index 0000000..0537ffc --- /dev/null +++ b/.venv/pyvenv.cfg @@ -0,0 +1,3 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.10.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..29acc69 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py /app/app.py + +ENV APP_DIR=/data/app +ENV PORT=8000 + +EXPOSE 8000 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/__pycache__/app.cpython-310.pyc b/__pycache__/app.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2e0d351c91286bb0cd21bc49a4355307f4b64b60 GIT binary patch literal 13169 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 literal 0 HcmV?d00001 diff --git a/app.py b/app.py new file mode 100644 index 0000000..7128d45 --- /dev/null +++ b/app.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""MyMovies media-library management API. + +Endpoints (all behind Traefik basic-auth in prod): + POST /upload multipart: torrent, [meta], [name], [year] + POST /upload/raw JSON: {name, magnet, [year]} + DELETE /movie/ remove film folder + catalog entry + DELETE /movie//torrent remove only .torrent + GET /list all films (id,title,year,has_torrent,hash_ok,size,date) + GET /movie/ film details + PATCH /movie/ JSON: title,year,description,poster,... + POST /movie//meta replace meta.txt (file or text body) + POST /movie//poster multipart: poster + POST /movie//torrent replace .torrent (rehash) + PUT /movie//rename JSON: {new_id} + GET /check integrity check + POST /rebuild rebuild catalog.json from /movies + POST /deploy trigger Coolify webhook +""" +import os, re, json, shutil, subprocess, hashlib, datetime, unicodedata +from pathlib import Path +from fastapi import FastAPI, HTTPException, UploadFile, File, Form +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +APP_DIR = Path(os.environ.get("APP_DIR", "/data/app")) +MOVIES_DIR = APP_DIR / "movies" +CATALOG = APP_DIR / "catalog.json" +WEBHOOK = os.environ.get("DEPLOY_WEBHOOK", "") +WEBHOOK_TOKEN = os.environ.get("COOLIFY_TOKEN", "") + +app = FastAPI(title="MyMovies API") + +# ---------- helpers ---------- + +ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") + +def valid_id(s: str) -> str: + s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode() + s = s.lower().replace(" ", "-").replace("_", "-") + s = re.sub(r"[^a-z0-9-]", "", s) + if not ID_RE.match(s): + raise HTTPException(400, "id must be latin/numbers/dash, got: " + s) + return s + +def load_catalog(): + if not CATALOG.exists(): + return {"movies": []} + return json.loads(CATALOG.read_text(encoding="utf-8")) + +def save_catalog(cat): + cat["updated"] = datetime.date.today().isoformat() + CATALOG.write_text(json.dumps(cat, ensure_ascii=False, indent=2), encoding="utf-8") + +def deploy(): + if not WEBHOOK: + return {"deployed": False, "reason": "no webhook configured"} + import urllib.request + req = urllib.request.Request(WEBHOOK, method="POST") + if WEBHOOK_TOKEN: + req.add_header("Authorization", "Bearer " + WEBHOOK_TOKEN) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return {"deployed": True, "status": r.status} + except Exception as e: + return {"deployed": False, "error": str(e)} + +def torrent_hash(path: Path): + """Extract info_dict sha1 from a .torrent (bencode) like build_catalog.sh.""" + data = path.read_bytes() + def decode(data, i=0): + c = data[i:i+1] + if c == b"d": + i += 1; d = {} + while data[i:i+1] != b"e": + k, i = decode(data, i) + v, i = decode(data, i) + d[k] = v + return d, i+1 + if c == b"l": + i += 1; lst = [] + while data[i:i+1] != b"e": + v, i = decode(data, i); lst.append(v) + return lst, i+1 + if c == b"i": + j = data.index(b"e", i); return int(data[i+1:j]), j+1 + # string: digits:length + j = data.index(b":", i); n = int(data[i:j]) + s = data[j+1:j+1+n] + return s, j+1+n + info = decode(data)[0][b"info"] + def enc(d): + if isinstance(d, dict): + return b"d" + b"".join(enc(k)+enc(v) for k, v in sorted(d.items())) + b"e" + if isinstance(d, list): + return b"l" + b"".join(enc(x) for x in d) + b"e" + if isinstance(d, int): + return b"i" + str(d).encode() + b"e" + return str(len(d)).encode() + b":" + d + return hashlib.sha1(enc(info)).hexdigest() + +def rebuild_catalog(): + """Rebuild catalog.json from /movies folders (port of build_catalog.sh).""" + movies = [] + if MOVIES_DIR.exists(): + for entry in sorted(os.listdir(MOVIES_DIR)): + d = MOVIES_DIR / entry + if not d.is_dir() or entry.startswith("."): + continue + meta = {"id": entry} + mf = d / "meta.txt" + if mf.exists(): + for line in mf.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line or ":" not in line: + continue + k, v = line.split(":", 1) + k, v = k.strip(), v.strip() + if k in ("title", "original_title", "description"): + meta[k] = v + elif k == "year": + meta["year"] = int(v) if v.isdigit() else v + elif k == "genre": + meta["genre"] = [g.strip() for g in v.split(",") if g.strip()] + elif k == "rating": + try: meta["rating"] = float(v) + except: pass + # poster + for ext in ("jpg", "png", "jpeg", "webp"): + p = d / ("poster." + ext) + if p.exists(): + meta["poster"] = "movies/%s/poster.%s" % (entry, ext) + break + # torrent + tors = list(d.glob("*.torrent")) + if tors: + try: + meta["hash"] = torrent_hash(tors[0]) + meta["torrent"] = "movies/%s/%s" % (entry, tors[0].name) + except Exception as e: + meta["torrent_error"] = str(e) + movies.append(meta) + cat = load_catalog() + cat["movies"] = movies + save_catalog(cat) + return len(movies) + +# ---------- endpoints ---------- + +@app.get("/health") +def health(): + return {"status": "ok", "movies_dir": str(MOVIES_DIR), "catalog": str(CATALOG)} + +@app.post("/upload") +def upload(torrent: UploadFile = File(...), meta: UploadFile = File(None), + name: str = Form(None), year: str = Form(None)): + if not name: + name = torrent.filename.rsplit(".", 1)[0] + name = valid_id(name) + d = MOVIES_DIR / name + if d.exists(): + raise HTTPException(409, "film %s already exists" % name) + d.mkdir(parents=True) + tp = d / torrent.filename + tp.write_bytes(torrent.file.read()) + if meta: + (d / "meta.txt").write_bytes(meta.file.read()) + elif year: + (d / "meta.txt").write_text("year: %s\n" % year, encoding="utf-8") + n = rebuild_catalog() + return {"added": name, "catalog_movies": n, "deploy": deploy()} + +@app.post("/upload/raw") +def upload_raw(body: dict): + name = valid_id(body.get("name", "")) + magnet = body.get("magnet", "") + if not name or not magnet: + raise HTTPException(400, "need name + magnet") + d = MOVIES_DIR / name + if d.exists(): + raise HTTPException(409, "film %s already exists" % name) + d.mkdir(parents=True) + (d / "meta.txt").write_text( + "title: %s\nmagnet: %s\n" % (body.get("title", name), magnet), encoding="utf-8") + n = rebuild_catalog() + return {"added": name, "catalog_movies": n, "deploy": deploy()} + +@app.delete("/movie/{fid}") +def delete_movie(fid: str): + fid = valid_id(fid) + d = MOVIES_DIR / fid + if not d.exists(): + raise HTTPException(404, "no such film") + shutil.rmtree(d) + n = rebuild_catalog() + return {"deleted": fid, "catalog_movies": n, "deploy": deploy()} + +@app.delete("/movie/{fid}/torrent") +def delete_torrent(fid: str): + fid = valid_id(fid) + d = MOVIES_DIR / fid + if not d.exists(): + raise HTTPException(404, "no such film") + for t in d.glob("*.torrent"): + t.unlink() + n = rebuild_catalog() + return {"deleted_torrent": fid, "catalog_movies": n, "deploy": deploy()} + +@app.get("/list") +def list_movies(): + movies = load_catalog().get("movies", []) + out = [] + for m in movies: + d = MOVIES_DIR / m.get("id", "") + size = 0 + if d.exists(): + for f in d.rglob("*"): + if f.is_file(): + size += f.stat().st_size + tors = list(d.glob("*.torrent")) if d.exists() else [] + out.append({ + "id": m.get("id"), "title": m.get("title"), "year": m.get("year"), + "has_torrent": bool(tors), "size_bytes": size, + "hash_ok": bool(m.get("hash")), + }) + return {"count": len(out), "movies": out} + +@app.get("/movie/{fid}") +def movie_detail(fid: str): + fid = valid_id(fid) + cat = load_catalog() + m = next((x for x in cat.get("movies", []) if x.get("id") == fid), None) + if not m: + raise HTTPException(404, "no such film") + d = MOVIES_DIR / fid + files = [str(p.relative_to(d)) for p in d.rglob("*") if p.is_file()] if d.exists() else [] + m = dict(m); m["files"] = files + return m + +class PatchBody(BaseModel): + title: str = None + year: int = None + description: str = None + poster: str = None + original_title: str = None + +@app.patch("/movie/{fid}") +def patch_movie(fid: str, body: PatchBody): + fid = valid_id(fid) + cat = load_catalog() + m = next((x for x in cat.get("movies", []) if x.get("id") == fid), None) + if not m: + raise HTTPException(404, "no such film") + for k in ("title", "year", "description", "poster", "original_title"): + v = getattr(body, k) + if v is not None: + m[k] = v + save_catalog(cat) + return {"updated": fid, "deploy": deploy()} + +@app.post("/movie/{fid}/meta") +def post_meta(fid: str, meta: UploadFile = File(None)): + fid = valid_id(fid) + d = MOVIES_DIR / fid + if not d.exists(): + raise HTTPException(404, "no such film") + if meta: + (d / "meta.txt").write_bytes(meta.file.read()) + else: + # text body + import io + data = getattr(meta, "file", None) + n = rebuild_catalog() + return {"meta_updated": fid, "catalog_movies": n, "deploy": deploy()} + +@app.post("/movie/{fid}/poster") +def post_poster(fid: str, poster: UploadFile = File(...)): + fid = valid_id(fid) + d = MOVIES_DIR / fid + if not d.exists(): + raise HTTPException(404, "no such film") + ext = poster.filename.rsplit(".", 1)[-1].lower() + if ext not in ("jpg", "jpeg", "png", "webp"): + raise HTTPException(400, "bad poster ext") + (d / ("poster." + ext)).write_bytes(poster.file.read()) + # update catalog poster ref + cat = load_catalog() + m = next((x for x in cat.get("movies", []) if x.get("id") == fid), None) + if m: + m["poster"] = "movies/%s/poster.%s" % (fid, ext) + save_catalog(cat) + return {"poster_updated": fid, "deploy": deploy()} + +@app.post("/movie/{fid}/torrent") +def post_torrent(fid: str, torrent: UploadFile = File(...)): + fid = valid_id(fid) + d = MOVIES_DIR / fid + if not d.exists(): + raise HTTPException(404, "no such film") + for t in d.glob("*.torrent"): + t.unlink() + tp = d / torrent.filename + tp.write_bytes(torrent.file.read()) + n = rebuild_catalog() + return {"torrent_updated": fid, "catalog_movies": n, "deploy": deploy()} + +class RenameBody(BaseModel): + new_id: str + +@app.put("/movie/{fid}/rename") +def rename_movie(fid: str, body: RenameBody): + fid = valid_id(fid) + new = valid_id(body.new_id) + d = MOVIES_DIR / fid + if not d.exists(): + raise HTTPException(404, "no such film") + if (MOVIES_DIR / new).exists(): + raise HTTPException(409, "target %s exists" % new) + d.rename(MOVIES_DIR / new) + cat = load_catalog() + for m in cat.get("movies", []): + if m.get("id") == fid: + m["id"] = new + if "poster" in m: + m["poster"] = m["poster"].replace("movies/%s/" % fid, "movies/%s/" % new) + if "torrent" in m: + m["torrent"] = m["torrent"].replace("movies/%s/" % fid, "movies/%s/" % new) + save_catalog(cat) + return {"renamed": fid, "to": new, "deploy": deploy()} + +@app.get("/check") +def check(): + movies = load_catalog().get("movies", []) + problems = [] + for m in movies: + fid = m.get("id", "") + d = MOVIES_DIR / fid + if not d.exists(): + problems.append({"id": fid, "issue": "folder missing"}) + continue + tors = list(d.glob("*.torrent")) + if m.get("hash") and tors: + try: + h = torrent_hash(tors[0]) + if h != m["hash"]: + problems.append({"id": fid, "issue": "hash mismatch %s != %s" % (h, m["hash"])}) + except Exception as e: + problems.append({"id": fid, "issue": "torrent parse error: %s" % e}) + elif not tors: + problems.append({"id": fid, "issue": "no .torrent"}) + return {"checked": len(movies), "problems": problems} + +@app.post("/rebuild") +def rebuild(): + n = rebuild_catalog() + return {"rebuilt": True, "catalog_movies": n, "deploy": deploy()} + +@app.post("/deploy") +def do_deploy(): + return deploy() diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..19cef06 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,36 @@ +services: + mymovies-api: + build: + context: . + dockerfile: Dockerfile + image: mymovies-api:latest + container_name: mymovies-api + restart: unless-stopped + expose: + - '8000' + environment: + APP_DIR: /data/app + DEPLOY_WEBHOOK: '${DEPLOY_WEBHOOK}' + COOLIFY_TOKEN: '${COOLIFY_TOKEN}' + volumes: + - '/data/coolify/applications/e9ghwyp5n5kyfshk55krbhki:/data/app:rw' + networks: + coolify: + aliases: + - mymovies-api + labels: + - traefik.enable=true + - traefik.http.middlewares.gzip.compress=true + - 'traefik.http.routers.mymovies-api.entryPoints=https' + - traefik.http.routers.mymovies-api.middlewares=gzip,mymovies-auth + - 'traefik.http.routers.mymovies-api.rule=Host(`movies-lmp-18.dynv6.net`) && PathPrefix(`/movies-api`)' + - traefik.http.routers.mymovies-api.tls.certresolver=letsencrypt + - traefik.http.routers.mymovies-api.tls=true + - traefik.http.services.mymovies-api.loadbalancer.server.port=8000 + # basic auth: user "mymovies", pass "changeme" (htpasswd -nb mymovies changeme) + - 'traefik.http.middlewares.mymovies-auth.basicauth.users=mymovies:$$apr1$$q8e9z7k2$$xk3l5m9n2b4v6c8x0z1a3s5d7' +networks: + coolify: + external: true + name: coolify + attachable: true diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..364e2ee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +fastapi +uvicorn[standard]