Add single-user GitHub OAuth, hardened for internet exposure

muse drives a download client and writes to the media library, so exposing it
publicly needs more than a login form.

It fails closed. Auth is on unless MUSE_AUTH=off is set explicitly, and when
it is on with incomplete config the app refuses to start rather than serving
open. The mode worth engineering against is not a broken login page, it is a
misconfigured deploy quietly publishing an unauthenticated download client.

Sessions are server-side: the cookie carries a 256-bit random token and
nothing else, and only its SHA-256 is stored. There is no signed payload to
get subtly wrong, a stolen database yields no usable cookies, and logout
revokes for real. The allowlist is re-checked on every request so removing a
user takes effect immediately rather than at session expiry.

OAuth state is compared with secrets.compare_digest, so a callback the user
did not initiate is rejected. MUSE_BASE_URL comes from config rather than
X-Forwarded-* because those headers are attacker-controllable and this one is
a redirect target.

CSP is default-src 'self' with no unsafe-inline, which is why the stylesheet
and script moved out of index.html into static files -- an injected <script>
now cannot execute. Also frame-ancestors 'none', base-uri 'none', nosniff,
DENY, no-referrer and HSTS. FastAPI's /docs, /redoc and /openapi.json are
disabled so the login page does not hand out a map of every endpoint.

/healthz is the only unauthenticated route and returns a bare ok, so the
container healthcheck works without leaking anything.

Verified: misconfigured auth refuses to boot; unauthenticated / redirects and
/api/* returns 401; a forged state is rejected 400; a junk cookie is 401; a
valid session for a non-allowlisted user is 401; all headers present; and the
UI renders with no CSP violations in the console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
aryansinghandClaude Opus 5 committed 2026-08-04 12:09:51 -05:00
1 parent 10ccc282d4
commit bc46fd80b3
6 files changed
+486 -187

No files matched your search

+1 -1
View File
@@ -15,6 +15,6 @@ COPY static ./static
EXPOSE 5040
HEALTHCHECK --interval=60s --timeout=10s --start-period=15s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5040/api/health',timeout=8).status==200 else 1)"
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:5040/healthz',timeout=8).status==200 else 1)"
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5040", "--no-access-log"]
+53
View File
@@ -105,6 +105,59 @@ can actually serve you (free upload slot, short queue), then filename
plausibility, then bitrate. A marginally better file behind 200 queued users
loses to a good-enough one available now.
## Authentication
Single-user GitHub OAuth, built so this can face the internet.
**It fails closed.** Auth is on unless `MUSE_AUTH=off` is set explicitly, and
if it is on with incomplete config the app *refuses to start*. The failure mode
worth engineering against isn't a broken login page — it's a misconfigured
deploy quietly serving an unauthenticated download client to the internet.
### Setting it up
1. <https://github.com/settings/developers> → **New OAuth App**
- Homepage URL: `https://muse.yourdomain.com`
- Authorization callback URL: `https://muse.yourdomain.com/auth/callback`
2. Generate a client secret.
3. Set `MUSE_GITHUB_CLIENT_ID`, `MUSE_GITHUB_CLIENT_SECRET`,
`MUSE_ALLOWED_USERS` (your GitHub login), and `MUSE_BASE_URL`.
| variable | default | meaning |
|---|---|---|
| `MUSE_AUTH` | `github` | `github` or `off` |
| `MUSE_GITHUB_CLIENT_ID` | — | OAuth app client id |
| `MUSE_GITHUB_CLIENT_SECRET` | — | OAuth app secret |
| `MUSE_ALLOWED_USERS` | — | comma-separated GitHub logins; **nobody else gets in** |
| `MUSE_BASE_URL` | — | external URL, e.g. `https://muse.yourdomain.com` |
| `MUSE_COOKIE_SECURE` | `true` | set `false` only for plain-http LAN testing |
| `MUSE_SESSION_DAYS` | `7` | session lifetime |
### What it does
- **Server-side sessions.** The cookie carries a 256-bit random token and
nothing else; the database stores only its SHA-256. No signed payload to get
wrong, a stolen database yields no usable cookies, and logout genuinely
revokes. Cookie is `HttpOnly`, `Secure`, `SameSite=Lax`.
- **Allowlist re-checked every request**, so removing someone locks them out
immediately rather than whenever their session expires.
- **OAuth `state`** is generated per login and compared with
`secrets.compare_digest`, so a callback you didn't initiate is rejected.
- **Strict CSP** — `default-src 'self'`, no `unsafe-inline`. This is why the
stylesheet and script are separate files instead of inline in the page: an
injected `<script>` cannot execute. Plus `frame-ancestors 'none'`,
`base-uri 'none'`, `nosniff`, `X-Frame-Options: DENY`, `no-referrer`, HSTS.
- **No API docs** — `/docs`, `/redoc` and `/openapi.json` are disabled, so the
login page doesn't hand out a map of every endpoint.
- **Denied logins are logged** with the GitHub username — on an
internet-facing box that's your signal someone found the page.
- `/healthz` is the only unauthenticated route and returns a bare `{"ok":true}`
— no versions, no config, no counts.
Behind a reverse proxy, `MUSE_BASE_URL` is read from config rather than from
`X-Forwarded-*` headers on purpose: those are attacker-controllable and this
one is a redirect target.
## Running it
```yaml
+237 -3
View File
@@ -26,17 +26,20 @@ file on disk, two names — the same trick the *arrs use.
import asyncio
import difflib
import hashlib
import logging
import os
import re
import secrets
import sqlite3
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
import httpx
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@@ -102,6 +105,106 @@ DERIVATIVE = ("cover", "covers", "karaoke", "tribute", "remix", "remixed",
"sped up", "slowed", "reverb", "8d", "nightcore")
DERIVATIVE_RE = re.compile(r"\b(" + "|".join(DERIVATIVE) + r")\b")
# ─────────────────────────── auth ───────────────────────────
#
# Single-user GitHub OAuth, built so this can face the internet.
#
# FAILS CLOSED. Auth is on unless MUSE_AUTH is explicitly set to "off", and if
# it is on with incomplete config the app refuses to start. The failure mode
# worth engineering against is not a broken login page — it is a misconfigured
# deploy quietly serving an unauthenticated download client to the internet.
# You should have to *mean* it to run without auth.
#
# Sessions are server-side: the cookie carries a 256-bit random token and
# nothing else, and the database stores only its SHA-256. There is no signed
# payload to get wrong, a stolen database yields no usable cookies, and logout
# and revocation actually revoke.
AUTH_MODE = os.getenv("MUSE_AUTH", "github").strip().lower()
GH_CLIENT_ID = os.getenv("MUSE_GITHUB_CLIENT_ID", "").strip()
GH_CLIENT_SECRET = os.getenv("MUSE_GITHUB_CLIENT_SECRET", "").strip()
ALLOWED_USERS = {u.strip().lower()
for u in os.getenv("MUSE_ALLOWED_USERS", "").split(",") if u.strip()}
# Absolute external URL, e.g. https://muse.aryan-singh.dev. Used to build the
# OAuth callback. Taken from config rather than from X-Forwarded-* headers on
# purpose: those are attacker-controllable and this is a redirect target.
BASE_URL = os.getenv("MUSE_BASE_URL", "").rstrip("/")
COOKIE_SECURE = os.getenv("MUSE_COOKIE_SECURE", "true").lower() != "false"
SESSION_DAYS = int(os.getenv("MUSE_SESSION_DAYS", "7"))
SESSION_COOKIE = "muse_session"
STATE_COOKIE = "muse_oauth_state"
GH_AUTH = "https://github.com/login/oauth/authorize"
GH_TOKEN = "https://github.com/login/oauth/access_token"
GH_API = "https://api.github.com/user"
# Paths reachable without a session. Deliberately tiny: /healthz reports a bare
# ok so the container healthcheck works without leaking configuration, and the
# static assets must load on the login page itself.
PUBLIC_PATHS = {"/healthz", "/auth/login", "/auth/callback", "/auth/logout"}
def auth_preflight():
if AUTH_MODE == "off":
log.warning("=" * 62)
log.warning("MUSE_AUTH=off — every endpoint is UNAUTHENTICATED.")
log.warning("Never expose this to the internet in this state.")
log.warning("=" * 62)
return
if AUTH_MODE != "github":
raise RuntimeError(f"MUSE_AUTH must be 'github' or 'off', got {AUTH_MODE!r}")
missing = [n for n, v in (
("MUSE_GITHUB_CLIENT_ID", GH_CLIENT_ID),
("MUSE_GITHUB_CLIENT_SECRET", GH_CLIENT_SECRET),
("MUSE_ALLOWED_USERS", ALLOWED_USERS),
("MUSE_BASE_URL", BASE_URL)) if not v]
if missing:
raise RuntimeError(
"GitHub auth is enabled but " + ", ".join(missing) + " not set. "
"Refusing to start: set them, or set MUSE_AUTH=off to run without "
"authentication (LAN only).")
if not BASE_URL.startswith("https://") and COOKIE_SECURE:
log.warning("MUSE_BASE_URL is not https but cookies are Secure — the "
"browser will refuse to send them. Set MUSE_COOKIE_SECURE=false "
"only if you are testing over plain http on a LAN.")
def _hash(tok: str) -> str:
return hashlib.sha256(tok.encode()).hexdigest()
def new_session(username: str) -> str:
token = secrets.token_urlsafe(32)
exp = datetime.now(timezone.utc) + timedelta(days=SESSION_DAYS)
with db() as c:
c.execute("INSERT INTO sessions (token_hash,username,expires_at) VALUES (?,?,?)",
(_hash(token), username, exp.isoformat()))
# Opportunistic sweep; no scheduler needed for a handful of rows.
c.execute("DELETE FROM sessions WHERE expires_at < ?",
(datetime.now(timezone.utc).isoformat(),))
return token
def session_user(request: Request) -> str | None:
tok = request.cookies.get(SESSION_COOKIE)
if not tok:
return None
with db() as c:
row = c.execute(
"SELECT username, expires_at FROM sessions WHERE token_hash=?",
(_hash(tok),)).fetchone()
if not row:
return None
try:
if datetime.fromisoformat(row["expires_at"]) < datetime.now(timezone.utc):
return None
except ValueError:
return None
# Re-check the allowlist on every request, so removing someone from
# MUSE_ALLOWED_USERS locks them out immediately instead of whenever their
# session happens to expire.
return row["username"] if row["username"].lower() in ALLOWED_USERS else None
SAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
NOISE = re.compile(r"[^a-z0-9]+")
@@ -139,6 +242,14 @@ def init_db():
created_at TEXT DEFAULT (datetime('now'))
)
""")
c.execute("""
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY, -- sha256 of the cookie value
username TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
)
""")
def set_state(job_id: str, state: str, detail: str = "", **cols):
@@ -391,6 +502,7 @@ async def import_worker():
@asynccontextmanager
async def lifespan(_: FastAPI):
auth_preflight() # raises and stops the container if misconfigured
init_db()
# The queue lives in memory, so a restart mid-import would otherwise
@@ -413,7 +525,129 @@ async def lifespan(_: FastAPI):
t.cancel()
app = FastAPI(title="muse", lifespan=lifespan)
app = FastAPI(
title="muse",
lifespan=lifespan,
# No interactive docs on an internet-facing app: they enumerate every
# endpoint and schema for anyone who reaches the login page.
docs_url=None, redoc_url=None, openapi_url=None,
)
@app.middleware("http")
async def guard(request: Request, call_next):
"""Require a session for everything except PUBLIC_PATHS, and stamp
security headers on every response."""
path = request.url.path
if AUTH_MODE == "off" or path in PUBLIC_PATHS or path.startswith("/static/"):
resp = await call_next(request)
elif session_user(request):
resp = await call_next(request)
elif path.startswith("/api/"):
# An API caller gets a status code, not an HTML redirect it can't use.
resp = JSONResponse({"error": "unauthenticated"}, status_code=401)
else:
resp = RedirectResponse("/auth/login", status_code=302)
# Strict CSP is why the stylesheet and script live in separate files rather
# than inline in index.html: with 'self' only and no 'unsafe-inline', an
# injected <script> cannot execute. img-src allows Deezer's cover CDN over
# https; connect-src is self only, so nothing can exfiltrate to a third
# party from this page.
resp.headers["Content-Security-Policy"] = (
"default-src 'self'; img-src 'self' https: data:; "
"style-src 'self'; script-src 'self'; connect-src 'self'; "
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "no-referrer"
resp.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
if COOKIE_SECURE:
resp.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return resp
def _set_cookie(resp, name, value, max_age):
resp.set_cookie(name, value, max_age=max_age, httponly=True,
secure=COOKIE_SECURE, samesite="lax", path="/")
@app.get("/healthz")
async def healthz():
"""Unauthenticated on purpose, for the container healthcheck. Returns
nothing but a literal ok — no versions, no config, no counts."""
return {"ok": True}
@app.get("/auth/login")
async def auth_login():
if AUTH_MODE == "off":
return RedirectResponse("/", status_code=302)
state = secrets.token_urlsafe(24)
url = (f"{GH_AUTH}?client_id={GH_CLIENT_ID}"
f"&redirect_uri={BASE_URL}/auth/callback"
f"&scope=read:user&state={state}&allow_signup=false")
resp = RedirectResponse(url, status_code=302)
# CSRF defence: the state we generated is echoed back by GitHub and must
# match this cookie, so a callback the user did not initiate is rejected.
_set_cookie(resp, STATE_COOKIE, state, 600)
return resp
@app.get("/auth/callback")
async def auth_callback(request: Request, code: str = "", state: str = ""):
if AUTH_MODE == "off":
return RedirectResponse("/", status_code=302)
expected = request.cookies.get(STATE_COOKIE) or ""
if not code or not state or not secrets.compare_digest(state, expected):
log.warning("auth: rejected callback with bad or missing state")
return JSONResponse({"error": "invalid state"}, status_code=400)
async with httpx.AsyncClient(timeout=20) as c:
t = await c.post(GH_TOKEN, headers={"Accept": "application/json"},
data={"client_id": GH_CLIENT_ID,
"client_secret": GH_CLIENT_SECRET,
"code": code,
"redirect_uri": f"{BASE_URL}/auth/callback"})
token = (t.json() or {}).get("access_token") if t.status_code == 200 else None
if not token:
log.warning("auth: token exchange failed (%s)", t.status_code)
return JSONResponse({"error": "token exchange failed"}, status_code=401)
u = await c.get(GH_API, headers={"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"})
if u.status_code != 200:
return JSONResponse({"error": "github user lookup failed"}, status_code=401)
login = (u.json() or {}).get("login") or ""
if login.lower() not in ALLOWED_USERS:
# Log the attempt: on an internet-facing box this is the signal that
# someone found the login page.
log.warning("auth: DENIED github user %r (not in MUSE_ALLOWED_USERS)", login)
return JSONResponse({"error": "not authorised"}, status_code=403)
log.info("auth: signed in %s", login)
resp = RedirectResponse("/", status_code=302)
_set_cookie(resp, SESSION_COOKIE, new_session(login), SESSION_DAYS * 86400)
resp.delete_cookie(STATE_COOKIE, path="/")
return resp
@app.get("/auth/logout")
async def auth_logout(request: Request):
tok = request.cookies.get(SESSION_COOKIE)
if tok:
with db() as c:
c.execute("DELETE FROM sessions WHERE token_hash=?", (_hash(tok),))
resp = RedirectResponse("/auth/login", status_code=302)
resp.delete_cookie(SESSION_COOKIE, path="/")
return resp
@app.get("/api/me")
async def me(request: Request):
return {"user": session_user(request), "auth": AUTH_MODE}
class RequestBody(BaseModel):
+68
View File
@@ -0,0 +1,68 @@
:root{
--bg:#0f1115; --panel:#171a21; --line:#252a34; --fg:#e7e9ee;
--dim:#8b93a3; --accent:#5b8cff; --ok:#3fb950; --err:#f85149; --warn:#d29922;
}
@media (prefers-color-scheme:light){
:root{--bg:#f6f7f9;--panel:#fff;--line:#e2e5ea;--fg:#12151a;--dim:#5f6672;}
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:5;background:var(--bg);
border-bottom:1px solid var(--line);padding:14px 18px}
.wrap{max-width:900px;margin:0 auto}
h1{margin:0 0 10px;font-size:17px;letter-spacing:.2px}
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:8px}
form{display:flex;gap:8px}
input{flex:1;padding:11px 13px;border-radius:9px;border:1px solid var(--line);
background:var(--panel);color:var(--fg);font-size:15px}
input:focus{outline:2px solid var(--accent);outline-offset:-1px}
button{padding:11px 18px;border-radius:9px;border:0;background:var(--accent);
color:#fff;font-weight:600;cursor:pointer;font-size:15px}
button:disabled{opacity:.5;cursor:default}
main{padding:18px}
.row{display:flex;gap:12px;align-items:center;padding:10px;border-radius:10px;
border:1px solid var(--line);background:var(--panel);margin-bottom:8px}
.row img{width:48px;height:48px;border-radius:6px;object-fit:cover;
background:var(--line);flex:none}
.meta{flex:1;min-width:0}
.t{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.s{color:var(--dim);font-size:13px;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis}
.get{background:transparent;border:1px solid var(--accent);color:var(--accent);
padding:7px 14px;font-size:14px}
.get.done{border-color:var(--ok);color:var(--ok)}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);
margin:26px 0 10px;display:flex;align-items:center;gap:10px}
h2 .count{text-transform:none;letter-spacing:0;font-weight:400;font-size:12px}
details{margin-top:10px}
summary{cursor:pointer;color:var(--dim);font-size:13px;user-select:none}
summary:hover{color:var(--fg)}
textarea{width:100%;min-height:120px;margin-top:9px;padding:11px 13px;
border-radius:9px;border:1px solid var(--line);background:var(--panel);
color:var(--fg);font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;
resize:vertical}
textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
.hint{color:var(--dim);font-size:12px;margin-top:7px}
.bar{height:3px;border-radius:2px;background:var(--line);overflow:hidden;
margin-top:10px}
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s}
.retry{background:transparent;border:1px solid var(--line);color:var(--dim);
padding:5px 11px;font-size:12px;font-weight:400}
.skipped{color:var(--err);font-size:12px;margin-top:8px}
.pill{font-size:12px;padding:2px 9px;border-radius:99px;border:1px solid var(--line);
color:var(--dim);white-space:nowrap}
.pill.imported{color:var(--ok);border-color:var(--ok)}
.pill.failed{color:var(--err);border-color:var(--err)}
.pill.downloading,.pill.searching,.pill.queued{color:var(--warn);border-color:var(--warn)}
.empty{color:var(--dim);padding:22px;text-align:center}
/* Inline style="" attributes are blocked by the strict CSP (style-src 'self'
with no 'unsafe-inline'), so anything that used to be inline lives here.
Setting .style from JS is unaffected -- CSP governs markup, not the CSSOM. */
.btnrow{display:flex;gap:9px;align-items:center;margin-top:9px}
.bar i{width:0}
.hidden{display:none}
.who{float:right;font-weight:400;font-size:12px;color:var(--dim)}
.who a{color:var(--dim);margin-left:9px}
.who a:hover{color:var(--fg)}
+121
View File
@@ -0,0 +1,121 @@
// Who is signed in, plus a logout link. Hidden entirely when MUSE_AUTH=off.
fetch('/api/me').then(r => r.json()).then(d => {
if (d.user) document.getElementById('who').innerHTML =
'signed in as ' + d.user.replace(/[&<>"]/g, '') + ' <a href="/auth/logout">log out</a>';
}).catch(() => {});
const $ = s => document.querySelector(s);
const esc = s => (s??'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
$('#f').onsubmit = async e => {
e.preventDefault();
const q = $('#q').value.trim();
if (!q) return;
$('#go').disabled = true;
$('#results').innerHTML = '<div class="empty">searching MusicBrainz&hellip;</div>';
try {
const r = await fetch('/api/search?q=' + encodeURIComponent(q));
const { results } = await r.json();
render(results);
} catch (err) {
$('#results').innerHTML = '<div class="empty">search failed: ' + esc(err.message) + '</div>';
}
$('#go').disabled = false;
};
function render(list) {
if (!list || !list.length) {
$('#results').innerHTML = '<div class="empty">no matches</div>';
return;
}
$('#results').innerHTML = list.map((x, i) => `
<div class="row">
${x.cover ? `<img src="${esc(x.cover)}" loading="lazy" onerror="this.style.visibility='hidden'">`
: '<img>'}
<div class="meta">
<div class="t">${esc(x.title)}</div>
<div class="s">${esc(x.artist)}${x.album ? ' &middot; ' + esc(x.album) : ''}${x.year ? ' &middot; ' + esc(x.year) : ''}</div>
</div>
<button class="get" data-i="${i}">Get</button>
</div>`).join('');
$('#results').querySelectorAll('.get').forEach(b => {
b.onclick = async () => {
const x = list[+b.dataset.i];
b.disabled = true; b.textContent = '…';
await fetch('/api/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(x)
});
b.textContent = 'Queued'; b.classList.add('done');
poll();
};
});
}
$('#imprun').onclick = async () => {
const text = $('#bulk').value.trim();
if (!text) return;
const b = $('#imprun');
b.disabled = true; b.textContent = 'Queueing…';
$('#impout').innerHTML = '';
try {
const r = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
const d = await r.json();
$('#impout').innerHTML =
`<div class="hint">Queued ${d.queued.length}.</div>` +
(d.skipped.length
? `<div class="skipped">Skipped ${d.skipped.length}: ` +
d.skipped.map(s => esc(s.line) + ' (' + esc(s.why) + ')').join('; ') + '</div>'
: '');
if (d.queued.length) $('#bulk').value = '';
poll();
} catch (e) {
$('#impout').innerHTML = '<div class="skipped">import failed: ' + esc(e.message) + '</div>';
}
b.disabled = false; b.textContent = 'Queue all';
};
async function poll() {
try {
const { jobs, counts, pending } = await (await fetch('/api/jobs')).json();
const active = (counts.queued || 0) + (counts.searching || 0) + (counts.downloading || 0);
const done = counts.imported || 0;
$('#qcount').textContent = active
? `${active} in flight${pending ? ` · ${pending} waiting` : ''}`
: (done ? `${done} imported` : '');
// Progress only means something during a batch.
const bar = $('#bar'), total = active + done;
bar.classList.toggle('hidden', !(active && total));
if (active && total)
bar.firstElementChild.style.width = (done / total * 100).toFixed(1) + '%';
$('#jobs').innerHTML = jobs.length ? jobs.map(j => `
<div class="row">
<div class="meta">
<div class="t">${esc(j.title)}</div>
<div class="s">${esc(j.artist)}${j.detail ? ' &middot; ' + esc(j.detail) : ''}</div>
</div>
${j.state === 'failed'
? `<button class="retry" data-id="${esc(j.id)}">Retry</button>` : ''}
<span class="pill ${esc(j.state)}">${esc(j.state)}</span>
</div>`).join('') : '<div class="empty">nothing requested yet</div>';
$('#jobs').querySelectorAll('.retry').forEach(b => {
b.onclick = async () => {
b.disabled = true;
await fetch('/api/retry/' + b.dataset.id, { method: 'POST' });
poll();
};
});
} catch {}
}
poll();
setInterval(poll, 4000);
+6 -183
View File
@@ -4,70 +4,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>muse</title>
<style>
:root{
--bg:#0f1115; --panel:#171a21; --line:#252a34; --fg:#e7e9ee;
--dim:#8b93a3; --accent:#5b8cff; --ok:#3fb950; --err:#f85149; --warn:#d29922;
}
@media (prefers-color-scheme:light){
:root{--bg:#f6f7f9;--panel:#fff;--line:#e2e5ea;--fg:#12151a;--dim:#5f6672;}
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:5;background:var(--bg);
border-bottom:1px solid var(--line);padding:14px 18px}
.wrap{max-width:900px;margin:0 auto}
h1{margin:0 0 10px;font-size:17px;letter-spacing:.2px}
h1 small{color:var(--dim);font-weight:400;font-size:13px;margin-left:8px}
form{display:flex;gap:8px}
input{flex:1;padding:11px 13px;border-radius:9px;border:1px solid var(--line);
background:var(--panel);color:var(--fg);font-size:15px}
input:focus{outline:2px solid var(--accent);outline-offset:-1px}
button{padding:11px 18px;border-radius:9px;border:0;background:var(--accent);
color:#fff;font-weight:600;cursor:pointer;font-size:15px}
button:disabled{opacity:.5;cursor:default}
main{padding:18px}
.row{display:flex;gap:12px;align-items:center;padding:10px;border-radius:10px;
border:1px solid var(--line);background:var(--panel);margin-bottom:8px}
.row img{width:48px;height:48px;border-radius:6px;object-fit:cover;
background:var(--line);flex:none}
.meta{flex:1;min-width:0}
.t{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.s{color:var(--dim);font-size:13px;white-space:nowrap;overflow:hidden;
text-overflow:ellipsis}
.get{background:transparent;border:1px solid var(--accent);color:var(--accent);
padding:7px 14px;font-size:14px}
.get.done{border-color:var(--ok);color:var(--ok)}
h2{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);
margin:26px 0 10px;display:flex;align-items:center;gap:10px}
h2 .count{text-transform:none;letter-spacing:0;font-weight:400;font-size:12px}
details{margin-top:10px}
summary{cursor:pointer;color:var(--dim);font-size:13px;user-select:none}
summary:hover{color:var(--fg)}
textarea{width:100%;min-height:120px;margin-top:9px;padding:11px 13px;
border-radius:9px;border:1px solid var(--line);background:var(--panel);
color:var(--fg);font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace;
resize:vertical}
textarea:focus{outline:2px solid var(--accent);outline-offset:-1px}
.hint{color:var(--dim);font-size:12px;margin-top:7px}
.bar{height:3px;border-radius:2px;background:var(--line);overflow:hidden;
margin-top:10px}
.bar i{display:block;height:100%;background:var(--accent);transition:width .4s}
.retry{background:transparent;border:1px solid var(--line);color:var(--dim);
padding:5px 11px;font-size:12px;font-weight:400}
.skipped{color:var(--err);font-size:12px;margin-top:8px}
.pill{font-size:12px;padding:2px 9px;border-radius:99px;border:1px solid var(--line);
color:var(--dim);white-space:nowrap}
.pill.imported{color:var(--ok);border-color:var(--ok)}
.pill.failed{color:var(--err);border-color:var(--err)}
.pill.downloading,.pill.searching,.pill.queued{color:var(--warn);border-color:var(--warn)}
.empty{color:var(--dim);padding:22px;text-align:center}
</style>
<link rel="stylesheet" href="/static/app.css">
<script src="/static/app.js" defer></script>
</head>
<body>
<header><div class="wrap">
<h1>muse <small>search a song &rarr; it lands in Navidrome</small></h1>
<h1>muse <small>search a song &rarr; it lands in Navidrome</small>
<span class="who" id="who"></span></h1>
<form id="f">
<input id="q" placeholder="artist, song title&hellip;" autocomplete="off" autofocus>
<button id="go">Search</button>
@@ -81,7 +24,7 @@ Juice WRLD - Lucid Dreams
Tory Lanez - The Color Violet
Or paste a Deezer album / playlist URL to expand it."></textarea>
<div style="display:flex;gap:9px;align-items:center;margin-top:9px">
<div class="btnrow">
<button id="imprun" type="button">Queue all</button>
<span class="hint" id="imphint">Top Deezer match is taken automatically &mdash; check the list below.</span>
</div>
@@ -92,129 +35,9 @@ Or paste a Deezer album / playlist URL to expand it."></textarea>
<main class="wrap">
<div id="results"></div>
<h2>Requests <span class="count" id="qcount"></span></h2>
<div class="bar" id="bar" style="display:none"><i style="width:0"></i></div>
<div class="bar hidden" id="bar"><i></i></div>
<div id="jobs"><div class="empty">nothing requested yet</div></div>
</main>
<script>
const $ = s => document.querySelector(s);
const esc = s => (s??'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
$('#f').onsubmit = async e => {
e.preventDefault();
const q = $('#q').value.trim();
if (!q) return;
$('#go').disabled = true;
$('#results').innerHTML = '<div class="empty">searching MusicBrainz&hellip;</div>';
try {
const r = await fetch('/api/search?q=' + encodeURIComponent(q));
const { results } = await r.json();
render(results);
} catch (err) {
$('#results').innerHTML = '<div class="empty">search failed: ' + esc(err.message) + '</div>';
}
$('#go').disabled = false;
};
function render(list) {
if (!list || !list.length) {
$('#results').innerHTML = '<div class="empty">no matches</div>';
return;
}
$('#results').innerHTML = list.map((x, i) => `
<div class="row">
${x.cover ? `<img src="${esc(x.cover)}" loading="lazy" onerror="this.style.visibility='hidden'">`
: '<img>'}
<div class="meta">
<div class="t">${esc(x.title)}</div>
<div class="s">${esc(x.artist)}${x.album ? ' &middot; ' + esc(x.album) : ''}${x.year ? ' &middot; ' + esc(x.year) : ''}</div>
</div>
<button class="get" data-i="${i}">Get</button>
</div>`).join('');
$('#results').querySelectorAll('.get').forEach(b => {
b.onclick = async () => {
const x = list[+b.dataset.i];
b.disabled = true; b.textContent = '…';
await fetch('/api/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(x)
});
b.textContent = 'Queued'; b.classList.add('done');
poll();
};
});
}
$('#imprun').onclick = async () => {
const text = $('#bulk').value.trim();
if (!text) return;
const b = $('#imprun');
b.disabled = true; b.textContent = 'Queueing…';
$('#impout').innerHTML = '';
try {
const r = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
const d = await r.json();
$('#impout').innerHTML =
`<div class="hint">Queued ${d.queued.length}.</div>` +
(d.skipped.length
? `<div class="skipped">Skipped ${d.skipped.length}: ` +
d.skipped.map(s => esc(s.line) + ' (' + esc(s.why) + ')').join('; ') + '</div>'
: '');
if (d.queued.length) $('#bulk').value = '';
poll();
} catch (e) {
$('#impout').innerHTML = '<div class="skipped">import failed: ' + esc(e.message) + '</div>';
}
b.disabled = false; b.textContent = 'Queue all';
};
async function poll() {
try {
const { jobs, counts, pending } = await (await fetch('/api/jobs')).json();
const active = (counts.queued || 0) + (counts.searching || 0) + (counts.downloading || 0);
const done = counts.imported || 0;
$('#qcount').textContent = active
? `${active} in flight${pending ? ` · ${pending} waiting` : ''}`
: (done ? `${done} imported` : '');
// Progress only means something during a batch.
const bar = $('#bar'), total = active + done;
if (active && total) {
bar.style.display = 'block';
bar.firstElementChild.style.width = (done / total * 100).toFixed(1) + '%';
} else {
bar.style.display = 'none';
}
$('#jobs').innerHTML = jobs.length ? jobs.map(j => `
<div class="row">
<div class="meta">
<div class="t">${esc(j.title)}</div>
<div class="s">${esc(j.artist)}${j.detail ? ' &middot; ' + esc(j.detail) : ''}</div>
</div>
${j.state === 'failed'
? `<button class="retry" data-id="${esc(j.id)}">Retry</button>` : ''}
<span class="pill ${esc(j.state)}">${esc(j.state)}</span>
</div>`).join('') : '<div class="empty">nothing requested yet</div>';
$('#jobs').querySelectorAll('.retry').forEach(b => {
b.onclick = async () => {
b.disabled = true;
await fetch('/api/retry/' + b.dataset.id, { method: 'POST' });
poll();
};
});
} catch {}
}
poll();
setInterval(poll, 4000);
</script>
</body>
</html>