## When things go wrong

Thirty rounds built the happy path. A phone is also in a lift, signed out, on
a dead cell, or holding a library folder that has gone away, and until this
round every one of those showed up as an empty box, a spinner with no end, or
a Rust string in a red toast.

**One translator**, `src/mobile/trouble.js`. `say(e)` takes what the backend
actually hands up — `ClientError`'s Display impls (`io: {0}`, `protocol: {0}`,
`login failed: INVALIDPASS`, `user {0} is offline or unknown`, `transfer
rejected: {0}`, `timed out`), the commands' own literals (`not connected to
Soulseek`, `no stored credentials`, `no cache dir`, `could not reach server:
{e}`), and rusqlite or `std::io` through `e.to_string()` — and answers with a
sentence a person can act on. The rule that matters: **the stores keep the raw
string and only the DRAWING is translated.** `player.error` still holds what
Rust said, Diagnostics and the walk still read it, and `say()` is called at the
one place the toast is written — which is why fifty-nine `String(e)` sinks were
fixed without touching fifty-nine call sites, and why the sixtieth is covered
too. A message the backend already wrote in English ("The playlist needs a
name") matches no rule and passes through untouched.

The worst one it retires was on the sign-in card: a phone with no DNS drew, in
red under the password box, `could not reach server: io: failed to lookup
address information: no address associated with hostname`. It now says
"Couldn't reach the Soulseek server. Check the connection and try again."

The red toast gained the notice's shape — the sentence, and one action beside
it. **Try again** when retrying is the answer (a resolve that found no sources,
a library read that failed at launch), **Sign in** when `signedOut()` says the
trouble is that nobody is. The action is tied to the exact message it belongs
to (`player.retry = { for, fn }`), so a later, different error can never
inherit the last one's button.

What each screen says now, where it used to say nothing or worse:

- **Tracks** — a rejected `library_track_index` drew "0 tracks · 0:00 · unable
  to open database file" and then "No tracks match.", which is a failed read
  dressed up as an empty library. Now: "Couldn't read the library index", the
  translated reason, and a Try again. "No tracks match" only when the read
  SUCCEEDED.
- **A playlist** — a rejected `playlist_items` said "This playlist is empty."
  Empty now means empty; failed says so, with a Try again.
- **A catalog record** and **an artist page** — the catalog's own error is
  translated, the record's stub offers a Try again, and the artist's stub says
  "the catalog didn't answer" rather than "had nothing to add" when that is
  what happened.
- **Gaps** (Library) — the lookup's `catch` was empty, so the line flipped back
  to "See what's missing" and could be tapped for ever. It now says "Couldn't
  reach the catalogue — tap to try again".
- **Home** — `home.svelte.js` sets `slot.error` and nothing read it: offline
  with follows, Home drew empty rails and said nothing. One line under the lead
  now does, and points at the library below.
- **Search** — the whole network section sat inside `{#if online}` with no
  `{:else}`, so signed out with a query typed there was no row and no reason,
  just a gap. Now there is a line and a door to Community.
- **The lyrics face** — a lookup that FAILED was drawn as "No lyrics found for
  this track.", and that branch was also the only one of the four with no `⋯`
  button, so Paste lyrics was unreachable exactly when you wanted it. Both are
  fixed: a failure says so, and both branches carry the `⋯`.
- **Transfers** — `failed · <rust>` on a row is `failed · <sentence>`.

**Two spinners were given an end.** `request_room_list` is fire-and-forget over
the wire (crates/soulseek-client/src/lib.rs — the client sends `room_list` and
there is no reply channel and no timeout), so "Asking the server for its
rooms…" could sit there for ever. `slsk.roomsWaitMs` (12 s) arms a timer in
`refreshRooms()`, the `soulseek:room-list` handler clears it, and a server that
never answers gets "The server hasn't sent its room list." and a Try again.
Tracks' own "Reading the index…" has its failure branch above. Everything else
was measured and left alone because it is already bounded: `user_info` 30 s,
`user_interests` 20 s, a browse 45 s, a login 15 s, a net search 15 s.

**One tap could wipe the library.** `rescan_library` (src-tauri/src/pipeline.rs)
ran `prune_missing()` FIRST, and `prune_missing` deletes every track row whose
path does not `exists()`, then every album left with no tracks. With the root
unreachable — an unmounted card, a revoked SAF grant, the drive-letter class of
trouble — You → **Rescan library** emptied the whole index, silently, and
returned `Ok(())`. There is now a guard above the prune: make the root if it is
merely not there yet (a fresh install still works), and refuse with
`library-folder-missing: <path>` if it cannot be made or read. You catches that
into a row under Library folder with the path and what to do about it, and the
toast says it in English.

