mymovies-api: FastAPI media library manager (upload/delete/update/check/deploy)
This commit is contained in:
commit
bf38c6f242
10 files changed
+420
No files matched your search
Symlink
+1
@@ -0,0 +1 @@
|
||||
python3
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/usr/bin/python3
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
python3
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
lib
|
||||
@@ -0,0 +1,3 @@
|
||||
home = /usr/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.10.12
|
||||
+15
@@ -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"]
|
||||
Binary file not shown.
@@ -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/<id> remove film folder + catalog entry
|
||||
DELETE /movie/<id>/torrent remove only .torrent
|
||||
GET /list all films (id,title,year,has_torrent,hash_ok,size,date)
|
||||
GET /movie/<id> film details
|
||||
PATCH /movie/<id> JSON: title,year,description,poster,...
|
||||
POST /movie/<id>/meta replace meta.txt (file or text body)
|
||||
POST /movie/<id>/poster multipart: poster
|
||||
POST /movie/<id>/torrent replace .torrent (rehash)
|
||||
PUT /movie/<id>/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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
Reference in new issue
Block a user