Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/player.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,20 @@ jobs:
lint-and-test:
name: lint + test
runs-on: ubuntu-latest
# A cold run takes ~10 min; the cap keeps a hung step (e.g. apt during a
# mirror/DNS outage) from holding the PR for GitHub's 6-hour default.
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

# webkit2gtk + appindicator for tauri, alsa for rodio, dbus for the
# keyring's secret-service backend. Same list as release.yml's player job,
# player/Makefile and docs/agents/player/testing.md — keep them in sync.
- name: Install system dependencies
timeout-minutes: 10
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
sudo apt-get -o Acquire::Retries=3 update
sudo apt-get -o Acquire::Retries=3 install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libasound2-dev libdbus-1-dev

Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ jobs:
target: x86_64-apple-darwin
bundles: dmg
runs-on: ${{ matrix.os }}
# A cold build takes ~10 min; the cap keeps a hung step (e.g. apt during a
# mirror/DNS outage) from holding the release for GitHub's 6-hour default.
timeout-minutes: 45
defaults:
run:
# One set of steps for all three OSes (Windows defaults to PowerShell).
Expand All @@ -172,9 +175,10 @@ jobs:
# Same list as player.yml plus patchelf for the AppImage.
- name: Install system dependencies (linux)
if: runner.os == 'Linux'
timeout-minutes: 10
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
sudo apt-get -o Acquire::Retries=3 update
sudo apt-get -o Acquire::Retries=3 install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libasound2-dev libdbus-1-dev patchelf

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`):

Expand Down
6 changes: 5 additions & 1 deletion docs/agents/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 66 additions & 4 deletions docs/agents/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -825,8 +845,9 @@ the note that it blocks saving (above).

**One `<audio>` element** (`new Audio()`, created once, never in the DOM) plays
every track: a track change — `ended`, a skip, a pick — re-points its `src` and
calls `play()`. Nothing is fetched ahead, so each change costs the next track's
fetch and decode before it sounds; there is no gapless playback.
calls `play()`. Nothing is buffered ahead (the warm-up below only wakes the
connection), so each change costs the next track's fetch and decode before it
sounds; there is no gapless playback.

Two elements swapping roles — one playing while the other pre-buffered the next
track — were tried for seamless transitions and removed (2026-10-25). Live
Expand All @@ -848,6 +869,47 @@ to come back. `usePlayer.trackChange.spec.ts` pins both: its fake element
refuses `play()` until a tap has started it, and the assertions run straight
after the event, with no await.

**Staying alive on a locked phone.** The track boundary is where a backgrounded
phone stops playback: the next track is the first request in minutes, and it
meets a dozing radio, a Wi-Fi ↔ mobile handover or an expired key. Four pieces
cover it, all in `usePlayer.ts`:

- **Intent, not element state.** `wantsPlayback` is set by `play()` and
cleared by any pause that is not a track's end (`el.ended` — the user's, or
the system's: headphones out, a call), by `clearQueue`, by the queue running
out, and by a refused session. Recovery and the watchdog act only on it, so
they never start sound nobody asked for.
- **Recovery** (`recover` → `attemptRecovery`): an element `error`, a `play()`
rejected for anything but autoplay (`NotAllowedError` just stops wanting
playback — only a tap can help) or a stall is recovered in place, at its
position. Each attempt asks `subsonicClient.checkSession()` first: `ok` →
re-point at the fresh URL and resume from `loadedmetadata`; `unreachable` →
banner, then retry after 1 s, 2 s … 30 s, or at once on `onReconnect`;
`refused` → stop, the session handling owns it. A direct `streamUrl` (radio)
is re-pointed without asking. A track that keeps failing with a decode/format
error while the server answers is skipped after two re-points; a broken-off
transfer (`MEDIA_ERR_NETWORK`) or a stall never counts toward that. Any
track change cancels the recovery under way.
- **Stall watchdog**: armed by `play()` and by `waiting`/`stalled`, disarmed by
progress. A position that has not moved for 15 s while playback is wanted is
recovered — a swallowed load does not always end in an error.
- **Warm-up**: 30 s before a track ends, a `Range: bytes=0-65535` request for
the next one (through `httpFetch`, body drained and dropped) wakes the radio
and the connection while the page is still busy playing. An answer that is
not audio (a refused key's Subsonic error) or none at all runs
`checkSession()`, so the next URL is built with a good key, or the banner is
already up. It is not pre-buffering: the element still fetches the track
itself, and the single-element rule above stands.

**Playback log** (`lib/playbackLog.ts`): the last 300 player and page-lifecycle
events — loads, `playing`, `ended`, errors with their `MediaError` code,
`waiting`/`stalled`, recovery attempts and session checks, warm-ups, the page
hidden/shown/frozen/resumed, browser online/offline — persisted to
localStorage (`aether:playbackLog`), shown newest first on About
(`PlaybackLogPanel`, with Copy and Clear). A locked phone leaves no other
trace. **Never log a URL**: stream URLs carry the `apiKey`; entries name track
ids only.

`loadTrack` seeds `duration` from the catalog's length until the element reports
the stream's own, so the timeline and the lock screen read right while the next
track loads. Queue/volume/shuffle/repeat persist to localStorage.
Expand Down
115 changes: 115 additions & 0 deletions webui/src/components/about/PlaybackLogPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import Button from 'primevue/button'
import { clearPlaybackLog, formatPlaybackLog, formatTime, playbackLog } from '@/lib/playbackLog'

// The player's on-device log (lib/playbackLog.ts), newest first: the only
// record of why playback stopped on a locked phone. Copy hands the whole log,
// oldest first, to a bug report.
const newestFirst = computed(() => [...playbackLog.value].reverse())

const copied = ref(false)
let copiedTimer: ReturnType<typeof setTimeout> | null = null

async function copyLog(): Promise<void> {
try {
await navigator.clipboard.writeText(formatPlaybackLog(playbackLog.value))
copied.value = true
if (copiedTimer) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => (copied.value = false), 2000)
} catch {
// No clipboard (insecure origin, denied): the log stays readable here.
}
}
</script>

<template>
<div class="playback-log">
<p class="log-intro">
What the player and the page went through — track loads, errors, stalls, recovery
attempts, the page being hidden or frozen. When playback stops on its own, copy this
into a bug report.
</p>
<div class="log-actions">
<Button
class="playback-log-copy"
size="small"
severity="secondary"
:label="copied ? 'Copied' : 'Copy'"
:icon="copied ? 'ms-check' : 'ms-content-copy'"
:disabled="playbackLog.length === 0"
@click="copyLog"
/>
<Button
class="playback-log-clear"
size="small"
severity="secondary"
text
label="Clear"
:disabled="playbackLog.length === 0"
@click="clearPlaybackLog"
/>
</div>
<p v-if="playbackLog.length === 0" class="log-empty">Nothing recorded yet.</p>
<ol v-else class="log-list">
<li v-for="(entry, i) in newestFirst" :key="playbackLog.length - i" class="log-entry">
<time class="log-time">{{ formatTime(entry.at) }}</time>
<span class="log-event">{{ entry.event }}</span>
<span v-if="entry.detail" class="log-detail">{{ entry.detail }}</span>
</li>
</ol>
</div>
</template>

<style scoped>
.log-intro,
.log-empty {
margin: 0;
color: var(--app-text-secondary);
}

.log-actions {
display: flex;
gap: 0.5rem;
margin: 0.75rem 0;
}

.log-empty {
font-size: 0.9rem;
}

.log-list {
list-style: none;
margin: 0;
padding: 0.5rem 0.75rem;
max-height: 20rem;
overflow-y: auto;
border: 1px solid var(--app-border);
border-radius: 6px;
background: var(--app-surface);
font-family: monospace;
font-size: 0.8rem;
}

.log-entry {
display: flex;
flex-wrap: wrap;
gap: 0 0.75rem;
padding: 0.15rem 0;
}

.log-time {
color: var(--app-text-secondary);
}

.log-event {
color: var(--app-text-primary);
font-weight: 600;
}

.log-detail {
color: var(--app-text-secondary);
min-width: 0;
overflow-wrap: anywhere;
}
</style>
53 changes: 53 additions & 0 deletions webui/src/components/about/__tests__/PlaybackLogPanel.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import PlaybackLogPanel from '@/components/about/PlaybackLogPanel.vue'
import { clearPlaybackLog, logPlayback } from '@/lib/playbackLog'

const mountPanel = () => mount(PlaybackLogPanel, { global: { plugins: [PrimeVue] } })

beforeEach(() => {
clearPlaybackLog()
})

describe('PlaybackLogPanel', () => {
it('says so when nothing is recorded, with its buttons disabled', () => {
const wrapper = mountPanel()
expect(wrapper.find('.log-empty').exists()).toBe(true)
expect(wrapper.find('.playback-log-copy').attributes('disabled')).toBeDefined()
expect(wrapper.find('.playback-log-clear').attributes('disabled')).toBeDefined()
})

it('lists the entries newest first', () => {
logPlayback('load', 'tr-1')
logPlayback('error', 'tr-1 code 2')
const wrapper = mountPanel()
const events = wrapper.findAll('.log-event').map((e) => e.text())
expect(events).toEqual(['error', 'load'])
expect(wrapper.find('.log-detail').text()).toBe('tr-1 code 2')
})

it('copies the log oldest first', async () => {
const writeText = vi.fn(() => Promise.resolve())
vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } })
logPlayback('load', 'tr-1')
logPlayback('ended')
const wrapper = mountPanel()

await wrapper.find('.playback-log-copy').trigger('click')
await flushPromises()

const text = (writeText.mock.calls[0] as unknown as [string])[0]
expect(text.split('\n')).toHaveLength(2)
expect(text).toMatch(/load tr-1\n.* ended$/)
expect(wrapper.find('.playback-log-copy').text()).toContain('Copied')
vi.unstubAllGlobals()
})

it('clears', async () => {
logPlayback('load')
const wrapper = mountPanel()
await wrapper.find('.playback-log-clear').trigger('click')
expect(wrapper.find('.log-empty').exists()).toBe(true)
})
})
Loading
Loading