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>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-09-02 11:33:52 -05:00
1 parent 0dca779f59
commit e1b7b69261
47 files changed
+2939 -805

No files matched your search

+88 -7
View File
@@ -148,8 +148,9 @@ automatically. Every job shows what it *resolved to*, so a bad auto-pick is
visible in the list rather than quietly landing the wrong song. Lines that
match nothing are reported back as skipped instead of failing the batch.
Failed jobs get a **Retry** button. Most Soulseek failures are the peer going
offline, not a bad request, so retrying later usually just works.
Failed jobs get a **Retry** button, but far fewer jobs reach that state now:
a request that loses its peer falls through to the next candidate on its own.
See "When a peer says no" below.
### Everything goes through a bounded queue
@@ -179,10 +180,83 @@ is needed to catch `Come As You Are (Live)`, but substring matching would also
penalise `Deliver Me` and `Olive`. And a term only counts against a result when
you didn't type it, so `oasis live forever` and `nirvana unplugged` still work.
**Picking the file (Soulseek).** Format preference first, then whether the peer
can actually serve you (free upload slot, short queue), then filename
plausibility, then bitrate. A marginally better file behind 200 queued users
loses to a good-enough one available now.
**Picking the file (Soulseek).** Whether the peer can actually serve you comes
first — a free upload slot and a short queue — then format preference, then
filename plausibility, then bitrate. A marginally better file behind 200 queued
users loses to a good-enough one available now.
That order is deliberate and was once the other way round, which was a bug
worth naming: format outranked availability, so a FLAC behind a five-thousand
deep queue always beat an MP3 that was free to download immediately. Whether
the peer can serve you is the single most predictive signal for whether the
job ever finishes, so nothing outranks it.
## Searching for a name the file doesn't use
Two things go wrong between "what Deezer calls this track" and "what a stranger
named the file", and both used to end the request.
**The Soulseek server filters some artist names.** This is measurable: on a
healthy, logged-in client, `Michael Jackson Billie Jean` and bare
`Michael Jackson` both return **zero** responses, while `Billie Jean` returns
over a thousand files. The same is true of The Weeknd, Kanye West and Frank
Ocean. Sending one query of `artist title` — which is all muse used to do —
means every request for a filtered artist fails reporting "searched 0 files",
indistinguishable from a track nobody shares.
So muse tries a **ladder** of queries, loosest last, and stops at the first
rung that yields enough candidates:
```
Michael Jackson Billie Jean 0 responses
Billie Jean 251 responses, 1083 files
```
The rungs drop, in order: a parenthetical suffix (`Novacane (Explicit Version)`),
a `feat.` credit, a leading article, everything after a `/` in a split title,
and finally the artist itself. Since most requests are answered by the first
query, the extra searches cost nothing in the common case — `MUSE_MAX_QUERIES`
caps how far it will go.
Dropping the artist from the *query* does not mean ignoring it. The artist is
then matched against the whole **folder path**, which is where a well-organised
share puts it anyway:
```
anjya music/Michael Jackson/1982 - Thriller [flac]/06 - Billie Jean.flac
^^^^^^^^^^^^^^^^ artist found here
```
**Names differ in punctuation and accents.** Normalisation folds accents away
before comparing, so `Beyoncé` matches a file called `Beyonce`, `Björk` matches
`Bjork`, and `Don't Stop` matches `Dont Stop`. Apostrophes are removed rather
than turned into a space; `&` still separates, so `R&B` stays two tokens
instead of collapsing into the unrelated word `rb`. Track numbers and release
tags (`02 - `, `[320kbps]`) are stripped before the comparison, and a
reordered filename (`Creep - Radiohead`) scores the same as `Radiohead - Creep`.
The title has to stand on its own: a matching artist cannot carry a filename
whose title is wrong. Without that floor, a strong artist match let a
*different song by the right artist* through.
## When a peer says no
`Transfer rejected: File not shared.` is the commonest failure on Soulseek —
the peer's share index is stale and the file it advertised is gone. It is a
condition of one *file*, not of the request, and the right response is to take
the next candidate.
muse keeps the whole ranked list (`MUSE_CANDIDATES`, default 8) on the job row
rather than only the winner, so when a transfer is rejected, times out, errors,
or slskd cannot open a connection to the peer at all, the next candidate is
enqueued instead of the request failing. The list is ordered to put a different
peer next, so one offline user cannot burn three attempts in a row. Only when
the list is exhausted — or `MUSE_MAX_ATTEMPTS` is reached — does the job fail,
and it then reports every peer it tried and what each one said.
A download that sits with no bytes for `MUSE_STALL_MINUTES` is abandoned the
same way, as is one slskd has forgotten about. Both used to leave the job in
`downloading` forever, with a cheerful pulsing dot and no log line.
## Review: when muse isn't sure
@@ -470,7 +544,14 @@ stat -c "%h" "/data/media/music/<Artist>/<Album> (<Year>)/<NN> - <Title>.flac"
| `MUSE_LIBRARY_DIR` | `/data/media/music` | where the library lives |
| `MUSE_DB` | `/config/muse.db` | job history |
| `MUSE_FORMATS` | `flac,mp3` | ordered preference; anything else is rejected |
| `MUSE_MIN_MATCH` | `0.45` | filename-to-title similarity floor |
| `MUSE_MIN_MATCH` | `0.45` | filename-to-track similarity floor |
| `MUSE_TITLE_FLOOR` | `0.5` | the title alone must clear this before the artist counts |
| `MUSE_CANDIDATES` | `8` | ranked candidates kept per request, for fallback |
| `MUSE_MAX_ATTEMPTS` | `5` | peers tried before a request is given up on |
| `MUSE_STALL_MINUTES` | `20` | a transfer moving no bytes for this long is abandoned |
| `MUSE_MAX_QUERIES` | `4` | rungs of the search ladder to try |
| `MUSE_MIN_CANDIDATES` | `3` | below this, escalate to the next query |
| `MUSE_SEARCH_RETRIES` | `3` | attempts at issuing a search before failing |
| `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 |