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}.

Files changed (1) hide show
  1. 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.get(url, timeout=30) as resp:
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})