Spaces:
Running
Running
Hermes Agent commited on
Commit ·
6529dfd
1
Parent(s): 1275920
fix(ops): accept PUT on jellyfin passthrough + ensure plugin repo at startup
Browse files- apex/gateway/main.py +52 -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 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()
|