Add review, discover, email; rebuild the design system
Three features and a typographic pass over everything. REVIEW. Picking a file and trusting it are different questions, so they now use different numbers. The existing rank decides which candidate to fetch and is right to weigh peer upload slots and queue length -- but none of that says anything about whether the file is the correct recording, and a fast peer must not be able to raise the confidence of a wrong match. So confidence() scores the filename alone: title 0.75, artist 0.25, album +0.05, and a variant tag you did not ask for multiplies by 0.8 AND caps below the auto threshold. Confidence gates the IMPORT, not the download. The transfer completes either way and the file waits in downloads, so approving is instant and rejecting is a delete. Holding the transfer instead would mean the peer is usually gone by the time anyone looks. The two cases it exists for: "02 Creep.flac" in an unsorted folder scores 0.75 -- title exactly right, artist simply absent, the commonest honest case there is -- and must be reviewed rather than binned. And "Creep (Live at Glastonbury)" scores 0.80 with BOTH artist and title perfect; the cap is what stops a live recording auto-filing over the studio version. Ask for "Creep (Acoustic)" and it auto-imports at 0.97, because then the variant is the request. DISCOVER. Related artists seeded from what you have actually imported, plus a recap of top artists, genres and decades. Built on muse's own request history, never on listening data: Navidrome owns play counts and history, and a second source of truth for those would disagree with the one you listen through. EMAIL. Optional, off unless MUSE_SMTP_HOST is set. Sends on import and on review-needed. smtplib is blocking so it runs off the event loop, and every failure is logged and swallowed -- a notification is never worth failing an import over. Google accounts need no configuration, since the identity is an address. Each user owns their own setting under Settings, deliberately not an admin route: an address you cannot change yourself is someone else's chore. DESIGN. A real type scale (display/title/headline/body/callout/caption) whose tracking tightens as size grows, which is the single biggest tell of typography nobody set. Specular top edges on glass surfaces -- blur alone reads as a grey rectangle. A two-part elevation scale, contact shadow plus soft light. Semantic colours are now per-theme: the amber tuned for the dark background failed contrast outright on white, which was exactly the text a reviewer most needs to read. The tab underline became a sliding pill so the eye tracks one object, and Review only appears when something is actually waiting. Add tests/test_matching.py: 13 assertions on the confidence bands rather than the exact scores. 90 tests pass across the three suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc3490483e
commit
205e0a44b8
23 files changed
+1484
-102
No files matched your search
@@ -13,3 +13,6 @@ config/
|
|||||||
.github
|
.github
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.md
|
*.md
|
||||||
|
dev/
|
||||||
|
dev.sh
|
||||||
|
tests/
|
||||||
@@ -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
|
plausibility, then bitrate. A marginally better file behind 200 queued users
|
||||||
loses to a good-enough one available now.
|
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
|
## Authentication
|
||||||
|
|
||||||
OAuth against GitHub and/or Google, built so this can face the internet.
|
OAuth against GitHub and/or Google, built so this can face the internet.
|
||||||
@@ -374,7 +444,14 @@ stat -c "%h" "/data/media/music/<Artist>/<Album> (<Year>)/<NN> - <Title>.flac"
|
|||||||
| `MUSE_DB` | `/config/muse.db` | job history |
|
| `MUSE_DB` | `/config/muse.db` | job history |
|
||||||
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
|
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
|
||||||
| `MUSE_MIN_MATCH` | `0.45` | filename-to-title similarity floor |
|
| `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_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_LOG_LEVEL` | `INFO` | |
|
||||||
|
|
||||||
`MUSE_FORMATS` is a preference list, best first — rejecting everything not on
|
`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/import` | queue many from pasted text |
|
||||||
| `POST /api/retry/{id}` | requeue a failed job |
|
| `POST /api/retry/{id}` | requeue a failed job |
|
||||||
| `GET /api/jobs` | request history and state (own, or all for admins) |
|
| `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 |
|
| `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/health` | slskd reachability, mount writability |
|
||||||
| `GET /api/admin/users` | list users with usage |
|
| `GET /api/admin/users` | list users with usage |
|
||||||
| `POST /api/admin/users` | invite |
|
| `POST /api/admin/users` | invite |
|
||||||
|
|||||||
@@ -414,12 +414,17 @@ def init_db():
|
|||||||
for col, decl in (("requested_by", "TEXT"),
|
for col, decl in (("requested_by", "TEXT"),
|
||||||
("cover", "TEXT"),
|
("cover", "TEXT"),
|
||||||
("genre", "TEXT"),
|
("genre", "TEXT"),
|
||||||
("duration", "INTEGER")):
|
("duration", "INTEGER"),
|
||||||
|
("confidence", "REAL"),
|
||||||
|
("match_why", "TEXT"),
|
||||||
|
("reviewed_by", "TEXT")):
|
||||||
if col not in cols:
|
if col not in cols:
|
||||||
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}")
|
c.execute(f"ALTER TABLE jobs ADD COLUMN {col} {decl}")
|
||||||
ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()}
|
ucols = {r["name"] for r in c.execute("PRAGMA table_info(users)").fetchall()}
|
||||||
for col, decl in (("storage_quota", "INTEGER NOT NULL DEFAULT 0"),
|
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:
|
if col not in ucols:
|
||||||
c.execute(f"ALTER TABLE users ADD COLUMN {col} {decl}")
|
c.execute(f"ALTER TABLE users ADD COLUMN {col} {decl}")
|
||||||
c.execute("CREATE INDEX IF NOT EXISTS jobs_by_user ON jobs (requested_by)")
|
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 ""
|
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:
|
def score(candidate: dict, peer: dict, want: str) -> float | None:
|
||||||
"""Rank one remote file. None = reject.
|
"""Rank one remote file. None = reject.
|
||||||
|
|
||||||
@@ -521,7 +604,8 @@ def score(candidate: dict, peer: dict, want: str) -> float | None:
|
|||||||
return s
|
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."""
|
"""Search slskd for one track and queue the best candidate."""
|
||||||
want = norm(f"{artist} {title}")
|
want = norm(f"{artist} {title}")
|
||||||
search_id = str(uuid.uuid4())
|
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}")
|
set_state(job_id, "failed", f"enqueue failed: {e}")
|
||||||
return
|
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",
|
set_state(job_id, "downloading",
|
||||||
f"from {peer['username']} (score {best_score:.0f})",
|
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 ───────────────────────────
|
# ─────────────────────────── import ───────────────────────────
|
||||||
@@ -676,7 +856,8 @@ async def search_worker(n: int):
|
|||||||
job = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
job = c.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
|
||||||
if job and job["state"] in ("queued", "searching"):
|
if job and job["state"] in ("queued", "searching"):
|
||||||
set_state(job_id, "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:
|
except Exception as e:
|
||||||
log.warning("worker %d: %s", n, e)
|
log.warning("worker %d: %s", n, e)
|
||||||
try:
|
try:
|
||||||
@@ -716,15 +897,27 @@ async def import_worker():
|
|||||||
src = find_downloaded(job["remote_file"])
|
src = find_downloaded(job["remote_file"])
|
||||||
if not src:
|
if not src:
|
||||||
continue # still being moved out of incomplete
|
continue # still being moved out of incomplete
|
||||||
dest = tag_and_file(job, src)
|
|
||||||
# Record the real size, not the size the peer advertised —
|
# Record the real size, not the size the peer advertised —
|
||||||
# storage quotas are computed from this column.
|
# storage quotas are computed from this column.
|
||||||
try:
|
try:
|
||||||
actual = src.stat().st_size
|
actual = src.stat().st_size
|
||||||
except OSError:
|
except OSError:
|
||||||
actual = job["size"]
|
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),
|
set_state(job["id"], "imported", str(dest),
|
||||||
imported_path=str(dest), size=actual)
|
imported_path=str(dest), size=actual)
|
||||||
|
await notify_imported(job, dest)
|
||||||
elif "Completed" in st and "Succeeded" not in st:
|
elif "Completed" in st and "Succeeded" not in st:
|
||||||
set_state(job["id"], "failed", f"transfer {st}")
|
set_state(job["id"], "failed", f"transfer {st}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1070,9 +1263,42 @@ async def me(request: Request):
|
|||||||
"quota": u["quota"], "storage_quota": u["storage_quota"],
|
"quota": u["quota"], "storage_quota": u["storage_quota"],
|
||||||
"usage": usage_for(u["username"]),
|
"usage": usage_for(u["username"]),
|
||||||
"providers": ENABLED_PROVIDERS,
|
"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):
|
class RequestBody(BaseModel):
|
||||||
artist: str
|
artist: str
|
||||||
title: str
|
title: str
|
||||||
@@ -1530,6 +1756,158 @@ async def import_list(body: ImportBody, request: Request):
|
|||||||
return {"queued": queued, "skipped": skipped, "pending": work_q.qsize()}
|
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")
|
@app.get("/api/jobs")
|
||||||
async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
|
async def jobs(request: Request, limit: int = Query(200, ge=1, le=1000),
|
||||||
mine: bool = False):
|
mine: bool = False):
|
||||||
@@ -1602,7 +1980,7 @@ def user_dict(r: sqlite3.Row) -> dict:
|
|||||||
"quota": r["quota"], "storage_quota": r["storage_quota"],
|
"quota": r["quota"], "storage_quota": r["storage_quota"],
|
||||||
"disabled": bool(r["disabled"]), "created_at": r["created_at"],
|
"disabled": bool(r["disabled"]), "created_at": r["created_at"],
|
||||||
"last_seen": r["last_seen"], "invited_by": r["invited_by"],
|
"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
|
# 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.
|
# the accounts that cannot actually be locked out by disabling them.
|
||||||
"bootstrap": r["username"].lower() in ALLOWED_USERS,
|
"bootstrap": r["username"].lower() in ALLOWED_USERS,
|
||||||
|
|||||||
@@ -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())
|
||||||
+144
-5
@@ -20,19 +20,60 @@
|
|||||||
--color-edge-hi: var(--edge-hi);
|
--color-edge-hi: var(--edge-hi);
|
||||||
--color-faint: var(--faint);
|
--color-faint: var(--faint);
|
||||||
|
|
||||||
--color-ok: #3ddc84;
|
--color-ok: var(--ok);
|
||||||
--color-err: #ff6b6b;
|
--color-err: var(--err);
|
||||||
--color-warn: #ffc95c;
|
--color-warn: var(--warn);
|
||||||
|
|
||||||
--radius-glass: 14px;
|
--radius-glass: 14px;
|
||||||
--radius-panel: 28px;
|
--radius-panel: 28px;
|
||||||
|
--radius-control: 11px;
|
||||||
|
--radius-pill: 999px;
|
||||||
|
|
||||||
--ease-glass: cubic-bezier(0.22, 0.9, 0.28, 1);
|
--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:
|
--font-sans:
|
||||||
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
-apple-system, BlinkMacSystemFont, "SF Pro Text", ui-sans-serif, system-ui,
|
||||||
"Segoe UI", Roboto, sans-serif;
|
"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;
|
--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 {
|
:root {
|
||||||
@@ -52,8 +93,27 @@
|
|||||||
--header: rgb(8 9 13 / 0.62);
|
--header: rgb(8 9 13 / 0.62);
|
||||||
--scrim: rgb(8 9 13 / 0.74);
|
--scrim: rgb(8 9 13 / 0.74);
|
||||||
--scrim-2: rgb(8 9 13 / 0.4);
|
--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);
|
--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) {
|
@media (prefers-color-scheme: light) {
|
||||||
@@ -71,6 +131,18 @@
|
|||||||
--scrim: rgb(255 255 255 / 0.78);
|
--scrim: rgb(255 255 255 / 0.78);
|
||||||
--scrim-2: rgb(255 255 255 / 0.5);
|
--scrim-2: rgb(255 255 255 / 0.5);
|
||||||
--shadow: 0 10px 34px rgb(20 30 60 / 0.13);
|
--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-family: var(--font-sans);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
|
letter-spacing: -0.008em;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
overflow-x: hidden;
|
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 {
|
::selection {
|
||||||
background: var(--color-accent-soft);
|
background: var(--color-accent-soft);
|
||||||
}
|
}
|
||||||
@@ -103,6 +198,7 @@
|
|||||||
border: 1px solid var(--edge);
|
border: 1px solid var(--edge);
|
||||||
backdrop-filter: var(--blur);
|
backdrop-filter: var(--blur);
|
||||||
-webkit-backdrop-filter: var(--blur);
|
-webkit-backdrop-filter: var(--blur);
|
||||||
|
box-shadow: var(--spec);
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility glass-hi {
|
@utility glass-hi {
|
||||||
@@ -110,6 +206,7 @@
|
|||||||
border: 1px solid var(--edge-hi);
|
border: 1px solid var(--edge-hi);
|
||||||
backdrop-filter: var(--blur);
|
backdrop-filter: var(--blur);
|
||||||
-webkit-backdrop-filter: var(--blur);
|
-webkit-backdrop-filter: var(--blur);
|
||||||
|
box-shadow: var(--spec-hi);
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility sheet {
|
@utility sheet {
|
||||||
@@ -117,7 +214,42 @@
|
|||||||
border: 1px solid var(--edge-hi);
|
border: 1px solid var(--edge-hi);
|
||||||
backdrop-filter: var(--blur);
|
backdrop-filter: var(--blur);
|
||||||
-webkit-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 {
|
@utility clamp1 {
|
||||||
@@ -126,6 +258,13 @@
|
|||||||
text-overflow: ellipsis;
|
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
|
/* Very light noise over the gradients — large flat blurs band visibly on
|
||||||
8-bit panels without it. */
|
8-bit panels without it. */
|
||||||
@utility grain {
|
@utility grain {
|
||||||
|
|||||||
+77
-34
@@ -15,12 +15,22 @@ import { AlbumView } from "@/components/album-view";
|
|||||||
import { ArtistView } from "@/components/artist-view";
|
import { ArtistView } from "@/components/artist-view";
|
||||||
import { BulkImport } from "@/components/bulk-import";
|
import { BulkImport } from "@/components/bulk-import";
|
||||||
import { Requests, type JobsSnapshot } from "@/components/requests";
|
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 { Skeletons } from "@/components/skeletons";
|
||||||
import { PlayerProvider } from "@/components/player";
|
import { PlayerProvider } from "@/components/player";
|
||||||
import { ToastProvider } from "@/components/toast";
|
import { ToastProvider } from "@/components/toast";
|
||||||
import { AdminPanel } from "@/components/admin/admin-panel";
|
import { AdminPanel } from "@/components/admin/admin-panel";
|
||||||
|
|
||||||
type Tab = "search" | "jobs" | "bulk" | "admin";
|
type Tab =
|
||||||
|
| "search"
|
||||||
|
| "discover"
|
||||||
|
| "jobs"
|
||||||
|
| "review"
|
||||||
|
| "bulk"
|
||||||
|
| "settings"
|
||||||
|
| "admin";
|
||||||
type View =
|
type View =
|
||||||
| { k: "welcome" }
|
| { k: "welcome" }
|
||||||
| { k: "results"; q: string }
|
| { k: "results"; q: string }
|
||||||
@@ -118,6 +128,7 @@ function Muse() {
|
|||||||
(snap?.counts.queued ?? 0) +
|
(snap?.counts.queued ?? 0) +
|
||||||
(snap?.counts.searching ?? 0) +
|
(snap?.counts.searching ?? 0) +
|
||||||
(snap?.counts.downloading ?? 0);
|
(snap?.counts.downloading ?? 0);
|
||||||
|
const held = snap?.counts.review ?? 0;
|
||||||
|
|
||||||
const isAdmin = me?.role === "admin";
|
const isAdmin = me?.role === "admin";
|
||||||
// Only worth showing when a limit actually exists — a permanent "0 / 0" chip
|
// 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" />
|
<Music2 className="h-5 w-5" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-[19px] font-bold leading-tight tracking-[-0.4px]">muse</h1>
|
<h1 className="text-title leading-none">muse</h1>
|
||||||
<p className="text-xs text-dim">search · request · it appears in Navidrome</p>
|
<p className="mt-0.5 text-caption text-dim">
|
||||||
|
search · request · it appears in Navidrome
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{me?.user && (
|
{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 && (
|
{quotaLeft !== null && (
|
||||||
<span
|
<span
|
||||||
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
title={`${me.usage!.requests} of ${me.quota} requests used this month`}
|
||||||
className={cn(
|
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
|
quotaLeft === 0
|
||||||
? "border-err/50 text-err"
|
? "border-err/50 text-err"
|
||||||
: quotaLeft <= 5
|
: quotaLeft <= 5
|
||||||
@@ -177,15 +190,15 @@ function Muse() {
|
|||||||
</span>
|
</span>
|
||||||
{me.role && me.role !== "user" && (
|
{me.role && me.role !== "user" && (
|
||||||
<span
|
<span
|
||||||
className="rounded-full border border-accent-line bg-accent-soft px-1.5
|
className="rounded-pill border border-accent-line bg-accent-soft px-1.5
|
||||||
py-0.5 text-[10.5px] uppercase tracking-[0.6px] text-accent"
|
py-0.5 text-caption uppercase tracking-[0.6px] text-accent"
|
||||||
>
|
>
|
||||||
{me.role}
|
{me.role}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<a
|
<a
|
||||||
href="/auth/logout"
|
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
|
Log out
|
||||||
</a>
|
</a>
|
||||||
@@ -195,40 +208,67 @@ function Muse() {
|
|||||||
|
|
||||||
<SearchBar busy={busy} onSearch={doSearch} onAlbum={openAlbum} onArtist={openArtist} />
|
<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"],
|
["search", "Search", 0],
|
||||||
["jobs", "Requests"],
|
["discover", "Discover", 0],
|
||||||
["bulk", "Import list"],
|
["jobs", "Requests", active],
|
||||||
...(isAdmin ? ([["admin", "Admin"]] as [Tab, string][]) : []),
|
// Only exists when it is actionable. An always-present tab
|
||||||
] as [Tab, string][]
|
// that is empty nine days in ten is chrome; this one appears
|
||||||
).map(([k, label]) => (
|
// 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
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
onClick={() => setTab(k)}
|
onClick={() => setTab(k)}
|
||||||
|
aria-current={tab === k ? "page" : undefined}
|
||||||
className={cn(
|
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",
|
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 && (
|
{tab === k && (
|
||||||
<motion.i
|
<motion.span
|
||||||
layoutId="tab-underline"
|
layoutId="tab-pill"
|
||||||
className="absolute inset-x-0 bottom-0 h-0.5 rounded-t bg-accent"
|
className="absolute inset-0 rounded-pill border border-edge-hi bg-[var(--glass-hi)]"
|
||||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -296,8 +336,11 @@ function Muse() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tab === "discover" && <DiscoverPanel onArtist={doSearch} />}
|
||||||
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
{tab === "jobs" && <Requests snap={snap} refresh={refresh} />}
|
||||||
|
{tab === "review" && <Review onDecided={refresh} />}
|
||||||
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
{tab === "bulk" && <BulkImport onQueued={refresh} />}
|
||||||
|
{tab === "settings" && me && <Settings me={me} refresh={refresh} />}
|
||||||
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
{tab === "admin" && isAdmin && <AdminPanel me={me?.user ?? null} />}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</main>
|
</main>
|
||||||
@@ -321,10 +364,10 @@ function Welcome({ onPick }: { onPick: (q: string) => void }) {
|
|||||||
>
|
>
|
||||||
<Disc3 className="h-7 w-7" />
|
<Disc3 className="h-7 w-7" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<h2 className="mb-2 text-[23px] font-bold tracking-[-0.4px]">Find anything</h2>
|
<h2 className="mb-2 text-display">Find anything</h2>
|
||||||
<p className="mx-auto max-w-[46ch] text-sm text-dim">
|
<p className="mx-auto max-w-[46ch] text-body text-dim">
|
||||||
Search a song and press{" "}
|
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
|
Enter
|
||||||
</kbd>
|
</kbd>
|
||||||
. Hit play to hear a 30-second preview before you commit, then <b>Get</b> to pull it into
|
. 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 }}
|
transition={{ delay: 0.1 + i * 0.05 }}
|
||||||
whileHover={{ y: -2 }}
|
whileHover={{ y: -2 }}
|
||||||
onClick={() => onPick(e)}
|
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"
|
transition hover:border-accent-line hover:bg-accent-soft hover:text-ink"
|
||||||
>
|
>
|
||||||
{e}
|
{e}
|
||||||
|
|||||||
@@ -35,12 +35,12 @@ export function ActivityChart({ data }: { data: Activity }) {
|
|||||||
<div className="glass rounded-panel p-5">
|
<div className="glass rounded-panel p-5">
|
||||||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||||
<div>
|
<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
|
Requests · last {data.days} days
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-1 text-[22px] font-bold tracking-[-0.6px]">
|
<div className="mt-1 text-[22px] font-bold tracking-[-0.6px]">
|
||||||
{shown ? shown.n : totals.n}
|
{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
|
{shown
|
||||||
? new Date(shown.date + "T00:00:00").toLocaleDateString(undefined, {
|
? new Date(shown.date + "T00:00:00").toLocaleDateString(undefined, {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
@@ -51,7 +51,7 @@ export function ActivityChart({ data }: { data: Activity }) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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-accent" label={`${shown ? shown.imported : totals.imported} imported`} />
|
||||||
<Legend className="bg-err" label={`${shown ? shown.failed : totals.failed} failed`} />
|
<Legend className="bg-err" label={`${shown ? shown.failed : totals.failed} failed`} />
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +99,7 @@ export function ActivityChart({ data }: { data: Activity }) {
|
|||||||
})}
|
})}
|
||||||
</svg>
|
</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[0]?.date)}</span>
|
||||||
<span>{fmt(data.series[data.series.length - 1]?.date)}</span>
|
<span>{fmt(data.series[data.series.length - 1]?.date)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ function SlskdBanner({ slskd }: { slskd: SystemStats["slskd"] }) {
|
|||||||
initial={{ opacity: 0, y: -6 }}
|
initial={{ opacity: 0, y: -6 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className={cn(
|
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",
|
healthy ? "border-ok/30" : "border-err/40",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -142,7 +142,7 @@ function Breakdown({
|
|||||||
const max = Math.max(1, ...rows.map((r) => r.n));
|
const max = Math.max(1, ...rows.map((r) => r.n));
|
||||||
return (
|
return (
|
||||||
<div className="glass rounded-panel p-5">
|
<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">
|
<div className="flex flex-col gap-2">
|
||||||
{rows.slice(0, 8).map((r, i) => (
|
{rows.slice(0, 8).map((r, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -152,7 +152,7 @@ function Breakdown({
|
|||||||
transition={{ delay: i * 0.04 }}
|
transition={{ delay: i * 0.04 }}
|
||||||
className="flex items-center gap-3"
|
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">
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-faint">
|
||||||
<motion.i
|
<motion.i
|
||||||
className="block h-full rounded-full bg-accent"
|
className="block h-full rounded-full bg-accent"
|
||||||
@@ -161,11 +161,11 @@ function Breakdown({
|
|||||||
transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }}
|
transition={{ delay: i * 0.04, duration: 0.5, ease: "easeOut" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
{r.n}
|
||||||
</span>
|
</span>
|
||||||
{r.sub && (
|
{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}
|
{r.sub}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ export function StatCard({
|
|||||||
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
transition={{ delay: Math.min(index * 0.04, 0.4), type: "spring", stiffness: 320, damping: 30 }}
|
||||||
className="glass rounded-glass p-3.5"
|
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]">
|
<div className="mt-0.5 text-[22px] font-bold leading-tight tracking-[-0.6px]">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</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 && (
|
{pct != null && (
|
||||||
<div className="mt-2 h-1 overflow-hidden rounded-full bg-faint">
|
<div className="mt-2 h-1 overflow-hidden rounded-full bg-faint">
|
||||||
<motion.i
|
<motion.i
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ export function UsersTable({
|
|||||||
<div className="glass rounded-panel p-5">
|
<div className="glass rounded-panel p-5">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<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
|
Users
|
||||||
</h3>
|
</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
|
Adding someone here <em>is</em> the invitation — no email, no token. They sign in
|
||||||
with that provider and they’re in.
|
with that provider and they’re in.
|
||||||
</p>
|
</p>
|
||||||
@@ -117,12 +117,12 @@ function UserRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<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.display || u.username}
|
||||||
{u.role === "admin" && <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-accent" />}
|
{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>
|
||||||
<div className="text-[12px] text-dim clamp1">
|
<div className="text-caption text-dim clamp1">
|
||||||
{u.username} · {u.provider}
|
{u.username} · {u.provider}
|
||||||
{u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`}
|
{u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`}
|
||||||
{u.disabled && " · disabled"}
|
{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}`)}
|
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}`}
|
aria-label={`Role for ${u.username}`}
|
||||||
className="shrink-0 cursor-pointer rounded-full border border-edge-hi bg-transparent
|
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"
|
focus:border-accent-line"
|
||||||
>
|
>
|
||||||
{roles.map((r) => (
|
{roles.map((r) => (
|
||||||
@@ -188,7 +188,7 @@ function UserRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<QuotaField
|
||||||
label="Requests / month"
|
label="Requests / month"
|
||||||
value={u.quota}
|
value={u.quota}
|
||||||
@@ -206,7 +206,7 @@ function UserRow({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{u.bootstrap && (
|
{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
|
bootstrap account — disabling won’t lock them out
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -295,7 +295,7 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
|||||||
};
|
};
|
||||||
|
|
||||||
const input =
|
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";
|
"text-ink outline-none focus:border-accent-line";
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<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"}
|
{provider === "github" ? "GitHub username" : "Google email"}
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
@@ -320,14 +320,14 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1">
|
<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")}>
|
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}>
|
||||||
<option value="github" className="bg-[#12141c]">GitHub</option>
|
<option value="github" className="bg-[#12141c]">GitHub</option>
|
||||||
<option value="google" className="bg-[#12141c]">Google</option>
|
<option value="google" className="bg-[#12141c]">Google</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex flex-col gap-1">
|
<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")}>
|
<select value={role} onChange={(e) => setRole(e.target.value)} className={cn(input, "cursor-pointer")}>
|
||||||
{roles.map((r) => (
|
{roles.map((r) => (
|
||||||
<option key={r} value={r} className="bg-[#12141c]">
|
<option key={r} value={r} className="bg-[#12141c]">
|
||||||
@@ -337,17 +337,17 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex w-24 flex-col gap-1">
|
<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} />
|
<input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} className={input} />
|
||||||
</label>
|
</label>
|
||||||
<label className="flex w-24 flex-col gap-1">
|
<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} />
|
<input type="number" min={0} value={storage} onChange={(e) => setStorage(e.target.value)} className={input} />
|
||||||
</label>
|
</label>
|
||||||
<Button onClick={submit} disabled={busy}>
|
<Button onClick={submit} disabled={busy}>
|
||||||
{busy ? "Inviting…" : "Invite"}
|
{busy ? "Inviting…" : "Invite"}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function ArtistView({
|
|||||||
shadow-[0_8px_24px_rgb(0_0_0/0.35)]"
|
shadow-[0_8px_24px_rgb(0_0_0/0.35)]"
|
||||||
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
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">
|
<div className="text-xs text-dim clamp1">
|
||||||
{[a.year, a.record_type].filter(Boolean).join(" · ")}
|
{[a.year, a.record_type].filter(Boolean).join(" · ")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="glass rounded-panel p-5"
|
className="glass rounded-panel p-5"
|
||||||
>
|
>
|
||||||
<h2 className="mb-1 text-[17px] font-bold tracking-[-0.3px]">Import a list</h2>
|
<h2 className="mb-1 text-headline font-bold tracking-[-0.3px]">Import a list</h2>
|
||||||
<p className="mb-3.5 max-w-[62ch] text-[13px] text-dim">
|
<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
|
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
|
top match is taken automatically — every job shows what it resolved to, so a bad pick is
|
||||||
visible rather than silent.
|
visible rather than silent.
|
||||||
@@ -72,7 +72,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
|||||||
<Button onClick={run} disabled={busy}>
|
<Button onClick={run} disabled={busy}>
|
||||||
{busy ? "Queueing…" : "Queue all"}
|
{busy ? "Queueing…" : "Queue all"}
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-[12.5px] text-dim">
|
<span className="text-callout text-dim">
|
||||||
Top match taken automatically — check what each line resolved to.
|
Top match taken automatically — check what each line resolved to.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,14 +87,14 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
|||||||
>
|
>
|
||||||
{out.queued.length > 0 && (
|
{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) => (
|
{out.queued.map((q, i) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={q.id}
|
key={q.id}
|
||||||
initial={{ opacity: 0, x: -8 }}
|
initial={{ opacity: 0, x: -8 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: Math.min(i * 0.03, 0.5) }}
|
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 && (
|
{q.cover && (
|
||||||
/* eslint-disable-next-line @next/next/no-img-element */
|
/* eslint-disable-next-line @next/next/no-img-element */
|
||||||
@@ -113,7 +113,7 @@ export function BulkImport({ onQueued }: { onQueued?: () => void }) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{out.skipped.length > 0 && (
|
{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}:{" "}
|
Skipped {out.skipped.length}:{" "}
|
||||||
{out.skipped.map((s) => `${s.line} (${s.why})`).join("; ")}
|
{out.skipped.map((s) => `${s.line} (${s.why})`).join("; ")}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -60,19 +60,19 @@ export function Hero({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="relative z-[2] flex min-w-0 flex-1 flex-col justify-center">
|
<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}
|
{kind}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mb-2 text-2xl sm:text-[29px] font-bold leading-[1.14] tracking-[-0.8px]">
|
<h2 className="mb-2 text-2xl sm:text-[29px] font-bold leading-[1.14] tracking-[-0.8px]">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</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 && (
|
{facts && (
|
||||||
<div className="mb-3.5 flex flex-wrap justify-center sm:justify-start gap-1.5">
|
<div className="mb-3.5 flex flex-wrap justify-center sm:justify-start gap-1.5">
|
||||||
{facts.filter(Boolean).map((f, i) => (
|
{facts.filter(Boolean).map((f, i) => (
|
||||||
<span
|
<span
|
||||||
key={i}
|
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}
|
{f}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function PlayerProvider({ children }: { children: React.ReactNode }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<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 className="text-xs text-dim clamp1">{track.artist}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:block w-24 h-[3px] rounded-full bg-faint overflow-hidden shrink-0">
|
<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 }}
|
transition={{ ease: "linear", duration: 0.25 }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)}
|
{mmss(left)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export type JobsSnapshot = {
|
|||||||
const PILL: Record<Job["state"], string> = {
|
const PILL: Record<Job["state"], string> = {
|
||||||
imported: "text-ok border-ok/40 bg-ok/10",
|
imported: "text-ok border-ok/40 bg-ok/10",
|
||||||
failed: "text-err border-err/40 bg-err/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",
|
downloading: "text-warn border-warn/40 bg-warn/10",
|
||||||
searching: "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",
|
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 failed = counts.failed ?? 0;
|
||||||
const total = active + done;
|
const total = active + done;
|
||||||
|
|
||||||
|
const held = counts.review ?? 0;
|
||||||
const stats: [string, number, string][] = [
|
const stats: [string, number, string][] = [
|
||||||
["In flight", active, "text-accent"],
|
["In flight", active, "text-accent"],
|
||||||
["Imported", done, "text-ok"],
|
["Imported", done, "text-ok"],
|
||||||
|
["Held", held, held > 0 ? "text-warn" : ""],
|
||||||
["Failed", failed, "text-err"],
|
["Failed", failed, "text-err"],
|
||||||
["Waiting", pending, ""],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,11 +51,13 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
|
|||||||
key={n}
|
key={n}
|
||||||
initial={{ opacity: 0, y: -6 }}
|
initial={{ opacity: 0, y: -6 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
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}
|
{n}
|
||||||
</motion.b>
|
</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>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -102,8 +108,8 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[14.5px] font-semibold clamp1">{j.title}</div>
|
<div className="text-body font-semibold clamp1">{j.title}</div>
|
||||||
<div className="mt-px text-[12.5px] text-dim clamp1">
|
<div className="mt-px text-callout text-dim clamp1">
|
||||||
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
|
{[j.artist, j.album, j.detail].filter(Boolean).join(" · ")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +131,7 @@ export function Requests({ snap, refresh }: { snap: JobsSnapshot | null; refresh
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"shrink-0 whitespace-nowrap rounded-full border px-2.5 py-1",
|
"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",
|
PILL[j.state] ?? "text-dim border-edge",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -183,7 +183,7 @@ export function SearchBar({
|
|||||||
if (!rows.length) return null;
|
if (!rows.length) return null;
|
||||||
return (
|
return (
|
||||||
<div key={g.kind}>
|
<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}
|
{g.title}
|
||||||
</div>
|
</div>
|
||||||
{rows.map((it) => {
|
{rows.map((it) => {
|
||||||
@@ -218,7 +218,7 @@ export function SearchBar({
|
|||||||
<div className="text-sm font-medium clamp1">{it.label}</div>
|
<div className="text-sm font-medium clamp1">{it.label}</div>
|
||||||
<div className="text-xs text-dim clamp1">{it.sub}</div>
|
<div className="text-xs text-dim clamp1">{it.sub}</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -33,7 +33,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|||||||
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
exit={{ opacity: 0, y: 10, scale: 0.96 }}
|
||||||
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
transition={{ type: "spring", stiffness: 420, damping: 30 }}
|
||||||
className={cn(
|
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)]",
|
"max-w-[calc(100vw-32px)]",
|
||||||
t.err && "border-err/50 text-err",
|
t.err && "border-err/50 text-err",
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export function TrackRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{showNumber && (
|
{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 ?? ""}
|
{track.track ?? ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -121,7 +121,7 @@ export function TrackRow({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<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.title}
|
||||||
{track.explicit && (
|
{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">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ? (
|
{showArtist && track.artist_id && onArtist ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => onArtist(track.artist_id!)}
|
onClick={() => onArtist(track.artist_id!)}
|
||||||
@@ -149,7 +149,7 @@ export function TrackRow({
|
|||||||
</div>
|
</div>
|
||||||
</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) : ""}
|
{track.length ? mmss(track.length) : ""}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ const button = cva(
|
|||||||
// Solid accent. The label is near-black because the accent is clamped
|
// 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 a light-ish luminance in lib/colour.ts — the two decisions have
|
||||||
// to stay in step or this button becomes unreadable on some albums.
|
// 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:
|
ghost:
|
||||||
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
"glass-hi text-ink hover:border-accent-line hover:bg-accent-soft active:scale-[0.97]",
|
||||||
outline:
|
outline:
|
||||||
@@ -25,9 +27,9 @@ const button = cva(
|
|||||||
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
quiet: "bg-transparent border border-edge-hi text-dim hover:text-ink hover:border-accent-line",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
sm: "text-xs px-3 py-1.5 rounded-full",
|
sm: "text-caption px-3 py-1.5 rounded-pill",
|
||||||
md: "text-sm px-4.5 py-2.5 rounded-xl",
|
md: "text-callout px-4.5 py-2.5 rounded-control",
|
||||||
pill: "text-[13px] px-4 py-2 rounded-full",
|
pill: "text-callout px-4 py-2 rounded-pill",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: { variant: "primary", size: "md" },
|
defaultVariants: { variant: "primary", size: "md" },
|
||||||
|
|||||||
+57
-1
@@ -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 = {
|
export type Job = {
|
||||||
id: string;
|
id: string;
|
||||||
state: "queued" | "searching" | "downloading" | "imported" | "failed";
|
state: "queued" | "searching" | "downloading" | "review" | "imported" | "failed";
|
||||||
detail: string | null;
|
detail: string | null;
|
||||||
artist: string;
|
artist: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -105,6 +144,10 @@ export type Me = {
|
|||||||
storage_quota?: number;
|
storage_quota?: number;
|
||||||
usage?: Usage;
|
usage?: Usage;
|
||||||
providers?: string[];
|
providers?: string[];
|
||||||
|
email?: string | null;
|
||||||
|
notify?: boolean;
|
||||||
|
/** SMTP is configured server-side; without it the settings are inert. */
|
||||||
|
mail_configured?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminUser = {
|
export type AdminUser = {
|
||||||
@@ -120,6 +163,8 @@ export type AdminUser = {
|
|||||||
last_seen: string | null;
|
last_seen: string | null;
|
||||||
invited_by: string | null;
|
invited_by: string | null;
|
||||||
usage: Usage;
|
usage: Usage;
|
||||||
|
email: string | null;
|
||||||
|
notify: boolean;
|
||||||
/** In MUSE_ALLOWED_USERS — cannot actually be locked out by disabling. */
|
/** In MUSE_ALLOWED_USERS — cannot actually be locked out by disabling. */
|
||||||
bootstrap: boolean;
|
bootstrap: boolean;
|
||||||
};
|
};
|
||||||
@@ -228,6 +273,17 @@ export const api = {
|
|||||||
"/api/jobs",
|
"/api/jobs",
|
||||||
),
|
),
|
||||||
retry: (id: string) => post<{ ok: boolean }>(`/api/retry/${id}`),
|
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) =>
|
importList: (text: string) =>
|
||||||
post<{
|
post<{
|
||||||
queued: { id: string; line: string; matched: string; cover: string | null }[];
|
queued: { id: string; line: string; matched: string; cover: string | null }[];
|
||||||
|
|||||||
Reference in new issue
Block a user