3 Commits
  • Recover blocked searches, fall through to the next peer, rebuild the UI
    Measured against a live instance: 47 failed jobs against 33 imported. Three
    independent causes, all of which ended the request.
    
    The Soulseek server filters some artist names. "Michael Jackson Billie Jean"
    and bare "Michael Jackson" both return zero responses; "Billie Jean" returns
    1083 files. The Weeknd, Kanye West and Frank Ocean behave the same way. Since
    pick_and_enqueue sent exactly one query of `artist title`, every request for a
    filtered artist failed reporting "searched 0 files" -- 28 of 29 occurrences.
    search_queries() now walks a ladder, loosest last, ending at the bare title,
    and escalates only when a search underdelivers so the common case still costs
    one search. Dropping the artist from the query does not mean ignoring it:
    match_ratio() looks for it in the folder path, where an organised share puts it.
    
    A rejected transfer ended the request. pick_and_enqueue kept a running maximum
    and discarded every runner-up, so "Transfer rejected: File not shared." -- a
    per-file condition where candidate two almost always works -- was terminal, as
    were timeouts and slskd's 500 on an unreachable peer. The ranked list is now
    persisted on the job row, ordered to put a different peer next, and
    try_next_candidate() advances through it. A job fails only once the list is
    exhausted, and then reports every peer it tried and what each one said.
    
    norm() destroyed every non-ASCII character, so "Beyonce" never matched
    Beyonce, nor Bjork Bjork, nor Dont Don't. It now folds accents and drops
    apostrophes while leaving & as a separator.
    
    Also: availability outranks format in score(), because a FLAC behind 5000
    queued users used to beat an immediately available MP3; transfers that stall or
    that slskd forgets are abandoned instead of sitting in `downloading` forever;
    searches are deleted only once finished, since deleting one mid-flight makes
    slskd throw an optimistic-concurrency error and lose the record; a failed
    search POST is retried rather than failing the job on one blip.
    
    The UI moves to a sidebar shell -- rail at tablet, bottom bar on phones -- with
    neutral chrome and the sampled artwork colour confined to hero areas rather
    than repainting the whole interface. Adds URL routing, a primitive set under
    components/ui, and a request sheet showing the attempt ladder. Fixes
    hover:bg-glass-hi, which was never a valid class, so track rows and the search
    field had no hover or focus state at all; a reduced-motion path that could
    strand elements at opacity 0; the login page's hardcoded palette; and the
    missing 404 and error boundaries.
    
    tests/test_acquisition.py covers this path against a faked slskd. It had none.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • 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>
  • Add multi-user: Google OAuth, roles, quotas, admin panel
    Phase 2. The schema landed last commit; this makes it real.
    
    AUTH is now multi-provider. MUSE_AUTH takes a comma list, so github,google
    runs both. The callback URL is unchanged — /auth/callback for every provider,
    with the provider carried in the state COOKIE rather than the query string —
    so adding Google needs no edit to an already-registered GitHub app, and the
    callback can't be steered to a different provider by the redirect. With more
    than one enabled, /auth/login shows a chooser built from style attributes
    only, which the CSP already permits, so it needs no stylesheet and no extra
    hash.
    
    Google identities are the VERIFIED email. An unverified one is refused: anyone
    can put someone else's address on an account they control, and the allowlist is
    keyed on exactly that string.
    
    WHO MAY LOG IN is now two rules — the MUSE_ALLOWED_USERS bootstrap list, then
    a non-disabled users row. Inviting is adding a row; no email, no token, no
    expiry to manage. MUSE_ALLOWED_USERS stays mandatory only while the table is
    empty, so a working multi-user install isn't held hostage by an env var it has
    outgrown, but an empty database with no allowlist still refuses to start
    because nobody could ever get in. The rule is one function used at login AND
    on every request: checking only at sign-in isn't revocation, it's a delay.
    
    QUOTAS are requests per calendar month and bytes of imported audio, 0 =
    unlimited. Calendar month, not a rolling window — "12 left until the 1st" is
    something a person can plan around. Batches are checked against the whole
    batch, so a 30-track album with 5 requests left is refused up front rather
    than accepted and abandoned halfway. Storage is measured from the file
    actually written, not the size the peer advertised. Admins are exempt.
    
    ADMIN PANEL: host CPU/memory/disk read straight from /proc — no psutil, no
    compiled dependency, and in a container /proc reports the host, which is what
    you want for a homelab box. Plus library totals, slskd connection state, a
    30-day chart, per-user and per-artist breakdowns, and inline role/quota
    editing. The chart is hand-rolled SVG: a charting library would add ~150KB for
    one stacked bar chart and most of them inject <style> at runtime, which this
    app's CSP blocks.
    
    Guard rails: the last active admin cannot be demoted, disabled or deleted;
    disabling revokes sessions immediately; deleting a user keeps their jobs,
    because the history records what is in the library and why. Requests are
    private by default — users see their own, admins see all — and retrying
    someone else's job returns 404 rather than 403, since a distinct error would
    confirm the id exists.
    
    Also drops AnimatePresence mode="wait" from the tab container. It gates the
    incoming panel on the outgoing one's exit animation reporting finished, so
    anything that stalls the frame loop leaves the UI stuck on the old tab with no
    way forward — found while testing, where a backgrounded page froze navigation
    entirely. A cosmetic transition should never gate navigation.
    
    Verified: admin endpoints 200/403/403/401 for admin/user/guest/anon; last-admin
    demote, disable and delete all 409; disable revokes mid-session; quota refuses
    the 3rd of 2 and a 30-track batch, then succeeds once raised; users see only
    their own jobs; cross-user retry 404s; bad role/provider/duplicate/empty patch
    all rejected; multi-provider chooser and both redirects correct; three CSRF
    variants 400; preflight refuses unknown providers, missing secrets, and an
    empty database with no allowlist. CPU, memory and disk confirmed populated
    inside the linux/amd64 container.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>