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, genre: String,
): List<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( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " + "SELECT * FROM albums WHERE sourceId = :sourceId AND year >= :start AND year < :end " +
"ORDER BY year DESC, name COLLATE NOCASE, id", "ORDER BY year DESC, name COLLATE NOCASE, id",
@@ -219,4 +219,59 @@ interface QueueDao {
sourceId: Long, sourceId: Long,
ids: List<String>, ids: List<String>,
): List<AlbumSongItem> ): 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, val disc: Long,
) )
enum class QueueKind { Playlist, Album, Song, Artist } enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded }
@Entity( @Entity(
tableName = "queue_entries", tableName = "queue_entries",
@@ -106,6 +106,19 @@ class LibraryRepository(
genre: String, genre: String,
): List<String> = db.libraryDao().songsByGenreIds(sourceId, genre) ): 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( fun albumsByDecade(
sourceId: Long, sourceId: Long,
decade: Long, decade: Long,
@@ -255,6 +255,13 @@ class QueueRepository(
songId: String, songId: String,
) = QueueEntry(position = 0, sourceId = sourceId, kind = QueueKind.Song, refId = songId) ) = 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( suspend fun sourceName(
kind: QueueKind, kind: QueueKind,
sourceId: Long, sourceId: Long,
@@ -295,6 +302,14 @@ class QueueRepository(
?.album ?.album
?.takeIf { it.isNotBlank() } ?.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.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId) QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
QueueKind.Artist -> dao.artistSongIds(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 from = ref.offset.toInt().coerceIn(0, entryIds.size)
val to = (ref.offset + entry.length).toInt().coerceIn(from, 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.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
QueueKind.Album -> dao.albumSongs(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.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() QueueKind.Song -> emptyList()
} }
if (fresh.isNotEmpty()) remember(ref, offset, fresh) 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.Album -> OrderKey(row.song.id, disc = row.song.disc, track = row.song.track)
QueueKind.Playlist -> OrderKey(row.song.id, position = row.playlistPosition) QueueKind.Playlist -> OrderKey(row.song.id, position = row.playlistPosition)
QueueKind.Artist -> OrderKey(row.song.id) QueueKind.Artist -> OrderKey(row.song.id)
QueueKind.Genre -> OrderKey(row.song.id)
QueueKind.Downloaded -> OrderKey(row.song.id)
QueueKind.Song -> 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.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.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
QueueKind.Artist -> emptyList() QueueKind.Artist -> emptyList()
QueueKind.Genre -> emptyList()
QueueKind.Downloaded -> emptyList()
QueueKind.Song -> emptyList() QueueKind.Song -> emptyList()
} }
@@ -822,6 +845,8 @@ class QueueRepository(
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit) 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.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
QueueKind.Artist -> emptyList() QueueKind.Artist -> emptyList()
QueueKind.Genre -> emptyList()
QueueKind.Downloaded -> emptyList()
QueueKind.Song -> emptyList() QueueKind.Song -> emptyList()
} }
@@ -831,6 +856,8 @@ class QueueRepository(
QueueKind.Playlist -> position != null QueueKind.Playlist -> position != null
QueueKind.Album -> disc != null && track != null QueueKind.Album -> disc != null && track != null
QueueKind.Artist -> false QueueKind.Artist -> false
QueueKind.Genre -> false
QueueKind.Downloaded -> false
QueueKind.Song -> false QueueKind.Song -> false
} }
@@ -874,6 +901,14 @@ class QueueRepository(
QueueKind.Artist -> { QueueKind.Artist -> {
dao.artistLength(sourceId, refId) dao.artistLength(sourceId, refId)
} }
QueueKind.Genre -> {
dao.genreLength(sourceId, refId)
}
QueueKind.Downloaded -> {
dao.downloadedLength(sourceId)
}
} }
val available = (total - offset).coerceAtLeast(0) val available = (total - offset).coerceAtLeast(0)
return count?.coerceAtMost(available) ?: available return count?.coerceAtMost(available) ?: available
@@ -226,6 +226,17 @@ class PlaybackController(
startPosition: Long, startPosition: Long,
) = playContext(queueRepository.playlistEntry(sourceId, playlistId), startPosition) ) = 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( fun playAlbumInOrder(
sourceId: Long, sourceId: Long,
albumId: String, albumId: String,
@@ -241,8 +241,8 @@ internal fun CoroutineScope.playGenreList(
song: Song, song: Song,
) { ) {
launch { launch {
val ids = libraryRepository.genreSongIds(song.sourceId, genre) val ordinal = libraryRepository.genreSongOrdinal(song.sourceId, genre, song.title, song.id)
playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0)) playbackController.playGenre(song.sourceId, genre, ordinal)
} }
} }
@@ -252,7 +252,7 @@ internal fun CoroutineScope.playDownloadedList(
song: Song, song: Song,
) { ) {
launch { launch {
val ids = libraryRepository.downloadedSongIds(song.sourceId) val ordinal = libraryRepository.downloadedSongOrdinal(song.sourceId, song.title, song.id)
playbackController.playSongs(song.sourceId, ids, ids.indexOf(song.id).coerceAtLeast(0)) playbackController.playDownloaded(song.sourceId, ordinal)
} }
} }
@@ -470,6 +470,8 @@ private fun QueueKind.label(): String =
QueueKind.Playlist -> "playlist" QueueKind.Playlist -> "playlist"
QueueKind.Song -> "song" QueueKind.Song -> "song"
QueueKind.Artist -> "artist" QueueKind.Artist -> "artist"
QueueKind.Genre -> "genre"
QueueKind.Downloaded -> "downloads"
} }
private fun formatTime(milliseconds: Long): String { private fun formatTime(milliseconds: Long): String {
@@ -3,16 +3,19 @@ package com.subtracks.data.repo
import android.content.Context import android.content.Context
import androidx.room3.Room import androidx.room3.Room
import androidx.room3.useReaderConnection import androidx.room3.useReaderConnection
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.db.SubtracksDatabase import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.QueueEntry import com.subtracks.data.model.QueueEntry
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
@@ -55,6 +58,35 @@ class QueueRepositoryTest {
assertEquals(listOf("s1", "s2", "s3"), resolveAll(snapshot)) 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 @Test
fun rangeLimitsAnEntryToASlice() = fun rangeLimitsAnEntryToASlice() =
runTest { runTest {
@@ -912,4 +944,15 @@ class QueueRepositoryTest {
genre = null, genre = null,
created = created, 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() }
}
}
} }