Hermes Agent commited on
Commit
6529dfd
·
1 Parent(s): 1275920

fix(ops): accept PUT on jellyfin passthrough + ensure plugin repo at startup

Browse files
Files changed (2) hide show
  1. apex/gateway/main.py +52 -2
  2. apex/gateway/ops.py +9 -11
apex/gateway/main.py CHANGED
@@ -1,14 +1,64 @@
1
  import os
 
2
  import logging
 
3
  from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
4
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
5
  from gateway.utils import get_http_client, proxy_http_request, proxy_websocket_stream
6
- from gateway.ops import router as ops_router
7
 
8
  logger = logging.getLogger("gateway.main")
9
  logging.basicConfig(level=logging.INFO)
10
 
11
- app = FastAPI(title="OpenCode Space Gateway", docs_url=None, redoc_url=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  app.include_router(ops_router)
14
 
 
1
  import os
2
+ import asyncio
3
  import logging
4
+ from contextlib import asynccontextmanager
5
  from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
6
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
7
  from gateway.utils import get_http_client, proxy_http_request, proxy_websocket_stream
8
+ from gateway.ops import router as ops_router, _jellyfin_auth_header, _JELLYFIN_BASE
9
 
10
  logger = logging.getLogger("gateway.main")
11
  logging.basicConfig(level=logging.INFO)
12
 
13
+ # Default plugin catalog repository, guaranteed at startup so online plugins
14
+ # always show up in the web UI after every boot/rebuild (Jellyfin reads the
15
+ # repo list into memory at startup; the live config is mirrored to /data by
16
+ # the write-back loop, so an API-set repo also survives restarts).
17
+ DEFAULT_PLUGIN_REPO = {
18
+ "Name": "Jellyfin Stable",
19
+ "Url": "https://repo.jellyfin.org/files/plugin/manifest.json",
20
+ "Enabled": True,
21
+ }
22
+
23
+ async def ensure_jellyfin_plugin_repository() -> None:
24
+ """Idempotently configure the plugin repository on the live Jellyfin server."""
25
+ import aiohttp
26
+
27
+ for attempt in range(1, 61):
28
+ await asyncio.sleep(2)
29
+ headers = _jellyfin_auth_header()
30
+ if not headers:
31
+ continue
32
+ try:
33
+ async with aiohttp.ClientSession() as session:
34
+ async with session.get(
35
+ f"{_JELLYFIN_BASE}/Repositories", headers=headers, timeout=5
36
+ ) as resp:
37
+ if resp.status != 200:
38
+ continue
39
+ repos = await resp.json()
40
+ if isinstance(repos, list) and any(
41
+ isinstance(r, dict) and str(r.get("Url")) == DEFAULT_PLUGIN_REPO["Url"]
42
+ for r in repos
43
+ ):
44
+ logger.info("[PLUGINS] Repository already configured.")
45
+ return
46
+ async with session.post(
47
+ f"{_JELLYFIN_BASE}/Repositories", headers=headers, json=[DEFAULT_PLUGIN_REPO], timeout=10
48
+ ) as resp:
49
+ if resp.status in (200, 204):
50
+ logger.info("[PLUGINS] Plugin repository configured via API (attempt %s).", attempt)
51
+ return
52
+ except Exception:
53
+ continue
54
+ logger.warning("[PLUGINS] Could not ensure plugin repository during startup window.")
55
+
56
+ @asynccontextmanager
57
+ async def lifespan(app: FastAPI):
58
+ asyncio.create_task(ensure_jellyfin_plugin_repository())
59
+ yield
60
+
61
+ app = FastAPI(title="OpenCode Space Gateway", docs_url=None, redoc_url=None, lifespan=lifespan)
62
 
63
  app.include_router(ops_router)
64
 
apex/gateway/ops.py CHANGED
@@ -368,7 +368,7 @@ async def ops_jellyfin_theme(request: Request):
368
  })
369
 
370
 
371
- @router.api_route("/apex/ops/jellyfin/api", methods=["GET", "POST", "DELETE"])
372
  async def ops_jellyfin_api(request: Request):
373
  """Admin-gated passthrough to the internal Jellyfin API.
374
 
@@ -390,8 +390,8 @@ async def ops_jellyfin_api(request: Request):
390
  return JSONResponse({"ok": False, "error": "No Jellyfin API key available"}, status_code=503)
391
 
392
  method = str(request.query_params.get("method") or request.method or "GET").upper()
393
- if method not in ("GET", "POST", "DELETE"):
394
- return JSONResponse({"ok": False, "error": "method must be GET, POST or DELETE"}, status_code=400)
395
 
396
  api_path = str(request.query_params.get("path") or "").lstrip("/")
397
  if not api_path:
@@ -400,7 +400,7 @@ async def ops_jellyfin_api(request: Request):
400
  return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400)
401
 
402
  json_body = None
403
- if method == "POST":
404
  raw_body = request.query_params.get("body")
405
  if raw_body is None:
406
  try:
@@ -586,16 +586,14 @@ async def ops_jellyfin_cache_patch(request: Request):
586
 
587
  @router.post("/apex/ops/jellyfin/repositories")
588
  async def ops_jellyfin_repositories(request: Request):
589
- """Write the plugin repository list straight into system.xml.
590
 
591
  Body: {"repositories": [{"Name": "...", "Url": "...", "Enabled": true}]}
592
 
593
- Hardened Jellyfin builds strip POST /Repositories (the API returns 405,
594
- which also breaks the web UI's add-repository dialog with a
595
- 'r.map is not a function' crash). The server reads the repo list from
596
- the <PluginRepositories> element of system.xml at start-up, so this
597
- writes that element directly on the /data volume. Jellyfin only picks
598
- it up once the process (re)starts.
599
  """
600
  if not _is_admin(request):
601
  return _denied()
 
368
  })
369
 
370
 
371
+ @router.api_route("/apex/ops/jellyfin/api", methods=["GET", "POST", "PUT", "DELETE"])
372
  async def ops_jellyfin_api(request: Request):
373
  """Admin-gated passthrough to the internal Jellyfin API.
374
 
 
390
  return JSONResponse({"ok": False, "error": "No Jellyfin API key available"}, status_code=503)
391
 
392
  method = str(request.query_params.get("method") or request.method or "GET").upper()
393
+ if method not in ("GET", "POST", "PUT", "DELETE"):
394
+ return JSONResponse({"ok": False, "error": "method must be GET, POST, PUT or DELETE"}, status_code=400)
395
 
396
  api_path = str(request.query_params.get("path") or "").lstrip("/")
397
  if not api_path:
 
400
  return JSONResponse({"ok": False, "error": "Invalid path"}, status_code=400)
401
 
402
  json_body = None
403
+ if method in ("POST", "PUT"):
404
  raw_body = request.query_params.get("body")
405
  if raw_body is None:
406
  try:
 
586
 
587
  @router.post("/apex/ops/jellyfin/repositories")
588
  async def ops_jellyfin_repositories(request: Request):
589
+ """Write the plugin repository list straight into the on-disk system.xml.
590
 
591
  Body: {"repositories": [{"Name": "...", "Url": "...", "Enabled": true}]}
592
 
593
+ Fallback helper that edits the live <PluginRepositories> element so the
594
+ server reads it at the next start-up. Preferred path is the API
595
+ passthrough (POST /Repositories via /apex/ops/jellyfin/api), which Jellyfin
596
+ applies immediately and which its write-back loop also mirrors to /data.
 
 
597
  """
598
  if not _is_admin(request):
599
  return _denied()