Spaces:
Running
Running
Hermes Agent commited on
Commit ·
adea24e
1
Parent(s): 4edc067
feat(ops): passthrough POST/DELETE + ghost library cleanup
Browse files- /apex/ops/jellyfin/api now supports method=GET|POST|DELETE and body=json
so plugin-repository and item-deletion calls can be made server-side.
- /apex/ops/jellyfin/cleanup removes empty ghost folders (guarded to
/data/jellyfin and empty-only) and purges orphaned Jellyfin items via
DELETE /Items/{id}.
- apex/gateway/ops.py +74 -2
apex/gateway/ops.py
CHANGED
|
@@ -374,6 +374,8 @@ async def ops_jellyfin_api(request: Request):
|
|
| 374 |
|
| 375 |
Query: ?path=<api path incl. query string> e.g.
|
| 376 |
/Items?Recursive=true&IncludeItemTypes=Movie&searchTerm=Mollywood
|
|
|
|
|
|
|
| 377 |
Uses the server-side API key, so it survives behind the public proxy.
|
| 378 |
"""
|
| 379 |
if not _is_admin(request):
|
|
@@ -384,16 +386,29 @@ async def ops_jellyfin_api(request: Request):
|
|
| 384 |
if not headers:
|
| 385 |
return JSONResponse({"ok": False, "error": "No Jellyfin API key available"}, status_code=503)
|
| 386 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
api_path = str(request.query_params.get("path") or "").lstrip("/")
|
| 388 |
if not api_path:
|
| 389 |
return JSONResponse({"ok": False, "error": "Missing 'path' query param"}, status_code=400)
|
| 390 |
if "\n" in api_path or "\r" in api_path:
|
| 391 |
return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400)
|
| 392 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
url = f"{_JELLYFIN_BASE}/{api_path}"
|
| 394 |
async with aiohttp.ClientSession(headers=headers) as session:
|
| 395 |
try:
|
| 396 |
-
async with session.
|
| 397 |
body = await resp.text()
|
| 398 |
ctype = resp.headers.get("content-type", "")
|
| 399 |
if "application/json" in ctype:
|
|
@@ -403,4 +418,61 @@ async def ops_jellyfin_api(request: Request):
|
|
| 403 |
pass
|
| 404 |
return JSONResponse({"ok": True, "status": resp.status, "text": body[:40000]})
|
| 405 |
except Exception as exc:
|
| 406 |
-
return JSONResponse({"ok": False, "error": f"Jellyfin API call failed: {exc}"}, status_code=502)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
Query: ?path=<api path incl. query string> e.g.
|
| 376 |
/Items?Recursive=true&IncludeItemTypes=Movie&searchTerm=Mollywood
|
| 377 |
+
Optional: &method=GET|POST|DELETE (default GET)
|
| 378 |
+
&body=<json> (raw JSON body for POST)
|
| 379 |
Uses the server-side API key, so it survives behind the public proxy.
|
| 380 |
"""
|
| 381 |
if not _is_admin(request):
|
|
|
|
| 386 |
if not headers:
|
| 387 |
return JSONResponse({"ok": False, "error": "No Jellyfin API key available"}, status_code=503)
|
| 388 |
|
| 389 |
+
method = str(request.query_params.get("method") or "GET").upper()
|
| 390 |
+
if method not in ("GET", "POST", "DELETE"):
|
| 391 |
+
return JSONResponse({"ok": False, "error": "method must be GET, POST or DELETE"}, status_code=400)
|
| 392 |
+
|
| 393 |
api_path = str(request.query_params.get("path") or "").lstrip("/")
|
| 394 |
if not api_path:
|
| 395 |
return JSONResponse({"ok": False, "error": "Missing 'path' query param"}, status_code=400)
|
| 396 |
if "\n" in api_path or "\r" in api_path:
|
| 397 |
return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400)
|
| 398 |
|
| 399 |
+
json_body = None
|
| 400 |
+
if method == "POST":
|
| 401 |
+
raw_body = request.query_params.get("body")
|
| 402 |
+
if raw_body:
|
| 403 |
+
try:
|
| 404 |
+
json_body = json.loads(raw_body)
|
| 405 |
+
except Exception:
|
| 406 |
+
return JSONResponse({"ok": False, "error": "body must be valid JSON"}, status_code=400)
|
| 407 |
+
|
| 408 |
url = f"{_JELLYFIN_BASE}/{api_path}"
|
| 409 |
async with aiohttp.ClientSession(headers=headers) as session:
|
| 410 |
try:
|
| 411 |
+
async with session.request(method, url, json=json_body, timeout=60) as resp:
|
| 412 |
body = await resp.text()
|
| 413 |
ctype = resp.headers.get("content-type", "")
|
| 414 |
if "application/json" in ctype:
|
|
|
|
| 418 |
pass
|
| 419 |
return JSONResponse({"ok": True, "status": resp.status, "text": body[:40000]})
|
| 420 |
except Exception as exc:
|
| 421 |
+
return JSONResponse({"ok": False, "error": f"Jellyfin API call failed: {exc}"}, status_code=502)
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
@router.post("/apex/ops/jellyfin/cleanup")
|
| 425 |
+
async def ops_jellyfin_cleanup(request: Request):
|
| 426 |
+
"""Remove ghost library entries left behind by file relocations.
|
| 427 |
+
|
| 428 |
+
Body: {"folders": ["/abs/path/to/empty/dir", ...],
|
| 429 |
+
"item_ids": ["<jellyfin item id>", ...]}
|
| 430 |
+
A folder is only removed when it lives under /data/jellyfin and is empty.
|
| 431 |
+
Each item_id is then deleted through the Jellyfin API (DELETE /Items/{id},
|
| 432 |
+
best effort, tolerant of 404 when Jellyfin already dropped it).
|
| 433 |
+
"""
|
| 434 |
+
if not _is_admin(request):
|
| 435 |
+
return _denied()
|
| 436 |
+
import aiohttp
|
| 437 |
+
|
| 438 |
+
headers = _jellyfin_auth_header()
|
| 439 |
+
if not headers:
|
| 440 |
+
return JSONResponse({"ok": False, "error": "No Jellyfin API key available"}, status_code=503)
|
| 441 |
+
|
| 442 |
+
try:
|
| 443 |
+
payload = await request.json()
|
| 444 |
+
except Exception:
|
| 445 |
+
payload = {}
|
| 446 |
+
|
| 447 |
+
folders = payload.get("folders") or []
|
| 448 |
+
item_ids = payload.get("item_ids") or []
|
| 449 |
+
results = {"folders": [], "items": []}
|
| 450 |
+
|
| 451 |
+
for folder in folders:
|
| 452 |
+
folder = str(folder)
|
| 453 |
+
if not folder.startswith("/data/jellyfin/"):
|
| 454 |
+
results["folders"].append({"path": folder, "status": "skipped_not_under_data"})
|
| 455 |
+
continue
|
| 456 |
+
try:
|
| 457 |
+
if not os.path.isdir(folder):
|
| 458 |
+
results["folders"].append({"path": folder, "status": "not_found"})
|
| 459 |
+
elif os.listdir(folder):
|
| 460 |
+
results["folders"].append({"path": folder, "status": "not_empty", "entries": os.listdir(folder)[:20]})
|
| 461 |
+
else:
|
| 462 |
+
shutil.rmtree(folder)
|
| 463 |
+
results["folders"].append({"path": folder, "status": "removed"})
|
| 464 |
+
except Exception as exc:
|
| 465 |
+
results["folders"].append({"path": folder, "status": "error", "detail": str(exc)})
|
| 466 |
+
|
| 467 |
+
async with aiohttp.ClientSession(headers=headers) as session:
|
| 468 |
+
for item_id in item_ids:
|
| 469 |
+
item_id = str(item_id).strip()
|
| 470 |
+
if not item_id:
|
| 471 |
+
continue
|
| 472 |
+
try:
|
| 473 |
+
async with session.delete(f"{_JELLYFIN_BASE}/Items/{item_id}", timeout=15) as resp:
|
| 474 |
+
results["items"].append({"id": item_id, "status": resp.status})
|
| 475 |
+
except Exception as exc:
|
| 476 |
+
results["items"].append({"id": item_id, "status": "error", "detail": str(exc)})
|
| 477 |
+
|
| 478 |
+
return JSONResponse({"ok": True, "results": results})
|