mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -156,6 +156,63 @@ class LibraryDaoTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playRanksAreAggregatedFromSongsIntoAlbumsAndArtists() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-1", "Few", albumCount = 2),
|
||||
artist(sourceId, "ar-2", "Many", albumCount = 1),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "One", year = null, starred = null),
|
||||
album(sourceId, "al-2", "Two", year = null, starred = null).copy(artistId = "ar-2"),
|
||||
album(sourceId, "al-3", "Three", year = null, starred = null),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1", playCount = 3, played = 100),
|
||||
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", playCount = 4, played = 200),
|
||||
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-2", playCount = 5, played = 400),
|
||||
),
|
||||
)
|
||||
|
||||
dao.recomputeAlbumPlayData(sourceId)
|
||||
dao.recomputeArtistPlayData(sourceId)
|
||||
|
||||
assertEquals(3L, dao.albumOnce(sourceId, "al-1")!!.playCount)
|
||||
assertEquals(100L, dao.albumOnce(sourceId, "al-1")!!.played)
|
||||
assertEquals(9L, dao.albumOnce(sourceId, "al-2")!!.playCount)
|
||||
assertEquals(400L, dao.albumOnce(sourceId, "al-2")!!.played)
|
||||
assertEquals(0L, dao.albumOnce(sourceId, "al-3")!!.playCount)
|
||||
assertEquals(null, dao.albumOnce(sourceId, "al-3")!!.played)
|
||||
assertEquals(3L, dao.artistOnce(sourceId, "ar-1")!!.playCount)
|
||||
assertEquals(9L, dao.artistOnce(sourceId, "ar-2")!!.playCount)
|
||||
assertEquals(400L, dao.artistOnce(sourceId, "ar-2")!!.played)
|
||||
|
||||
assertEquals(
|
||||
listOf("Two", "One", "Three"),
|
||||
dao.albumsByFrequent(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Two", "One", "Three"),
|
||||
dao.albumsByRecent(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Many", "Few"),
|
||||
dao.artistsByFrequent(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Many", "Few"),
|
||||
dao.artistsByRecent(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reversedAlbumSortsReverseEveryTiebreaker() =
|
||||
runTest {
|
||||
@@ -163,10 +220,14 @@ class LibraryDaoTest {
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "a1", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Zed"),
|
||||
album(sourceId, "a2", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"),
|
||||
album(sourceId, "a3", "Same", year = 1990, starred = null, created = 200).copy(albumArtist = "Amy"),
|
||||
album(sourceId, "a4", "Other", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"),
|
||||
album(sourceId, "a1", "Same", year = 2000, starred = null, created = 100)
|
||||
.copy(albumArtist = "Zed", playCount = 1, played = 100),
|
||||
album(sourceId, "a2", "Same", year = 2000, starred = null, created = 100)
|
||||
.copy(albumArtist = "Amy", playCount = 2, played = 200),
|
||||
album(sourceId, "a3", "Same", year = 1990, starred = null, created = 200)
|
||||
.copy(albumArtist = "Amy", playCount = 1, played = 300),
|
||||
album(sourceId, "a4", "Other", year = 2000, starred = null, created = 100)
|
||||
.copy(albumArtist = "Amy", playCount = 3, played = 100),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -176,6 +237,8 @@ class LibraryDaoTest {
|
||||
dao.albumsByArtist(sourceId, 0, "") to dao.albumsByArtistReversed(sourceId, 0, ""),
|
||||
dao.albumsByYear(sourceId, 0, "") to dao.albumsByYearReversed(sourceId, 0, ""),
|
||||
dao.albumsByRecentlyAdded(sourceId, 0, "") to dao.albumsByRecentlyAddedReversed(sourceId, 0, ""),
|
||||
dao.albumsByFrequent(sourceId, 0, "") to dao.albumsByFrequentReversed(sourceId, 0, ""),
|
||||
dao.albumsByRecent(sourceId, 0, "") to dao.albumsByRecentReversed(sourceId, 0, ""),
|
||||
)
|
||||
for ((base, reversed) in pairs) {
|
||||
val ids = base.page().map { it.id }
|
||||
@@ -190,9 +253,9 @@ class LibraryDaoTest {
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "r1", "Same", albumCount = 2),
|
||||
artist(sourceId, "r2", "Other", albumCount = 1),
|
||||
artist(sourceId, "r3", "Same", albumCount = 1),
|
||||
artist(sourceId, "r1", "Same", albumCount = 2).copy(playCount = 1, played = 100),
|
||||
artist(sourceId, "r2", "Other", albumCount = 1).copy(playCount = 2, played = 200),
|
||||
artist(sourceId, "r3", "Same", albumCount = 1).copy(playCount = 1, played = 300),
|
||||
),
|
||||
)
|
||||
dao.upsertPlaylists(
|
||||
@@ -209,6 +272,10 @@ class LibraryDaoTest {
|
||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.id },
|
||||
dao.artistsByAlbumCount(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByAlbumCountReversed(sourceId, 0, "").page().map { it.id },
|
||||
dao.artistsByFrequent(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByFrequentReversed(sourceId, 0, "").page().map { it.id },
|
||||
dao.artistsByRecent(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByRecentReversed(sourceId, 0, "").page().map { it.id },
|
||||
)
|
||||
val playlistPairs =
|
||||
listOf(
|
||||
@@ -241,6 +308,10 @@ class LibraryDaoTest {
|
||||
ALBUM_ORDER_BY_ADDED_REVERSED to "index_albums_added",
|
||||
ALBUM_ORDER_BY_STARRED to "index_albums_starred",
|
||||
ALBUM_ORDER_BY_STARRED_REVERSED to "index_albums_starred",
|
||||
ALBUM_ORDER_BY_FREQUENT to "index_albums_frequent",
|
||||
ALBUM_ORDER_BY_FREQUENT_REVERSED to "index_albums_frequent",
|
||||
ALBUM_ORDER_BY_RECENT to "index_albums_recent",
|
||||
ALBUM_ORDER_BY_RECENT_REVERSED to "index_albums_recent",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
@@ -260,6 +331,10 @@ class LibraryDaoTest {
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
|
||||
ARTIST_ORDER_BY_STARRED_REVERSED to "index_artists_starred",
|
||||
ARTIST_ORDER_BY_FREQUENT to "index_artists_frequent",
|
||||
ARTIST_ORDER_BY_FREQUENT_REVERSED to "index_artists_frequent",
|
||||
ARTIST_ORDER_BY_RECENT to "index_artists_recent",
|
||||
ARTIST_ORDER_BY_RECENT_REVERSED to "index_artists_recent",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan = plan("SELECT * $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
|
||||
@@ -62,9 +62,13 @@ class MigrationsTest {
|
||||
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_frequent", "playCount").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_albums_recent", "played").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_frequent", "playCount").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_artists_recent", "played").descending)
|
||||
assertEquals("NOCASE", indexColumn(migrated, "index_playlists_name", "name").collation)
|
||||
assertEquals(1, indexColumn(migrated, "index_playlists_added", "created").descending)
|
||||
assertEquals(1, indexColumn(migrated, "index_playlists_updated", "changed").descending)
|
||||
|
||||
@@ -134,6 +134,26 @@ class SubsonicXmlTest {
|
||||
assertEquals("sg1", entries[0].songId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapsSongPlayData() {
|
||||
val xml =
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<searchResult3>
|
||||
<song id="s1" title="Played" playCount="12" played="2023-03-26T22:27:46Z"/>
|
||||
<song id="s2" title="Never"/>
|
||||
</searchResult3>
|
||||
</subsonic-response>
|
||||
""".trimIndent()
|
||||
|
||||
val songs = parse(xml, tag = "song", create = { SubsonicXml.song(1, it) })
|
||||
|
||||
assertEquals(12L, songs[0].playCount)
|
||||
assertEquals(1679869666L, songs[0].played)
|
||||
assertEquals(0L, songs[1].playCount)
|
||||
assertEquals(null, songs[1].played)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusOnlyReadRejectsAnHtmlLoginPage() {
|
||||
val failure =
|
||||
|
||||
@@ -611,6 +611,59 @@ class SyncServiceTest {
|
||||
assertEquals(survivors, storedDiscKeys().map { it.albumId }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumAndArtistPlayRanksAreDerivedFromTheSongRows() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1")),
|
||||
albums = listOf(album("al1")),
|
||||
songs =
|
||||
listOf(
|
||||
song("s1").copy(playCount = 5, played = 100),
|
||||
song("s2").copy(playCount = 2, played = 300),
|
||||
),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(7L, db.libraryDao().albumOnce(1, "al1")!!.playCount)
|
||||
assertEquals(300L, db.libraryDao().albumOnce(1, "al1")!!.played)
|
||||
assertEquals(7L, db.libraryDao().artistOnce(1, "a1")!!.playCount)
|
||||
assertEquals(300L, db.libraryDao().artistOnce(1, "a1")!!.played)
|
||||
|
||||
source.songs = listOf(song("s1").copy(playCount = 0, played = null))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(0L, db.libraryDao().albumOnce(1, "al1")!!.playCount)
|
||||
assertEquals(null, db.libraryDao().albumOnce(1, "al1")!!.played)
|
||||
assertEquals(0L, db.libraryDao().artistOnce(1, "a1")!!.playCount)
|
||||
assertEquals(null, db.libraryDao().artistOnce(1, "a1")!!.played)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anUnchangedSyncWithPlayDataDoesNotRewriteTheRanks() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1")),
|
||||
albums = listOf(album("al1")),
|
||||
songs = listOf(song("s1").copy(playCount = 5, played = 100)),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
val before = totalChanges()
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(before, totalChanges())
|
||||
assertEquals(5L, db.libraryDao().albumOnce(1, "al1")!!.playCount)
|
||||
assertEquals(5L, db.libraryDao().artistOnce(1, "a1")!!.playCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIdenticalSyncDoesNotRewriteRows() =
|
||||
runTest {
|
||||
|
||||
@@ -22,6 +22,7 @@ import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.ui.library.ALBUM_COVER_TAG
|
||||
import com.subtracks.ui.library.LibraryScreen
|
||||
import com.subtracks.ui.library.LibraryTab
|
||||
import com.subtracks.ui.library.PlaySortAvailability
|
||||
import com.subtracks.ui.library.sortOptionsFor
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
@@ -96,6 +97,26 @@ class LibraryOptionsTest {
|
||||
assertEquals(StarredFilter.Any, query.starred)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playSortsAreHiddenUntilTheServerReturnsPlayData() {
|
||||
assertEquals(
|
||||
listOf("Name", "Artist", "Year", "Added", "Starred"),
|
||||
sortOptionsFor(LibraryTab.Albums).map { it.label },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Name", "Artist", "Year", "Added", "Starred", "Frequently played", "Recently played"),
|
||||
sortOptionsFor(LibraryTab.Albums, PlaySortAvailability(frequent = true, recent = true)).map { it.label },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Name", "Albums", "Starred"),
|
||||
sortOptionsFor(LibraryTab.Artists).map { it.label },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Name", "Albums", "Starred", "Frequently played", "Recently played"),
|
||||
sortOptionsFor(LibraryTab.Artists, PlaySortAvailability(frequent = true, recent = true)).map { it.label },
|
||||
)
|
||||
}
|
||||
|
||||
private fun albums() =
|
||||
(1..3).map { index ->
|
||||
Album(
|
||||
|
||||
Reference in New Issue
Block a user