2 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>
  • 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>