diff --git a/.dockerignore b/.dockerignore index 8075efb..44f4651 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,3 +13,6 @@ config/ .github .DS_Store *.md +dev/ +dev.sh +tests/ diff --git a/README.md b/README.md index 35e48ba..c2d7860 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,76 @@ 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. +## Review: when muse isn't sure + +Picking a file and *trusting* it are different questions, so they use different +numbers. The rank above decides which candidate to fetch and is right to weigh +peer speed and queue length. None of that says anything about whether the file +is the correct recording — a fast peer must not be able to raise the confidence +of a wrong match. So a second score, **confidence**, is computed from the +filename alone: + +| | weight | +|---|---| +| title present in the filename | 0.75 | +| artist present in the filename, folder, or partially | 0.25 | +| album name somewhere in the path | +0.05 | +| a variant tag you didn't ask for (live, remix, cover, karaoke…) | ×0.8, and capped below auto | + +Then: + +- **≥ 0.95** — filed automatically. Nothing to do. +- **0.70 – 0.95** — held in **Review** with the reasons shown. +- **< 0.70** — also held, but it will almost certainly be rejected. + +Confidence gates the **import, not the download**. The transfer completes +either way and the file waits in the downloads directory: approving is then +instant and rejecting is a delete. Holding the *transfer* instead would mean +the peer has usually vanished by the time anyone looks. + +The two cases this exists for are worth naming. A file called `02 Creep.flac` +in an unsorted folder scores 0.75 — the title is exactly right and the artist +is simply absent, which is the commonest honest case there is, so it must land +in review rather than be binned. And `Creep (Live at Glastonbury)` scores 0.80 +with *both* artist and title matching perfectly: the variant cap is what stops +a live recording auto-filing over the studio version you asked for. Ask for +`Creep (Acoustic)` explicitly and it auto-imports at 0.97, because then the +variant is the request. + +Tune with `MUSE_AUTO_CONFIDENCE` and `MUSE_REVIEW_CONFIDENCE`. +`tests/test_matching.py` asserts the bands, not the exact scores. + +## Discover + +Recommendations and a personal recap, both built from **muse's own request +history** plus Deezer's related-artists graph — never from listening data. +Navidrome owns play counts, favourites and history; a second source of truth +for those would end up disagreeing with the one you actually listen through. + +So "because you added" seeds off the artists you have imported most, and the +recap counts requests, genres and release decades. What you *played* is +Navidrome's business. + +## Email + +Off unless `MUSE_SMTP_HOST` and `MUSE_SMTP_FROM` are set. muse writes to you +when a request lands in your library, and when a download finished but needs +your call in Review. + +``` +MUSE_SMTP_HOST=smtp.fastmail.com +MUSE_SMTP_PORT=587 +MUSE_SMTP_USER=you@example.com +MUSE_SMTP_PASS=an-app-password +MUSE_SMTP_FROM=muse@example.com +MUSE_SMTP_TLS=starttls # or ssl, or none +``` + +Each user sets their own address under **Settings**, or turns mail off +entirely. Google accounts need no configuration — the identity *is* an email +address. Sending runs on a worker thread and every failure is logged and +swallowed: a notification is never worth failing an import over. + ## Authentication OAuth against GitHub and/or Google, built so this can face the internet. @@ -374,7 +444,14 @@ stat -c "%h" "/data/media/music// ()/ - .flac" | `MUSE_DB` | `/config/muse.db` | job history | | `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected | | `MUSE_MIN_MATCH` | `0.45` | filename-to-title similarity floor | +| `MUSE_AUTO_CONFIDENCE` | `0.95` | at or above this, import without asking | +| `MUSE_REVIEW_CONFIDENCE` | `0.70` | below this, held but probably wrong | | `MUSE_WORKERS` | `3` | concurrent Soulseek searches | +| `MUSE_SMTP_HOST` | — | set to enable email; see **Email** above | +| `MUSE_SMTP_PORT` | `587` | | +| `MUSE_SMTP_USER` / `_PASS` | — | omit for an unauthenticated relay | +| `MUSE_SMTP_FROM` | `MUSE_SMTP_USER` | envelope sender | +| `MUSE_SMTP_TLS` | `starttls` | `starttls`, `ssl`, or anything else for none | | `MUSE_LOG_LEVEL` | `INFO` | | `MUSE_FORMATS` is a preference list, best first — rejecting everything not on @@ -426,7 +503,12 @@ package page → Package settings → Change visibility. | `POST /api/import` | queue many from pasted text | | `POST /api/retry/{id}` | requeue a failed job | | `GET /api/jobs` | request history and state (own, or all for admins) | +| `GET /api/review` | downloads held back for a decision | +| `POST /api/review/{id}` | `approve` files it, `reject` deletes it | +| `GET /api/discover` | related artists, seeded from what you've imported | +| `GET /api/recap` | your top artists, genres and decades | | `GET /api/me` | who you are, your role and quota usage | +| `PATCH /api/me` | your own email address and notification toggle | | `GET /api/health` | slskd reachability, mount writability | | `GET /api/admin/users` | list users with usage | | `POST /api/admin/users` | invite | diff --git a/app.py b/app.py index 8784d2a..c8f6415 100644 --- a/app.py +++ b/app.py @@ -414,12 +414,17 @@ def init_db(): for col, decl in (("requested_by", "TEXT"), ("cover", "TEXT"), ("genre", "TEXT"), - ("duration", "INTEGER")): + ("duration", "INTEGER"), + ("confidence", "REAL"), + ("match_why", "TEXT"), + ("reviewed_by", "TEXT")): if col not in cols: c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}") ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()} for col, decl in (("storage_quota", "INTEGER NOT NULL DEFAULT 0"), - ("invited_by", "TEXT")): + ("invited_by", "TEXT"), + ("email", "TEXT"), + ("notify", "INTEGER NOT NULL DEFAULT 1")): if col not in ucols: c.execute(f"ALTER TABLE users ADD COLUMN {col} {decl}") c.execute("CREATE INDEX IF NOT EXISTS jobs_by_user ON jobs (requested_by)") @@ -491,6 +496,84 @@ def ext_of(remote_path: str) -> str: return remote_path.rsplit(".", 1)[-1].lower() if "." in remote_path else "" +def confidence(filename: str, artist: str, title: str, + album: str | None = None) -> tuple[float, list[str]]: + """How sure are we this file is the song that was asked for, 0..1. + + Deliberately separate from score(). score() decides *which* candidate to + fetch and is right to weigh peer speed and queue length; none of that says + anything about whether the file is the correct recording. Mixing the two + would let a fast peer raise the confidence of a wrong match. + + Returns the score and the human-readable reasons behind it, because a + number alone gives the reviewer nothing to act on. + """ + base = filename.replace("\\", "/").rsplit("/", 1)[-1] + stem = norm(base.rsplit(".", 1)[0]) + path = norm(filename.replace("\\", "/")) + na, nt = norm(artist), norm(title) + why: list[str] = [] + + # Title is the load-bearing signal: a wrong title is a wrong song, whereas + # a missing artist is routine in files organised into per-artist folders. + t_ratio = difflib.SequenceMatcher(None, nt, stem).ratio() + if nt and nt in stem: + t_score, t_ratio = 1.0, max(t_ratio, 1.0) + why.append("title matches") + elif t_ratio >= 0.75: + t_score = t_ratio + why.append(f"title close ({t_ratio:.0%})") + else: + t_score = t_ratio + why.append(f"title differs ({t_ratio:.0%})") + + # Artist may legitimately live in the path rather than the filename. + if na and na in stem: + a_score = 1.0 + why.append("artist in filename") + elif na and na in path: + a_score = 0.9 + why.append("artist in folder") + elif na and set(na.split()) & set(path.split()): + a_score = 0.5 + why.append("artist partly matches") + else: + a_score = 0.0 + why.append("artist not found in path") + + # 0.75/0.25 rather than an even split. A file named exactly right but + # sitting in an unorganised folder is the commonest honest case there is, + # and it has to land in the review band, not below it — 0.65 would have + # binned it alongside genuinely wrong songs. + c = 0.75 * t_score + 0.25 * a_score + + if album and norm(album) and norm(album) in path: + c = min(1.0, c + 0.05) + why.append("album in path") + + # A live or remix version of the right song is still the wrong file when + # the request was for the studio recording, and that is exactly the case a + # reviewer needs to see rather than have silently imported. So the penalty + # caps confidence below AUTO instead of merely scaling it: "a human must + # decide" is the whole meaning of the flag, and a perfect artist+title + # match should not be able to auto-import past it. + variants = [w for w in ("live", "remix", "cover", "karaoke", "instrumental", + "acoustic", "demo", "edit", "radio edit") + if re.search(rf"\b{w}\b", stem) and w not in nt] + if variants: + c = min(c * 0.8, AUTO_CONFIDENCE - 0.1) + why.append(f"looks like a {variants[0]} version, which was not requested") + + return round(max(0.0, min(1.0, c)), 3), why + + +# >= AUTO imports without asking. Below REVIEW the match is bad enough that a +# human almost certainly rejects it, but it is still held rather than binned: +# the file is already downloaded and only the reviewer knows if it is right. +AUTO_CONFIDENCE = float(os.getenv("MUSE_AUTO_CONFIDENCE", "0.95")) +REVIEW_CONFIDENCE = float(os.getenv("MUSE_REVIEW_CONFIDENCE", "0.70")) + + def score(candidate: dict, peer: dict, want: str) -> float | None: """Rank one remote file. None = reject. @@ -521,7 +604,8 @@ def score(candidate: dict, peer: dict, want: str) -> float | None: return s -async def pick_and_enqueue(job_id: str, artist: str, title: str): +async def pick_and_enqueue(job_id: str, artist: str, title: str, + album: str | None = None): """Search slskd for one track and queue the best candidate.""" want = norm(f"{artist} {title}") search_id = str(uuid.uuid4()) @@ -578,9 +662,105 @@ async def pick_and_enqueue(job_id: str, artist: str, title: str): set_state(job_id, "failed", f"enqueue failed: {e}") return + # Confidence is recorded now, while the candidate list is still in + # hand, but it gates the IMPORT rather than the download. Holding the + # transfer instead would mean the peer is often gone by the time + # someone reviews it; this way the file is already on disk, approving + # is instant, and rejecting is a delete. + conf, why = confidence(f["filename"], artist, title, album) set_state(job_id, "downloading", f"from {peer['username']} (score {best_score:.0f})", - username=peer["username"], remote_file=f["filename"], size=f["size"]) + username=peer["username"], remote_file=f["filename"], + size=f["size"], confidence=conf, match_why="; ".join(why)) + + +# ─────────────────────────── email ─────────────────────────── +# +# Optional and off unless SMTP_HOST is set. A homelab tool that cannot send +# mail should still import music, so every failure here is logged and +# swallowed — a notification is never worth failing an import over. + +SMTP_HOST = os.getenv("MUSE_SMTP_HOST", "").strip() +SMTP_PORT = int(os.getenv("MUSE_SMTP_PORT", "587")) +SMTP_USER = os.getenv("MUSE_SMTP_USER", "").strip() +SMTP_PASS = os.getenv("MUSE_SMTP_PASS", "") +SMTP_FROM = os.getenv("MUSE_SMTP_FROM", "").strip() or SMTP_USER +SMTP_TLS = os.getenv("MUSE_SMTP_TLS", "starttls").strip().lower() +MAIL_ON = bool(SMTP_HOST and SMTP_FROM) + + +def _send_sync(to: str, subject: str, body: str): + import smtplib + from email.message import EmailMessage + + msg = EmailMessage() + msg["From"], msg["To"], msg["Subject"] = SMTP_FROM, to, subject + msg.set_content(body) + if SMTP_TLS == "ssl": + s = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=20) + else: + s = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=20) + with s: + if SMTP_TLS == "starttls": + s.starttls() + if SMTP_USER: + s.login(SMTP_USER, SMTP_PASS) + s.send_message(msg) + + +async def send_mail(to: str, subject: str, body: str): + """Fire-and-forget. smtplib is blocking, so it runs off the event loop — + otherwise a slow relay would stall the import worker for every job.""" + if not (MAIL_ON and to): + return + try: + await asyncio.to_thread(_send_sync, to, subject, body) + log.info("mail: sent %r to %s", subject, to) + except Exception as e: + log.warning("mail: sending to %s failed: %s", to, errstr(e)) + + +def recipient(username: str | None) -> str | None: + """Where to write to, and whether they want to hear from us at all.""" + if not username: + return None + with db() as c: + r = c.execute("SELECT email, notify, provider FROM users WHERE username=?", + (username,)).fetchone() + if not r or not r["notify"]: + return None + # A Google identity IS an email address, so there is nothing to configure + # for those accounts unless they want mail somewhere else. + if r["email"]: + return r["email"] + return username if r["provider"] == "google" and "@" in username else None + + +async def notify_imported(job, dest): + to = recipient(job["requested_by"]) + if not to: + return + album = job["album"] or "Singles" + if job["year"]: + album += f" ({job['year']})" + await send_mail( + to, f"muse: {job['title']} is in your library", + f"{job['artist']} — {job['title']}\n{album}\n\n" + f"Filed as: {dest}\n\nIt should appear in Navidrome on the next scan.\n") + + +async def notify_review(job): + to = recipient(job["requested_by"]) + if not to: + return + await send_mail( + to, f"muse: {job['title']} needs a look", + f"{job['artist']} — {job['title']}\n\n" + f"The best match found was only {(job['confidence'] or 0):.0%} confident, " + f"so it has not been added to your library yet.\n\n" + f"Why: {job['match_why'] or 'unknown'}\n" + f"File: {job['remote_file']}\n\n" + f"Approve or reject it under Review in muse.\n") # ─────────────────────────── import ─────────────────────────── @@ -676,7 +856,8 @@ async def search_worker(n: int): job = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone() if job and job["state"] in ("queued", "searching"): set_state(job_id, "searching", "") - await pick_and_enqueue(job_id, job["artist"], job["title"]) + await pick_and_enqueue(job_id, job["artist"], job["title"], + job["album"]) except Exception as e: log.warning("worker %d: %s", n, e) try: @@ -716,15 +897,27 @@ async def import_worker(): src = find_downloaded(job["remote_file"]) if not src: continue # still being moved out of incomplete - dest = tag_and_file(job, src) # Record the real size, not the size the peer advertised — # storage quotas are computed from this column. try: actual = src.stat().st_size except OSError: actual = job["size"] + + conf = job["confidence"] + if conf is not None and conf < AUTO_CONFIDENCE: + # Hold it. The file stays in downloads, untagged and + # unlinked, until someone approves or rejects it. + set_state(job["id"], "review", + f"{conf:.0%} confident — {job['match_why'] or ''}", + size=actual) + await notify_review(job) + continue + + dest = tag_and_file(job, src) set_state(job["id"], "imported", str(dest), imported_path=str(dest), size=actual) + await notify_imported(job, dest) elif "Completed" in st and "Succeeded" not in st: set_state(job["id"], "failed", f"transfer {st}") except Exception as e: @@ -1070,9 +1263,42 @@ async def me(request: Request): "quota": u["quota"], "storage_quota": u["storage_quota"], "usage": usage_for(u["username"]), "providers": ENABLED_PROVIDERS, + "email": u["email"], "notify": bool(u["notify"]), + "mail_configured": MAIL_ON, } +class SettingsBody(BaseModel): + email: str | None = None + notify: bool | None = None + + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s.]+\.[^@\s]+$") + + +@app.patch("/api/me") +async def update_me(body: SettingsBody, request: Request): + """Your own notification settings. Not an admin route on purpose — an + address you cannot change yourself is a support ticket.""" + me = session_user(request) + if not me: + raise HTTPException(401, "unauthenticated") + sets, vals = [], [] + if body.email is not None: + e = body.email.strip() + if e and not EMAIL_RE.match(e): + raise HTTPException(400, "that does not look like an email address") + sets.append("email=?"); vals.append(e or None) + if body.notify is not None: + sets.append("notify=?"); vals.append(1 if body.notify else 0) + if not sets: + raise HTTPException(400, "nothing to change") + with db() as c: + c.execute(f"UPDATE users SET {', '.join(sets)} WHERE username=?", + [*vals, me]) + return {"ok": True, "mail_configured": MAIL_ON} + + class RequestBody(BaseModel): artist: str title: str @@ -1530,6 +1756,158 @@ async def import_list(body: ImportBody, request: Request): return {"queued": queued, "skipped": skipped, "pending": work_q.qsize()} +class ReviewBody(BaseModel): + action: str # approve | reject + + +@app.get("/api/review") +async def review_queue(request: Request): + """Downloads held back because the match was not convincing enough. + + Scoped like /api/jobs: your own unless you are an admin, because a held + file is a record of what someone asked for. + """ + me = session_user(request) + admin = user_role(request) == "admin" + with db() as c: + if admin: + rows = c.execute( + "SELECT * FROM jobs WHERE state='review' ORDER BY created_at DESC" + ).fetchall() + else: + rows = c.execute( + "SELECT * FROM jobs WHERE state='review' AND requested_by IS ? " + "ORDER BY created_at DESC", (me,)).fetchall() + return {"items": [{ + "id": r["id"], "artist": r["artist"], "title": r["title"], + "album": r["album"], "year": r["year"], "cover": r["cover"], + "confidence": r["confidence"], "why": r["match_why"], + "file": (r["remote_file"] or "").replace("\\", "/").rsplit("/", 1)[-1], + "path": r["remote_file"], "size": r["size"], + "requested_by": r["requested_by"], "created_at": r["created_at"], + } for r in rows], "thresholds": {"auto": AUTO_CONFIDENCE, + "review": REVIEW_CONFIDENCE}} + + +@app.post("/api/review/{job_id}") +async def review_decide(job_id: str, body: ReviewBody, request: Request): + me = session_user(request) + admin = user_role(request) == "admin" + if body.action not in ("approve", "reject"): + raise HTTPException(400, "action must be approve or reject") + + with db() as c: + job = c.execute("SELECT * FROM jobs WHERE id=? AND state='review'", + (job_id,)).fetchone() + # 404 rather than 403 for someone else's job: a distinct error would + # confirm the id exists, same as /api/retry. + if not job or (not admin and job["requested_by"] != me): + raise HTTPException(404, "no such review item") + + src = find_downloaded(job["remote_file"] or "") + if body.action == "reject": + if src: + try: + src.unlink() + except OSError as e: + log.warning("review: could not delete %s: %s", src, e) + set_state(job_id, "failed", "rejected in review", reviewed_by=me) + return {"ok": True, "state": "failed"} + + if not src: + raise HTTPException(409, "the downloaded file is no longer on disk") + dest = tag_and_file(job, src) + set_state(job_id, "imported", str(dest), imported_path=str(dest), + reviewed_by=me) + log.info("review: %s approved %s", me, job_id[:8]) + return {"ok": True, "state": "imported", "path": str(dest)} + + +@app.get("/api/discover") +async def discover(request: Request): + """What to listen to next, derived from what you have actually asked for. + + Deliberately built on muse's own request history plus Deezer's related + artists, not on listening data — Navidrome owns play counts and history, + and duplicating them here would mean two disagreeing sources of truth. + """ + me = session_user(request) + with db() as c: + top = c.execute( + "SELECT artist, COUNT(*) n FROM jobs " + "WHERE requested_by IS ? AND state='imported' " + "GROUP BY artist COLLATE NOCASE ORDER BY n DESC LIMIT 5", + (me,)).fetchall() + if not top: + return {"seeds": [], "artists": [], "tracks": []} + + async def related(name: str) -> list[dict]: + try: + async with httpx.AsyncClient(timeout=15) as c: + s = await c.get(f"{DEEZER}/search/artist", + params={"q": name, "limit": 1}) + hits = (s.json() or {}).get("data") or [] + if not hits: + return [] + r = await c.get(f"{DEEZER}/artist/{hits[0]['id']}/related", + params={"limit": 6}) + return (r.json() or {}).get("data") or [] + except Exception as e: + log.info("discover: related for %r failed: %s", name, errstr(e)) + return [] + + seeds = [r["artist"] for r in top] + groups = await asyncio.gather(*(related(s) for s in seeds)) + + have = {norm(r["artist"]) for r in top} + seen, artists = set(), [] + for g in groups: + for a in g: + k = norm(a.get("name") or "") + if not k or k in have or k in seen: + continue + seen.add(k) + artists.append({"id": a.get("id"), "name": a.get("name"), + "picture": a.get("picture_medium"), + "fans": a.get("nb_fan")}) + artists.sort(key=lambda a: -(a["fans"] or 0)) + return {"seeds": seeds, "artists": artists[:18]} + + +@app.get("/api/recap") +async def recap(request: Request, days: int = Query(365, ge=7, le=3650)): + """Your year in muse — from request history, which muse does own.""" + me = session_user(request) + since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat(" ")[:19] + with db() as c: + base = ("FROM jobs WHERE requested_by IS ? AND created_at >= ? " + "AND state='imported'") + total = c.execute(f"SELECT COUNT(*) n, COALESCE(SUM(size),0) b {base}", + (me, since)).fetchone() + artists = c.execute( + f"SELECT artist, COUNT(*) n {base} GROUP BY artist COLLATE NOCASE " + "ORDER BY n DESC LIMIT 10", (me, since)).fetchall() + genres = c.execute( + f"SELECT genre, COUNT(*) n {base} AND genre IS NOT NULL AND genre<>'' " + "GROUP BY genre ORDER BY n DESC LIMIT 8", (me, since)).fetchall() + decades = c.execute( + f"SELECT substr(year,1,3) d, COUNT(*) n {base} " + "AND year GLOB '[0-9][0-9][0-9][0-9]' GROUP BY d ORDER BY d", + (me, since)).fetchall() + busiest = c.execute( + f"SELECT substr(created_at,1,10) d, COUNT(*) n {base} " + "GROUP BY d ORDER BY n DESC LIMIT 1", (me, since)).fetchone() + return { + "days": days, + "tracks": total["n"], "bytes": total["b"], + "artists": [{"name": r["artist"], "n": r["n"]} for r in artists], + "genres": [{"name": r["genre"], "n": r["n"]} for r in genres], + "decades": [{"decade": f"{r['d']}0s", "n": r["n"]} for r in decades], + "busiest_day": ({"date": busiest["d"], "n": busiest["n"]} + if busiest and busiest["n"] else None), + } + + @app.get("/api/jobs") async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000), mine: bool = False): @@ -1602,7 +1980,7 @@ def user_dict(r: sqlite3.Row) -> dict: "quota": r["quota"], "storage_quota": r["storage_quota"], "disabled": bool(r["disabled"]), "created_at": r["created_at"], "last_seen": r["last_seen"], "invited_by": r["invited_by"], - "usage": u, + "usage": u, "email": r["email"], "notify": bool(r["notify"]), # Surfaced so the UI doesn't have to know the allowlist rule: these are # the accounts that cannot actually be locked out by disabling them. "bootstrap": r["username"].lower() in ALLOWED_USERS, diff --git a/tests/test_matching.py b/tests/test_matching.py new file mode 100644 index 0000000..7033ffc --- /dev/null +++ b/tests/test_matching.py @@ -0,0 +1,77 @@ +"""Confidence calibration. These are the thresholds that decide whether a +download lands in the library untouched or waits for a human, so the bands +matter more than the exact numbers -- assert the band, not the score.""" +import os, sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("MUSE_AUTH", "off") +os.environ.setdefault("MUSE_DB", "dev/matching.db") +import app # noqa: E402 + +CASES = [ + # well-organised shares: artist and album in the path + (r"@@music\Radiohead\Pablo Honey\02 - Creep.flac", "Radiohead", "Creep", + "Pablo Honey", "auto"), + (r"Kendrick Lamar\good kid\06 - Money Trees.flac", "Kendrick Lamar", + "Money Trees", "good kid, m.A.A.d city", "auto"), + (r"Music\R\Radiohead - Creep.mp3", "Radiohead", "Creep", None, "auto"), + (r"x\Tame Impala - The Less I Know The Better.flac", "Tame Impala", + "The Less I Know The Better", None, "auto"), + + # right song, but something is missing or off -- a human should look + (r"shared\02 Creep.flac", "Radiohead", "Creep", "Pablo Honey", "review"), + (r"Live\Radiohead - Creep (Live at Glastonbury 1997).mp3", "Radiohead", + "Creep", None, "review"), + (r"Radiohead\Creep (Remix).mp3", "Radiohead", "Creep", None, "review"), + + # wrong song, or too little to tell + (r"covers\Creep (Acoustic Cover).mp3", "Radiohead", "Creep", None, "low"), + (r"Various\Karma Police.flac", "Radiohead", "Creep", None, "low"), + (r"stuff\track01.mp3", "Radiohead", "Creep", None, "low"), + (r"Nirvana\Nevermind\01 - Smells Like Teen Spirit.flac", "Radiohead", + "Creep", None, "low"), +] + + +def band(c: float) -> str: + if c >= app.AUTO_CONFIDENCE: + return "auto" + return "review" if c >= app.REVIEW_CONFIDENCE else "low" + + +def main() -> int: + bad = 0 + for fn, artist, title, album, want in CASES: + c, why = app.confidence(fn, artist, title, album) + got = band(c) + if got != want: + bad += 1 + print(f" FAIL {got:<6} (want {want:<6}) {c:.2f} {fn}") + print(f" {'; '.join(why)}") + else: + print(f" PASS {got:<6} {c:.2f} {fn}") + + # A variant tag must never auto-import, however perfect the rest is. + c, _ = app.confidence(r"Radiohead\Pablo Honey\Creep (Live).flac", + "Radiohead", "Creep", "Pablo Honey") + if c >= app.AUTO_CONFIDENCE: + bad += 1 + print(f" FAIL a live version auto-imported at {c:.2f}") + else: + print(f" PASS live version capped below auto ({c:.2f})") + + # ...unless the variant is what was asked for. + c, _ = app.confidence(r"Radiohead\Creep (Acoustic).mp3", "Radiohead", + "Creep (Acoustic)", None) + if c < app.AUTO_CONFIDENCE: + bad += 1 + print(f" FAIL requested acoustic version held at {c:.2f}") + else: + print(f" PASS requested acoustic version auto-imports ({c:.2f})") + + print(f"\n{len(CASES) + 2 - bad} passed, {bad} failed") + return 1 if bad else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/web/app/globals.css b/web/app/globals.css index 77f6d7b..e042eda 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -20,19 +20,60 @@ --color-edge-hi: var(--edge-hi); --color-faint: var(--faint); - --color-ok: #3ddc84; - --color-err: #ff6b6b; - --color-warn: #ffc95c; + --color-ok: var(--ok); + --color-err: var(--err); + --color-warn: var(--warn); --radius-glass: 14px; --radius-panel: 28px; + --radius-control: 11px; + --radius-pill: 999px; --ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1); + /* Apple's motion reads as weight, not duration: things overshoot slightly + and settle. A single shared curve keeps unrelated components feeling like + one physical system. */ + --ease-spring: linear( + 0, 0.006, 0.025, 0.101, 0.539, 0.721, 0.849, 0.937, 0.968, 0.991, 1 + ); --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif; + --font-display: + -apple-system, BlinkMacSystemFont, "SF Pro Display", ui-sans-serif, + system-ui, "Segoe UI", Roboto, sans-serif; --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; + + /* Type scale. The defining trait of Apple's typography is that tracking + tightens as size grows — display text at default tracking looks loose and + amateurish, and small text at display tracking looks cramped. */ + --text-display: 2.125rem; + --text-display--line-height: 1.08; + --text-display--letter-spacing: -0.033em; + --text-display--font-weight: 700; + + --text-title: 1.4375rem; + --text-title--line-height: 1.18; + --text-title--letter-spacing: -0.024em; + --text-title--font-weight: 700; + + --text-headline: 1.0625rem; + --text-headline--line-height: 1.35; + --text-headline--letter-spacing: -0.014em; + --text-headline--font-weight: 600; + + --text-body: 0.9375rem; + --text-body--line-height: 1.5; + --text-body--letter-spacing: -0.008em; + + --text-callout: 0.8125rem; + --text-callout--line-height: 1.45; + --text-callout--letter-spacing: -0.003em; + + --text-caption: 0.6875rem; + --text-caption--line-height: 1.35; + --text-caption--letter-spacing: 0.005em; } :root { @@ -52,8 +93,27 @@ --header: rgb(8 9 13 / 0.62); --scrim: rgb(8 9 13 / 0.74); --scrim-2: rgb(8 9 13 / 0.4); - --blur: saturate(170%) blur(22px); + --blur: saturate(180%) blur(24px); --shadow: 0 10px 40px rgb(0 0 0 / 0.45); + + /* Semantic colours are per-theme, not fixed. These are tuned to sit on the + dark background; the same amber on white fails contrast outright, which + is exactly the text a reviewer most needs to read. */ + --ok: #3ddc84; + --err: #ff6b6b; + --warn: #ffc95c; + + /* The specular edge. A real frosted panel catches light along its top rim; + without this, blur alone reads as a flat grey rectangle. This one line is + most of the difference between "translucent" and "material". */ + --spec: inset 0 1px 0 rgb(255 255 255 / 0.09); + --spec-hi: inset 0 1px 0 rgb(255 255 255 / 0.14); + + /* Elevation. Two shadows each: a tight contact shadow that anchors the + element, and a wide soft one for the light. One alone always looks fake. */ + --e1: 0 1px 2px rgb(0 0 0 / 0.28), 0 4px 12px rgb(0 0 0 / 0.18); + --e2: 0 2px 6px rgb(0 0 0 / 0.32), 0 12px 32px rgb(0 0 0 / 0.28); + --e3: 0 6px 16px rgb(0 0 0 / 0.36), 0 28px 68px rgb(0 0 0 / 0.42); } @media (prefers-color-scheme: light) { @@ -71,6 +131,18 @@ --scrim: rgb(255 255 255 / 0.78); --scrim-2: rgb(255 255 255 / 0.5); --shadow: 0 10px 34px rgb(20 30 60 / 0.13); + /* On light material the specular rim is near-white and the shadows have + to lose most of their weight, or every card looks like it is hovering. */ + --spec: inset 0 1px 0 rgb(255 255 255 / 0.9); + --spec-hi: inset 0 1px 0 rgb(255 255 255 / 1); + --e1: 0 1px 2px rgb(20 30 60 / 0.07), 0 4px 12px rgb(20 30 60 / 0.06); + --e2: 0 2px 6px rgb(20 30 60 / 0.08), 0 12px 32px rgb(20 30 60 / 0.09); + --e3: 0 6px 16px rgb(20 30 60 / 0.1), 0 28px 68px rgb(20 30 60 / 0.14); + /* Darkened to clear 4.5:1 on the light background. Same hues, so a status + still reads as the same status when the theme flips. */ + --ok: #0a7d43; + --err: #c62828; + --warn: #8a5a00; } } @@ -89,10 +161,33 @@ font-family: var(--font-sans); font-size: 15px; line-height: 1.55; + letter-spacing: -0.008em; -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; overflow-x: hidden; } + /* Headings take the display face and its tighter tracking automatically, + so a component never has to remember to ask for it. */ + h1, + h2, + h3 { + font-family: var(--font-display); + text-wrap: balance; + } + /* Ragged-right paragraphs with one orphan word are the giveaway of an + interface nobody typeset. */ + p { + text-wrap: pretty; + } + button, + input, + select, + textarea { + font: inherit; + letter-spacing: inherit; + color: inherit; + } ::selection { background: var(--color-accent-soft); } @@ -103,6 +198,7 @@ border: 1px solid var(--edge); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); + box-shadow: var(--spec); } @utility glass-hi { @@ -110,6 +206,7 @@ border: 1px solid var(--edge-hi); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); + box-shadow: var(--spec-hi); } @utility sheet { @@ -117,7 +214,42 @@ border: 1px solid var(--edge-hi); backdrop-filter: var(--blur); -webkit-backdrop-filter: var(--blur); - box-shadow: var(--shadow); + box-shadow: var(--spec-hi), var(--e3); +} + +/* A card that lifts on hover. The translate is tiny on purpose: Apple's + hover feedback is barely perceptible frame to frame and obvious in + aggregate. Anything larger reads as a web page, not an interface. */ +@utility lift { + transition: + transform 0.28s var(--ease-glass), + box-shadow 0.28s var(--ease-glass), + border-color 0.28s var(--ease-glass), + background 0.28s var(--ease-glass); + &:hover { + transform: translateY(-2px); + box-shadow: var(--spec-hi), var(--e2); + border-color: var(--edge-hi); + } + &:active { + transform: translateY(0) scale(0.994); + transition-duration: 0.06s; + } +} + +/* Keyboard focus only — a mouse click should never draw a ring. */ +@utility ring { + &:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + } +} + +/* Numbers that change in place (counts, durations, percentages) must not + reflow the text around them as digits swap. */ +@utility nums { + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; } @utility clamp1 { @@ -126,6 +258,13 @@ text-overflow: ellipsis; } +@utility clamp2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + /* Very light noise over the gradients — large flat blurs band visibly on 8-bit panels without it. */ @utility grain { diff --git a/web/app/page.tsx b/web/app/page.tsx index 52e8b5a..85c78cf 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -15,12 +15,22 @@ import { AlbumView } from "@/components/album-view"; import { ArtistView } from "@/components/artist-view"; import { BulkImport } from "@/components/bulk-import"; import { Requests, type JobsSnapshot } from "@/components/requests"; +import { Review } from "@/components/review"; +import { DiscoverPanel } from "@/components/discover"; +import { Settings } from "@/components/settings"; import { Skeletons } from "@/components/skeletons"; import { PlayerProvider } from "@/components/player"; import { ToastProvider } from "@/components/toast"; import { AdminPanel } from "@/components/admin/admin-panel"; -type Tab = "search" | "jobs" | "bulk" | "admin"; +type Tab = + | "search" + | "discover" + | "jobs" + | "review" + | "bulk" + | "settings" + | "admin"; type View = | { k: "welcome" } | { k: "results"; q: string } @@ -118,6 +128,7 @@ function Muse() { (snap?.counts.queued ?? 0) + (snap?.counts.searching ?? 0) + (snap?.counts.downloading ?? 0); + const held = snap?.counts.review ?? 0; const isAdmin = me?.role === "admin"; // Only worth showing when a limit actually exists — a permanent "0 / 0" chip @@ -144,16 +155,18 @@ function Muse() { <Music2 className="h-5 w-5" /> </motion.div> <div className="min-w-0 flex-1"> - <h1 className="text-[19px] font-bold leading-tight tracking-[-0.4px]">muse</h1> - <p className="text-xs text-dim">search · request · it appears in Navidrome</p> + <h1 className="text-title leading-none">muse</h1> + <p className="mt-0.5 text-caption text-dim"> + search · request · it appears in Navidrome + </p> </div> {me?.user && ( - <div className="flex items-center gap-2 text-[12.5px] text-dim"> + <div className="flex items-center gap-2 text-callout text-dim"> {quotaLeft !== null && ( <span title={`${me.usage!.requests} of ${me.quota} requests used this month`} className={cn( - "hidden rounded-full border px-2 py-0.5 text-[11px] sm:inline", + "hidden rounded-pill border px-2 py-0.5 text-caption nums sm:inline", quotaLeft === 0 ? "border-err/50 text-err" : quotaLeft <= 5 @@ -177,15 +190,15 @@ function Muse() { </span> {me.role && me.role !== "user" && ( <span - className="rounded-full border border-accent-line bg-accent-soft px-1.5 - py-0.5 text-[10.5px] uppercase tracking-[0.6px] text-accent" + className="rounded-pill border border-accent-line bg-accent-soft px-1.5 + py-0.5 text-caption uppercase tracking-[0.6px] text-accent" > {me.role} </span> )} <a href="/auth/logout" - className="border-b border-transparent hover:border-edge-hi hover:text-ink" + className="ring rounded-md px-1 transition-colors hover:text-ink" > Log out </a> @@ -195,40 +208,67 @@ function Muse() { <SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} /> - <nav className="flex overflow-x-auto pt-3 no-scrollbar"> + {/* A sliding pill rather than an underline: the moving surface is + the selection itself, so the eye tracks one object across the + row instead of watching two things fade. layoutId does the + interpolation, so it stays correct when tabs appear or vanish + with a role change. */} + {/* The mask is the scroll affordance: on a narrow screen the last + tab fades out at the edge instead of being clipped flat, which + is the only cue that there is more to the right. */} + <nav + className="flex gap-0.5 overflow-x-auto pb-3 pt-2.5 no-scrollbar + [mask-image:linear-gradient(to_right,#000_calc(100%-28px),transparent)] + sm:[mask-image:none]" + > {( [ - ["search", "Search"], - ["jobs", "Requests"], - ["bulk", "Import list"], - ...(isAdmin ? ([["admin", "Admin"]] as [Tab, string][]) : []), - ] as [Tab, string][] - ).map(([k, label]) => ( + ["search", "Search", 0], + ["discover", "Discover", 0], + ["jobs", "Requests", active], + // Only exists when it is actionable. An always-present tab + // that is empty nine days in ten is chrome; this one appears + // exactly when something is waiting on you. + ...(held > 0 || tab === "review" + ? ([["review", "Review", held]] as [Tab, string, number][]) + : []), + ["bulk", "Import", 0], + ["settings", "Settings", 0], + ...(isAdmin ? ([["admin", "Admin", 0]] as [Tab, string, number][]) : []), + ] as [Tab, string, number][] + ).map(([k, label, badge]) => ( <button key={k} onClick={() => setTab(k)} + aria-current={tab === k ? "page" : undefined} className={cn( - "relative mr-4 cursor-pointer px-1 pb-3 pt-2 text-[13.5px] font-medium transition-colors", + "ring relative shrink-0 cursor-pointer rounded-pill px-3 py-1.5", + "text-callout font-medium transition-colors duration-200", tab === k ? "text-ink" : "text-dim hover:text-ink", )} > - {label} - {k === "jobs" && active > 0 && ( - <span - className="ml-1.5 inline-block min-w-[18px] rounded-full border - border-accent-line bg-accent-soft px-1.5 text-[11px] - font-bold text-accent" - > - {active} - </span> - )} {tab === k && ( - <motion.i - layoutId="tab-underline" - className="absolute inset-x-0 bottom-0 h-0.5 rounded-t bg-accent" - transition={{ type: "spring", stiffness: 420, damping: 34 }} + <motion.span + layoutId="tab-pill" + className="absolute inset-0 rounded-pill border border-edge-hi bg-[var(--glass-hi)]" + transition={{ type: "spring", stiffness: 460, damping: 38 }} /> )} + <span className="relative flex items-center gap-1.5"> + {label} + {badge > 0 && ( + <span + className={cn( + "min-w-[17px] rounded-pill px-1 text-caption font-bold nums", + k === "review" + ? "bg-warn/20 text-warn" + : "bg-accent-soft text-accent", + )} + > + {badge} + </span> + )} + </span> </button> ))} </nav> @@ -296,8 +336,11 @@ function Muse() { </> )} + {tab === "discover" && <DiscoverPanel onArtist={doSearch} />} {tab === "jobs" && <Requests snap={snap} refresh={refresh} />} + {tab === "review" && <Review onDecided={refresh} />} {tab === "bulk" && <BulkImport onQueued={refresh} />} + {tab === "settings" && me && <Settings me={me} refresh={refresh} />} {tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />} </motion.div> </main> @@ -321,10 +364,10 @@ function Welcome({ onPick }: { onPick: (q: string) => void }) { > <Disc3 className="h-7 w-7" /> </motion.div> - <h2 className="mb-2 text-[23px] font-bold tracking-[-0.4px]">Find anything</h2> - <p className="mx-auto max-w-[46ch] text-sm text-dim"> + <h2 className="mb-2 text-display">Find anything</h2> + <p className="mx-auto max-w-[46ch] text-body text-dim"> Search a song and press{" "} - <kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-[11.5px] text-ink"> + <kbd className="rounded-md border border-edge-hi bg-[var(--glass-hi)] px-1.5 py-0.5 text-caption text-ink"> Enter </kbd> . Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into @@ -339,7 +382,7 @@ function Welcome({ onPick }: { onPick: (q: string) => void }) { transition={{ delay: 0.1 + i * 0.05 }} whileHover={{ y: -2 }} onClick={() => onPick(e)} - className="glass cursor-pointer rounded-full px-3.5 py-1.5 text-[13px] text-dim + className="glass ring cursor-pointer rounded-pill px-3.5 py-1.5 text-callout text-dim transition hover:border-accent-line hover:bg-accent-soft hover:text-ink" > {e} diff --git a/web/components/admin/activity-chart.tsx b/web/components/admin/activity-chart.tsx index 8410069..265726e 100644 --- a/web/components/admin/activity-chart.tsx +++ b/web/components/admin/activity-chart.tsx @@ -35,12 +35,12 @@ export function ActivityChart({ data }: { data: Activity }) { <div className="glass rounded-panel p-5"> <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2"> <div> - <h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim"> + <h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim"> Requests · last {data.days} days </h3> <div className="mt-1 text-[22px] font-bold tracking-[-0.6px]"> {shown ? shown.n : totals.n} - <span className="ml-2 text-[13px] font-normal text-dim"> + <span className="ml-2 text-callout font-normal text-dim"> {shown ? new Date(shown.date + "T00:00:00").toLocaleDateString(undefined, { weekday: "short", @@ -51,7 +51,7 @@ export function ActivityChart({ data }: { data: Activity }) { </span> </div> </div> - <div className="flex gap-3 text-[11.5px] text-dim"> + <div className="flex gap-3 text-caption text-dim"> <Legend className="bg-accent" label={`${shown ? shown.imported : totals.imported} imported`} /> <Legend className="bg-err" label={`${shown ? shown.failed : totals.failed} failed`} /> </div> @@ -99,7 +99,7 @@ export function ActivityChart({ data }: { data: Activity }) { })} </svg> - <div className="mt-1.5 flex justify-between text-[11px] text-dim"> + <div className="mt-1.5 flex justify-between text-caption text-dim"> <span>{fmt(data.series[0]?.date)}</span> <span>{fmt(data.series[data.series.length - 1]?.date)}</span> </div> diff --git a/web/components/admin/admin-panel.tsx b/web/components/admin/admin-panel.tsx index 4ef918f..d4df859 100644 --- a/web/components/admin/admin-panel.tsx +++ b/web/components/admin/admin-panel.tsx @@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) { initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} className={cn( - "glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-[13px]", + "glass flex items-center gap-2.5 rounded-glass px-4 py-2.5 text-callout", healthy ? "border-ok/30" : "border-err/40", )} > @@ -142,7 +142,7 @@ function Breakdown({ const max = Math.max(1, ...rows.map((r) => r.n)); return ( <div className="glass rounded-panel p-5"> - <h3 className="mb-3 text-[13px] font-bold uppercase tracking-[0.9px] text-dim">{title}</h3> + <h3 className="mb-3 text-callout font-bold uppercase tracking-[0.9px] text-dim">{title}</h3> <div className="flex flex-col gap-2"> {rows.slice(0, 8).map((r, i) => ( <motion.div @@ -152,7 +152,7 @@ function Breakdown({ transition={{ delay: i * 0.04 }} className="flex items-center gap-3" > - <span className="w-32 shrink-0 text-[13px] clamp1">{r.label}</span> + <span className="w-32 shrink-0 text-callout clamp1">{r.label}</span> <div className="h-1.5 flex-1 overflow-hidden rounded-full bg-faint"> <motion.i className="block h-full rounded-full bg-accent" @@ -161,11 +161,11 @@ function Breakdown({ transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }} /> </div> - <span className="w-10 shrink-0 text-right text-[12.5px] tabular-nums text-dim"> + <span className="w-10 shrink-0 text-right text-callout tabular-nums text-dim"> {r.n} </span> {r.sub && ( - <span className="hidden w-16 shrink-0 text-right text-[11.5px] text-dim sm:block"> + <span className="hidden w-16 shrink-0 text-right text-caption text-dim sm:block"> {r.sub} </span> )} diff --git a/web/components/admin/stat-card.tsx b/web/components/admin/stat-card.tsx index 4630334..9c9262b 100644 --- a/web/components/admin/stat-card.tsx +++ b/web/components/admin/stat-card.tsx @@ -28,11 +28,11 @@ export function StatCard({ transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }} className="glass rounded-glass p-3.5" > - <div className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</div> + <div className="text-caption uppercase tracking-[0.6px] text-dim">{label}</div> <div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]"> {value} </div> - {sub && <div className="mt-0.5 text-[11.5px] text-dim clamp1">{sub}</div>} + {sub && <div className="mt-0.5 text-caption text-dim clamp1">{sub}</div>} {pct != null && ( <div className="mt-2 h-1 overflow-hidden rounded-full bg-faint"> <motion.i diff --git a/web/components/admin/users-table.tsx b/web/components/admin/users-table.tsx index d253402..8689238 100644 --- a/web/components/admin/users-table.tsx +++ b/web/components/admin/users-table.tsx @@ -25,10 +25,10 @@ export function UsersTable({ <div className="glass rounded-panel p-5"> <div className="mb-3 flex flex-wrap items-center justify-between gap-2"> <div> - <h3 className="text-[13px] font-bold uppercase tracking-[0.9px] text-dim"> + <h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim"> Users </h3> - <p className="mt-1 text-[12.5px] text-dim"> + <p className="mt-1 text-callout text-dim"> Adding someone here <em>is</em> the invitation — no email, no token. They sign in with that provider and they’re in. </p> @@ -117,12 +117,12 @@ function UserRow({ </div> <div className="min-w-0 flex-1"> - <div className="flex items-center gap-1.5 text-[14px] font-semibold clamp1"> + <div className="flex items-center gap-1.5 text-body font-semibold clamp1"> {u.display || u.username} {u.role === "admin" && <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-accent" />} - {isMe && <span className="text-[11px] font-normal text-dim">(you)</span>} + {isMe && <span className="text-caption font-normal text-dim">(you)</span>} </div> - <div className="text-[12px] text-dim clamp1"> + <div className="text-caption text-dim clamp1"> {u.username} · {u.provider} {u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`} {u.disabled && " · disabled"} @@ -135,7 +135,7 @@ function UserRow({ onChange={(e) => run(() => api.admin.patchUser(u.username, { role: e.target.value }), `${u.username} is now ${e.target.value}`)} aria-label={`Role for ${u.username}`} className="shrink-0 cursor-pointer rounded-full border border-edge-hi bg-transparent - px-2.5 py-1 text-[12px] text-ink outline-none + px-2.5 py-1 text-caption text-ink outline-none focus:border-accent-line" > {roles.map((r) => ( @@ -188,7 +188,7 @@ function UserRow({ )} </div> - <div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-[12px]"> + <div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-caption"> <QuotaField label="Requests / month" value={u.quota} @@ -206,7 +206,7 @@ function UserRow({ } /> {u.bootstrap && ( - <span className="text-[11.5px] text-warn" title="Listed in MUSE_ALLOWED_USERS"> + <span className="text-caption text-warn" title="Listed in MUSE_ALLOWED_USERS"> bootstrap account — disabling won’t lock them out </span> )} @@ -295,7 +295,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) }; const input = - "rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-[13px] " + + "rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " + "text-ink outline-none focus:border-accent-line"; return ( @@ -307,7 +307,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) > <div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3"> <label className="flex min-w-[210px] flex-1 flex-col gap-1"> - <span className="text-[11.5px] text-dim"> + <span className="text-caption text-dim"> {provider === "github" ? "GitHub username" : "Google email"} </span> <input @@ -320,14 +320,14 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) /> </label> <label className="flex flex-col gap-1"> - <span className="text-[11.5px] text-dim">Provider</span> + <span className="text-caption text-dim">Provider</span> <select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}> <option value="github" className="bg-[#12141c]">GitHub</option> <option value="google" className="bg-[#12141c]">Google</option> </select> </label> <label className="flex flex-col gap-1"> - <span className="text-[11.5px] text-dim">Role</span> + <span className="text-caption text-dim">Role</span> <select value={role} onChange={(e) => setRole(e.target.value)} className={cn(input, "cursor-pointer")}> {roles.map((r) => ( <option key={r} value={r} className="bg-[#12141c]"> @@ -337,17 +337,17 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) </select> </label> <label className="flex w-24 flex-col gap-1"> - <span className="text-[11.5px] text-dim">Req/month</span> + <span className="text-caption text-dim">Req/month</span> <input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} className={input} /> </label> <label className="flex w-24 flex-col gap-1"> - <span className="text-[11.5px] text-dim">Storage GB</span> + <span className="text-caption text-dim">Storage GB</span> <input type="number" min={0} value={storage} onChange={(e) => setStorage(e.target.value)} className={input} /> </label> <Button onClick={submit} disabled={busy}> {busy ? "Inviting…" : "Invite"} </Button> - <span className="w-full text-[11.5px] text-dim">0 means unlimited.</span> + <span className="w-full text-caption text-dim">0 means unlimited.</span> </div> </motion.div> ); diff --git a/web/components/artist-view.tsx b/web/components/artist-view.tsx index 35f8c7b..68e6d33 100644 --- a/web/components/artist-view.tsx +++ b/web/components/artist-view.tsx @@ -96,7 +96,7 @@ export function ArtistView({ shadow-[0_8px_24px_rgb(0_0_0/0.35)]" onError={(e) => (e.currentTarget.style.visibility = "hidden")} /> - <div className="text-[13.5px] font-medium clamp1">{a.title}</div> + <div className="text-callout font-medium clamp1">{a.title}</div> <div className="text-xs text-dim clamp1"> {[a.year, a.record_type].filter(Boolean).join(" · ")} </div> diff --git a/web/components/bulk-import.tsx b/web/components/bulk-import.tsx index 595c497..9e38b6b 100644 --- a/web/components/bulk-import.tsx +++ b/web/components/bulk-import.tsx @@ -49,8 +49,8 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) { animate={{ opacity: 1, y: 0 }} className="glass rounded-panel p-5" > - <h2 className="mb-1 text-[17px] font-bold tracking-[-0.3px]">Import a list</h2> - <p className="mb-3.5 max-w-[62ch] text-[13px] text-dim"> + <h2 className="mb-1 text-headline font-bold tracking-[-0.3px]">Import a list</h2> + <p className="mb-3.5 max-w-[62ch] text-callout text-dim"> One song per line. Deezer album and playlist URLs are expanded into their tracks. The top match is taken automatically — every job shows what it resolved to, so a bad pick is visible rather than silent. @@ -72,7 +72,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) { <Button onClick={run} disabled={busy}> {busy ? "Queueing…" : "Queue all"} </Button> - <span className="text-[12.5px] text-dim"> + <span className="text-callout text-dim"> Top match taken automatically — check what each line resolved to. </span> </div> @@ -87,14 +87,14 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) { > {out.queued.length > 0 && ( <> - <div className="mb-1 text-[12.5px] text-dim">Queued {out.queued.length}:</div> + <div className="mb-1 text-callout text-dim">Queued {out.queued.length}:</div> {out.queued.map((q, i) => ( <motion.div key={q.id} initial={{ opacity: 0, x: -8 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: Math.min(i * 0.03, 0.5) }} - className="flex items-center gap-2.5 border-b border-edge py-1.5 text-[13px] last:border-0" + className="flex items-center gap-2.5 border-b border-edge py-1.5 text-callout last:border-0" > {q.cover && ( /* eslint-disable-next-line @next/next/no-img-element */ @@ -113,7 +113,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) { </> )} {out.skipped.length > 0 && ( - <div className="mt-2.5 text-[12.5px] leading-[1.7] text-err"> + <div className="mt-2.5 text-callout leading-[1.7] text-err"> Skipped {out.skipped.length}:{" "} {out.skipped.map((s) => `${s.line} (${s.why})`).join("; ")} </div> diff --git a/web/components/discover.tsx b/web/components/discover.tsx new file mode 100644 index 0000000..eb564c5 --- /dev/null +++ b/web/components/discover.tsx @@ -0,0 +1,210 @@ +"use client"; + +import * as React from "react"; +import { motion } from "framer-motion"; +import { CalendarDays, Compass, Sparkles, User } from "lucide-react"; + +import { api, bytes, type Discover as DiscoverData, type Recap } from "@/lib/api"; +import { Skeletons } from "@/components/skeletons"; + +/** + * Recommendations and a personal recap, both derived from muse's OWN request + * history plus Deezer's related-artists graph. + * + * Deliberately not built on listening data: Navidrome owns play counts, + * favourites and history, and a second source of truth for those would + * disagree with the one the user actually listens through. + */ +export function DiscoverPanel({ onArtist }: { onArtist: (name: string) => void }) { + const [d, setD] = React.useState<DiscoverData | null>(null); + const [r, setR] = React.useState<Recap | null>(null); + + React.useEffect(() => { + api.discover().then(setD).catch(() => setD({ seeds: [], artists: [] })); + api.recap().then(setR).catch(() => undefined); + }, []); + + if (!d) return <Skeletons n={4} />; + + const empty = d.artists.length === 0 && (!r || r.tracks === 0); + if (empty) + return ( + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + className="px-5 py-16 text-center" + > + <div + className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px] + border border-accent-line bg-accent-soft text-accent" + > + <Compass className="h-6 w-6" /> + </div> + <h2 className="text-title">Nothing to go on yet</h2> + <p className="mx-auto mt-1.5 max-w-[46ch] text-callout text-dim"> + Recommendations are built from what you’ve actually added. Request a few + tracks and this fills in. + </p> + </motion.div> + ); + + return ( + <div className="flex flex-col gap-5"> + {r && r.tracks > 0 && <RecapPanel r={r} />} + + {d.artists.length > 0 && ( + <section> + <SectionHead + icon={<Sparkles className="h-3.5 w-3.5" />} + title="Because you added" + sub={d.seeds.slice(0, 3).join(" · ")} + /> + <div className="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-6"> + {d.artists.map((a, i) => ( + <motion.button + key={a.id} + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + transition={{ delay: Math.min(i * 0.03, 0.3) }} + onClick={() => onArtist(a.name)} + className="glass lift ring group cursor-pointer rounded-panel p-3 text-center" + > + {a.picture ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + <img + src={a.picture} + alt="" + className="mx-auto mb-2.5 h-20 w-20 rounded-full border border-edge object-cover" + /> + ) : ( + <div className="mx-auto mb-2.5 grid h-20 w-20 place-items-center rounded-full bg-faint text-dim"> + <User className="h-7 w-7" /> + </div> + )} + <div className="text-callout font-semibold clamp1">{a.name}</div> + {a.fans ? ( + <div className="text-caption text-dim nums"> + {a.fans.toLocaleString()} fans + </div> + ) : null} + </motion.button> + ))} + </div> + </section> + )} + </div> + ); +} + +function RecapPanel({ r }: { r: Recap }) { + const maxDecade = Math.max(1, ...r.decades.map((d) => d.n)); + return ( + <section> + <SectionHead + icon={<CalendarDays className="h-3.5 w-3.5" />} + title="Your year in muse" + sub={`${r.tracks} tracks · ${bytes(r.bytes)}`} + /> + <div className="grid gap-2.5 md:grid-cols-3"> + <Card title="Most requested"> + {r.artists.slice(0, 5).map((a, i) => ( + <Row key={a.name} i={i} label={a.name} n={a.n} /> + ))} + </Card> + <Card title="Genres"> + {r.genres.slice(0, 5).map((g, i) => ( + <Row key={g.name} i={i} label={g.name} n={g.n} /> + ))} + {r.genres.length === 0 && <Muted>No genre data yet.</Muted>} + </Card> + <Card title="Decades"> + {r.decades.length === 0 && <Muted>No release years yet.</Muted>} + {r.decades.map((d, i) => ( + <motion.div + key={d.decade} + initial={{ opacity: 0, x: -6 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: i * 0.04 }} + className="flex items-center gap-2.5" + > + <span className="w-12 shrink-0 text-callout text-dim nums">{d.decade}</span> + <div className="h-1.5 flex-1 overflow-hidden rounded-pill bg-faint"> + <motion.i + className="block h-full rounded-pill bg-accent" + initial={{ width: 0 }} + animate={{ width: `${(d.n / maxDecade) * 100}%` }} + transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }} + /> + </div> + <span className="w-6 shrink-0 text-right text-caption text-dim nums"> + {d.n} + </span> + </motion.div> + ))} + {r.busiest_day && ( + <div className="mt-1 border-t border-edge pt-2 text-caption text-dim"> + Busiest day {r.busiest_day.date} — {r.busiest_day.n} tracks + </div> + )} + </Card> + </div> + </section> + ); +} + +function SectionHead({ + icon, + title, + sub, +}: { + icon: React.ReactNode; + title: string; + sub?: string; +}) { + return ( + <div className="mb-2.5 flex items-baseline gap-2"> + <span className="translate-y-[1px] text-accent">{icon}</span> + <h2 className="text-headline">{title}</h2> + {sub && <span className="text-caption text-dim clamp1">{sub}</span>} + </div> + ); +} + +function Card({ title, children }: { title: string; children: React.ReactNode }) { + return ( + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + className="glass rounded-panel p-4" + > + <h3 className="mb-2.5 text-caption font-bold uppercase tracking-[0.9px] text-dim"> + {title} + </h3> + <div className="flex flex-col gap-2">{children}</div> + </motion.div> + ); +} + +function Row({ i, label, n }: { i: number; label: string; n: number }) { + return ( + <motion.div + initial={{ opacity: 0, x: -6 }} + animate={{ opacity: 1, x: 0 }} + transition={{ delay: i * 0.04 }} + className="flex items-center gap-2" + > + <span + className="grid h-5 w-5 shrink-0 place-items-center rounded-md bg-faint + text-caption text-dim nums" + > + {i + 1} + </span> + <span className="min-w-0 flex-1 text-callout clamp1">{label}</span> + <span className="text-caption text-dim nums">{n}</span> + </motion.div> + ); +} + +function Muted({ children }: { children: React.ReactNode }) { + return <span className="text-callout text-dim">{children}</span>; +} diff --git a/web/components/hero.tsx b/web/components/hero.tsx index 2249de4..64a9c1b 100644 --- a/web/components/hero.tsx +++ b/web/components/hero.tsx @@ -60,19 +60,19 @@ export function Hero({ )} <div className="relative z-[2] flex min-w-0 flex-1 flex-col justify-center"> - <div className="mb-1.5 text-[11px] font-bold uppercase tracking-[1px] text-accent"> + <div className="mb-1.5 text-caption font-bold uppercase tracking-[1px] text-accent"> {kind} </div> <h2 className="mb-2 text-2xl sm:text-[29px] font-bold leading-[1.14] tracking-[-0.8px]"> {title} </h2> - {by && <div className="mb-2.5 text-[15px] font-semibold">{by}</div>} + {by && <div className="mb-2.5 text-body font-semibold">{by}</div>} {facts && ( <div className="mb-3.5 flex flex-wrap justify-center sm:justify-start gap-1.5"> {facts.filter(Boolean).map((f, i) => ( <span key={i} - className="glass-hi rounded-full px-2.5 py-0.5 text-[11.5px] text-dim" + className="glass-hi rounded-full px-2.5 py-0.5 text-caption text-dim" > {f} </span> diff --git a/web/components/player.tsx b/web/components/player.tsx index d31ade3..1f7663c 100644 --- a/web/components/player.tsx +++ b/web/components/player.tsx @@ -118,7 +118,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) { /> )} <div className="flex-1 min-w-0"> - <div className="text-[13.5px] font-semibold clamp1">{track.title}</div> + <div className="text-callout font-semibold clamp1">{track.title}</div> <div className="text-xs text-dim clamp1">{track.artist}</div> </div> <div className="hidden sm:block w-24 h-[3px] rounded-full bg-faint overflow-hidden shrink-0"> @@ -128,7 +128,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) { transition={{ ease: "linear", duration: 0.25 }} /> </div> - <span className="hidden sm:block text-[11.5px] text-dim tabular-nums shrink-0"> + <span className="hidden sm:block text-caption text-dim tabular-nums shrink-0"> {mmss(left)} </span> <button diff --git a/web/components/requests.tsx b/web/components/requests.tsx index e916662..d3088fe 100644 --- a/web/components/requests.tsx +++ b/web/components/requests.tsx @@ -16,6 +16,9 @@ export type JobsSnapshot = { const PILL: Record<Job["state"], string> = { imported: "text-ok border-ok/40 bg-ok/10", failed: "text-err border-err/40 bg-err/10", + // Held for review, not in flight and not finished — it needs a person, so + // it reads as the accent rather than as another in-progress amber. + review: "text-accent border-accent-line bg-accent-soft", downloading: "text-warn border-warn/40 bg-warn/10", searching: "text-warn border-warn/40 bg-warn/10", queued: "text-warn border-warn/40 bg-warn/10", @@ -31,11 +34,12 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh const failed = counts.failed ?? 0; const total = active + done; + const held = counts.review ?? 0; const stats: [string, number, string][] = [ ["In flight", active, "text-accent"], ["Imported", done, "text-ok"], + ["Held", held, held > 0 ? "text-warn" : ""], ["Failed", failed, "text-err"], - ["Waiting", pending, ""], ]; return ( @@ -47,11 +51,13 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh key={n} initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} - className={cn("block text-2xl font-bold leading-tight tracking-[-0.7px]", colour)} + className={cn("block text-title leading-none nums", colour)} > {n} </motion.b> - <span className="text-[11.5px] uppercase tracking-[0.6px] text-dim">{label}</span> + <span className="mt-1 block text-caption uppercase tracking-[0.6px] text-dim"> + {label} + </span> </motion.div> ))} </div> @@ -102,8 +108,8 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh </div> <div className="min-w-0 flex-1"> - <div className="text-[14.5px] font-semibold clamp1">{j.title}</div> - <div className="mt-px text-[12.5px] text-dim clamp1"> + <div className="text-body font-semibold clamp1">{j.title}</div> + <div className="mt-px text-callout text-dim clamp1"> {[j.artist, j.album, j.detail].filter(Boolean).join(" · ")} </div> </div> @@ -125,7 +131,7 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh <span className={cn( "shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1", - "text-[11px] font-semibold capitalize", + "text-caption font-semibold capitalize", PILL[j.state] ?? "text-dim border-edge", )} > diff --git a/web/components/review.tsx b/web/components/review.tsx new file mode 100644 index 0000000..916643e --- /dev/null +++ b/web/components/review.tsx @@ -0,0 +1,215 @@ +"use client"; + +import * as React from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Check, FileAudio, ShieldQuestion, X } from "lucide-react"; + +import { api, bytes, type ReviewItem } from "@/lib/api"; +import { useToast } from "@/components/toast"; +import { Button } from "@/components/ui/button"; +import { Skeletons } from "@/components/skeletons"; +import { cn } from "@/lib/utils"; + +/** + * Downloads that finished but did not clearly match what was asked for. + * + * The file is already on disk — confidence gates the *import*, not the + * transfer — so approving is instant and rejecting is a delete. Holding the + * download instead would mean the peer has usually vanished by the time + * anyone looks. + */ +export function Review({ onDecided }: { onDecided: () => void }) { + const [items, setItems] = React.useState<ReviewItem[] | null>(null); + const [err, setErr] = React.useState<string | null>(null); + + const load = React.useCallback(() => { + api + .review() + .then((r) => setItems(r.items)) + .catch((e) => setErr((e as Error).message)); + }, []); + + React.useEffect(() => { + load(); + const t = setInterval(load, 8000); + return () => clearInterval(t); + }, [load]); + + if (err) return <div className="py-12 text-center text-dim">{err}</div>; + if (!items) return <Skeletons n={3} />; + + if (items.length === 0) + return ( + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + className="px-5 py-16 text-center" + > + <div + className="mx-auto mb-4 grid h-14 w-14 place-items-center rounded-[18px] + border border-edge bg-[var(--glass)] text-dim" + > + <Check className="h-6 w-6" /> + </div> + <h2 className="text-title">Nothing to review</h2> + <p className="mx-auto mt-1.5 max-w-[44ch] text-callout text-dim"> + Anything muse wasn’t confident about lands here before it reaches your + library. An empty list means every match was unambiguous. + </p> + </motion.div> + ); + + return ( + <div className="flex flex-col gap-3"> + <motion.p + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + className="text-callout text-dim" + > + {items.length} download{items.length === 1 ? "" : "s"} finished but didn’t + clearly match the request. Each file is already on disk — approving files it + into your library, rejecting deletes it. + </motion.p> + + <AnimatePresence initial={false} mode="popLayout"> + {items.map((it) => ( + <ReviewCard key={it.id} item={it} refresh={load} onDecided={onDecided} /> + ))} + </AnimatePresence> + </div> + ); +} + +function ReviewCard({ + item, + refresh, + onDecided, +}: { + item: ReviewItem; + refresh: () => void; + onDecided: () => void; +}) { + const toast = useToast(); + const [busy, setBusy] = React.useState(false); + + const decide = async (action: "approve" | "reject") => { + setBusy(true); + try { + await api.decide(item.id, action); + toast(action === "approve" ? `Filed ${item.title}` : `Deleted ${item.file}`); + refresh(); + onDecided(); + } catch (e) { + toast((e as Error).message, true); + setBusy(false); + } + }; + + const pct = Math.round((item.confidence ?? 0) * 100); + const reasons = (item.why ?? "").split(";").map((s) => s.trim()).filter(Boolean); + + return ( + <motion.div + layout + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.18 } }} + transition={{ type: "spring", stiffness: 340, damping: 30 }} + className="glass rounded-panel p-4 sm:p-5" + > + <div className="flex gap-4"> + {item.cover ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + <img + src={item.cover} + alt="" + className="h-16 w-16 shrink-0 rounded-glass border border-edge object-cover" + /> + ) : ( + <div + className="grid h-16 w-16 shrink-0 place-items-center rounded-glass + border border-edge bg-faint text-dim" + > + <FileAudio className="h-6 w-6" /> + </div> + )} + + <div className="min-w-0 flex-1"> + <div className="text-headline clamp1">{item.title}</div> + <div className="text-callout text-dim clamp1"> + {item.artist} + {item.album ? ` · ${item.album}` : ""} + {item.year ? ` · ${item.year}` : ""} + </div> + <ConfidenceBar pct={pct} /> + </div> + </div> + + <div className="mt-3.5 rounded-glass border border-edge bg-[var(--glass)] p-3"> + <div className="mb-1.5 flex items-center gap-1.5 text-caption uppercase tracking-[0.7px] text-dim"> + <ShieldQuestion className="h-3 w-3" /> what was found + </div> + <div className="break-all font-mono text-[12px] text-ink">{item.file}</div> + {item.size ? ( + <div className="mt-1 text-caption text-dim nums">{bytes(item.size)}</div> + ) : null} + + {reasons.length > 0 && ( + <ul className="mt-2.5 flex flex-wrap gap-1.5"> + {reasons.map((r) => ( + <li + key={r} + className={cn( + "rounded-pill border px-2 py-0.5 text-caption", + /not |differs|looks like/.test(r) + ? "border-warn/40 text-warn" + : "border-edge text-dim", + )} + > + {r} + </li> + ))} + </ul> + )} + </div> + + <div className="mt-3.5 flex flex-wrap items-center gap-2"> + <Button onClick={() => decide("approve")} disabled={busy}> + <Check className="h-3.5 w-3.5" /> Add to library + </Button> + <Button + variant="quiet" + className="border-err/45 text-err" + onClick={() => decide("reject")} + disabled={busy} + > + <X className="h-3.5 w-3.5" /> Delete + </Button> + {item.requested_by && ( + <span className="ml-auto text-caption text-dim"> + requested by {item.requested_by} + </span> + )} + </div> + </motion.div> + ); +} + +/** Confidence as a bar rather than a bare number: the useful question is + * "how far below certain is this", which a percentage alone doesn't show. */ +function ConfidenceBar({ pct }: { pct: number }) { + const tone = pct >= 85 ? "bg-warn" : pct >= 70 ? "bg-warn" : "bg-err"; + return ( + <div className="mt-2 flex items-center gap-2.5"> + <div className="h-1.5 max-w-[220px] flex-1 overflow-hidden rounded-pill bg-faint"> + <motion.i + className={cn("block h-full rounded-pill", tone)} + initial={{ width: 0 }} + animate={{ width: `${pct}%` }} + transition={{ duration: 0.5, ease: "easeOut" }} + /> + </div> + <span className="text-caption text-dim nums">{pct}% confident</span> + </div> + ); +} diff --git a/web/components/search-bar.tsx b/web/components/search-bar.tsx index 736ce87..a10c65a 100644 --- a/web/components/search-bar.tsx +++ b/web/components/search-bar.tsx @@ -183,7 +183,7 @@ export function SearchBar({ if (!rows.length) return null; return ( <div key={g.kind}> - <div className="px-2.5 pt-2 pb-1 text-[10.5px] font-bold uppercase tracking-[0.9px] text-dim"> + <div className="px-2.5 pt-2 pb-1 text-caption font-bold uppercase tracking-[0.9px] text-dim"> {g.title} </div> {rows.map((it) => { @@ -218,7 +218,7 @@ export function SearchBar({ <div className="text-sm font-medium clamp1">{it.label}</div> <div className="text-xs text-dim clamp1">{it.sub}</div> </div> - {it.meta && <div className="shrink-0 text-[11px] text-dim">{it.meta}</div>} + {it.meta && <div className="shrink-0 text-caption text-dim">{it.meta}</div>} </div> ); })} diff --git a/web/components/settings.tsx b/web/components/settings.tsx new file mode 100644 index 0000000..b1f693d --- /dev/null +++ b/web/components/settings.tsx @@ -0,0 +1,171 @@ +"use client"; + +import * as React from "react"; +import { motion } from "framer-motion"; +import { Bell, BellOff, Mail } from "lucide-react"; + +import { api, type Me } from "@/lib/api"; +import { useToast } from "@/components/toast"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +/** Your own notification settings. Not behind admin on purpose: an address + * you cannot change yourself turns into someone else's chore. */ +export function Settings({ me, refresh }: { me: Me; refresh: () => void }) { + const toast = useToast(); + const [email, setEmail] = React.useState(me.email ?? ""); + const [notify, setNotify] = React.useState(me.notify ?? true); + const [busy, setBusy] = React.useState(false); + + const dirty = email !== (me.email ?? "") || notify !== (me.notify ?? true); + + const save = async () => { + setBusy(true); + try { + await api.settings({ email: email.trim(), notify }); + toast("Settings saved"); + refresh(); + } catch (e) { + toast((e as Error).message, true); + } + setBusy(false); + }; + + const googleImplicit = me.provider === "google" && !me.email; + // MUSE_AUTH=off means there is no account to attach a preference to, and + // PATCH /api/me would 401. Say so rather than offering a form that fails. + const anonymous = !me.user; + + return ( + <motion.div + initial={{ opacity: 0, y: 10 }} + animate={{ opacity: 1, y: 0 }} + className="flex flex-col gap-4" + > + {anonymous && ( + <div className="glass rounded-panel border-warn/40 p-4 text-callout text-warn"> + Authentication is turned off on this instance, so there is no account to + save preferences against. Set <code className="font-mono text-[12px]">MUSE_AUTH</code>{" "} + to <code className="font-mono text-[12px]">github</code> or{" "} + <code className="font-mono text-[12px]">google</code> to enable sign-in. + </div> + )} + <section className="glass rounded-panel p-5"> + <div className="mb-1 flex items-center gap-2"> + <Mail className="h-4 w-4 text-accent" /> + <h2 className="text-headline">Notifications</h2> + </div> + <p className="mb-4 max-w-[58ch] text-callout text-dim"> + muse can email you when a request lands in your library, or when a download + finished but needs your call before it’s filed. + </p> + + {!me.mail_configured && ( + <div className="mb-4 rounded-glass border border-warn/40 bg-[var(--glass)] p-3 text-callout text-warn"> + No SMTP server is configured on this instance, so nothing will actually be + sent. Set <code className="font-mono text-[12px]">MUSE_SMTP_HOST</code> and{" "} + <code className="font-mono text-[12px]">MUSE_SMTP_FROM</code> to turn it on. + </div> + )} + + <label className="flex max-w-md flex-col gap-1.5"> + <span className="text-caption uppercase tracking-[0.7px] text-dim"> + Where to write + </span> + <input + type="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && dirty && save()} + disabled={anonymous} + placeholder={googleImplicit ? me.user ?? "you@example.com" : "you@example.com"} + className="ring rounded-control border border-edge-hi bg-transparent px-3 py-2 + text-body outline-none transition focus:border-accent-line + disabled:opacity-45" + /> + {googleImplicit && ( + <span className="text-caption text-dim"> + Blank uses your Google address, {me.user}. + </span> + )} + </label> + + <button + onClick={() => setNotify(!notify)} + role="switch" + aria-checked={notify} + disabled={anonymous} + className="ring mt-4 flex cursor-pointer items-center gap-3 rounded-glass + text-left disabled:cursor-default disabled:opacity-45" + > + <span + className={cn( + "relative h-[26px] w-[44px] shrink-0 rounded-pill border transition-colors", + notify ? "border-accent-line bg-accent-soft" : "border-edge bg-faint", + )} + > + <motion.span + layout + transition={{ type: "spring", stiffness: 520, damping: 34 }} + className={cn( + "absolute top-[2px] h-[20px] w-[20px] rounded-full", + notify ? "left-[21px] bg-accent" : "left-[2px] bg-dim", + )} + /> + </span> + <span className="flex items-center gap-1.5 text-body"> + {notify ? ( + <Bell className="h-3.5 w-3.5 text-accent" /> + ) : ( + <BellOff className="h-3.5 w-3.5 text-dim" /> + )} + {notify ? "Email me about my requests" : "Don't email me"} + </span> + </button> + + <div className="mt-5"> + <Button onClick={save} disabled={!dirty || busy || anonymous}> + {busy ? "Saving…" : "Save"} + </Button> + </div> + </section> + + <section className="glass rounded-panel p-5"> + <h2 className="mb-3 text-headline">Account</h2> + {anonymous ? ( + <p className="text-callout text-dim"> + Nobody is signed in — every request is anonymous and every role check + reports admin. + </p> + ) : ( + <dl className="grid gap-x-6 gap-y-2 text-callout sm:grid-cols-[auto_1fr]"> + <Field k="Signed in as" v={me.display || me.user || "—"} /> + <Field k="Identity" v={`${me.user} · ${me.provider ?? "—"}`} /> + <Field k="Role" v={me.role ?? "—"} /> + <Field + k="Requests this month" + v={ + me.quota + ? `${me.usage?.requests ?? 0} of ${me.quota}` + : `${me.usage?.requests ?? 0} · no limit` + } + /> + </dl> + )} + <p className="mt-4 max-w-[58ch] text-caption text-dim"> + muse is the front door. Playback, playlists and listening history live in + Navidrome — this only decides what gets into the library. + </p> + </section> + </motion.div> + ); +} + +function Field({ k, v }: { k: string; v: string }) { + return ( + <> + <dt className="text-dim">{k}</dt> + <dd className="clamp1">{v}</dd> + </> + ); +} diff --git a/web/components/toast.tsx b/web/components/toast.tsx index ab73e16..31981f8 100644 --- a/web/components/toast.tsx +++ b/web/components/toast.tsx @@ -33,7 +33,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { exit={{ opacity: 0, y: 10, scale: 0.96 }} transition={{ type: "spring", stiffness: 420, damping: 30 }} className={cn( - "sheet rounded-full px-5 py-2.5 text-[13.5px] font-medium text-center", + "sheet rounded-full px-5 py-2.5 text-callout font-medium text-center", "max-w-[calc(100vw-32px)]", t.err && "border-err/50 text-err", )} diff --git a/web/components/track-row.tsx b/web/components/track-row.tsx index b30df6a..5e07431 100644 --- a/web/components/track-row.tsx +++ b/web/components/track-row.tsx @@ -79,7 +79,7 @@ export function TrackRow({ )} > {showNumber && ( - <div className="w-6 shrink-0 text-right text-[12.5px] text-dim tabular-nums"> + <div className="w-6 shrink-0 text-right text-callout text-dim tabular-nums"> {track.track ?? ""} </div> )} @@ -121,7 +121,7 @@ export function TrackRow({ </button> <div className="flex-1 min-w-0"> - <div className="text-[14.5px] font-semibold tracking-[-0.15px] clamp1"> + <div className="text-body font-semibold tracking-[-0.15px] clamp1"> {track.title} {track.explicit && ( <span className="ml-1.5 align-[1px] inline-block rounded-[3px] border border-edge bg-faint px-1 text-[9px] font-bold text-dim"> @@ -129,7 +129,7 @@ export function TrackRow({ </span> )} </div> - <div className="mt-px text-[12.5px] text-dim clamp1"> + <div className="mt-px text-callout text-dim clamp1"> {showArtist && track.artist_id && onArtist ? ( <button onClick={() => onArtist(track.artist_id!)} @@ -149,7 +149,7 @@ export function TrackRow({ </div> </div> - <div className="hidden sm:block shrink-0 text-[12.5px] text-dim tabular-nums"> + <div className="hidden sm:block shrink-0 text-callout text-dim tabular-nums"> {track.length ? mmss(track.length) : ""} </div> diff --git a/web/components/ui/button.tsx b/web/components/ui/button.tsx index e884f6e..050c945 100644 --- a/web/components/ui/button.tsx +++ b/web/components/ui/button.tsx @@ -16,7 +16,9 @@ const button = cva( // Solid accent. The label is near-black because the accent is clamped // to a light-ish luminance in lib/colour.ts — the two decisions have // to stay in step or this button becomes unreadable on some albums. - primary: "bg-accent text-[#08090d] hover:brightness-110 active:scale-[0.97]", + primary: + "bg-accent text-[#08090d] shadow-[var(--e1)] hover:brightness-110 " + + "active:scale-[0.97] active:brightness-95", ghost: "glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]", outline: @@ -25,9 +27,9 @@ const button = cva( quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line", }, size: { - sm: "text-xs px-3 py-1.5 rounded-full", - md: "text-sm px-4.5 py-2.5 rounded-xl", - pill: "text-[13px] px-4 py-2 rounded-full", + sm: "text-caption px-3 py-1.5 rounded-pill", + md: "text-callout px-4.5 py-2.5 rounded-control", + pill: "text-callout px-4 py-2 rounded-pill", }, }, defaultVariants: { variant: "primary", size: "md" }, diff --git a/web/lib/api.ts b/web/lib/api.ts index 707d79d..2d8ae61 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -77,9 +77,48 @@ export type Suggestions = { }[]; }; +export type ReviewItem = { + id: string; + artist: string; + title: string; + album: string | null; + year: string | null; + cover: string | null; + /** 0..1. Below the auto threshold, which is why this is here at all. */ + confidence: number | null; + /** Human-readable reasons behind the score, semicolon-joined. */ + why: string | null; + /** Basename of the file sitting in downloads, awaiting a decision. */ + file: string; + path: string | null; + size: number | null; + requested_by: string | null; + created_at: string; +}; + +export type Discover = { + seeds: string[]; + artists: { + id: number; + name: string; + picture: string | null; + fans: number | null; + }[]; +}; + +export type Recap = { + days: number; + tracks: number; + bytes: number; + artists: { name: string; n: number }[]; + genres: { name: string; n: number }[]; + decades: { decade: string; n: number }[]; + busiest_day: { date: string; n: number } | null; +}; + export type Job = { id: string; - state: "queued" | "searching" | "downloading" | "imported" | "failed"; + state: "queued" | "searching" | "downloading" | "review" | "imported" | "failed"; detail: string | null; artist: string; title: string; @@ -105,6 +144,10 @@ export type Me = { storage_quota?: number; usage?: Usage; providers?: string[]; + email?: string | null; + notify?: boolean; + /** SMTP is configured server-side; without it the settings are inert. */ + mail_configured?: boolean; }; export type AdminUser = { @@ -120,6 +163,8 @@ export type AdminUser = { last_seen: string | null; invited_by: string | null; usage: Usage; + email: string | null; + notify: boolean; /** In MUSE_ALLOWED_USERS — cannot actually be locked out by disabling. */ bootstrap: boolean; }; @@ -228,6 +273,17 @@ export const api = { "/api/jobs", ), retry: (id: string) => post<{ ok: boolean }>(`/api/retry/${id}`), + settings: (s: { email?: string | null; notify?: boolean }) => + send<{ ok: boolean; mail_configured: boolean }>("PATCH", "/api/me", s), + review: () => + get<{ + items: ReviewItem[]; + thresholds: { auto: number; review: number }; + }>("/api/review"), + decide: (id: string, action: "approve" | "reject") => + post<{ ok: boolean; state: string }>(`/api/review/${id}`, { action }), + discover: () => get<Discover>("/api/discover"), + recap: () => get<Recap>("/api/recap"), importList: (text: string) => post<{ queued: { id: string; line: string; matched: string; cover: string | null }[];