Stop the admin panel hanging when slskd is down

/api/admin/system probed slskd with the shared client, whose 30s timeout is
sized for searches and transfers. So the panel sat on skeletons for the full
30s exactly when slskd was unreachable -- the case you open it to diagnose --
and the 5s UI poll stacked requests behind it. Probes now get 4s: above a LAN
round-trip, under the poll interval.

httpx.ConnectTimeout also stringifies to "", so the banner rendered
"slskd unreachable -- " with nothing after the dash. Fall back to the
exception class name.

Add dev.sh, because the documented dev commands could not work: MUSE_DB
defaults to /config/muse.db which a Mac cannot write, and the auth preflight
refuses to boot without a client ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-05 01:36:10 -05:00
1 parent f3c64d4fd0
commit 6ef580780d
4 files changed
+90 -12

No files matched your search

+1
View File
@@ -4,3 +4,4 @@ config/
*.db
.venv/
.DS_Store
dev/
+29 -6
View File
@@ -95,16 +95,39 @@ dedicated endpoint is the only source that carries `track_position` and
### Developing
```bash
# terminal 1 — the API
uvicorn app:app --port 5041
./dev.sh # http://127.0.0.1:5041
```
# terminal 2 — the UI, proxying /api and /auth to the above
Creates the venv on first run, keeps its state in `dev/` (gitignored), and
serves the prebuilt `web/out` exactly as the container does — same CSP, same
routes, same middleware. Add `--build` after editing the UI.
It sets **`MUSE_AUTH=off`**, so there is no OAuth app to register against a
localhost callback. With auth off every role check reports `admin`, which is
what keeps the admin panel reachable; the startup banner says so in capitals.
What works with nothing else running: search, autocomplete, 30-second previews,
album and artist pages, artwork-derived accent, the admin panel, and queueing.
Requests reach `searching` and then fail, because acquisition needs slskd —
point `SLSKD_URL`/`SLSKD_API_KEY` at a real instance to test that half.
Two things read differently on a Mac. **CPU and memory show `—`**: those come
from `/proc`, which is Linux-only, so they populate in the container and not
here (load average and disk use `getloadavg`/`statvfs` and work everywhere).
And the **`Users` count includes accounts you seed by hand** — with auth off
nobody can sign in, so add them through the panel's Invite button or the API.
For UI work, leave that running and start Next's dev server beside it:
```bash
cd web && npm install && npm run dev
```
`next dev` serves on :3000 and rewrites `/api/*` and `/auth/*` to the FastAPI
process, so the browser sees one origin and cookies behave exactly as they do
in production. `npm run build` writes `web/out/`, which is what ships.
`next dev` serves on :3000 with hot reload and rewrites `/api/*` and `/auth/*`
to the FastAPI process, so the browser sees one origin and cookies behave
exactly as they do in production. `npm run build` writes `web/out/`, which is
what ships. Note that :3000 serves the UI without the CSP the Python process
attaches — check anything CSP-sensitive on :5041 after a `--build`.
## Bulk import
+24 -6
View File
@@ -434,14 +434,32 @@ def set_state(job_id: str, state: str, detail: str = "", **cols):
# ─────────────────────────── slskd ───────────────────────────
def slskd_client() -> httpx.AsyncClient:
def slskd_client(timeout: float = 30.0) -> httpx.AsyncClient:
"""30s suits searches and transfer polling, which are legitimately slow.
Status probes pass something much shorter: they run on a UI poll, and a
probe that outlasts its own poll interval stacks requests and leaves the
admin panel on skeletons for the whole timeout — precisely when slskd is
down and you opened the panel to find out why.
"""
return httpx.AsyncClient(
base_url=SLSKD_URL,
headers={"X-API-Key": SLSKD_API_KEY},
timeout=httpx.Timeout(30.0),
timeout=httpx.Timeout(timeout),
)
# slskd is on the LAN, so a reachable instance answers in milliseconds. Long
# enough to absorb a slow moment, short enough to stay under the 5s UI poll.
PROBE_TIMEOUT = 4.0
def why(e: Exception) -> str:
"""httpx's timeout and connection errors often str() to an empty string,
which would render as 'slskd unreachable — ' with nothing after it."""
return (str(e) or type(e).__name__)[:120]
def ext_of(remote_path: str) -> str:
# Soulseek paths are Windows-style; the API's `extension` field comes back
# empty in practice, so derive it ourselves.
@@ -1752,7 +1770,7 @@ async def admin_system(request: Request):
slskd: dict = {"ok": False}
try:
async with slskd_client() as sc:
async with slskd_client(PROBE_TIMEOUT) as sc:
r = await sc.get("/api/v0/application")
slskd["ok"] = r.status_code == 200
if r.status_code == 200:
@@ -1767,7 +1785,7 @@ async def admin_system(request: Request):
else:
slskd["error"] = f"HTTP {r.status_code}"
except Exception as e:
slskd["error"] = str(e)[:120]
slskd["error"] = why(e)
out["slskd"] = slskd
return out
@@ -1775,13 +1793,13 @@ async def admin_system(request: Request):
@app.get("/api/health")
async def health():
ok, detail = True, {}
async with slskd_client() as sc:
async with slskd_client(PROBE_TIMEOUT) as sc:
try:
r = await sc.get("/api/v0/application")
detail["slskd"] = r.status_code
ok = ok and r.status_code == 200
except Exception as e:
detail["slskd"] = str(e)
detail["slskd"] = why(e)
ok = False
detail["library_writable"] = os.access(LIBRARY_DIR, os.W_OK)
detail["downloads_readable"] = os.access(DOWNLOAD_DIR, os.R_OK)
Executable
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Run muse on this machine, against live Deezer, with no OAuth app and no slskd.
#
# ./dev.sh serve the built UI from web/out on :5041
# ./dev.sh --build rebuild web/out first (needed after any UI edit)
#
# For UI work with hot reload, leave this running and start `next dev` beside
# it -- see "Developing" in the README.
set -euo pipefail
cd "$(dirname "$0")"
[[ "${1:-}" == "--build" ]] && (cd web && npm install && npm run build)
if [[ ! -d .venv ]]; then
python3 -m venv .venv
.venv/bin/pip install -q -r requirements.txt
fi
mkdir -p dev/library dev/downloads
# Auth off, because the alternative is registering an OAuth app whose callback
# points at localhost. Every role check then reports admin, which is what makes
# the admin panel reachable here. The startup banner says this loudly; heed it.
export MUSE_AUTH="${MUSE_AUTH:-off}"
export MUSE_DB="${MUSE_DB:-dev/muse.db}"
export MUSE_LIBRARY_DIR="${MUSE_LIBRARY_DIR:-dev/library}"
export MUSE_DOWNLOAD_DIR="${MUSE_DOWNLOAD_DIR:-dev/downloads}"
export MUSE_COOKIE_SECURE=false
# Unset by default: with no slskd, requests queue and then fail at the search
# step, which is the correct behaviour and still exercises the whole UI path.
# Point these at a real instance to test acquisition end to end.
export SLSKD_URL="${SLSKD_URL:-http://127.0.0.1:5030}"
export SLSKD_API_KEY="${SLSKD_API_KEY:-}"
exec .venv/bin/uvicorn app:app --host 127.0.0.1 --port 5041 --reload