From c94dd502bb8bcacb1282dc689636551bca26cb47 Mon Sep 17 00:00:00 2001 From: Andres Bott Date: Wed, 11 Nov 2026 14:15:55 +0100 Subject: [PATCH 1/2] feat(webui): retry failed requests, recover mobile playback after network loss, and add a playback log --- AGENTS.md | 2 +- docs/agents/authentication.md | 6 +- docs/agents/frontend.md | 70 +++- .../src/components/about/PlaybackLogPanel.vue | 115 ++++++ .../about/__tests__/PlaybackLogPanel.spec.ts | 53 +++ .../src/composables/__tests__/useAuth.spec.ts | 45 +- .../__tests__/useConnectivity.spec.ts | 81 ++++ .../__tests__/usePlayer.recoverStream.spec.ts | 385 ++++++++++++++---- webui/src/composables/useAuth.ts | 21 +- webui/src/composables/useConnectivity.ts | 136 ++++++- webui/src/composables/usePlayer.ts | 351 ++++++++++++++-- webui/src/lib/__tests__/playbackLog.spec.ts | 55 +++ webui/src/lib/__tests__/retry.spec.ts | 100 +++++ webui/src/lib/api/Libraries.ts | 7 +- webui/src/lib/api/Metadata.ts | 20 +- webui/src/lib/api/Tokens.ts | 11 +- webui/src/lib/api/__tests__/Tokens.spec.ts | 10 +- webui/src/lib/api/__tests__/client.spec.ts | 65 +++ .../lib/api/__tests__/subsonic.remint.spec.ts | 15 +- .../lib/api/__tests__/subsonic.retry.spec.ts | 123 ++++++ webui/src/lib/api/client.ts | 34 ++ webui/src/lib/api/subsonic.ts | 57 ++- webui/src/lib/playbackLog.ts | 96 +++++ webui/src/lib/retry.ts | 92 +++++ webui/src/lib/subsonicSession.ts | 18 +- webui/src/main.ts | 51 ++- webui/src/views/AboutView.vue | 6 + webui/src/views/__tests__/AboutView.spec.ts | 4 +- 28 files changed, 1853 insertions(+), 176 deletions(-) create mode 100644 webui/src/components/about/PlaybackLogPanel.vue create mode 100644 webui/src/components/about/__tests__/PlaybackLogPanel.spec.ts create mode 100644 webui/src/lib/__tests__/playbackLog.spec.ts create mode 100644 webui/src/lib/__tests__/retry.spec.ts create mode 100644 webui/src/lib/api/__tests__/subsonic.retry.spec.ts create mode 100644 webui/src/lib/playbackLog.ts create mode 100644 webui/src/lib/retry.ts diff --git a/AGENTS.md b/AGENTS.md index fc22207e..b6a4325d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,7 +74,7 @@ a server behind a portal, offered on native servers too (see | `WishlistAddView` | `/wishlist/new` | `SearchView`'s layout pointed at MusicBrainz: one pill search box (the shared `SearchField`) with a Search button, and an All / Albums / Artists / Songs filter row (`?kind=`, absent for All). It searches only on Enter, the button or a filter click — never per keystroke, MusicBrainz allows one request per second — and `useWishlistSearch` caches each kind's answer for the submitted text, so narrowing All costs no request. Results come in sections (`WishlistResultRow`); picks survive a new search and the selection pill under the filters adds them all in one request. Results already listed read "On wishlist" and cannot be picked; results already in the catalog link there. Below the search, whatever it shows, `WishlistManualForm` adds an entry by hand (type, title, optional description) for music MusicBrainz does not list — no MBID, so no scan can mark it found — and returns to the wishlist | | `WishlistItemView` | `/wishlist/:id` | One entry: found status and, once found, the link to it in the library (`lib/wishlist.ts` `wishlistLibraryRoute`: the artist, the album, or a song's album), a MusicBrainz link (none for an entry added by hand), and editing through `EditActionBar` (Delete removes the entry): a MusicBrainz pick edits only its description (`note` in the API) — the rest is its MusicBrainz snapshot — while an entry added by hand edits every field, type, title and description (`PUT /wishlist/{id}` is a partial update; the server refuses `title`/`kind` on a pick) | | `UserSettingsView` | `/user-settings/:tab?` | Personal settings (identity, theme, change password, API tokens). Vertical tablist whose active section is the `tab` path segment (`general` \| `account` \| `access`), so a reload or shared link reopens it; bare `/user-settings` is General, an unknown/unavailable section rewrites back to it. `account` holds the change-password form and appears only in native mode when signed in; `access` needs a signed-in user and `useAuth().managementApi`, so a key session has no Connected apps tab. Reached from the sidebar's `UserMenu` popup, not from `/settings` | -| `AboutView` | `/about` | About Aether (keyboard shortcuts reference, build info, source link). In the desktop app the build info also lists the player's version (App) and the server's address (Server), and labels the server's version as such. Reached from the `UserMenu` popup | +| `AboutView` | `/about` | About Aether (keyboard shortcuts reference, build info, the playback log — `PlaybackLogPanel`, the on-device record of player and page-lifecycle events for diagnosing stops on a locked phone — source link). In the desktop app the build info also lists the player's version (App) and the server's address (Server), and labels the server's version as such. Reached from the `UserMenu` popup | **Settings views** — nested under `/settings` with `meta: { layout: 'settings' }` (not the music layout); `/settings` redirects to `/settings/libraries`. Settings is administration only: account concerns (user settings, logout) live in the sidebar's `UserMenu` popup. The whole area is **admin-only**: `useAuth().isAdmin && managementApi` (a key session has no `/api/v0`) gates the `UserMenu` Admin entry and `App.vue` redirects everyone else landing on a settings route; the backend enforces it with 403 on `/api/v0` (see `docs/agents/authentication.md`): diff --git a/docs/agents/authentication.md b/docs/agents/authentication.md index e23478d2..7c3d24ae 100644 --- a/docs/agents/authentication.md +++ b/docs/agents/authentication.md @@ -213,7 +213,11 @@ mirroring the tokenID oracle the `pat` library warns about. Mint-time sweep is per-device (see "One session per device" above); `GET /api/v0/auth/tokens` reports live spa tokens as `kind: "session"` and drops -expired ones. A boot-mint that +expired ones. A boot mint that gets no answer at all (`remintApiKey` → +`unreachable`) is not a verdict on the session: it raises the connectivity +banner, spends no retry budget, and the credential watcher mints again once +the banner's probe finds the server (`onReconnect` in `useAuth.ts`) — the login +gate's purge would empty the queue over a network blip. A boot mint that keeps failing for a non-401 reason surfaces the login gate, whose purge refetches `/me` and re-runs the mint watcher — so the SPA caps consecutive failed mints (`MAX_MINT_ATTEMPTS` in `webui/src/lib/subsonicSession.ts`) and then leaves the diff --git a/docs/agents/frontend.md b/docs/agents/frontend.md index 1f8ec7f5..216bae61 100644 --- a/docs/agents/frontend.md +++ b/docs/agents/frontend.md @@ -138,6 +138,25 @@ When a view diverges from these registries, the registry wins. `isMeResponse` is what counts as Aether's `/me`: `getMe` rejects any other answer with `NotAetherMeError`, and the probe applies the same check. - `types/` — one file per API domain, mirroring backend response shapes. +- `lib/retry.ts` + `composables/useConnectivity.ts` — **network failures heal + on their own.** Both clients retry a request that got no answer (or a + 502/503/504) twice, 400 ms then 1.5 s: `subsonic.ts` `send()` for every + `/rest` call except the additive ones (`createPlaylist`, `updatePlaylist`, + `createInternetRadioStation`, `scrobble` — a dropped request may have + landed), `client.ts`'s first response interceptor for GET/HEAD/OPTIONS/PUT/ + DELETE and a POST that passes `idempotent: true` (the per-device mint, the + reads carried in a body). Anything the server answered is final. Past that, + a failure raises the connectivity banner, and while it is up a probe — + `checkSession()` (a `/rest` ping, which re-mints an expired key on the way), + or `/api/v0/version` before the client is configured — runs on a 2 s → 30 s + backoff, at once on the browser's `online` event or when the page is shown + again (both are hints, never proof: a phone's are unreliable). Recovery runs + the `onReconnect` listeners: `main.ts` refetches every errored active query, + the credential watcher re-runs a mint the network swallowed, and the player + resumes a track that died with it. Queries and mutations run with + `networkMode: 'always'` — the default parks them as paused on `offline` until + an `online` that may never come, which was the "works again only after a + reload" bug. - `lib/apiError.ts` — **the only place a thrown API error becomes text.** Use `apiErrorMessage(err, fallback)` instead of re-deriving `err?.response?.data?.error ?? err.message`: it reads the server's @@ -404,8 +423,9 @@ folder's, so a key session offers it only for an upload staged in that edit. `canSignOut` (a native or a key session) is what shows Log out. In a key session `client.ts` refuses to send at all, and a `/rest` error 40 or 44 sets `sessionExpired` instead of minting (`handleRestAuthFailure` in -`lib/subsonicSession.ts`; `remintApiKey` answers `failed` without a verdict, so -a media element error reports connectivity rather than signing out); the expiry +`lib/subsonicSession.ts`; `remintApiKey` answers `failed` without a verdict, and +the player's recovery asks a ping before it re-points, so a media element error +alone never signs out); the expiry purge forgets the key (`clearApiKey`) before the usual scrub. `useMe` is disabled until a server is known (`enabled` — a disabled query is @@ -825,8 +845,9 @@ the note that it blocks saving (above). **One `