Commit Graph
17 Commits
  • Make the artist a gate, not a quarter of the score
    Requesting "Michael Jackson - Chicago" filed The Fall's "Chicago, Now!" from
    Extricate. The chain: the artist name is filtered by the Soulseek server, so
    the ladder correctly fell back to the bare title -- at which point the result
    set is every song anyone has ever called Chicago, and the artist is the only
    thing telling them apart.
    
    match_ratio weights the artist at 0.25, so a perfect title with no artist
    anywhere scores 0.75. That clears MIN_MATCH, and the ~22 point gap it leaves
    against a genuine match is nothing next to the 340 points score() awards for a
    free upload slot and a short queue. An idle peer holding the wrong song
    therefore beat a busy peer holding the right one, every time.
    
    That is the failure the confidence score was explicitly designed to prevent --
    "a fast peer must not be able to raise the confidence of a wrong match" -- and
    rewriting score() to rank availability first reintroduced it on the other side
    of the wall. Availability is a tiebreak among plausible files; it is never a
    reason to take a different song.
    
    Candidates whose path names the artist are now kept outright and the rest
    discarded, falling back to title-only matches only when there are none. The
    downstream confidence gate still ran correctly here: it scored 0.75, said
    "artist not found in path", and held the file for review.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Correct the reasoning behind the attempt backoff
    baa936e claimed muse's enqueue burst stalled slskd and took other users'
    searches down with it. That is not what happened, and the log says so plainly:
    "Failed to resolve search response" timeouts run at 10-178 per hour, every
    hour, for as long as the log is retained -- they are constant background from
    slskd answering other people's searches over a network full of unreachable
    peers. Seeing three of them in the same minute as five of muse's own enqueue
    timeouts was a coincidence I read as causation.
    
    What is actually true is narrower: an unreachable peer is common, slskd reports
    it as a 500, and the ladder exists to walk past it. The backoff stands as
    ordinary politeness rather than a fix, and the README now says that instead of
    claiming a harm that was never measured.
    
    No behaviour change.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Try the whole ladder, rather than five eighths of it
    MAX_ATTEMPTS was 5 while CANDIDATES was 8, so three ranked candidates were
    gathered and then never tried. The cap was arbitrary and the live numbers show
    what it cost.
    
    slskd answers 500 whenever it cannot open a connection to a peer, and that is
    common: retrying "Michael Jackson - Billie Jean" walked four unreachable peers
    before the fifth served it -- succeeding on precisely the last attempt the cap
    allowed. "The Weeknd - Die For You" ran the same gauntlet and lost, giving up
    with three untried candidates still on the row.
    
    Defaults to CANDIDATES, so the ladder is the ladder. Anyone wanting the old
    behaviour can still set MUSE_MAX_ATTEMPTS.
    
    The cap test lowers the value explicitly now; with the two equal by default it
    was only ever proving the list ran out, which is the other branch.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Stop the candidate ladder from hammering slskd through a stall
    A live request for "Michael Jackson - Chicago" exercised both new paths
    correctly -- the blocked artist name fell back to the bare title and found real
    Xscape rips, and the ladder tried five peers instead of dying on the first --
    and then failed anyway, with all five reporting a 500 from slskd.
    
    slskd's own log shows why, and it is not the peers. Every timeout in the
    preceding hour landed inside the single minute muse was enqueueing, and in the
    same window slskd also failed to resolve three OTHER users' searches. Each
    enqueue blocks about five seconds waiting on a peer message connection, so five
    back-to-back attempts is twenty-five seconds of sustained pressure on one
    subsystem. The ladder turned one bad moment into five spent candidates and a
    dead request, and made slskd worse for everyone else while doing it.
    
    Attempts are now spaced by MUSE_ATTEMPT_BACKOFF. Failures are classified: "File
    not shared" is about a file and still advances at once, while a timeout is about
    slskd and, after MUSE_SYSTEMIC_LIMIT of them in a row, stops the walk and leaves
    the untried candidates on the row. A retry resumes that ladder instead of
    re-running a search whose answer has not changed.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Count the peers actually tried, not the attempt counter
    A job carried over from before the candidate ladder has attempts=0 while
    carrying a real failure in `tried`, so the summary rendered as "tried 0 peers:
    1x no bytes in 20m" -- a count of zero next to the evidence contradicting it.
    The live instance produced exactly that when the stall check finally cleared
    its long-stuck job.
    
    Counts distinct usernames in `tried` instead. The two agree for anything this
    version enqueued; the list is the one that is right in both cases.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Age a stalled transfer from created_at when started_at is missing
    The stall clock read started_at and returned zero when it was absent, which
    exempted every row written before that column existed -- precisely the jobs
    that have been stuck the longest. The live instance had one: a `downloading`
    job from before the upgrade, its transfer parked in Queued, Remotely, which
    the new check would have skipped forever.
    
    Falls back to created_at, which for a job still in `downloading` is the same
    question asked a slightly different way.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • 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>
  • Publish arm64 alongside amd64
    muse is now the front door of a public project, and it was the only
    image in that stack without an arm64 build. Raspberry Pis, arm64 NASes
    and Ampere VPSes are a large share of self-hosters, so amd64-only made
    the whole stack un-runnable for them — gluetun, slskd, soularr, lidarr,
    navidrome and the socket proxy all already publish both.
    
    Three parts, because changing `platforms:` alone would have failed:
    
    - setup-qemu-action registers the binfmt handlers buildx needs to
      produce arm64 on an amd64 runner. Without it the build errors with
      "exec format error"; it does not quietly fall back to amd64.
    - The web stage is pinned to --platform=$BUILDPLATFORM so the Next.js
      build runs natively once instead of being emulated per platform. It
      is the expensive step, and its output is static HTML/JS/CSS with no
      native code, so the bytes are identical either way. This is what
      makes the old comment's "3-5x slower for nothing" no longer true.
    - platforms: linux/amd64,linux/arm64.
    
    Verified locally before pushing: both platforms build in ~52s, and the
    loaded arm64 image reports aarch64 with uvloop, watchfiles, httptools
    and pydantic_core all importing, `import app` succeeding, and the
    static UI present.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Bind invites to a provider, and email them
    Wanted shape: the owner signs in with GitHub, everyone invited later signs in
    with Google. That was previously a convention, not a boundary -- may_login
    checked only that a live row existed, so any enabled provider could satisfy
    any invite.
    
    Now an invite is bound to the provider it was issued for. friend@gmail.com
    invited for Google cannot be redeemed over GitHub, and there is never a
    second, weaker door onto an existing account. The per-request re-check still
    passes provider=None, because there the question is only whether the account
    is still live.
    
    Two related fixes fell out:
    
      - upsert_user no longer refreshes `provider` from the login. It is part of
        the identity an admin issued and may_login now enforces it, so overwriting
        it on sign-in would let a successful login silently rewrite the very rule
        that permitted it.
      - a Google invite must be a full email address. A bare username would
        authorise something Google can never return, and the invitee would see
        only "not authorised" with no way to work out why.
    
    INVITE EMAILS. Adding a user now mails them, when SMTP is configured and there
    is an address -- for Google there always is, since the identity IS one. The
    response reports whether the mail actually went and why not, because "Invited"
    alone reads as success while the person waits for an email nobody sent.
    
    The link carries NO token, deliberately. The row already names the exact
    identity permitted and Google will not hand us an unverified address, so
    possession of the mail is not what authorises anyone: a forwarded invite
    grants nothing, nothing expires, and resend is always safe. A signed expiring
    token would only buy the ability to withdraw an unused invitation, which
    deleting the row already does.
    
    Resend is offered in the panel on anyone who has an address and has never
    signed in -- the likeliest reason being that they missed it.
    
    tests/test_invites.py: 29 assertions driving a real SMTP conversation against
    a sink server, plus the full provider-binding matrix. 119 tests pass across
    four suites.
    
    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>
  • Fix a path traversal out of the music library
    `year` was appended to the album directory name AFTER safe() had run, so it
    was the one path component reaching the filesystem unchecked:
    
      POST /api/request {"artist":"E","title":"p","year":"../../../../tmp/pwned"}
      -> library/E/Singles (../../../../tmp/pwned)/p.flac
      -> normalises to ../tmp/pwned)/p.flac
    
    Any authenticated user could do it, guests included, and it wrote a real
    audio file to an arbitrary path the process could reach. Existing files were
    not overwritten -- tag_and_file returns early when the destination exists --
    so this created new files and directories rather than clobbering.
    
    Fixed in two layers, because the first is a property of five call sites that
    must each stay correct and the second cannot be forgotten:
    
      1. safe_year() whitelists \d{4}, applied in enqueue_job (the choke point for
         all three request paths) and again in tag_and_file.
      2. tag_and_file resolves the destination and refuses anything that is not
         under the library root, before mkdir rather than after.
    
    Also from the same pass:
    
      - Cap import at 500 pasted lines / 1000 expanded tracks. URL expansion runs
        BEFORE the quota check -- it has to, since a playlist URL is one line and
        200 tracks -- so without a cap a paste of 10,000 album URLs was 10,000
        requests to Deezer from this IP before any quota was consulted.
      - Clamp search limit (1-50), jobs limit (1-1000) and activity days (1-365).
        search limit fanned out one /album/{id} enrichment per distinct album.
      - safe_cover() requires https and <=300 chars. img-src permits data:, so
        the field was free storage for megabytes of base64 per job.
      - Truncate filename components to 150 chars; a long title used to fail the
        import with ENAMETOOLONG after the download had already completed.
      - Treat Deezer's 403 as no-results. Its WAF rejects injection-looking
        queries, which surfaced to the user as a 502 "service broken".
    
    Add tests/: 42 security assertions and 35 authorization assertions covering
    the role matrix, last-admin protection, mid-session revocation, job scoping
    and OAuth CSRF. All 77 pass.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Stop the admin panel hanging when slskd is down
    /api/admin/system probed slskd with the shared client, whose 30s timeout is
    sized for searches and transfers. So the panel sat on skeletons for the full
    30s exactly when slskd was unreachable -- the case you open it to diagnose --
    and the 5s UI poll stacked requests behind it. Probes now get 4s: above a LAN
    round-trip, under the poll interval.
    
    httpx.ConnectTimeout also stringifies to "", so the banner rendered
    "slskd unreachable -- " with nothing after the dash. Fall back to the
    exception class name.
    
    Add dev.sh, because the documented dev commands could not work: MUSE_DB
    defaults to /config/muse.db which a Mac cannot write, and the auth preflight
    refuses to boot without a client ID.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • 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>
  • Rebuild the UI on Next.js, and give search real metadata
    The old page was hand-written HTML/CSS/JS. That was fine for a search box and
    a list, but the roadmap here is multi-user, an admin dashboard with usage
    graphs, and an import-review queue — all of which get painful in vanilla DOM
    code. Moves to Next.js 16 / React 19 / Tailwind 4 / Framer Motion.
    
    Built as a STATIC EXPORT served by FastAPI. Running `next start` alongside
    uvicorn would add a Node runtime, a second process to supervise, and a second
    thing that can be down while the other is up. There is no use for SSR — every
    view is behind auth and renders from live JSON — so the deployment stays one
    container, one process, one healthcheck. Image is 60MB; Node only exists in
    the build stage.
    
    Search now carries what a music UI needs:
    
    - year and genre, grafted on from /album/{id} concurrently, deduplicated by
      album and cached forever since a release date is immutable
    - 30-second previews, so you can hear a track before committing a Soulseek
      search to it. Not a library player — that is Navidrome's job.
    - autocomplete over songs/albums/artists, debounced and aborted per keystroke
      so a slow response to "rad" cannot land after "radiohead"
    - album and artist pages, with `Get all` to queue a release in one request
    - the accent colour is sampled from the artwork, so the palette follows the
      music. Deezer's CDN sends ACAO:*, which is what makes canvas sampling work
      with no image proxy.
    
    /api/album/{id} deliberately calls Deezer twice: the track list embedded in
    the album response has no track_position, and using the list index instead
    misnumbers every multi-disc release — verified against the White Album, where
    disc 2 track 1 would have been filed as 18.
    
    Schema is multi-user ready ahead of the feature: users table with roles and
    quotas, requested_by on jobs, and the first login becomes admin. Auth is still
    the single-user GitHub allowlist.
    
    CSP survives all of this intact. Next emits inline hydration scripts, which
    script-src 'self' blocks; rather than per-request nonces, their SHA-256 is
    computed at startup from the built page — a static export never varies between
    requests, so hashes need no maintenance and no HTML rewriting. The one
    concession is style-src-attr 'unsafe-inline', because Framer Motion writes its
    pre-hydration state into style="" and without it every element flashes in.
    That permits style attributes only. img-src/media-src now name Deezer's CDN
    explicitly instead of allowing https: wholesale, closing an exfiltration
    channel that survived connect-src 'self'. /_next/* sits behind the session
    too — there is no local login page to style, so the bundle need not be public.
    
    Verified: unauthenticated /→302 and every /api/*→401 including the four new
    endpoints, forged state→400, forged cookie→401, misconfigured auth refuses to
    start, and the production image hydrates with zero CSP violations.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Add single-user GitHub OAuth, hardened for internet exposure
    muse drives a download client and writes to the media library, so exposing it
    publicly needs more than a login form.
    
    It fails closed. Auth is on unless MUSE_AUTH=off is set explicitly, and when
    it is on with incomplete config the app refuses to start rather than serving
    open. The mode worth engineering against is not a broken login page, it is a
    misconfigured deploy quietly publishing an unauthenticated download client.
    
    Sessions are server-side: the cookie carries a 256-bit random token and
    nothing else, and only its SHA-256 is stored. There is no signed payload to
    get subtly wrong, a stolen database yields no usable cookies, and logout
    revokes for real. The allowlist is re-checked on every request so removing a
    user takes effect immediately rather than at session expiry.
    
    OAuth state is compared with secrets.compare_digest, so a callback the user
    did not initiate is rejected. MUSE_BASE_URL comes from config rather than
    X-Forwarded-* because those headers are attacker-controllable and this one is
    a redirect target.
    
    CSP is default-src 'self' with no unsafe-inline, which is why the stylesheet
    and script moved out of index.html into static files -- an injected <script>
    now cannot execute. Also frame-ancestors 'none', base-uri 'none', nosniff,
    DENY, no-referrer and HSTS. FastAPI's /docs, /redoc and /openapi.json are
    disabled so the login page does not hand out a map of every endpoint.
    
    /healthz is the only unauthenticated route and returns a bare ok, so the
    container healthcheck works without leaking anything.
    
    Verified: misconfigured auth refuses to boot; unauthenticated / redirects and
    /api/* returns 401; a forged state is rejected 400; a junk cookie is 401; a
    valid session for a non-allowlisted user is 401; all headers present; and the
    UI renders with no CSP violations in the console.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • Add bulk import, and a bounded queue to make it safe
    Requesting songs one at a time does not scale past a handful, so this adds a
    paste-a-list import. It accepts "Artist - Title", en dashes, "Title by
    Artist", numbered lists, bare titles, and Deezer album/playlist URLs, which
    get expanded into their tracks.
    
    The queue is the load-bearing part. Every request previously spawned its own
    asyncio task, so a 50-track paste would have launched 50 concurrent Soulseek
    searches -- each 10-40s of polling and real load on slskd -- and drowned the
    client. Work now goes through an asyncio.Queue drained by MUSE_WORKERS
    consumers, default 3. Requests return immediately and the batch drains at a
    civil rate. The queue is in memory, so jobs left 'queued' or 'searching' are
    re-queued at startup rather than stranded by a restart.
    
    Ranking needed two fixes found by testing against the real API:
    
    Import was calling deezer_search(limit=1), which fetches two rows, so the
    ranker was choosing between a demo and a rehearsal take while the studio
    version -- six times the popularity -- sat unseen further down. It now pulls
    a real candidate pool.
    
    More seriously, the Deezer ranker had no title-relevance term at all: only
    artist match, popularity and penalties. A different song by the right artist
    therefore competed on equal footing, and "Oasis - Live Forever" resolved to
    "Cloudburst" as soon as a variant penalty tipped the balance. Title overlap
    now dominates, and the variant penalty is small enough only to break ties
    between versions of the same song.
    
    Derivative terms match on word boundaries rather than substrings, so a bare
    "live" catches "Come As You Are (Live)" without penalising "Deliver Me" or
    "Olive", and a term is ignored when the user typed it -- "oasis live forever"
    still works.
    
    Verified against live slskd: six tracks pasted, six resolved to the correct
    song, five imported with correct album/year/track and link count 2, in about
    two minutes. The sixth returned an empty Soulseek search and is retryable
    from the UI.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
  • muse: request a single song, get it tagged in your library
    Lidarr's smallest unit is a MusicBrainz release, so asking for one track
    means monitoring a whole discography. Soulseek trades individual files but
    has no concept of a request. muse bridges the two: search, click, and the
    song lands in the library correctly tagged.
    
    The design point is that the user picks the exact track before anything
    downloads, so muse holds artist, album, year and track number as ground
    truth and tags deterministically rather than inferring them afterwards from
    whatever a stranger named the file.
    
    Search is Deezer rather than MusicBrainz, decided on measurement:
    MusicBrainz put eight covers above the real recording for "radiohead creep"
    and never surfaced Queen for "bohemian rhapsody" across 90 results, because
    its index matches a word in a cover's title as readily as in the artist
    field. Re-ranking cannot rescue a result set missing the right row.
    
    Imports are os.link(), so downloads keep seeding from the Soulseek share
    while the library gets a second name for the same bytes.