#!/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()