play genres and downloads as lazy queue references instead of expanding every song

This commit is contained in:
austinried
2026-10-04 00:30:06 +00:00
parent adbd3b8d8e
commit 8848e951e4
9 changed files with 189 additions and 5 deletions
@@ -753,6 +753,31 @@ interface LibraryDao {
genre: String,
): List<String>
@Query(
"SELECT COUNT(*) FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))",
)
suspend fun genreSongOrdinal(
sourceId: Long,
genre: String,
title: String,
id: String,
): Long
@Query(
"SELECT COUNT(*) FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"AND (songs.title COLLATE NOCASE < :title OR (songs.title COLLATE NOCASE = :title AND songs.id < :id))",
)
suspend fun downloadedSongOrdinal(
sourceId: Long,
title: String,
id: String,
): Long
@Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
@@ -219,4 +219,59 @@ interface QueueDao {
sourceId: Long,
ids: List<String>,
): List<AlbumSongItem>
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
suspend fun genreLength(
sourceId: Long,
genre: String,
): Long
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun genreSongs(
sourceId: Long,
genre: String,
offset: Long,
limit: Int,
): List<AlbumSongItem>
@Query(
"SELECT songs.id FROM song_genres " +
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
)
suspend fun genreSongIds(
sourceId: Long,
genre: String,
): List<String>
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
suspend fun downloadedLength(sourceId: Long): Long
@Query(
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY songs.title COLLATE NOCASE, songs.id LIMIT :limit OFFSET :offset",
)
suspend fun downloadedSongs(
sourceId: Long,
offset: Long,
limit: Int,
): List<AlbumSongItem>
@Query(
"SELECT songs.id FROM song_downloads d " +
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
"ORDER BY songs.title COLLATE NOCASE, songs.id",
)
suspend fun downloadedSongIds(sourceId: Long): List<String>
}
@@ -316,7 +316,7 @@ data class DiscKey(
val disc: Long,
)
enum class QueueKind { Playlist, Album, Song, Artist }
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded }
@Entity(
tableName = "queue_entries",
@@ -106,6 +106,19 @@ class LibraryRepository(
genre: String,
): List<String> = db.libraryDao().songsByGenreIds(sourceId, genre)
suspend fun genreSongOrdinal(
sourceId: Long,
genre: String,
title: String,
id: String,
): Long = db.libraryDao().genreSongOrdinal(sourceId, genre, title, id)
suspend fun downloadedSongOrdinal(
sourceId: Long,
title: String,
id: String,
): Long = db.libraryDao().downloadedSongOrdinal(sourceId, title, id)
fun albumsByDecade(
sourceId: Long,
decade: Long,
@@ -255,6 +255,13 @@ class QueueRepository(
songId: String,
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId)
fun genreEntry(
sourceId: Long,
genre: String,
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Genre, refId = genre)
fun downloadedEntry(sourceId: Long) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Downloaded, refId = "")
suspend fun sourceName(
kind: QueueKind,
sourceId: Long,
@@ -295,6 +302,14 @@ class QueueRepository(
?.album
?.takeIf { it.isNotBlank() }
}
QueueKind.Genre -> {
refId.takeIf { it.isNotBlank() }
}
QueueKind.Downloaded -> {
null
}
}
}
@@ -646,6 +661,8 @@ class QueueRepository(
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
}
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
@@ -746,6 +763,8 @@ class QueueRepository(
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
QueueKind.Song -> emptyList()
}
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
@@ -769,6 +788,8 @@ class QueueRepository(
QueueKind.Album -> OrderKey(row.song.id, disc = row.song.disc, track = row.song.track)
QueueKind.Playlist -> OrderKey(row.song.id, position = row.playlistPosition)
QueueKind.Artist -> OrderKey(row.song.id)
QueueKind.Genre -> OrderKey(row.song.id)
QueueKind.Downloaded -> OrderKey(row.song.id)
QueueKind.Song -> OrderKey(row.song.id)
}
@@ -809,6 +830,8 @@ class QueueRepository(
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
QueueKind.Artist -> emptyList()
QueueKind.Genre -> emptyList()
QueueKind.Downloaded -> emptyList()
QueueKind.Song -> emptyList()
}
@@ -822,6 +845,8 @@ class QueueRepository(
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
QueueKind.Artist -> emptyList()
QueueKind.Genre -> emptyList()
QueueKind.Downloaded -> emptyList()
QueueKind.Song -> emptyList()
}
@@ -831,6 +856,8 @@ class QueueRepository(
QueueKind.Playlist -> position != null
QueueKind.Album -> disc != null && track != null
QueueKind.Artist -> false
QueueKind.Genre -> false
QueueKind.Downloaded -> false
QueueKind.Song -> false
}
@@ -874,6 +901,14 @@ class QueueRepository(
QueueKind.Artist -> {
dao.artistLength(sourceId, refId)
}
QueueKind.Genre -> {
dao.genreLength(sourceId, refId)
}
QueueKind.Downloaded -> {
dao.downloadedLength(sourceId)
}
}
val available = (total - offset).coerceAtLeast(0)
return count?.coerceAtMost(available) ?: available
@@ -226,6 +226,17 @@ class PlaybackController(
startPosition: Long,
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), startPosition)
fun playGenre(
sourceId: Long,
genre: String,
startOrdinal: Long,
) = playContext(queueRepository.genreEntry(sourceId, genre), startOrdinal)
fun playDownloaded(
sourceId: Long,
startOrdinal: Long,
) = playContext(queueRepository.downloadedEntry(sourceId), startOrdinal)
fun playAlbumInOrder(
sourceId: Long,
albumId: String,
@@ -241,8 +241,8 @@ internal fun CoroutineScope.playGenreList(
song: Song,
) {
launch {
val ids = libraryRepository.genreSongIds(song.sourceId, genre)
playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0))
val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id)
playbackController.playGenre(song.sourceId, genre, ordinal)
}
}
@@ -252,7 +252,7 @@ internal fun CoroutineScope.playDownloadedList(
song: Song,
) {
launch {
val ids = libraryRepository.downloadedSongIds(song.sourceId)
playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0))
val ordinal = libraryRepository.downloadedSongOrdinal(song.sourceId, song.title, song.id)
playbackController.playDownloaded(song.sourceId, ordinal)
}
}
@@ -470,6 +470,8 @@ private fun QueueKind.label(): String =
QueueKind.Playlist -> "playlist"
QueueKind.Song -> "song"
QueueKind.Artist -> "artist"
QueueKind.Genre -> "genre"
QueueKind.Downloaded -> "downloads"
}
private fun formatTime(milliseconds: Long): String {
@@ -3,16 +3,19 @@ package com.subtracks.data.repo
import android.content.Context
import androidx.room3.Room
import androidx.room3.useReaderConnection
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest
import org.junit.After
@@ -55,6 +58,35 @@ class QueueRepositoryTest {
assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot))
}
@Test
fun wholeGenreEntryResolvesEveryTrackInTitleOrder() =
runTest {
seedLibrary()
insertGenre("s4", "Rock")
insertGenre("s1", "Rock")
insertGenre("s3", "Rock")
insertGenre("s2", "Jazz")
val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock")))
assertEquals(3, snapshot.size)
assertEquals(listOf("s1", "s3", "s4"), resolveAll(snapshot))
}
@Test
fun downloadedEntryResolvesOnlyCompletedDownloads() =
runTest {
seedLibrary()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running))
val snapshot = repository.snapshotAfter(listOf(repository.downloadedEntry(1)))
assertEquals(2, snapshot.size)
assertEquals(listOf("s1", "s3"), resolveAll(snapshot))
}
@Test
fun rangeLimitsAnEntryToASlice() =
runTest {
@@ -912,4 +944,15 @@ class QueueRepositoryTest {
genre = null,
created = created,
)
private suspend fun insertGenre(
songId: String,
genre: String,
) {
db.useWriterConnection { connection ->
connection.usePrepared(
"INSERT INTO song_genres (sourceId, songId, position, genre) VALUES (1, '$songId', 0, '$genre')",
) { it.step() }
}
}
}