commit bf38c6f242c72435d72170538ca19a69df44dc57 Author: Hermes Date: Sun Aug 9 22:49:07 2026 +0200 mymovies-api: FastAPI media library manager (upload/delete/update/check/deploy) 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 0000000..2e0d351 Binary files /dev/null and b/__pycache__/app.cpython-310.pyc differ 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]