46 Commits
Author SHA1 Message Date
austinried f46f43b6cd ui: finish the library info dialogs and add playlist info
Sort names and titles are only shown when they differ from the regular
name, so artists without a distinct sort name no longer repeat the name.

Track info uses plural labels for both the artists and their sort name
when the track has more than one artist, and singular labels otherwise.
Added and last played sit together in album info.

Playlists gain an Info dialog with owner, sharing, song count, length,
creation and update dates. Owner and the public flag are synced from
getPlaylists, so a schema migration adds both columns.
2026-10-21 13:54:37 +00:00
austinried c56a6d4f83 add library info dialogs and sort name preference
Album and artist context menus gain an Info dialog covering the plural
album artists, sort names and the play and download details the screens
do not show. Multiple artist links collapse into one ellipsised entry
that opens the artist chooser instead of listing each artist.

Sort lists can now prefer regular names over sort names, or only use
sort names for names without Latin letters. The new plain-name indexes
keep those orders out of a temp B-tree.

Also carry plural artists through the Media3 queue so Now Playing and
the mini player keep them, drop the album artist tap ripple, and show
the aggregate download progress in the tab bar where offline mode used
to sit.
2026-10-20 14:28:32 +00:00
austinried 77e3fa87f1 library: keep artist art maintenance self-healing and cheap
Move change detection into a desired CTE with UPDATE ... FROM so mass updates do not turn the trailing WHERE into a quadratic correlated scan, and so no-op passes write zero rows. Fold the trigger optimization into the 35->36 migration and re-check/rewrite it from onOpen, so a future migration cannot silently restore the full UPDATE triggers. Re-resolve artist detail art when the active source becomes ready, and cover artist search renames in a Room-level test.
2026-10-20 11:23:02 +00:00
austinried 0a5b3512da library: stop artist art updates from reindexing artist search
The materialized art update rewrote every artist row on every library-changing sync. That fired the FTS5 content-sync triggers for each artist, which was the real cost: the same SQL is ~10ms with the triggers removed and ~4.7s for 100 changed artists with them in place. Only update rows whose cover lists actually changed, and replace the generated artist search triggers with UPDATE OF name, sortName so art-only writes never touch the FTS index. Existing databases get this through migration 36->37; fresh databases get it from the onCreate callback.
2026-10-20 10:13:20 +00:00
austinried 18118492c7 library: materialize artist art and provider images
Store the OpenSubsonic artistImageUrl and two materialized cover-id lists on each artist row, backfill them in the 35->36 migration, and refresh downloaded covers only for affected artists when downloads change. Icons and the artist hero now resolve provider art, server artist art, then the same materialized grid synchronously, with a definitive-failure fallback so a dead provider URL does not strand the letter placeholder.

Download artwork also caches the provider image for the track and album artists, falls back to the artist cover when that fetch fails, and keeps both keys across sweeps. Query timing for the materialization pass is down from roughly 3.4s to 1.9s on the 20k-song benchmark.
2026-10-20 08:53:23 +00:00
austinried 194787dfcc library: cache on-demand artist biographies
Artist biographies come from getArtistInfo2, which the library sync does
not call. Store them in their own table so a page open can use the last
value immediately and downloads can keep it for offline use, without
pulling a large text column into every artist list query.

A missing row means the artist has never been fetched; a row with a null
biography means the server had none. That lets the page show the loading
state only until the first fetch completes.
2026-10-19 13:14:08 +00:00
austinried e0cfba489e library: align artist counts with credited songs
Artist badges and play rankings only counted album credits, while the artist
page, shuffle queue and download attribution already included track credits.
Derive albumCount and the artist plays from the same four-way credit union,
count lead ids that the server omits from the plural refs, and bump the song's
credited artists on a local scrobble even when the track has no album.

Sort the downloaded artist list by the downloaded album count it displays
instead of the synced total, and backfill the artist totals on upgrade so the
fix applies before the next sync.
2026-10-18 14:25:07 +00:00
austinried 9d9550be48 library: reconcile artist credits with playback and downloads
Left-join albums in the artist queue queries so albumless credited songs can
be played, downloaded and deleted. Add sortName to the starred index, persist
a per-source stale-credit marker so an interrupted sync cannot strand artist
counts or hide artists, and recompute artist play data in one grouped pass.
2026-10-18 06:42:40 +00:00
austinried c64cdd5172 library: track whether an artist has credits
Add artists.credited, updated at the end of a sync that changed artists or
credits and backfilled on upgrade when relation data exists. Filter the
artist lists and the downloaded artist list on it, which keeps partially
synced libraries visible and hides display-only rows once credits are known.
2026-10-18 05:59:36 +00:00
austinried 7d8a2dae9f library: sort and search by server sort names
Parse the OpenSubsonic sortName attributes, store sort keys on artists,
albums and songs, and sort on them instead of the display names. Resolve
album and track artist sort names from the artists table, and include the
keys in the FTS content so romaji sort names are searchable as well.

Schema 31 rebuilds the sort indexes and search tables, and backfills
existing rows from their display names.
2026-10-18 05:16:41 +00:00
austinried ba77b31dc4 library: store artist song counts for download badges
Add artists.songCount, recomputed after syncs that change albums or songs,
and derive the Artists tab status map from the download rows plus that count
instead of walking every song. The badge query is now proportional to
downloads rather than library size.
2026-10-18 04:02:48 +00:00
austinried daf305f680 library: sync every artist credited on an album or song
OpenSubsonic servers send plural artists/albumArtists refs and can omit the
singular artistId when an album or song has more than one artist. Parse and
store the ordered refs on the item and in indexed album_artists/song_artists
tables, so artist pages, shuffles, downloads and the artist filters include
every credited artist.

Album and now playing artist taps open a chooser when several artists are
credited; context menus get one artist row per artist.
2026-10-18 01:50:34 +00:00
austinried 526373c10a feat: sync OpenSubsonic replay gain
Store the per-track replay gain a server reports on OpenSubsonic's
`Child.replayGain` so playback can normalise loudness later.

- add trackGain/albumGain/trackPeak/albumPeak (REAL) to the songs table
  and the 27 -> 28 migration
- parse the nested `<replayGain>` element, folding the child-element
  dispatch into a single `songChild` shared by the search and album paths
- teach the sync upsert to bind Float, which it previously rejected

baseGain and fallbackGain are omitted: the servers that send the object
do not populate them, so they would only ever be null columns.

Nothing applies the values yet; this slice only mirrors them.
2026-10-16 13:06:20 +00:00
austinried 0e70268129 Queue stars and scrobbles while the server is unreachable
Actions that cannot be delivered are persisted in a pending_actions
table and replayed once the server is reachable again. Stars coalesce so
only the latest state per item is sent, and replay happens on the next
successful action, when offline mode is turned off, or before a sync.
2026-10-04 00:37:29 +00:00
austinried e8ac683b42 normalize every song genre into song_genres
Split genre tags on Navidrome's separators (semicolon, slash, comma) and
resolve ID3v1 numeric indices to their names, so a song's genres are one
deduped list. songs.genre is now derived data with no reader: drop the
column and its index, and read the song info dialog's genres from
song_genres.
2026-10-04 00:30:07 +00:00
austinried 2061ebafca keep playback running when rebuilding the offline window, and order downloads newest-first 2026-10-04 00:30:06 +00:00
austinried adbd3b8d8e normalize every song's genres into song_genres and simplify the genre queries 2026-10-04 00:30:06 +00:00
austinried 378d6a3b47 add a home tab with local recommendation rows 2026-10-04 00:30:06 +00:00
austinried 18dc929218 store multiple OpenSubsonic song genres
OpenSubsonic servers return a per-song <genres> array that was dropped,
leaving every song with only its first (or joined) legacy genre. Parse it
and store the ordered list in a song_genres table, keeping the single
genre column as the fallback for base Subsonic servers.

Album genres are derived by unioning the album's song genres, falling
back to the single song genre, so album-level genre never trusts the
per-server album genres[] behaviour.

Schema 21 -> 22, with parser, sync, unit and integration coverage.
2026-10-04 00:30:06 +00:00
austinried 06ca2da48d add frequently and recently played album and artist sorts
Song playCount/played are parsed and synced, then aggregated per album
(SUM/MAX) and per artist (through the album's artistId) after the song
sync. Albums and artists gain Frequent/Recent sorts with their own
indices; the options show only when the active source returned the
fields, and the aggregate only writes rows whose value changed.

The artist subqueries pin index_albums_sourceId_artistId: without it the
planner answers MAX(played) from index_albums_recent (sourceId, played),
which cannot apply the artistId equality, and scans every album once per
artist. The availability flows read the indexed album ranks rather than
scanning songs, which would re-run on every song-sync batch.
2026-10-04 00:30:05 +00:00
austinried 954d0eddd2 search the library through FTS5 trigram tables 2026-10-04 00:30:03 +00:00
austinried 8ba9e92fa6 export the v19 schema after the queue keyset index migration 2026-10-04 00:30:01 +00:00
austinried 31c1fec322 fix downloaded playback and tighten the download state machine
Review findings on the first downloads block:

- ExoPlayer could not open a downloaded file at all: the service's data source
  factory was OkHttp only, so a file:// URI went to the HTTP stack and failed
  with a malformed URL. Wrap it in DefaultDataSource so the file, asset and
  content schemes are resolved before the stream chain, and cover it with a
  test that reads a local file through the production factory.
- Reconcile no longer restarts a failed download, or a running one whose engine
  entry vanished: only a queued row is started again, and the rest become failed
  so the explicit Retry in the UI means something.
- song_downloads also references songs with CASCADE now, so a song pruned from
  the mirror takes its download row with it and the sweep can reclaim the file.
- The mutating methods run on the injected dispatcher rather than the caller's,
  which was the main thread for every call site, and the repository exposes one
  shared state flow instead of a second map plus five subscriptions.
- Playback stops when the active source disappears, including deleting the last
  source while a downloaded track is playing.
- Dropped the unused request mime type, the queued-at column nothing read, the
  metered constructor parameter nobody varied, and a dead index guard.
- The running indicator is asserted positively, the engine's status and reason
  mapping has a test, and the failure paths use the fake's failure and forget
  helpers.
2026-10-04 00:30:01 +00:00
austinried 2b104f1387 download a song to the device and play it from there
First block of #65: the state machine, the platform download engine, a
single-song download from the song context menu, the downloaded indicator in
song lists, and local-first playback.

- `song_downloads`, keyed `(sourceId, songId)` with CASCADE on the source, and
  the 16 -> 17 migration.
- `DownloadEngine` over `android.app.DownloadManager`, with a fake for tests.
  Files land in app-specific external storage at
  `.../Music/downloads/<sourceId>/<songId>`, plus a `.nomedia` so media scanners
  on pre-Android-10 devices leave them alone.
- `DownloadRepository` enqueues, mirrors progress by polling, cancels, deletes
  and reconciles on start and on source change: a queued row whose engine entry
  is gone is started again, a completed row whose file is gone becomes failed
  rather than being resurrected, and files with no row are swept.
- Playback prefers the downloaded file: the media item build takes the local URI
  when the download is complete and leaves it unclipped.
- Deleting a download reloads the player window at its position, so the item
  falls back to streaming instead of failing mid-read.
- Deleting a source cancels its downloads and removes their files.

Downloads are the system's own notifications and stay off metered networks for
now. Artwork, bulk downloads and the management screen are later blocks.
2026-10-04 00:30:01 +00:00
austinried 93742e8584 rework: derive the shuffle order from a seed on top of the up-next queue
Replaces the per-track `shuffle_order` table and the retained whole-order
arrays with `playback_cursor.shuffleSeed`/`shuffleSize` and a seekable
Feistel permutation of `[0, size)` (`Shuffle.kt`). Positions map through
`flatContext`/`playContext`; a snapshot is shuffled only while the stored
size matches the context, so a stale size is ignored. Queue migration
16->17 adds `shuffleSize` and drops `shuffle_order`.

Drops the "dragging a context row while shuffled reorders the canonical
queue" behaviour: it cannot be made WYSIWYG without materialising the
order. The context is playback-only while shuffled (the view already
disables context drags) and `QueueRepository.move` no-ops for a context
move while shuffled; manual ordering goes through the up-next block.

Removing a context track while shuffled re-derives the permutation at the
smaller size, keeping the same seed and the playhead on the current track,
or on whatever now fills the removed track's slot when it was playing.
2026-10-04 00:30:01 +00:00
austinried 0a11585205 Revert the downloads block 2026-10-04 00:30:01 +00:00
austinried e037f3ef37 fix downloaded playback and tighten the download state machine
Review findings on the first downloads block:

- ExoPlayer could not open a downloaded file at all: the service's data source
  factory was OkHttp only, so a file:// URI went to the HTTP stack and failed
  with a malformed URL. Wrap it in DefaultDataSource so the file, asset and
  content schemes are resolved before the stream chain, and cover it with a
  test that reads a local file through the production factory.
- Reconcile no longer restarts a failed download, or a running one whose engine
  entry vanished: only a queued row is started again, and the rest become failed
  so the explicit Retry in the UI means something.
- song_downloads also references songs with CASCADE now, so a song pruned from
  the mirror takes its download row with it and the sweep can reclaim the file.
- The mutating methods run on the injected dispatcher rather than the caller's,
  which was the main thread for every call site, and the repository exposes one
  shared state flow instead of a second map plus five subscriptions.
- Playback stops when the active source disappears, including deleting the last
  source while a downloaded track is playing.
- Dropped the unused request mime type, the queued-at column nothing read, the
  metered constructor parameter nobody varied, and a dead index guard.
- The running indicator is asserted positively, the engine's status and reason
  mapping has a test, and the failure paths use the fake's failure and forget
  helpers.
2026-10-04 00:30:01 +00:00
austinried 7ac79288b3 download a song to the device and play it from there
First block of #65: the state machine, the platform download engine, a
single-song download from the song context menu, the downloaded indicator in
song lists, and local-first playback.

- `song_downloads`, keyed `(sourceId, songId)` with CASCADE on the source, and
  the 16 -> 17 migration.
- `DownloadEngine` over `android.app.DownloadManager`, with a fake for tests.
  Files land in app-specific external storage at
  `.../Music/downloads/<sourceId>/<songId>`, plus a `.nomedia` so media scanners
  on pre-Android-10 devices leave them alone.
- `DownloadRepository` enqueues, mirrors progress by polling, cancels, deletes
  and reconciles on start and on source change: a queued row whose engine entry
  is gone is started again, a completed row whose file is gone becomes failed
  rather than being resurrected, and files with no row are swept.
- Playback prefers the downloaded file: the media item build takes the local URI
  when the download is complete and leaves it unclipped.
- Deleting a download reloads the player window at its position, so the item
  falls back to streaming instead of failing mid-read.
- Deleting a source cancels its downloads and removes their files.

Downloads are the system's own notifications and stay off metered networks for
now. Artwork, bulk downloads and the management screen are later blocks.
2026-10-04 00:30:01 +00:00
austinried d5a9a11cef add a separate, editable up-next queue (#61)
Split playback into a shuffled context and a manual up-next list.
`up_next_entries` holds the same reference-plus-range rows as the
context but is never shuffled; `playback_cursor.upNextAnchor` pins the
block between a context track and its successor. `QueueRepository`
resolves a combined position by mapping through the context order and
splicing the block after the anchor, so the controller's window and
next/previous keep working.

PlaybackController gains addToQueue/playNext, consumes a queued track
when playback advances out of it, resumes the context when the block
empties, and clears the block on a new context. The queue view gets an
editable "Up next" section and a read-only context section, and the
context menu gains Play next / Add to queue for songs, albums and
playlists.
2026-10-04 00:30:00 +00:00
austinried d703b8ec5b index the album, artist and playlist list sorts 2026-10-04 00:29:59 +00:00
austinried 5fb38a7c9b rebase onto main: move the rank-column drop to 13 to 14 and drop the rank fixture 2026-10-04 00:29:59 +00:00
austinried 11c0276929 store artwork seeds without read-time writes and prune periodically 2026-10-04 00:29:58 +00:00
austinried f4ca0fd6d4 compute and persist the artist name busy flag with the artwork seed
- ArtworkSeedStore now carries the flag; artwork_seeds gains a nullable nameBusy column
  (DB v12, migration 11->12) so it survives cold starts with no decode
- resolve() computes the flag from the same bitmap it extracts the colour seed from, so
  there is no second decode, and persists it alongside the seed
- overlaidNameBusy reuses the seed job and reads the cached flag synchronously, so the
  scrim is known before the art shows; drop the fade
- bottomBandBusy now uses perceptual (gamma-space) luma, edge density and a bright
  fraction, which catches busy art on dark backgrounds (issue #40) that mean/variance missed
2026-10-04 00:29:58 +00:00
austinried 863c17be7d show album disc titles instead of generic disc numbers
Navidrome and gonic expose OpenSubsonic discTitles on the album response.
Parse them into a transient Album.discTitles map and persist each album's
titles in a child discs table (sourceId, albumId, disc, title) cascading
from albums. Songs keep only their disc number, and the album screen joins
by disc to render a title when one exists, falling back to "Disc N".

Titles are read opportunistically: a server that doesn't send discTitles
just leaves the table empty, so no API version negotiation is needed.
Titles apply to single- and multi-disc albums, are pruned when removed,
and are stored per album.

Schema 10 -> 11.
2026-10-04 00:29:57 +00:00
austinried ef5bf40bf7 drop the unused FTS5 search index 2026-10-04 00:29:56 +00:00
austinried e99ed1dc45 song added sort, robust scroll reset, restyled search and filter controls 2026-10-04 00:29:55 +00:00
austinried a2d42f7957 rework the library lists: inline filter, playlist updated, starred sort, clearer controls 2026-10-04 00:29:55 +00:00
austinried 20ab30920f persist and restore the playback position within the track 2026-10-04 00:29:55 +00:00
austinried 8bd07e4332 store the shuffle order in a table with a cache; keep previous at the start unless repeating 2026-10-04 00:29:55 +00:00
austinried a755de5356 add repeat and shuffle modes and fix duplicate track ordering 2026-10-04 00:29:55 +00:00
austinried 9ff1cfa1da store playlist duration from the server 2026-10-04 00:29:53 +00:00
austinried 9c1ba2bea6 drop the artwork seed expiry 2026-10-04 00:29:53 +00:00
austinried 1701aeaf79 persist artwork seeds in the database 2026-10-04 00:29:53 +00:00
austinried 592c27c99c add media3, the playback session and the sql-backed queue 2026-10-04 00:29:51 +00:00
austinried 3f91a29850 add preferences, paging reads, repositories and sync status 2026-10-04 00:29:51 +00:00
austinried 2c377f0f22 add the subsonic data layer on Room 3
SubsonicClient (token/plaintext auth, XXE-hardened parsing, root validation), SubsonicXml mapping (ISO-8601 with fractions and offsets), SubsonicSource (paged albums/ranks, search fallback), Room 3 entities/DAOs with diff-based sync pruning and an FTS5 trigram search index.
2026-10-04 00:29:51 +00:00