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.
This commit is contained in:
austinried
2026-10-04 00:30:05 +00:00
parent a261612017
commit 06ca2da48d
17 changed files with 1905 additions and 28 deletions
+2 -2
View File
@@ -102,13 +102,13 @@ The media notification carries a star button for the playing track. `PlaybackSer
`sources` and `subsonic_sources` hold the configured servers. Library tables are keyed by `(sourceId, id)` and cascade from `sources`: `artists`, `albums`, `playlists`, `playlist_songs`, `songs`.
The library is mirrored wholesale at sync and must work offline, so metadata is never fetched on demand: everything used to browse, filter or sort a local list has to be present locally. The only on-demand network is media bytes (streams and downloads) and images. A sort whose key is server-derived — say play frequency — therefore has to be synced into a local column, not requested lazily when the sort is selected.
The library is mirrored wholesale at sync and must work offline, so metadata is never fetched on demand: everything used to browse, filter or sort a local list has to be present locally. The only on-demand network is media bytes (streams and downloads) and images. A sort whose key is server-derived — say play frequency — therefore has to be synced into a local column, not requested lazily when the sort is selected. Play frequency and last-played are read from the synced song rows and aggregated into the album and artist rows by the same sync (`SUM(playCount)`, `MAX(played)`): servers disagree on what they put on album objects (Airsonic returns no album play count at all), while the song fields are consistent, and the aggregate only writes rows whose value changed. The artist roll-up attributes plays to the album's artist (the same `artistId` the artist list is built from), so a track artist credited only on a compilation can appear unplayed; that is deliberate, since it keeps the rank consistent with the album rows rather than the server's per-track artists.
UI and playback preferences live in DataStore (`user_prefs`), not in SQLite: they are not relational, nothing joins against them, and keeping them out of Room avoids a schema migration per preference. Room is for the library only.
Every schema version step ships a hand-written `Migration` (`data/db/Migrations.kt`), and there is no destructive fallback, so an upgrade never silently wipes the local mirror. The exported schemas under `app/schemas/` are the source of truth for the migration SQL, and `MigrationsTest` runs each step against a real SQLite connection and asserts the resulting tables, columns and indices.
Library screens page over Room with `PagingSource<Int, T>` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Every album, artist and playlist order has an index on its own table, and the sortable text columns carry `COLLATE NOCASE`, so a page seeks the index in order instead of re-sorting the table for each `LIMIT`/`OFFSET`. Starred orders pin unstarred rows last in both directions, matching the list grouping, so a starred "reversed" order is not a literal mirror of the base order. Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this.
Library screens page over Room with `PagingSource<Int, T>` queries, ordering by a sort chosen in preferences (name, artist, year, recently added, frequently/recently played). Every album, artist and playlist order has an index on its own table, and the sortable text columns carry `COLLATE NOCASE`, so a page seeks the index in order instead of re-sorting the table for each `LIMIT`/`OFFSET`. Starred orders pin unstarred rows last in both directions, matching the list grouping, so a starred "reversed" order is not a literal mirror of the base order. Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this.
Song rows show the album's cover art, fetched by joining `songs` to `albums` into a `SongListItem` rather than storing cover art on the song: some servers return a distinct cover ID per track, and keeping those would fragment the image cache for no visual gain. Artists show their image cropped to a circle.