Commit Graph
36 Commits
Author SHA1 Message Date
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