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:
@@ -4,6 +4,7 @@ import androidx.paging.PagingSource
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.DaoReturnTypeConverters
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||
import com.subtracks.data.model.Album
|
||||
@@ -56,6 +57,11 @@ internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NO
|
||||
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
internal const val ALBUM_ORDER_BY_FREQUENT = "playCount DESC, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_FREQUENT_REVERSED = "playCount ASC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_RECENT = "played DESC NULLS LAST, name COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
internal const val ARTISTS_FILTER =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||
@@ -78,7 +84,7 @@ internal const val ARTISTS_SELECT =
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = artists.sourceId AND dl.artistId = artists.id AND sd.status = 'Completed' " +
|
||||
") ELSE artists.albumCount END AS albumCount, " +
|
||||
"artists.starred, artists.coverArt "
|
||||
"artists.starred, artists.coverArt, artists.playCount, artists.played "
|
||||
|
||||
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||
@@ -86,6 +92,10 @@ internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "artists.albumCount DESC, name
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "artists.albumCount ASC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_FREQUENT = "artists.playCount DESC, name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_FREQUENT_REVERSED = "artists.playCount ASC, name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ARTIST_ORDER_BY_RECENT = "artists.played DESC NULLS LAST, name COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
|
||||
|
||||
internal const val PLAYLISTS_FILTER =
|
||||
"FROM playlists WHERE sourceId = :sourceId " +
|
||||
@@ -133,6 +143,50 @@ interface LibraryDao {
|
||||
@Upsert
|
||||
suspend fun upsertPlaylistSongs(items: List<PlaylistSong>)
|
||||
|
||||
@Query(
|
||||
"UPDATE albums SET " +
|
||||
"playCount = COALESCE((SELECT SUM(s.playCount) FROM songs s " +
|
||||
"WHERE s.sourceId = albums.sourceId AND s.albumId = albums.id), 0), " +
|
||||
"played = (SELECT MAX(s.played) FROM songs s " +
|
||||
"WHERE s.sourceId = albums.sourceId AND s.albumId = albums.id) " +
|
||||
"WHERE sourceId = :sourceId AND (" +
|
||||
"playCount IS NOT COALESCE((SELECT SUM(s.playCount) FROM songs s " +
|
||||
"WHERE s.sourceId = albums.sourceId AND s.albumId = albums.id), 0) " +
|
||||
"OR played IS NOT (SELECT MAX(s.played) FROM songs s " +
|
||||
"WHERE s.sourceId = albums.sourceId AND s.albumId = albums.id))",
|
||||
)
|
||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||
|
||||
// INDEXED BY is required: the planner otherwise answers MAX(played) from index_albums_recent
|
||||
// (sourceId, played), which cannot apply the artistId equality.
|
||||
@Query(
|
||||
"UPDATE artists SET " +
|
||||
"playCount = COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0), " +
|
||||
"played = (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id) " +
|
||||
"WHERE sourceId = :sourceId AND (" +
|
||||
"playCount IS NOT COALESCE((SELECT SUM(a.playCount) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id), 0) " +
|
||||
"OR played IS NOT (SELECT MAX(a.played) FROM albums a INDEXED BY index_albums_sourceId_artistId " +
|
||||
"WHERE a.sourceId = artists.sourceId AND a.artistId = artists.id))",
|
||||
)
|
||||
suspend fun recomputeArtistPlayData(sourceId: Long)
|
||||
|
||||
@Transaction
|
||||
suspend fun recomputePlayData(sourceId: Long) {
|
||||
recomputeAlbumPlayData(sourceId)
|
||||
recomputeArtistPlayData(sourceId)
|
||||
}
|
||||
|
||||
// Albums, not songs: these flows re-run on every write to their table, and songs are written
|
||||
// one batch at a time during a sync.
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM albums WHERE sourceId = :sourceId AND playCount > 0)")
|
||||
fun hasAlbumPlayCount(sourceId: Long): Flow<Boolean>
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL)")
|
||||
fun hasAlbumPlayed(sourceId: Long): Flow<Boolean>
|
||||
|
||||
@Query("UPDATE artists SET starred = :starred WHERE sourceId = :sourceId AND id = :id")
|
||||
suspend fun setArtistStar(
|
||||
sourceId: Long,
|
||||
@@ -327,6 +381,38 @@ interface LibraryDao {
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT")
|
||||
fun albumsByFrequent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT_REVERSED")
|
||||
fun albumsByFrequentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT")
|
||||
fun albumsByRecent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT_REVERSED")
|
||||
fun albumsByRecentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
|
||||
fun artistsByName(
|
||||
sourceId: Long,
|
||||
@@ -375,6 +461,38 @@ interface LibraryDao {
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT")
|
||||
fun artistsByFrequent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT_REVERSED")
|
||||
fun artistsByFrequentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT")
|
||||
fun artistsByRecent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT_REVERSED")
|
||||
fun artistsByRecentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
|
||||
fun playlistsByName(
|
||||
sourceId: Long,
|
||||
|
||||
@@ -280,6 +280,34 @@ val MIGRATION_19_20 =
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_20_21 =
|
||||
object : Migration(20, 21) {
|
||||
override suspend fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `playCount` INTEGER NOT NULL DEFAULT 0")
|
||||
connection.execSQL("ALTER TABLE `songs` ADD COLUMN `played` INTEGER")
|
||||
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `playCount` INTEGER NOT NULL DEFAULT 0")
|
||||
connection.execSQL("ALTER TABLE `albums` ADD COLUMN `played` INTEGER")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `playCount` INTEGER NOT NULL DEFAULT 0")
|
||||
connection.execSQL("ALTER TABLE `artists` ADD COLUMN `played` INTEGER")
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_frequent` ON `albums` " +
|
||||
"(`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_albums_recent` ON `albums` " +
|
||||
"(`sourceId` ASC, `played` DESC, `name` ASC, `id` ASC)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_frequent` ON `artists` " +
|
||||
"(`sourceId` ASC, `playCount` DESC, `name` ASC, `id` ASC)",
|
||||
)
|
||||
connection.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS `index_artists_recent` ON `artists` " +
|
||||
"(`sourceId` ASC, `played` DESC, `name` ASC, `id` ASC)",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors the FTS5 table and content-sync triggers Room generates for the @Fts5 entities.
|
||||
private suspend fun createSearchIndex(
|
||||
connection: SQLiteConnection,
|
||||
@@ -333,4 +361,5 @@ val MIGRATIONS: Array<Migration> =
|
||||
MIGRATION_17_18,
|
||||
MIGRATION_18_19,
|
||||
MIGRATION_19_20,
|
||||
MIGRATION_20_21,
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ import com.subtracks.data.model.UpNextEntry
|
||||
ArtworkSeed::class,
|
||||
SongDownload::class,
|
||||
],
|
||||
version = 20,
|
||||
version = 21,
|
||||
exportSchema = true,
|
||||
)
|
||||
@ColumnTypeConverters(QueueKindConverter::class, DownloadStatusConverter::class)
|
||||
|
||||
@@ -63,6 +63,16 @@ data class SubsonicSource(
|
||||
value = ["sourceId", "starred", "name", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_artists_frequent",
|
||||
value = ["sourceId", "playCount", "name", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_artists_recent",
|
||||
value = ["sourceId", "played", "name", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
],
|
||||
)
|
||||
data class Artist(
|
||||
@@ -72,6 +82,8 @@ data class Artist(
|
||||
val albumCount: Long,
|
||||
val starred: Long?,
|
||||
val coverArt: String? = null,
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -105,6 +117,16 @@ data class Artist(
|
||||
value = ["sourceId", "starred", "name", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_frequent",
|
||||
value = ["sourceId", "playCount", "name", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
Index(
|
||||
name = "index_albums_recent",
|
||||
value = ["sourceId", "played", "name", "id"],
|
||||
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
|
||||
),
|
||||
],
|
||||
)
|
||||
data class Album(
|
||||
@@ -120,6 +142,8 @@ data class Album(
|
||||
val starred: Long?,
|
||||
val songCount: Long,
|
||||
@Ignore val discTitles: Map<Long, String> = emptyMap(),
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
@@ -233,6 +257,8 @@ data class Song(
|
||||
val starred: Long?,
|
||||
val genre: String?,
|
||||
@ColumnInfo(defaultValue = "0") val created: Long = 0,
|
||||
@ColumnInfo(defaultValue = "0") val playCount: Long = 0,
|
||||
val played: Long? = null,
|
||||
)
|
||||
|
||||
sealed interface SongItem {
|
||||
|
||||
@@ -14,9 +14,9 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
enum class AlbumSort { Name, Artist, Year, Added, Starred }
|
||||
enum class AlbumSort { Name, Artist, Year, Added, Starred, Frequent, Recent }
|
||||
|
||||
enum class ArtistSort { Name, AlbumCount, Starred }
|
||||
enum class ArtistSort { Name, AlbumCount, Starred, Frequent, Recent }
|
||||
|
||||
enum class PlaylistSort { Name, Added, Updated }
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ class LibraryRepository(
|
||||
) {
|
||||
val activeSourceId: Flow<Long?> = sourceRepository.activeSourceId()
|
||||
|
||||
fun hasAlbumPlayCount(sourceId: Long): Flow<Boolean> = db.libraryDao().hasAlbumPlayCount(sourceId)
|
||||
|
||||
fun hasAlbumPlayed(sourceId: Long): Flow<Boolean> = db.libraryDao().hasAlbumPlayed(sourceId)
|
||||
|
||||
fun albums(
|
||||
sourceId: Long,
|
||||
sort: AlbumSort,
|
||||
@@ -88,6 +92,22 @@ class LibraryRepository(
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +146,22 @@ class LibraryRepository(
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,8 @@ object SubsonicXml {
|
||||
starred = attrs.dateAttr("starred"),
|
||||
genre = attrs.attr("genre").ifEmpty { null },
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
playCount = attrs.longAttr("playCount") ?: 0L,
|
||||
played = attrs.dateAttr("played"),
|
||||
)
|
||||
|
||||
internal fun playlist(
|
||||
|
||||
@@ -23,6 +23,7 @@ class SyncService(
|
||||
syncArtists()
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
aggregatePlayData()
|
||||
syncPlaylists()
|
||||
syncPlaylistSongs()
|
||||
}
|
||||
@@ -69,6 +70,10 @@ class SyncService(
|
||||
pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
||||
}
|
||||
|
||||
private suspend fun aggregatePlayData() {
|
||||
db.libraryDao().recomputePlayData(source.id)
|
||||
}
|
||||
|
||||
private suspend fun syncPlaylists() {
|
||||
val library = db.libraryDao()
|
||||
val seen = HashedIds()
|
||||
@@ -249,6 +254,8 @@ private val songColumns =
|
||||
"starred",
|
||||
"genre",
|
||||
"created",
|
||||
"playCount",
|
||||
"played",
|
||||
)
|
||||
|
||||
private val playlistColumns = listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration")
|
||||
@@ -264,7 +271,23 @@ private fun Album.values() = listOf<Any?>(sourceId, id, artistId, name, albumArt
|
||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||
|
||||
private fun Song.values() =
|
||||
listOf<Any?>(sourceId, id, albumId, artistId, title, album, artist, duration, track, disc, starred, genre, created)
|
||||
listOf<Any?>(
|
||||
sourceId,
|
||||
id,
|
||||
albumId,
|
||||
artistId,
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
duration,
|
||||
track,
|
||||
disc,
|
||||
starred,
|
||||
genre,
|
||||
created,
|
||||
playCount,
|
||||
played,
|
||||
)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration)
|
||||
|
||||
|
||||
@@ -154,24 +154,31 @@ fun LibraryTab.listTab(): LibraryListTab = LibraryListTab.valueOf(name)
|
||||
|
||||
internal fun libraryTabFor(name: String?): LibraryTab = LibraryTab.entries.firstOrNull { it.name == name } ?: LibraryTab.Albums
|
||||
|
||||
fun sortOptionsFor(tab: LibraryTab): List<SortOption> =
|
||||
fun sortOptionsFor(
|
||||
tab: LibraryTab,
|
||||
play: PlaySortAvailability = PlaySortAvailability(frequent = false, recent = false),
|
||||
): List<SortOption> =
|
||||
when (tab) {
|
||||
LibraryTab.Albums -> {
|
||||
listOf(
|
||||
SortOption(AlbumSort.Name.name, "Name"),
|
||||
SortOption(AlbumSort.Artist.name, "Artist"),
|
||||
SortOption(AlbumSort.Year.name, "Year", descendingByDefault = true),
|
||||
SortOption(AlbumSort.Added.name, "Added", descendingByDefault = true),
|
||||
SortOption(AlbumSort.Starred.name, "Starred", descendingByDefault = true),
|
||||
)
|
||||
buildList {
|
||||
add(SortOption(AlbumSort.Name.name, "Name"))
|
||||
add(SortOption(AlbumSort.Artist.name, "Artist"))
|
||||
add(SortOption(AlbumSort.Year.name, "Year", descendingByDefault = true))
|
||||
add(SortOption(AlbumSort.Added.name, "Added", descendingByDefault = true))
|
||||
add(SortOption(AlbumSort.Starred.name, "Starred", descendingByDefault = true))
|
||||
if (play.frequent) add(SortOption(AlbumSort.Frequent.name, "Frequently played", descendingByDefault = true))
|
||||
if (play.recent) add(SortOption(AlbumSort.Recent.name, "Recently played", descendingByDefault = true))
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.Artists -> {
|
||||
listOf(
|
||||
SortOption(ArtistSort.Name.name, "Name"),
|
||||
SortOption(ArtistSort.AlbumCount.name, "Albums", descendingByDefault = true),
|
||||
SortOption(ArtistSort.Starred.name, "Starred", descendingByDefault = true),
|
||||
)
|
||||
buildList {
|
||||
add(SortOption(ArtistSort.Name.name, "Name"))
|
||||
add(SortOption(ArtistSort.AlbumCount.name, "Albums", descendingByDefault = true))
|
||||
add(SortOption(ArtistSort.Starred.name, "Starred", descendingByDefault = true))
|
||||
if (play.frequent) add(SortOption(ArtistSort.Frequent.name, "Frequently played", descendingByDefault = true))
|
||||
if (play.recent) add(SortOption(ArtistSort.Recent.name, "Recently played", descendingByDefault = true))
|
||||
}
|
||||
}
|
||||
|
||||
LibraryTab.Playlists -> {
|
||||
@@ -213,8 +220,14 @@ fun LibraryRoute(
|
||||
}
|
||||
val listQuery by viewModel.listQuery(listTab).collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val playSort by viewModel.playSortAvailability.collectAsStateWithLifecycle()
|
||||
val displayQuery = if (offline) listQuery.copy(downloaded = true) else listQuery
|
||||
val search by viewModel.search(listTab).collectAsStateWithLifecycle()
|
||||
LaunchedEffect(listTab, playSort, listQuery.sort) {
|
||||
if (sortOptionsFor(selectedTab, playSort).none { it.value == listQuery.sort }) {
|
||||
viewModel.setListQuery(listTab, listQuery.copy(sort = listTab.defaultSort))
|
||||
}
|
||||
}
|
||||
val resetKeys =
|
||||
LibraryTab.entries.associateWith { tab ->
|
||||
val query by viewModel.listQuery(tab.listTab()).collectAsStateWithLifecycle()
|
||||
@@ -266,7 +279,7 @@ fun LibraryRoute(
|
||||
resetKeys = resetKeys,
|
||||
offline = offline,
|
||||
onExitOffline = { viewModel.setOffline(false) },
|
||||
sortOptions = sortOptionsFor(selectedTab),
|
||||
sortOptions = sortOptionsFor(selectedTab, playSort),
|
||||
starredSupported = listTab.supportsStarred,
|
||||
onSortChange = { viewModel.setListQuery(listTab, listQuery.copy(sort = it)) },
|
||||
onToggleSortDirection = { viewModel.setListQuery(listTab, listQuery.copy(descending = !listQuery.descending)) },
|
||||
|
||||
@@ -122,6 +122,19 @@ class LibraryViewModel(
|
||||
|
||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
|
||||
val playSortAvailability: StateFlow<PlaySortAvailability> =
|
||||
activeSourceId
|
||||
.flatMapLatest { sourceId ->
|
||||
combine(
|
||||
libraryRepository.hasAlbumPlayCount(sourceId),
|
||||
libraryRepository.hasAlbumPlayed(sourceId),
|
||||
) { frequent, recent -> PlaySortAvailability(frequent, recent) }
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5_000),
|
||||
PlaySortAvailability(frequent = false, recent = false),
|
||||
)
|
||||
|
||||
private val albumDownloads: StateFlow<Map<String, ListDownloadStatus>> =
|
||||
activeSourceId
|
||||
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
|
||||
@@ -201,3 +214,8 @@ internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull {
|
||||
internal fun ListQuery.artistSort(): ArtistSort = ArtistSort.entries.firstOrNull { it.name == sort } ?: ArtistSort.Name
|
||||
|
||||
internal fun ListQuery.playlistSort(): PlaylistSort = PlaylistSort.entries.firstOrNull { it.name == sort } ?: PlaylistSort.Name
|
||||
|
||||
data class PlaySortAvailability(
|
||||
val frequent: Boolean,
|
||||
val recent: Boolean,
|
||||
)
|
||||
|
||||
@@ -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