22 Commits
Author SHA1 Message Date
austinried 11abd4afbd ui: share bulk download deletion confirmation 2026-11-04 12:16:06 +00:00
austinried 69f41b3459 playback: collapse the restore guard into one null chain 2026-11-04 11:12:07 +00:00
austinried 09baaeb77d playback: keep speculative prepare failures quiet
A restore now prepares the saved window before any play gesture, so a
network failure at launch reached the error listener and showed a toast
even though the user had not asked to play. Only report playback errors
when playback was requested.

The new tests now wait for the prepare operation rather than the window
contents, which can be observed a poll before the prepare lands.
2026-11-04 11:12:07 +00:00
austinried 484cc55385 playback: prepare the restored queue so the session starts on launch
Restoring the saved queue left the player idle, so the media session had no
notification and Bluetooth or Automate saw no active player until playback
began. Prepare the restored window without autoplaying, which surfaces the
paused session when the app opens. A queue from another source stays stopped,
and an offline queue without downloads has no window to prepare.

Closes austinried/subtracks#202
2026-11-04 11:12:07 +00:00
austinried 599924bdd0 test: share Room database and repository fixtures 2026-11-04 11:08:31 +00:00
austinried d6ff3ee086 build: fetch Robolectric's Android SDK jar from the mirror
Robolectric resolves android-all-instrumented itself at test time with
MavenArtifactFetcher, whose default repository is repo1.maven.org. The
Gradle mirror in settings.gradle.kts never applied to that fetch, so the
CI egress IP still got HTTP 429 there.

That fetcher also stores the jar in ~/.m2/repository, outside the
~/.gradle/caches directory CI restores, so every ephemeral runner
downloaded it again. Point robolectric.dependency.repo.url at the same
mirror for every Test task and cache the Robolectric Maven repository in
both workflows, keyed by the Robolectric version and the configured SDK.
2026-11-04 10:21:49 +00:00
austinried b2a1716be2 library: generate artist credit queries from one definition 2026-11-04 10:11:17 +00:00
austinried 51792f836b test: wait for the repository's active source in artist art tests
awaitActiveSourceId() watched the DAO's activeSourceId flow, which turns
non-null as soon as the source row is written. SourceRepository sets its
in-memory active source in a separate collector, so artistImage() could
still see null and the online URL assertion failed. Wait on the
repository's own activeSource readiness signal instead.
2026-11-03 09:08:54 +00:00
austinried 446a35d26e sync: declare upsert columns and values together 2026-11-03 01:21:28 +00:00
austinried b18e0891f6 db: tighten the paging observer and filter comments 2026-11-03 01:03:45 +00:00
austinried c53f1aef06 db: correct the paging filter comment
The downloaded statements are no longer the old hand-written text, and
RawQuery invalidation comes from observedEntities, not the filter text.
2026-11-03 01:03:45 +00:00
austinried 87e0609c05 db: drop the constant downloaded filter flag
The downloaded statements only run with the filter on, so the
(? = 0 OR EXISTS (...)) guards and the CASE around the downloaded counts
were always true. Keep those statements direct and remove the unused bind.
2026-11-03 01:03:45 +00:00
austinried c3a5382c27 db: bind named parameters directly and cover the repository dispatch
SQLite gives a repeated named parameter one index, so rawQuery can pass the
SQL through unchanged and bind the distinct names in first-appearance order
instead of rewriting them to positional placeholders.

Use named arguments for the repository dispatch, since the adjacent
descending/downloaded booleans were easy to transpose, and add a repository
test that collects the downloaded album grid. Fix the query-timing labels so
the unqualified rows measure the same default path as before.
2026-11-03 01:03:45 +00:00
austinried e8cbb93714 db: drop order constants the paging builders replaced
The deleted order methods and the old plan tests were the last users of the
plain name/artist order constants and the reverse sort-mode variants.
2026-11-03 01:03:45 +00:00
austinried 6f1e05d15a db: carry the sort-name mode through the paging builders
Rebase follow-up for the new SortNameMode preference: pass it into the
shared album/artist order functions so downloaded pages honour it too,
cover the new _plain indexes in the downloaded artist plan test, and add a
downloaded-mode ordering test.
2026-11-03 01:03:45 +00:00
austinried c165a6cc88 db: pin the downloaded artist count access path in the plan test
Assert the sourceId index as well as the in-memory sort for the downloaded
Artist album count order, and fold the top-level plan helper into plan().
2026-11-03 01:03:45 +00:00
austinried 18c24ca566 db: cover downloaded paging invalidation and filters
Add tests for the downloaded artist and playlist paging sources now that
their observed entities are hand-picked, and assert the downloaded
search/starred filters directly. The downloaded artist plan test uses the
real select list and documents the computed album count's in-memory sort,
which the hand-written query also had.
2026-11-03 01:03:45 +00:00
austinried 8d6f7e2341 db: collapse paged library queries into one builder per entity
Replace the thirty hand-written order variants with one query builder per
entity plus a default and a downloaded @RawQuery entry point. The
repository's sort dispatch trees and the duplicated positional filters in
LibraryPaging are gone, and the SQL now lives in multi-line strings with
named parameters.

The generated statements are identical to the previous ones modulo
whitespace: the downloaded variants keep the same CASE/download conditions,
and the observed entities match what Room inferred for the old queries. A
small helper rewrites the named parameters to RoomRawQuery's positional
form, including the repeated :sourceId bindings.
2026-11-03 01:03:45 +00:00
austinried 29935b7bee build: mirror Maven Central for dependency resolution
The CI runner's egress IP is rate-limited by repo.maven.apache.org (HTTP 429 on a Robolectric native runtime jar), and a missing Gradle cache then breaks the run. Try Google's Maven Central mirror first and fall back to the canonical repository.
2026-11-01 05:45:25 +00:00
austinried 4bfe67ec19 build: add the nightly res through the directories set
AGP 9 deprecates AndroidSourceDirectorySet.srcDir() in favour of the directories mutable set, which was the last source-set deprecation warning in the build.
2026-11-01 05:35:04 +00:00
austinried 060a5f036f ci: key the test music cache by the demo album ids
The album id is the server id, so use it for the download, the local folder and the prune fixture instead of maintaining a separate remoteId mapping. The cache key moves to v3 because v2 may already hold folders named after the old local ids.
2026-11-01 03:19:17 +00:00
austinried c169549ef4 ci: take integration test music from the Navidrome demo
demo.subsonic.org no longer accepts the guest1 account, so the harness downloaded an XML error page and unzip failed before Gradle ran. Download the same three albums from demo.navidrome.org with the demo account, fetch each album's cover art into folder.jpg, and match the album title the files actually carry (Retroconnaissance, without the EP suffix). Bump the music cache key so stale copies from the old source are not reused.
2026-11-01 03:12:01 +00:00
46 changed files with 1696 additions and 1463 deletions
+5
View File
@@ -40,6 +40,11 @@ runs:
path: ~/.gradle/caches
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: gradle-v2-
- uses: actions/cache@v6
with:
path: ~/.m2/repository/org/robolectric
key: robolectric-m2-v1-${{ hashFiles('gradle/libs.versions.toml', 'app/src/test/resources/robolectric.properties') }}
restore-keys: robolectric-m2-v1-
- id: signing
if: inputs.sign == 'true'
shell: bash
+1 -1
View File
@@ -50,5 +50,5 @@ jobs:
- uses: actions/cache@v6
with:
path: .integration/music
key: integration-music-v1
key: integration-music-v3
- run: nix develop .#integration --command nu tools/integration-test.nu
+3 -1
View File
@@ -119,7 +119,7 @@ android {
}
sourceSets {
getByName("debug").res.srcDir("src/nightly/res")
getByName("debug").res.directories.add("src/nightly/res")
}
buildTypes {
@@ -181,6 +181,8 @@ val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
tasks.withType<Test>().configureEach {
// Robolectric fetches its Android SDK jar itself, outside Gradle's repositories.
systemProperty("robolectric.dependency.repo.url", providers.gradleProperty("mavenCentralMirrorUrl").get())
if (name.endsWith("UnitTest")) {
filter {
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
@@ -56,7 +56,7 @@ class SubsonicSourceIntegrationTest(
assertNotNull(kosmo.starred)
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred)
assertNull(albums.first { it.name == "Retroconnaissance" }.starred)
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
}
@@ -92,7 +92,7 @@ class SubsonicSourceIntegrationTest(
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
.map { it.name }
.sorted()
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
val bradAlbums =
albums
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
.albums()
.toList()
.flatten()
.first { it.name == "Retroconnaissance EP" }
.first { it.name == "Retroconnaissance" }
source.setStar(StarType.Album, album.id, true)
try {
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
.songs()
.toList()
.flatten()
.first { it.album == "Retroconnaissance EP" }
.first { it.album == "Retroconnaissance" }
source.setStar(StarType.Song, song.id, true)
try {
@@ -1,11 +1,7 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
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.db.createTestDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.TestServers
import kotlinx.coroutines.runBlocking
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
@Test
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
runBlocking {
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val db = createTestDatabase()
try {
TestServers.all.forEachIndexed { index, server ->
val id = index + 1L
@@ -1,11 +1,7 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
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.db.createTestDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.SubsonicClient
import com.subtracks.data.source.subsonic.SubsonicSource
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
val removed = File(musicDir, REMOVED_ALBUM_DIR)
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val db = createTestDatabase()
try {
db.sourcesDao().upsertSource(
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
@@ -93,7 +84,7 @@ class PruneSyncIntegrationTest {
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
private companion object {
const val REMOVED_ALBUM_DIR = "197"
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
}
}
@@ -1,11 +1,7 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.room3.Room
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.db.createTestDatabase
import com.subtracks.data.model.Source
import com.subtracks.data.source.subsonic.TestServers
import kotlinx.coroutines.runBlocking
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
runBlocking {
servers.forEach { server ->
val context = ApplicationProvider.getApplicationContext<Context>()
val db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val db = createTestDatabase()
try {
db.sourcesDao().upsertSource(
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
@@ -0,0 +1,127 @@
package com.subtracks.data.db
import androidx.room3.RoomRawQuery
internal val CREDITED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery())
internal val DOWNLOADED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true))
internal val DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST =
albumCreditRows(AlbumCreditQuery(downloaded = true, artistScoped = true, selectAlbums = true))
internal val DOWNLOADED_COVER_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true, coverOnly = true))
internal val ARTIST_COVER_CREDITS = albumCreditRows(AlbumCreditQuery(coverOnly = true))
internal val ARTIST_COVER_CREDITS_FOR_ARTIST = albumCreditRows(AlbumCreditQuery(coverOnly = true, artistScoped = true))
private data class AlbumCreditQuery(
val artistScoped: Boolean = false,
val coverOnly: Boolean = false,
val downloaded: Boolean = false,
val selectAlbums: Boolean = false,
)
private fun albumCreditRows(query: AlbumCreditQuery): String =
if (query.selectAlbums) {
listOf(
primaryAlbumCredits(query),
albumRelationCredits(query),
trackRelationCredits(query),
trackCredits(query),
)
} else {
listOf(
primaryAlbumCredits(query),
albumRelationCredits(query),
trackCredits(query),
trackRelationCredits(query),
)
}.joinToString(if (query.downloaded) " UNION " else " UNION ALL ")
private fun primaryAlbumCredits(query: AlbumCreditQuery): String =
buildString {
append(
when {
query.selectAlbums -> "SELECT albums.* FROM albums "
query.downloaded -> "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums "
else -> "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType FROM albums "
},
)
append("WHERE albums.sourceId = :sourceId AND ${query.directArtist("albums.artistId")} ")
append(query.coverFilter())
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
}.trim()
private fun albumRelationCredits(query: AlbumCreditQuery): String =
buildString {
append(
when {
query.selectAlbums -> "SELECT albums.* FROM album_artists "
query.downloaded -> "SELECT albums.id, album_artists.artistId FROM album_artists "
else -> "SELECT album_artists.artistId, albums.id, 'album' FROM album_artists "
},
)
append("JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId ")
append("WHERE album_artists.sourceId = :sourceId ")
if (query.artistScoped) append("AND album_artists.artistId = :artistId ")
append(query.coverFilter())
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
}.trim()
private fun trackCredits(query: AlbumCreditQuery): String =
buildString {
append(
when {
query.selectAlbums -> "SELECT albums.* FROM songs "
query.downloaded -> "SELECT songs.albumId, songs.artistId FROM songs "
else -> "SELECT songs.artistId, songs.albumId, 'track' FROM songs "
},
)
append(SONG_ALBUM_JOIN)
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
append("WHERE songs.sourceId = :sourceId AND ${query.directArtist("songs.artistId")} ")
append(query.coverFilter())
if (query.downloaded) append("AND sd.status = 'Completed' ")
}.trim()
private fun trackRelationCredits(query: AlbumCreditQuery): String =
buildString {
append(
when {
query.selectAlbums -> "SELECT albums.* FROM song_artists "
query.downloaded -> "SELECT songs.albumId, song_artists.artistId FROM song_artists "
else -> "SELECT song_artists.artistId, songs.albumId, 'track' FROM song_artists "
},
)
append("JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId ")
append(SONG_ALBUM_JOIN)
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
append("WHERE song_artists.sourceId = :sourceId ")
if (query.artistScoped) append("AND song_artists.artistId = :artistId ")
append(query.coverFilter())
if (query.downloaded) append("AND sd.status = 'Completed' ")
}.trim()
private fun AlbumCreditQuery.directArtist(column: String): String = if (artistScoped) "$column = :artistId" else "$column IS NOT NULL"
private fun AlbumCreditQuery.coverFilter(): String = if (coverOnly) "AND albums.coverArt IS NOT NULL " else ""
private const val SONG_ALBUM_JOIN = "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId "
private const val TRACK_DOWNLOAD_JOIN = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id "
private const val ALBUM_DOWNLOADED =
"EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')"
internal fun downloadedArtistArtQuery(
sourceId: Long,
artistIds: List<String>,
): RoomRawQuery {
val bindings = mutableMapOf<String, Any?>("sourceId" to sourceId)
val placeholders =
artistIds
.mapIndexed { index, artistId ->
val name = "artistId$index"
bindings[name] = artistId
":$name"
}.ifEmpty { listOf("NULL") }
return rawQuery(DOWNLOADED_ARTIST_ART_UPDATE_SQL.replace(":artistIds", placeholders.joinToString(",")), bindings)
}
@@ -2,6 +2,7 @@ package com.subtracks.data.db
import android.content.Context
import androidx.room3.Room
import androidx.room3.RoomDatabase
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.subtracks.log.Log
@@ -11,7 +12,9 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(driver)
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
.addCallback(ArtistSearchTriggerOptimization)
.addCallback(SqliteLifecycleLogging)
.addSubtracksDatabaseCallbacks()
.build()
}
internal fun RoomDatabase.Builder<SubtracksDatabase>.addSubtracksDatabaseCallbacks(): RoomDatabase.Builder<SubtracksDatabase> =
addCallback(ArtistSearchTriggerOptimization).addCallback(SqliteLifecycleLogging)
@@ -10,15 +10,21 @@ import androidx.room3.Transaction
import androidx.room3.Upsert
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumArtistRef
import com.subtracks.data.model.AlbumSearch
import com.subtracks.data.model.AlbumSongItem
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistBio
import com.subtracks.data.model.ArtistSearch
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DiscKey
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSearch
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongArtistRef
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.Flow
@@ -36,20 +42,6 @@ internal const val DOWNLOADED_SONG =
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
internal const val ALBUMS_FILTER =
"FROM albums WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0 " +
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
@@ -67,13 +59,7 @@ internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sort
private const val ALBUM_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
private const val ALBUM_ARTIST_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
internal const val ARTIST_CREDITED =
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
@@ -98,134 +84,18 @@ internal const val CREDITED_SONG_ROWS =
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId"
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
internal const val CREDITED_ALBUM_ROWS =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId"
internal const val ALBUMS_FOR_ARTIST =
internal val ALBUMS_FOR_ARTIST =
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
internal const val ALBUM_CREDIT_ALBUM_IDS =
internal val ALBUM_CREDIT_ALBUM_IDS =
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
"WHERE artistId = :artistId AND creditType = 'album'"
// Download visibility follows the credit path: an album credit includes the album when any song is
// downloaded, while a track credit includes it only when the credited track is downloaded.
internal const val DOWNLOADED_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
private const val DOWNLOADED_ARTIST_ALBUMS =
internal val DOWNLOADED_ARTIST_ALBUMS =
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
private const val DOWNLOADED_COVER_ALBUM_ROWS =
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.id, album_artists.artistId FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT songs.albumId, songs.artistId FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"AND sd.status = 'Completed' " +
"UNION " +
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
private const val ARTIST_COVER_CREDITS =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT album_artists.artistId, albums.id, 'album' " +
"FROM album_artists JOIN albums " +
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT songs.artistId, songs.albumId, 'track' " +
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
"UNION ALL " +
"SELECT song_artists.artistId, songs.albumId, 'track' " +
"FROM song_artists JOIN songs " +
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"AND albums.coverArt IS NOT NULL"
private const val ARTIST_COVER_RANKS_TAIL =
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
@@ -236,19 +106,19 @@ private const val ARTIST_COVER_RANKS_TAIL =
"deduped AS (SELECT artistId, coverArt, " +
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
private const val ARTIST_COVER_RANKS =
private val ARTIST_COVER_RANKS =
"credits AS (SELECT artistId, albumId, " +
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
ARTIST_COVER_RANKS_TAIL
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
private val ARTIST_COVER_RANKS_FOR_ARTIST =
"credits AS (SELECT artistId, albumId, " +
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
ARTIST_COVER_RANKS_TAIL
internal const val ARTIST_ART_UPDATE_SQL =
internal val ARTIST_ART_UPDATE_SQL =
"WITH $ARTIST_COVER_RANKS, " +
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
@@ -274,11 +144,11 @@ internal const val ARTIST_ART_UPDATE_SQL =
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
internal const val ARTIST_COVER_ARTS_SQL =
internal val ARTIST_COVER_ARTS_SQL =
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
internal val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
"WHERE artistId IN (:artistIds)), " +
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
@@ -300,33 +170,13 @@ internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
"FROM desired WHERE artists.rowid = desired.rowid AND " +
"artists.downloadedCoverArts != desired.downloadedCoverArts"
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)) " +
"AND $ARTIST_CREDITED " +
"AND (:downloadedFilter = 0 OR EXISTS (" +
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
"OR instr(lower(sortName), lower(:search)) > 0))) "
internal const val ARTIST_COLUMNS_TAIL =
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, " +
"artists.downloadedCoverArts, artists.playCount, artists.played, artists.songCount, " +
"artists.sortName, artists.credited"
internal const val ARTIST_COLUMNS =
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
// With the Downloaded filter on, count the albums that actually have a download so the number under
// an artist matches the albums its detail screen lists.
internal const val ARTISTS_SELECT =
"SELECT artists.sourceId, artists.id, artists.name, " +
"CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
") ELSE artists.albumCount END AS albumCount, " +
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
"artists.sourceId, artists.id, artists.name, artists.albumCount, $ARTIST_COLUMNS_TAIL"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
@@ -340,33 +190,11 @@ internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS L
private const val ARTIST_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
private const val SONG_NAME_SORT_KEY_MODE =
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
internal const val PLAYLISTS_FILTER =
"FROM playlists WHERE sourceId = :sourceId " +
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId " +
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')) " +
"AND (:search = '' " +
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
"WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
// With the Downloaded filter on, count the songs that actually have a download so the number under
// a playlist matches the songs its detail screen lists.
internal const val PLAYLISTS_SELECT =
"SELECT playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt, " +
"CASE WHEN :downloadedFilter = 1 THEN (" +
"SELECT COUNT(DISTINCT ps.songId) FROM playlist_songs ps " +
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
") ELSE playlists.songCount END AS songCount, " +
"playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
internal const val PLAYLIST_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
@@ -394,6 +222,39 @@ internal const val SONG_SEARCH_FILTER =
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))"
internal val ARTIST_AGGREGATES_SQL =
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
"totals AS (" +
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
"SUM(playCount) AS playCount, MAX(played) AS played " +
"FROM credited GROUP BY artistId" +
"), album_totals AS (" +
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
"FROM credited_albums GROUP BY artistId" +
") " +
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
"playCount = c.playCount, played = c.played, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
"OR EXISTS (SELECT 1 FROM album_artists aa " +
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM song_artists sa " +
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
"OR artists.credited != c.credited)"
internal val ALBUMS_FOR_ARTIST_DOWNLOADED =
"SELECT * FROM ($DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST) ORDER BY year DESC, sortName COLLATE NOCASE, id"
@Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface LibraryDao {
@@ -426,54 +287,37 @@ interface LibraryDao {
)
suspend fun recomputeAlbumPlayData(sourceId: Long)
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
// queries are separate because their visibility follows the credit path.
@Query(
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
"totals AS (" +
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
"SUM(playCount) AS playCount, MAX(played) AS played " +
"FROM credited GROUP BY artistId" +
"), album_totals AS (" +
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
"FROM credited_albums GROUP BY artistId" +
") " +
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
"playCount = c.playCount, played = c.played, credited = c.credited " +
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
"OR EXISTS (SELECT 1 FROM album_artists aa " +
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
"OR EXISTS (SELECT 1 FROM song_artists sa " +
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
"WHERE artists.sourceId = :sourceId) c " +
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
"OR artists.credited != c.credited)",
)
suspend fun recomputeArtistAggregates(sourceId: Long)
@RawQuery
suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int
@Query(ARTIST_ART_UPDATE_SQL)
suspend fun recomputeArtistArt(sourceId: Long)
suspend fun recomputeArtistAggregates(sourceId: Long) {
recomputeArtistAggregates(rawQuery(ARTIST_AGGREGATES_SQL, mapOf("sourceId" to sourceId)))
}
@RawQuery
suspend fun recomputeArtistArt(query: RoomRawQuery): Int
suspend fun recomputeArtistArt(sourceId: Long) {
recomputeArtistArt(rawQuery(ARTIST_ART_UPDATE_SQL, mapOf("sourceId" to sourceId)))
}
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
fun artistCoverArts(query: RoomRawQuery): Flow<List<String>>
@Query(ARTIST_COVER_ARTS_SQL)
fun artistCoverArts(
sourceId: Long,
artistId: String,
): Flow<List<String>>
): Flow<List<String>> = artistCoverArts(rawQuery(ARTIST_COVER_ARTS_SQL, mapOf("sourceId" to sourceId, "artistId" to artistId)))
@RawQuery
suspend fun recomputeDownloadedArtistArt(query: RoomRawQuery): Int
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
suspend fun recomputeDownloadedArtistArt(
sourceId: Long,
artistIds: List<String>,
)
) {
recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds))
}
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
suspend fun clearDownloadedArtistArt(sourceId: Long)
@@ -804,245 +648,27 @@ interface LibraryDao {
@RawQuery(observedEntities = [Playlist::class])
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
fun albumsByName(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album>
// These observe every table the statements read; the sets match what Room inferred for the
// old downloaded @Query methods, so download and library writes invalidate as before.
@RawQuery(observedEntities = [Album::class, Song::class, SongDownload::class, AlbumSearch::class])
fun downloadedAlbumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
fun albumsByNameReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album>
@RawQuery(
observedEntities = [
Album::class,
Song::class,
SongDownload::class,
AlbumArtistRef::class,
SongArtistRef::class,
Artist::class,
Source::class,
ArtistSearch::class,
],
)
fun downloadedArtistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
fun albumsByArtist(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
fun albumsByArtistReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
fun albumsByYear(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
fun albumsByYearReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
fun albumsByRecentlyAdded(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
fun albumsByRecentlyAddedReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
fun albumsByStarred(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Album>
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
fun albumsByStarredReversed(
sourceId: Long,
starredFilter: Int,
search: String,
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_SORT_MODE")
fun artistsByName(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
fun artistsByNameReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
sortMode: String = "",
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
fun artistsByAlbumCount(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
fun artistsByAlbumCountReversed(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
fun artistsByStarred(
sourceId: Long,
starredFilter: Int,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Artist>
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
fun artistsByStarredReversed(
sourceId: Long,
starredFilter: Int,
search: String,
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,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
fun playlistsByNameReversed(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
fun playlistsByAdded(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
fun playlistsByAddedReversed(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
fun playlistsByUpdated(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
fun playlistsByUpdatedReversed(
sourceId: Long,
search: String,
downloadedFilter: Int = 0,
): PagingSource<Int, Playlist>
@RawQuery(observedEntities = [PlaylistSong::class, SongDownload::class, Playlist::class, PlaylistSearch::class])
fun downloadedPlaylistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
@@ -1276,17 +902,21 @@ interface LibraryDao {
songId: String,
): Flow<List<String>>
@Query(ALBUMS_FOR_ARTIST)
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
fun albumsForArtist(query: RoomRawQuery): Flow<List<Album>>
fun albumsForArtist(
sourceId: Long,
artistId: String,
): Flow<List<Album>>
): Flow<List<Album>> = albumsForArtist(rawQuery(ALBUMS_FOR_ARTIST, mapOf("sourceId" to sourceId, "artistId" to artistId)))
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
fun albumCreditAlbumIds(query: RoomRawQuery): Flow<List<String>>
@Query(ALBUM_CREDIT_ALBUM_IDS)
fun albumCreditAlbumIds(
sourceId: Long,
artistId: String,
): Flow<List<String>>
): Flow<List<String>> = albumCreditAlbumIds(rawQuery(ALBUM_CREDIT_ALBUM_IDS, mapOf("sourceId" to sourceId, "artistId" to artistId)))
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
fun discs(
@@ -1323,36 +953,16 @@ interface LibraryDao {
playlistId: String,
): PagingSource<Int, PlaylistSongItem>
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
@Query(
"SELECT * FROM (" +
"SELECT albums.* FROM albums " +
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.* FROM album_artists " +
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
"AND EXISTS (SELECT 1 FROM songs " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
"UNION " +
"SELECT albums.* FROM song_artists " +
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
"UNION " +
"SELECT albums.* FROM songs " +
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
@RawQuery(
observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class],
)
fun albumsForArtistDownloaded(query: RoomRawQuery): Flow<List<Album>>
fun albumsForArtistDownloaded(
sourceId: Long,
artistId: String,
): Flow<List<Album>>
): Flow<List<Album>> =
albumsForArtistDownloaded(
rawQuery(ALBUMS_FOR_ARTIST_DOWNLOADED, mapOf("sourceId" to sourceId, "artistId" to artistId)),
)
}
@@ -7,49 +7,161 @@ import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SortNameMode
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
// the resulting PagingSource is not invalidated by download writes (including progress updates).
internal const val ALBUMS_FILTER_BASE =
"FROM albums WHERE sourceId = ? " +
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(albumArtist), lower(?)) > 0 " +
"OR instr(lower(sortName), lower(?)) > 0 " +
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
// join, and their paging sources do not observe download writes (including progress updates).
internal const val ALBUMS_FILTER = """
FROM albums WHERE sourceId = :sourceId
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
OR instr(lower(albumArtist), lower(:search)) > 0
OR instr(lower(sortName), lower(:search)) > 0
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
"""
internal const val ARTISTS_FILTER_BASE =
"FROM artists WHERE sourceId = ? " +
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
"AND $ARTIST_CREDITED " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
"OR instr(lower(sortName), lower(?)) > 0))) "
internal const val ALBUMS_FILTER_DOWNLOADED = """
FROM albums WHERE sourceId = :sourceId
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
AND EXISTS (SELECT 1 FROM songs
JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id
WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
OR instr(lower(albumArtist), lower(:search)) > 0
OR instr(lower(sortName), lower(:search)) > 0
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
"""
internal const val PLAYLISTS_FILTER_BASE =
"FROM playlists WHERE sourceId = ? " +
"AND (? = '' " +
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
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))
AND $ARTIST_CREDITED
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
OR instr(lower(sortName), lower(:search)) > 0)))
"""
internal fun albumsDefaultQuery(
internal const val ARTISTS_FILTER_DOWNLOADED = """
FROM artists WHERE sourceId = :sourceId
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
AND $ARTIST_CREDITED
AND EXISTS (SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl
WHERE dl.id = artists.id AND dl.status = 'Completed')
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
OR instr(lower(sortName), lower(:search)) > 0)))
"""
// The downloaded list counts the albums that actually have a download so the number under an artist
// matches the albums its detail screen lists.
internal val ARTIST_COLUMNS_DOWNLOADED = """
artists.sourceId, artists.id, artists.name,
(SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount,
$ARTIST_COLUMNS_TAIL
"""
internal const val PLAYLISTS_FILTER = """
FROM playlists WHERE sourceId = :sourceId
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
"""
internal const val PLAYLISTS_FILTER_DOWNLOADED = """
FROM playlists WHERE sourceId = :sourceId
AND EXISTS (SELECT 1 FROM playlist_songs ps
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')
AND (:search = ''
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
"""
// The downloaded list counts the songs that actually have a download so the number under a
// playlist matches the songs its detail screen lists.
internal const val PLAYLISTS_COLUMNS_DOWNLOADED = """
playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt,
(SELECT COUNT(DISTINCT ps.songId)
FROM playlist_songs ps
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed'
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id
) AS songCount,
playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared
"""
internal fun albumsPageQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: AlbumSort,
descending: Boolean,
downloaded: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery {
val order = albumOrder(sort, descending, sortNameMode)
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search)
}
}
): RoomRawQuery =
rawQuery(
sql = "SELECT * ${if (downloaded) ALBUMS_FILTER_DOWNLOADED else ALBUMS_FILTER} ORDER BY ${albumOrder(
sort,
descending,
sortNameMode,
)}",
bindings =
mapOf(
"sourceId" to sourceId,
"starredFilter" to starredFilter,
"search" to search,
),
)
internal fun artistsPageQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: ArtistSort,
descending: Boolean,
downloaded: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery =
rawQuery(
sql =
"SELECT ${if (downloaded) ARTIST_COLUMNS_DOWNLOADED else ARTIST_COLUMNS} " +
"${if (downloaded) ARTISTS_FILTER_DOWNLOADED else ARTISTS_FILTER} " +
"ORDER BY ${artistOrder(sort, descending, sortNameMode)}",
bindings =
mapOf(
"sourceId" to sourceId,
"starredFilter" to starredFilter,
"search" to search,
),
)
internal fun playlistsPageQuery(
sourceId: Long,
search: String,
sort: PlaylistSort,
descending: Boolean,
downloaded: Boolean,
): RoomRawQuery =
rawQuery(
sql =
"SELECT ${if (downloaded) PLAYLISTS_COLUMNS_DOWNLOADED else "*"} " +
"${if (downloaded) PLAYLISTS_FILTER_DOWNLOADED else PLAYLISTS_FILTER} " +
"ORDER BY ${playlistOrder(sort, descending)}",
bindings =
mapOf(
"sourceId" to sourceId,
"search" to search,
),
)
internal fun rediscoverAlbumsQuery(
sourceId: Long,
@@ -57,70 +169,47 @@ internal fun rediscoverAlbumsQuery(
search: String,
cutoff: Long,
): RoomRawQuery =
RoomRawQuery(
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
) { stmt ->
stmt.bindAlbumFilters(sourceId, starredFilter, search)
stmt.bindLong(13, cutoff)
}
rawQuery(
sql =
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
bindings =
mapOf(
"sourceId" to sourceId,
"starredFilter" to starredFilter,
"search" to search,
"cutoff" to cutoff,
),
)
private fun SQLiteStatement.bindAlbumFilters(
sourceId: Long,
starredFilter: Int,
search: String,
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
// index, so the SQL can keep its names and only the first-appearance order needs binding.
internal fun rawQuery(
sql: String,
bindings: Map<String, Any?>,
): RoomRawQuery {
val values =
Regex(":(\\w+)")
.findAll(sql)
.map { it.groupValues[1] }
.distinct()
.map(bindings::getValue)
.toList()
return RoomRawQuery(sql) { statement ->
values.forEachIndexed { index, value -> statement.bind(index + 1, value) }
}
}
private fun SQLiteStatement.bind(
index: Int,
value: Any?,
) {
bindLong(1, sourceId)
bindInt(2, starredFilter)
bindInt(3, starredFilter)
bindInt(4, starredFilter)
bindText(5, search)
bindText(6, search)
bindText(7, search)
bindText(8, search)
bindText(9, search)
bindText(10, search)
bindText(11, search)
bindText(12, search)
}
internal fun artistsDefaultQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: ArtistSort,
descending: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery {
val order = artistOrder(sort, descending, sortNameMode)
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindInt(2, starredFilter)
stmt.bindInt(3, starredFilter)
stmt.bindInt(4, starredFilter)
stmt.bindText(5, search)
stmt.bindText(6, search)
stmt.bindText(7, search)
stmt.bindText(8, search)
stmt.bindText(9, search)
stmt.bindText(10, search)
}
}
internal fun playlistsDefaultQuery(
sourceId: Long,
search: String,
sort: PlaylistSort,
descending: Boolean,
): RoomRawQuery {
val order = playlistOrder(sort, descending)
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindText(2, search)
stmt.bindText(3, search)
stmt.bindText(4, search)
stmt.bindText(5, search)
stmt.bindText(6, search)
when (value) {
null -> bindNull(index)
is Int -> bindLong(index, value.toLong())
is Long -> bindLong(index, value)
is String -> bindText(index, value)
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
}
}
@@ -7,9 +7,9 @@ import androidx.paging.PagingSource
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.albumsDefaultQuery
import com.subtracks.data.db.artistsDefaultQuery
import com.subtracks.data.db.playlistsDefaultQuery
import com.subtracks.data.db.albumsPageQuery
import com.subtracks.data.db.artistsPageQuery
import com.subtracks.data.db.playlistsPageQuery
import com.subtracks.data.db.rediscoverAlbumsQuery
import com.subtracks.data.model.Album
import com.subtracks.data.model.AlbumSongItem
@@ -195,68 +195,17 @@ class LibraryRepository(
userPreferences.sortNameMode().flatMapLatest { mode ->
pager(40, enablePlaceholders = true) {
val dao = db.libraryDao()
if (!downloaded) {
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
} else {
val downloadedFilter = 1
when (sort) {
AlbumSort.Name -> {
if (descending) {
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
} else {
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
}
AlbumSort.Artist -> {
if (descending) {
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
} else {
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
}
AlbumSort.Year -> {
if (descending) {
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
}
}
AlbumSort.Added -> {
if (descending) {
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
}
}
AlbumSort.Starred -> {
if (descending) {
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
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)
}
}
}
}
val query =
albumsPageQuery(
sourceId = sourceId,
starredFilter = starred.ordinal,
search = search,
sort = sort,
descending = descending,
downloaded = downloaded,
sortNameMode = mode,
)
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
}
}
@@ -271,52 +220,17 @@ class LibraryRepository(
userPreferences.sortNameMode().flatMapLatest { mode ->
pager(60) {
val dao = db.libraryDao()
if (!downloaded) {
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
} else {
val downloadedFilter = 1
when (sort) {
ArtistSort.Name -> {
if (descending) {
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
} else {
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
}
}
ArtistSort.AlbumCount -> {
if (descending) {
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
}
}
ArtistSort.Starred -> {
if (descending) {
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
} else {
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)
}
}
}
}
val query =
artistsPageQuery(
sourceId = sourceId,
starredFilter = starred.ordinal,
search = search,
sort = sort,
descending = descending,
downloaded = downloaded,
sortNameMode = mode,
)
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
}
}
@@ -329,36 +243,15 @@ class LibraryRepository(
): Flow<PagingData<Playlist>> =
pager(40) {
val dao = db.libraryDao()
if (!downloaded) {
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
} else {
val downloadedFilter = 1
when (sort) {
PlaylistSort.Name -> {
if (descending) {
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
} else {
dao.playlistsByName(sourceId, search, downloadedFilter)
}
}
PlaylistSort.Added -> {
if (descending) {
dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
} else {
dao.playlistsByAdded(sourceId, search, downloadedFilter)
}
}
PlaylistSort.Updated -> {
if (descending) {
dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
} else {
dao.playlistsByUpdated(sourceId, search, downloadedFilter)
}
}
}
}
val query =
playlistsPageQuery(
sourceId = sourceId,
search = search,
sort = sort,
descending = descending,
downloaded = downloaded,
)
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
}
fun album(
@@ -61,7 +61,7 @@ class SyncService(
source.artists().collect { batch ->
if (batch.isEmpty()) return@collect
changed =
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
write("artists", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } ||
changed
batch.forEach { seen.add(idHash(it.id)) }
artistsSynced += batch.size
@@ -137,7 +137,7 @@ class SyncService(
connection.immediateTransaction {
val before = connection.totalChanges()
val resolved = connection.resolveSongSortNames(batch)
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
connection.upsertChanged("songs", primaryKey, resolved) { it.columns() }
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
@@ -148,7 +148,7 @@ class SyncService(
statement.clearBindings()
}
}
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
connection.upsertChanged("song_genres", songGenreKey, genres) { it.columns() }
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
batch.forEach { song ->
statement.bind(1, song.sourceId)
@@ -159,7 +159,7 @@ class SyncService(
statement.clearBindings()
}
}
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
(connection.totalChanges() != before).also { changed ->
if (changed) connection.markCreditsStale(batch.first().sourceId)
}
@@ -256,7 +256,7 @@ class SyncService(
val seen = HashedIds()
source.playlists().collect { batch ->
if (batch.isEmpty()) return@collect
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
write("playlists", primaryKey, batch) { it.columns() }
batch.forEach { seen.add(idHash(it.id)) }
playlistsSynced += batch.size
}
@@ -269,7 +269,7 @@ class SyncService(
val counts = HashMap<String, Long>()
source.playlistSongs(playlistIds).collect { batch ->
if (batch.isEmpty()) return@collect
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
write("playlist_songs", playlistSongKey, batch) { it.columns() }
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
playlistSongsSynced += batch.size
}
@@ -280,17 +280,16 @@ class SyncService(
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
private suspend fun <T> write(
table: String,
columns: List<String>,
key: List<String>,
rows: List<T>,
preserved: Set<String> = emptySet(),
markStale: Boolean = false,
values: (T) -> List<Any?>,
columns: (T) -> List<Pair<String, Any?>>,
): Boolean =
db.useWriterConnection { connection ->
connection.immediateTransaction {
val before = connection.totalChanges()
connection.upsertChanged(table, columns, key, rows, preserved, values)
connection.upsertChanged(table, key, rows, preserved, columns)
(connection.totalChanges() != before).also { changed ->
if (changed && markStale) connection.markCreditsStale(source.id)
}
@@ -309,8 +308,8 @@ class SyncService(
connection.immediateTransaction {
val before = connection.totalChanges()
val resolved = connection.resolveAlbumSortNames(albums)
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
connection.upsertChanged("albums", primaryKey, resolved) { it.columns() }
connection.upsertChanged("discs", discKey, discs) { it.columns() }
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
albums.forEach { album ->
statement.bind(1, album.sourceId)
@@ -321,7 +320,7 @@ class SyncService(
statement.clearBindings()
}
}
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
(connection.totalChanges() != before).also { changed ->
if (changed) connection.markCreditsStale(albums.first().sourceId)
}
@@ -438,58 +437,8 @@ private fun discHash(
private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
private val albumColumns =
listOf(
"sourceId",
"id",
"artistId",
"name",
"albumArtist",
"created",
"coverArt",
"genre",
"year",
"starred",
"songCount",
"artists",
"sortName",
"albumArtistSort",
)
private val discKey = listOf("sourceId", "albumId", "disc")
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
private val songColumns =
listOf(
"sourceId",
"id",
"albumId",
"artistId",
"title",
"album",
"artist",
"duration",
"track",
"disc",
"starred",
"created",
"playCount",
"played",
"trackGain",
"albumGain",
"trackPeak",
"albumPeak",
"artists",
"sortName",
"artistSort",
)
private val playlistColumns =
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
private fun Song.genreNames(): List<String> =
genres
.flatMap { it.genreParts() }
@@ -500,71 +449,93 @@ private fun Song.genreNames(): List<String> =
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
private val songGenreKey = listOf("sourceId", "songId", "position")
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
private val albumArtistKey = listOf("sourceId", "albumId", "position")
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
private val songArtistKey = listOf("sourceId", "songId", "position")
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
private fun Album.values() =
listOf<Any?>(
sourceId,
id,
artistId,
name,
albumArtist,
created,
coverArt,
genre,
year,
starred,
songCount,
ArtistRefsCodec.encode(artistRefs),
sortName,
albumArtistSort,
private fun Artist.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"id" to id,
"name" to name,
"albumCount" to albumCount,
"starred" to starred,
"coverArt" to coverArt,
"artistImageUrl" to artistImageUrl,
"sortName" to sortName,
)
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
private fun Album.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"id" to id,
"artistId" to artistId,
"name" to name,
"albumArtist" to albumArtist,
"created" to created,
"coverArt" to coverArt,
"genre" to genre,
"year" to year,
"starred" to starred,
"songCount" to songCount,
"artists" to ArtistRefsCodec.encode(artistRefs),
"sortName" to sortName,
"albumArtistSort" to albumArtistSort,
)
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
private fun AlbumArtistRef.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"albumId" to albumId,
"position" to position,
"artistId" to artistId,
"name" to name,
)
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
private fun SongArtistRef.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"songId" to songId,
"position" to position,
"artistId" to artistId,
"name" to name,
)
private fun Song.values() =
listOf<Any?>(
sourceId,
id,
albumId,
artistId,
title,
album,
artist,
duration,
track,
disc,
starred,
created,
playCount,
played,
trackGain,
albumGain,
trackPeak,
albumPeak,
ArtistRefsCodec.encode(artistRefs),
sortName,
artistSort,
private fun Disc.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"albumId" to albumId,
"disc" to disc,
"title" to title,
)
private fun Song.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"id" to id,
"albumId" to albumId,
"artistId" to artistId,
"title" to title,
"album" to album,
"artist" to artist,
"duration" to duration,
"track" to track,
"disc" to disc,
"starred" to starred,
"created" to created,
"playCount" to playCount,
"played" to played,
"trackGain" to trackGain,
"albumGain" to albumGain,
"trackPeak" to trackPeak,
"albumPeak" to albumPeak,
"artists" to ArtistRefsCodec.encode(artistRefs),
"sortName" to sortName,
"artistSort" to artistSort,
)
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
@@ -576,32 +547,56 @@ private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.po
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
private fun SongGenre.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"songId" to songId,
"position" to position,
"genre" to genre,
)
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
private fun Playlist.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"id" to id,
"name" to name,
"comment" to comment,
"coverArt" to coverArt,
"songCount" to songCount,
"created" to created,
"changed" to changed,
"duration" to duration,
"owner" to owner,
"shared" to shared,
)
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
private fun PlaylistSong.columns(): List<Pair<String, Any?>> =
listOf(
"sourceId" to sourceId,
"playlistId" to playlistId,
"songId" to songId,
"position" to position,
)
private suspend fun <T> PooledConnection.upsertChanged(
table: String,
columns: List<String>,
key: List<String>,
rows: List<T>,
preserved: Set<String> = emptySet(),
values: (T) -> List<Any?>,
columns: (T) -> List<Pair<String, Any?>>,
) {
if (rows.isEmpty()) return
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
val updates = columns.filterNot { it in key || it in preserved }
val columnNames = columns(rows.first()).map { it.first }
val updates = columnNames.filterNot { it in key || it in preserved }
val sql =
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
"INSERT INTO $table (${columnNames.joinToString()}) VALUES (${columnNames.joinToString { "?" }}) " +
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
updates.joinToString { "$it = excluded.$it" } +
" WHERE " +
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
usePrepared(sql) { statement ->
rows.forEach { row ->
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) }
statement.step()
statement.reset()
statement.clearBindings()
@@ -1070,6 +1070,7 @@ class PlaybackController(
}
lastSavedPositionMs = savedPositionMs
loadWindow(anchor.position, autoplay = false, startPositionMs = savedPositionMs)
player?.takeIf { it.itemCount > 0 }?.ensurePrepared()
}
private suspend fun stop() = startLock.withLock { stopLocked() }
@@ -1451,7 +1452,7 @@ class PlaybackController(
}
override fun onError(message: UiMessage) {
showMessage(message)
if (player?.playWhenReady == true) showMessage(message)
refresh()
}
}
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
data class PendingDownloadDelete(
val name: String,
@@ -18,6 +29,70 @@ data class PendingDownloadDelete(
val bytes: Long,
)
class PendingDownloadDeleteController internal constructor(
private val scope: CoroutineScope,
private val downloadedBytes: State<suspend (DownloadList, String) -> Long>,
) {
var pending by mutableStateOf<PendingDownloadDelete?>(null)
private set
fun onAction(
target: MenuTarget,
action: BulkDownloadAction,
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
) {
val (list, refId) = target.bulkRef() ?: return
onAction(target.title, list, refId, action, onAction)
}
fun onAction(
name: String,
list: DownloadList,
refId: String,
action: BulkDownloadAction,
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
) {
if (action == BulkDownloadAction.Delete) {
scope.launch {
pending = PendingDownloadDelete(name, list, refId, downloadedBytes.value(list, refId))
}
} else {
onAction(list, refId, action)
}
}
fun dismiss() {
pending = null
}
}
@Composable
fun rememberPendingDownloadDelete(
dismissals: Flow<Unit>,
downloadedBytes: suspend (DownloadList, String) -> Long,
): PendingDownloadDeleteController {
val scope = rememberCoroutineScope()
val currentDownloadedBytes = rememberUpdatedState(downloadedBytes)
val controller = remember(scope) { PendingDownloadDeleteController(scope, currentDownloadedBytes) }
DismissOnRequest(dismissals, controller::dismiss)
return controller
}
@Composable
fun PendingDownloadDeleteConfirmation(
controller: PendingDownloadDeleteController,
onConfirm: (PendingDownloadDelete) -> Unit,
) {
controller.pending?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { onConfirm(pending) },
onDismiss = controller::dismiss,
)
}
}
@Composable
fun DeleteDownloadsDialog(
name: String,
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -30,6 +29,7 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
@@ -39,16 +39,16 @@ import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistChooserSheet
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
import com.subtracks.ui.components.rememberPendingDownloadDelete
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -74,21 +74,16 @@ fun AlbumDetailRoute(
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) }
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
var showArtistChooser by remember { mutableStateOf(false) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
pendingDelete = null
showArtistChooser = false
}
DismissOnRequest(dismissals) { showArtistChooser = false }
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
if (action == BulkDownloadAction.Delete) {
scope.launch { pendingDelete = viewModel.downloadedBytes() }
} else {
viewModel.onDownloadAction(action)
pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested ->
viewModel.onDownloadAction(requested)
}
}
val actions =
@@ -141,13 +136,8 @@ fun AlbumDetailRoute(
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
)
pendingDelete?.let { bytes ->
DeleteDownloadsDialog(
name = album?.name.orEmpty(),
bytes = bytes,
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
PendingDownloadDeleteConfirmation(pendingDelete) {
viewModel.onDownloadAction(BulkDownloadAction.Delete)
}
if (showArtistChooser) {
@@ -59,7 +59,6 @@ import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -111,13 +110,12 @@ import com.subtracks.ui.components.ArtistHeroArt
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete
import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
import com.subtracks.ui.components.rememberPendingDownloadDelete
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.theme.ArtworkColors
@@ -130,7 +128,6 @@ import com.subtracks.ui.theme.rememberArtworkColors
import com.subtracks.ui.theme.rememberOverlaidNameBusy
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -217,26 +214,13 @@ fun ArtistDetailRoute(
val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions =
ItemActions(
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
bulkDownload = { target, action ->
target.bulkRef()?.let { (list, refId) ->
if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
setStar = setStar,
viewAlbum = onViewAlbum,
)
@@ -267,13 +251,8 @@ fun ArtistDetailRoute(
onShuffle = viewModel::shuffle,
)
pendingDelete?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
}
}
@@ -68,7 +68,6 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -126,13 +125,12 @@ import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ArtistArt
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
import com.subtracks.ui.components.SearchField
import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.rememberPendingDownloadDelete
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.home.HomeRoute
import com.subtracks.ui.home.HomeSection
@@ -143,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.abs
@@ -278,26 +275,13 @@ fun LibraryRoute(
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
val downloads = downloadsState?.value ?: emptyMap()
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
val itemActions =
ItemActions(
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
bulkDownload = { target, action ->
target.bulkRef()?.let { (list, refId) ->
if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -377,13 +361,8 @@ fun LibraryRoute(
},
)
pendingDelete?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
}
}
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey
import com.subtracks.R
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem
@@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.CloseWhenDownloadsGone
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
import com.subtracks.ui.components.rememberPendingDownloadDelete
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@@ -60,16 +60,12 @@ fun PlaylistDetailRoute(
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) }
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() }
val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
if (action == BulkDownloadAction.Delete) {
scope.launch { pendingDelete = viewModel.downloadedBytes() }
} else {
viewModel.onDownloadAction(action)
pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested ->
viewModel.onDownloadAction(requested)
}
}
val actions =
@@ -104,13 +100,8 @@ fun PlaylistDetailRoute(
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
)
pendingDelete?.let { bytes ->
DeleteDownloadsDialog(
name = playlist?.name.orEmpty(),
bytes = bytes,
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
PendingDownloadDeleteConfirmation(pendingDelete) {
viewModel.onDownloadAction(BulkDownloadAction.Delete)
}
}
@@ -0,0 +1,58 @@
package com.subtracks.data.db
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AlbumCreditsTest {
@Test
fun everyVariantUsesAllFourCreditPaths() {
val variants =
listOf(
CREDITED_ALBUM_ROWS,
DOWNLOADED_ALBUM_ROWS,
DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST,
DOWNLOADED_COVER_ALBUM_ROWS,
ARTIST_COVER_CREDITS,
ARTIST_COVER_CREDITS_FOR_ARTIST,
ALBUMS_FOR_ARTIST_DOWNLOADED,
)
variants.forEach { sql ->
assertEquals(sql, 1, sql.occurrences("FROM albums"))
assertEquals(sql, 1, sql.occurrences("FROM album_artists"))
assertEquals(sql, 1, sql.occurrences("FROM songs JOIN albums"))
assertEquals(sql, 1, sql.occurrences("FROM song_artists"))
}
}
@Test
fun variantsKeepTheirOwnFiltersAndDuplicateSemantics() {
assertFalse(CREDITED_ALBUM_ROWS.contains("coverArt IS NOT NULL"))
assertFalse(CREDITED_ALBUM_ROWS.contains("song_downloads"))
assertTrue(CREDITED_ALBUM_ROWS.contains(" UNION ALL "))
assertEquals(4, ARTIST_COVER_CREDITS.occurrences("coverArt IS NOT NULL"))
assertEquals(4, ARTIST_COVER_CREDITS_FOR_ARTIST.occurrences("= :artistId"))
assertEquals(4, DOWNLOADED_ALBUM_ROWS.occurrences("sd.status = 'Completed'"))
assertEquals(4, DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST.occurrences("= :artistId"))
assertEquals(4, DOWNLOADED_COVER_ALBUM_ROWS.occurrences("coverArt IS NOT NULL"))
assertTrue(DOWNLOADED_ALBUM_ROWS.contains(" UNION "))
assertFalse(DOWNLOADED_ALBUM_ROWS.contains(" UNION ALL "))
assertFalse(DOWNLOADED_ALBUM_ROWS.contains("creditType"))
}
@Test
fun consumersEmbedTheSharedVariants() {
assertTrue(ALBUMS_FOR_ARTIST.contains(CREDITED_ALBUM_ROWS))
assertTrue(ALBUM_CREDIT_ALBUM_IDS.contains(CREDITED_ALBUM_ROWS))
assertTrue(ARTIST_AGGREGATES_SQL.contains(CREDITED_ALBUM_ROWS))
assertTrue(DOWNLOADED_ARTIST_ALBUMS.contains(DOWNLOADED_ALBUM_ROWS))
assertTrue(ALBUMS_FOR_ARTIST_DOWNLOADED.contains(DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST))
assertTrue(ARTIST_COVER_ARTS_SQL.contains(ARTIST_COVER_CREDITS_FOR_ARTIST))
assertTrue(ARTIST_ART_UPDATE_SQL.contains(ARTIST_COVER_CREDITS))
assertTrue(ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
assertTrue(DOWNLOADED_ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
}
private fun String.occurrences(value: String): Int = split(value).size - 1
}
@@ -1,9 +1,5 @@
package com.subtracks.data.db
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.ArtworkSeed
import com.subtracks.data.model.Source
@@ -22,12 +18,7 @@ class ArtworkSeedDaoTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
}
@After
@@ -1,12 +1,8 @@
package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
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.awaitUntil
import com.subtracks.data.model.Album
@@ -39,12 +35,7 @@ class LibraryDaoTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
}
@After
@@ -67,17 +58,17 @@ class LibraryDaoTest {
assertEquals(
listOf("Alpha", "Beta", "Gamma"),
dao.albumsByName(sourceId, starredFilter = 0, search = "").page().map { it.name },
dao.albumPages(sourceId, starredFilter = 0, search = "").page().map { it.name },
)
assertEquals(
listOf("Gamma", "Beta", "Alpha"),
dao.albumsByNameReversed(sourceId, starredFilter = 0, search = "").page().map { it.name },
dao.albumPages(sourceId, starredFilter = 0, search = "", descending = true).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta", "Gamma"),
dao.albumsByYear(sourceId, starredFilter = 0, search = "").page().map { it.name },
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Year).page().map { it.name },
)
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredFilter = 1, search = "").page().map { it.name })
assertEquals(listOf("Alpha"), dao.albumPages(sourceId, starredFilter = 1, search = "").page().map { it.name })
}
@Test
@@ -95,14 +86,14 @@ class LibraryDaoTest {
assertEquals(
listOf("Beta", "Gamma", "Alpha"),
dao.albumsByRecentlyAdded(sourceId, starredFilter = 0, search = "").page().map { it.name },
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Added).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Gamma", "Beta"),
dao.albumsByStarred(sourceId, starredFilter = 0, search = "").page().map { it.name },
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Starred).page().map { it.name },
)
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredFilter = 0, search = "gam").page().map { it.name })
assertEquals(listOf("Beta"), dao.albumsByName(sourceId, starredFilter = 2, search = "").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, starredFilter = 0, search = "gam").page().map { it.name })
assertEquals(listOf("Beta"), dao.albumPages(sourceId, starredFilter = 2, search = "").page().map { it.name })
}
@Test
@@ -148,19 +139,19 @@ class LibraryDaoTest {
assertEquals(
listOf("Beta", "Alpha", "Delta", "Gamma"),
dao.albumsByStarred(sourceId, 0, "").page().map { it.name },
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Starred).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta", "Gamma", "Delta"),
dao.albumsByStarredReversed(sourceId, 0, "").page().map { it.name },
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Starred, descending = true).page().map { it.name },
)
assertEquals(
listOf("Beta", "Alpha", "Delta", "Gamma"),
dao.artistsByStarred(sourceId, 0, "").page().map { it.name },
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Starred).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta", "Gamma", "Delta"),
dao.artistsByStarredReversed(sourceId, 0, "").page().map { it.name },
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Starred, descending = true).page().map { it.name },
)
}
@@ -206,19 +197,19 @@ class LibraryDaoTest {
assertEquals(
listOf("Two", "One", "Three"),
dao.albumsByFrequent(sourceId, 0, "").page().map { it.name },
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent).page().map { it.name },
)
assertEquals(
listOf("Two", "One", "Three"),
dao.albumsByRecent(sourceId, 0, "").page().map { it.name },
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent).page().map { it.name },
)
assertEquals(
listOf("Many", "Few"),
dao.artistsByFrequent(sourceId, 0, "").page().map { it.name },
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent).page().map { it.name },
)
assertEquals(
listOf("Many", "Few"),
dao.artistsByRecent(sourceId, 0, "").page().map { it.name },
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent).page().map { it.name },
)
}
@@ -268,12 +259,17 @@ class LibraryDaoTest {
val pairs =
listOf(
dao.albumsByName(sourceId, 0, "") to dao.albumsByNameReversed(sourceId, 0, ""),
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, ""),
dao.albumPages(sourceId, 0, "") to dao.albumPages(sourceId, 0, "", descending = true),
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist) to
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, descending = true),
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Year) to
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Year, descending = true),
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Added) to
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Added, descending = true),
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent) to
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent, descending = true),
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent) to
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent, descending = true),
)
for ((base, reversed) in pairs) {
val ids = base.page().map { it.id }
@@ -303,23 +299,23 @@ class LibraryDaoTest {
val artistPairs =
listOf(
dao.artistsByName(sourceId, 0, "").page().map { it.id } to
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 },
dao.artistPages(sourceId, 0, "").page().map { it.id } to
dao.artistPages(sourceId, 0, "", descending = true).page().map { it.id },
dao.artistPages(sourceId, 0, "", sort = ArtistSort.AlbumCount).page().map { it.id } to
dao.artistPages(sourceId, 0, "", sort = ArtistSort.AlbumCount, descending = true).page().map { it.id },
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent).page().map { it.id } to
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent, descending = true).page().map { it.id },
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent).page().map { it.id } to
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent, descending = true).page().map { it.id },
)
val playlistPairs =
listOf(
dao.playlistsByName(sourceId, "").page().map { it.id } to
dao.playlistsByNameReversed(sourceId, "").page().map { it.id },
dao.playlistsByAdded(sourceId, "").page().map { it.id } to
dao.playlistsByAddedReversed(sourceId, "").page().map { it.id },
dao.playlistsByUpdated(sourceId, "").page().map { it.id } to
dao.playlistsByUpdatedReversed(sourceId, "").page().map { it.id },
dao.playlistPages(sourceId, "").page().map { it.id } to
dao.playlistPages(sourceId, "", descending = true).page().map { it.id },
dao.playlistPages(sourceId, "", sort = PlaylistSort.Added).page().map { it.id } to
dao.playlistPages(sourceId, "", sort = PlaylistSort.Added, descending = true).page().map { it.id },
dao.playlistPages(sourceId, "", sort = PlaylistSort.Updated).page().map { it.id } to
dao.playlistPages(sourceId, "", sort = PlaylistSort.Updated, descending = true).page().map { it.id },
)
for ((base, reversed) in artistPairs + playlistPairs) {
assertEquals(base.reversed(), reversed)
@@ -357,9 +353,11 @@ class LibraryDaoTest {
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")
assertTrue(plan, plan.contains("USING INDEX $index"))
assertFalse(plan, plan.contains("TEMP B-TREE"))
for (filter in listOf(ALBUMS_FILTER, ALBUMS_FILTER_DOWNLOADED)) {
val plan = plan("SELECT * $filter ORDER BY $order LIMIT 20 OFFSET 0")
assertTrue(plan, plan.contains("USING INDEX $index"))
assertFalse(plan, plan.contains("TEMP B-TREE"))
}
}
}
@@ -382,12 +380,49 @@ class LibraryDaoTest {
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")
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
assertTrue(plan, plan.contains("USING INDEX $index"))
assertFalse(plan, plan.contains("TEMP B-TREE"))
}
}
@Test
fun downloadedArtistOrdersUseTheIndexesTheirSortColumnsHave() =
runTest {
val orders =
listOf(
"${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name",
"${artistNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_artists_name",
"${artistNameSortKey(SortNameMode.Name)}, id" to "index_artists_name_plain",
"${artistNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_artists_name_plain",
ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount",
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 $ARTIST_COLUMNS_DOWNLOADED $ARTISTS_FILTER_DOWNLOADED " +
"ORDER BY $order LIMIT 20 OFFSET 0",
topLevelOnly = true,
)
if (order == ARTIST_ORDER_BY_ALBUM_COUNT || order == ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED) {
// The displayed count comes from song_downloads, so this order cannot use
// index_artists_albumCount; the hand-written statement sorted in memory too.
assertTrue("$order\n$plan", plan.contains("USING INDEX index_artists_sourceId"))
assertTrue("$order\n$plan", plan.contains("USE TEMP B-TREE FOR ORDER BY"))
} else {
assertTrue("$order\n$plan", plan.contains("USING INDEX $index"))
assertFalse("$order\n$plan", plan.contains("USE TEMP B-TREE FOR ORDER BY"))
}
}
}
@Test
fun playlistOrdersAreIndexBacked() =
runTest {
@@ -401,9 +436,18 @@ class LibraryDaoTest {
PLAYLIST_ORDER_BY_UPDATED_REVERSED to "index_playlists_updated",
)
for ((order, index) in orders) {
val plan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
assertTrue(plan, plan.contains("USING INDEX $index"))
assertFalse(plan, plan.contains("TEMP B-TREE"))
val defaultPlan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
assertTrue(defaultPlan, defaultPlan.contains("USING INDEX $index"))
assertFalse(defaultPlan, defaultPlan.contains("TEMP B-TREE"))
val downloadedPlan =
plan(
"SELECT $PLAYLISTS_COLUMNS_DOWNLOADED $PLAYLISTS_FILTER_DOWNLOADED " +
"ORDER BY $order LIMIT 20 OFFSET 0",
topLevelOnly = true,
)
assertTrue(downloadedPlan, downloadedPlan.contains("USING INDEX $index"))
assertFalse(downloadedPlan, downloadedPlan.contains("USE TEMP B-TREE FOR ORDER BY"))
}
}
@@ -456,17 +500,85 @@ class LibraryDaoTest {
assertEquals(
listOf("Downloaded"),
dao.albumsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
dao.albumPages(sourceId, 0, "", downloaded = true).page().map { it.name },
)
assertEquals(
listOf("ar-1"),
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.id },
dao.artistPages(sourceId, 0, "", downloaded = true).page().map { it.id },
)
assertEquals(
listOf("pl-1"),
dao.playlistsByName(sourceId, "", downloadedFilter = 1).page().map { it.id },
dao.playlistPages(sourceId, "", downloaded = true).page().map { it.id },
)
assertEquals(3, dao.albumsByName(sourceId, 0, "").page().size)
assertEquals(3, dao.albumPages(sourceId, 0, "").page().size)
}
@Test
fun downloadedFiltersHonourSearchAndStarred() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "Artist", albumCount = 2).copy(starred = 5),
artist(sourceId, "ar-2", "Other", albumCount = 1),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Downloaded One", year = 2000, starred = 5),
album(sourceId, "al-2", "Downloaded Two", year = 2001, starred = null),
album(sourceId, "al-3", "Missing", year = 2002, starred = 5),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2"),
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-2"),
),
)
dao.upsertPlaylists(
listOf(
playlist(sourceId, "pl-1", "Has download", created = 0, changed = 0),
playlist(sourceId, "pl-2", "No download", created = 1, changed = 1),
),
)
dao.upsertPlaylistSongs(
listOf(
PlaylistSong(sourceId, "pl-1", "s1", 0),
PlaylistSong(sourceId, "pl-2", "s2", 0),
),
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
assertEquals(
listOf("Downloaded One"),
dao.albumPages(sourceId, 0, "one", downloaded = true).page().map { it.name },
)
assertTrue(dao.albumPages(sourceId, 0, "missing", downloaded = true).page().isEmpty())
assertEquals(
listOf("Downloaded One"),
dao.albumPages(sourceId, 1, "", downloaded = true).page().map { it.name },
)
assertTrue(dao.albumPages(sourceId, 2, "", downloaded = true).page().isEmpty())
assertEquals(
listOf("ar-1"),
dao.artistPages(sourceId, 0, "art", downloaded = true).page().map { it.id },
)
assertTrue(dao.artistPages(sourceId, 0, "other", downloaded = true).page().isEmpty())
assertEquals(
listOf("ar-1"),
dao.artistPages(sourceId, 1, "", downloaded = true).page().map { it.id },
)
assertTrue(dao.artistPages(sourceId, 2, "", downloaded = true).page().isEmpty())
assertEquals(
listOf("pl-1"),
dao.playlistPages(sourceId, "has", downloaded = true).page().map { it.id },
)
assertTrue(dao.playlistPages(sourceId, "no", downloaded = true).page().isEmpty())
}
@Test
@@ -628,7 +740,7 @@ class LibraryDaoTest {
dao.recomputeArtistArt(sourceId)
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
val artists = dao.artistPages(sourceId, 0, "").page().associateBy { it.id }
assertEquals("own-art", artists.getValue("ar-own").coverArt)
assertNull(artists.getValue("ar-album").coverArt)
@@ -648,7 +760,7 @@ class LibraryDaoTest {
assertEquals(
listOf("album-ref-art"),
dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
.artistPages(sourceId, 0, "", ArtistSort.Name, false)
.page()
.first { it.id == "ar-album-ref" }
.coverArts,
@@ -774,6 +886,26 @@ class LibraryDaoTest {
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
}
@Test
fun freshDatabaseOptimizesArtistSearchTriggers() =
runTest {
val triggers =
db.useReaderConnection { connection ->
connection.usePrepared(
"SELECT sql FROM sqlite_master WHERE type = 'trigger' " +
"AND name IN ('room_fts_content_sync_artist_search_BEFORE_UPDATE', " +
"'room_fts_content_sync_artist_search_AFTER_UPDATE')",
) { statement ->
buildList {
while (statement.step()) add(statement.getText(0))
}
}
}
assertEquals(2, triggers.size)
assertTrue(triggers.all { it.contains("UPDATE OF `name`, `sortName`") })
}
@Test
fun artistNameUpdatesKeepTheSearchIndexValid() =
runTest {
@@ -869,18 +1001,7 @@ class LibraryDaoTest {
runTest {
val plan = plan(ALBUM_CREDIT_ALBUM_IDS)
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertFalse(plan, plan.contains("SCAN albums"))
assertFalse(plan, plan.contains("SCAN songs"))
assertArtistCreditIndexes(plan)
}
@Test
@@ -904,7 +1025,7 @@ class LibraryDaoTest {
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
assertEquals(
listOf("Album Artist"),
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
dao.artistPages(sourceId, 0, "", downloaded = true).page().map { it.name },
)
assertEquals(
listOf("Album Artist"),
@@ -913,7 +1034,7 @@ class LibraryDaoTest {
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
val filtered = dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page()
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true).page()
assertEquals(listOf("Album Artist", "Guest"), filtered.map { it.name })
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
@@ -961,14 +1082,14 @@ class LibraryDaoTest {
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
val filtered = dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page()
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true).page()
assertEquals(1, filtered.size)
assertEquals(1L, filtered.first().albumCount)
assertEquals(
5L,
dao
.artistsByName(sourceId, 0, "")
.artistPages(sourceId, 0, "")
.page()
.first { it.id == "ar-1" }
.albumCount,
@@ -1006,7 +1127,7 @@ class LibraryDaoTest {
// ar-1 has two downloaded albums, ar-2 one, so the downloaded order is ar-1 first even
// though the synced counts would put ar-2 first.
val filtered = dao.artistsByAlbumCount(sourceId, 0, "", downloadedFilter = 1).page()
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true, sort = ArtistSort.AlbumCount).page()
assertEquals(listOf("ar-1", "ar-2"), filtered.map { it.id })
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
}
@@ -1036,7 +1157,7 @@ class LibraryDaoTest {
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
val filtered = dao.playlistPages(sourceId, "", downloaded = true).page()
assertEquals(1, filtered.size)
assertEquals(1L, filtered.first().songCount)
assertEquals("austin", filtered.first().owner)
@@ -1045,7 +1166,7 @@ class LibraryDaoTest {
assertEquals(
5L,
dao
.playlistsByName(sourceId, "")
.playlistPages(sourceId, "")
.page()
.first { it.id == "pl-1" }
.songCount,
@@ -1064,11 +1185,11 @@ class LibraryDaoTest {
),
)
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "GAM").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "ga").page().map { it.name })
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, 0, "Else").page().map { it.name })
assertTrue(dao.albumsByName(sourceId, 0, "zzz").page().isEmpty())
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "amm").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "GAM").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "ga").page().map { it.name })
assertEquals(listOf("Alpha"), dao.albumPages(sourceId, 0, "Else").page().map { it.name })
assertTrue(dao.albumPages(sourceId, 0, "zzz").page().isEmpty())
}
@Test
@@ -1077,17 +1198,17 @@ class LibraryDaoTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null)))
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "gam").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "gam").page().map { it.name })
dao.setAlbumStar(sourceId, "a", 7)
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "gam").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "gam").page().map { it.name })
dao.upsertAlbums(listOf(album(sourceId, "a", "Delta", year = null, starred = null)))
assertTrue(dao.albumsByName(sourceId, 0, "gam").page().isEmpty())
assertEquals(listOf("Delta"), dao.albumsByName(sourceId, 0, "elt").page().map { it.name })
assertTrue(dao.albumPages(sourceId, 0, "gam").page().isEmpty())
assertEquals(listOf("Delta"), dao.albumPages(sourceId, 0, "elt").page().map { it.name })
dao.deleteAlbums(sourceId, listOf("a"))
assertTrue(dao.albumsByName(sourceId, 0, "elt").page().isEmpty())
assertTrue(dao.albumPages(sourceId, 0, "elt").page().isEmpty())
}
@Test
@@ -1101,8 +1222,8 @@ class LibraryDaoTest {
val second = source()
dao.upsertAlbums(listOf(album(second, "a", "Beta", year = null, starred = null)))
assertEquals(listOf("Beta"), dao.albumsByName(second, 0, "eta").page().map { it.name })
assertTrue(dao.albumsByName(second, 0, "gam").page().isEmpty())
assertEquals(listOf("Beta"), dao.albumPages(second, 0, "eta").page().map { it.name })
assertTrue(dao.albumPages(second, 0, "gam").page().isEmpty())
}
@Test
@@ -1113,10 +1234,10 @@ class LibraryDaoTest {
dao.upsertArtists(listOf(artist(sourceId, "r1", "The Beatles", albumCount = 1)))
dao.upsertPlaylists(listOf(playlist(sourceId, "l1", "Road Trip", created = 0, changed = 0)))
assertEquals(listOf("r1"), dao.artistsByName(sourceId, 0, "eat").page().map { it.id })
assertEquals(listOf("l1"), dao.playlistsByName(sourceId, "rip").page().map { it.id })
assertTrue(dao.artistsByName(sourceId, 0, "rip").page().isEmpty())
assertTrue(dao.playlistsByName(sourceId, "eat").page().isEmpty())
assertEquals(listOf("r1"), dao.artistPages(sourceId, 0, "eat").page().map { it.id })
assertEquals(listOf("l1"), dao.playlistPages(sourceId, "rip").page().map { it.id })
assertTrue(dao.artistPages(sourceId, 0, "rip").page().isEmpty())
assertTrue(dao.playlistPages(sourceId, "eat").page().isEmpty())
}
@Test
@@ -1131,10 +1252,10 @@ class LibraryDaoTest {
),
)
assertEquals(listOf("Say \"Hello\""), dao.albumsByName(sourceId, 0, "\"Hel").page().map { it.name })
assertEquals(listOf("Say \"Hello\""), dao.albumPages(sourceId, 0, "\"Hel").page().map { it.name })
assertEquals(
listOf("Say \"Hello\""),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "\"Hel", AlbumSort.Name, false)).page().map { it.name },
dao.albumPages(sourceId, 0, "\"Hel", AlbumSort.Name, false).page().map { it.name },
)
}
@@ -1145,7 +1266,7 @@ class LibraryDaoTest {
val dao = db.libraryDao()
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null).copy(albumArtist = null)))
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "amm").page().map { it.name })
}
@Test
@@ -1236,25 +1357,48 @@ class LibraryDaoTest {
runTest {
val plan = plan(ALBUMS_FOR_ARTIST)
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertFalse(plan, plan.contains("SCAN albums"))
assertFalse(plan, plan.contains("SCAN songs"))
assertArtistCreditIndexes(plan)
}
private suspend fun plan(sql: String): String =
@Test
fun downloadedArtistAlbumsFindCreditsThroughArtistIndexes() =
runTest {
val plan = plan(ALBUMS_FOR_ARTIST_DOWNLOADED)
assertTrue(plan, plan.contains("SEARCH albums USING INDEX sqlite_autoindex_albums_1"))
assertTrue(plan, plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(plan, plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"))
assertFalse(plan, plan.contains("SCAN albums"))
assertFalse(plan, plan.contains("SCAN songs"))
assertFalse(plan, plan.contains("SCAN album_artists"))
assertFalse(plan, plan.contains("SCAN song_artists"))
}
private fun assertArtistCreditIndexes(plan: String) {
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
assertTrue(
plan,
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
)
assertFalse(plan, plan.contains("SCAN albums"))
assertFalse(plan, plan.contains("SCAN songs"))
}
private suspend fun plan(
sql: String,
topLevelOnly: Boolean = false,
): String =
db.useReaderConnection { connection ->
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
val details = mutableListOf<String>()
while (statement.step()) details += statement.getText(3)
while (statement.step()) {
if (!topLevelOnly || statement.getLong(1) == 0L) details += statement.getText(3)
}
details.joinToString("\n")
}
}
@@ -1274,35 +1418,35 @@ class LibraryDaoTest {
assertEquals(
listOf("Alpha", "Beta", "Gamma"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false)).page().map { it.name },
dao.albumPages(sourceId, 0, "", AlbumSort.Name, false).page().map { it.name },
)
assertEquals(
listOf("Gamma", "Beta", "Alpha"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, true)).page().map { it.name },
dao.albumPages(sourceId, 0, "", AlbumSort.Name, true).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta", "Gamma"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Year, false)).page().map { it.name },
dao.albumPages(sourceId, 0, "", AlbumSort.Year, false).page().map { it.name },
)
assertEquals(
listOf("Gamma", "Beta", "Alpha"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Artist, false)).page().map { it.name },
dao.albumPages(sourceId, 0, "", AlbumSort.Artist, false).page().map { it.name },
)
assertEquals(
listOf("Alpha"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "alp", AlbumSort.Name, false)).page().map { it.name },
dao.albumPages(sourceId, 0, "alp", AlbumSort.Name, false).page().map { it.name },
)
assertEquals(
listOf("Gamma"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "am", AlbumSort.Name, false)).page().map { it.name },
dao.albumPages(sourceId, 0, "am", AlbumSort.Name, false).page().map { it.name },
)
assertEquals(
listOf("Alpha"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 1, "", AlbumSort.Name, false)).page().map { it.name },
dao.albumPages(sourceId, 1, "", AlbumSort.Name, false).page().map { it.name },
)
assertEquals(
listOf("Beta", "Gamma"),
dao.albumsPaging(albumsDefaultQuery(sourceId, 2, "", AlbumSort.Name, false)).page().map { it.name },
dao.albumPages(sourceId, 2, "", AlbumSort.Name, false).page().map { it.name },
)
}
@@ -1326,23 +1470,23 @@ class LibraryDaoTest {
assertEquals(
listOf("Alpha", "Beta"),
dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "a", ArtistSort.Name, false)).page().map { it.name },
dao.artistPages(sourceId, 0, "a", ArtistSort.Name, false).page().map { it.name },
)
assertEquals(
listOf("Beta", "Alpha"),
dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.AlbumCount, false)).page().map { it.name },
dao.artistPages(sourceId, 0, "", ArtistSort.AlbumCount, false).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta"),
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "a", PlaylistSort.Name, false)).page().map { it.name },
dao.playlistPages(sourceId, "a", PlaylistSort.Name, false).page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta"),
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Added, false)).page().map { it.name },
dao.playlistPages(sourceId, "", PlaylistSort.Added, false).page().map { it.name },
)
assertEquals(
listOf("Beta", "Alpha"),
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Updated, false)).page().map { it.name },
dao.playlistPages(sourceId, "", PlaylistSort.Updated, false).page().map { it.name },
)
}
@@ -1355,12 +1499,12 @@ class LibraryDaoTest {
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null).copy(albumId = "a")))
val defaultInvalidated = AtomicBoolean(false)
val default = dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false))
val default = dao.albumPages(sourceId, 0, "", AlbumSort.Name, false)
default.registerInvalidatedCallback { defaultInvalidated.set(true) }
default.page()
val downloadedInvalidated = AtomicBoolean(false)
val downloaded = dao.albumsByName(sourceId, 0, "", downloadedFilter = 1)
val downloaded = dao.albumPages(sourceId, 0, "", downloaded = true)
downloaded.registerInvalidatedCallback { downloadedInvalidated.set(true) }
downloaded.page()
@@ -1373,6 +1517,52 @@ class LibraryDaoTest {
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
}
@Test
fun downloadWritesInvalidateTheDownloadedArtistAndPlaylistPagingSources() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 1)))
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Album", year = null, starred = null)))
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null)))
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "Playlist", created = 0, changed = 0)))
dao.upsertPlaylistSongs(listOf(PlaylistSong(sourceId, "pl-1", "s1", 0)))
val artistInvalidated = AtomicBoolean(false)
val artists = dao.artistPages(sourceId, downloaded = true)
artists.registerInvalidatedCallback { artistInvalidated.set(true) }
artists.page()
val playlistInvalidated = AtomicBoolean(false)
val playlists = dao.playlistPages(sourceId, downloaded = true)
playlists.registerInvalidatedCallback { playlistInvalidated.set(true) }
playlists.page()
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
awaitUntil("the downloaded artists observe download writes") { artistInvalidated.get() }
awaitUntil("the downloaded playlists observe download writes") { playlistInvalidated.get() }
}
@Test
fun songWritesInvalidateTheDownloadedArtistPagingSource() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 1)))
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Album", year = null, starred = null)))
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null)))
val invalidated = AtomicBoolean(false)
val artists = dao.artistPages(sourceId, downloaded = true)
artists.registerInvalidatedCallback { invalidated.set(true) }
artists.page()
dao.upsertSongs(listOf(song(sourceId, "s2", "Two", starred = null)))
awaitUntil("the downloaded artists observe song writes") { invalidated.get() }
}
@Test
fun sortNamesDriveArtistAndAlbumOrder() =
runTest {
@@ -1395,27 +1585,27 @@ class LibraryDaoTest {
assertEquals(
listOf("宇多田ヒカル", "あいみょん"),
dao.artistsByName(sourceId, 0, "").page().map { it.name },
dao.artistPages(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("あいみょん", "宇多田ヒカル"),
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.name },
dao.artistPages(sourceId, 0, "", descending = true).page().map { it.name },
)
assertEquals(
listOf("Zzz", "Aaa"),
dao.albumsByName(sourceId, 0, "").page().map { it.name },
dao.albumPages(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz"),
dao.albumsByNameReversed(sourceId, 0, "").page().map { it.name },
dao.albumPages(sourceId, 0, "", descending = true).page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz"),
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist).page().map { it.name },
)
assertEquals(
listOf("Zzz", "Aaa"),
dao.albumsByArtistReversed(sourceId, 0, "").page().map { it.name },
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, descending = true).page().map { it.name },
)
}
@@ -1445,39 +1635,111 @@ class LibraryDaoTest {
assertEquals(
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.SortName.name).page().map { it.name },
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.SortName).page().map { it.name },
)
assertEquals(
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.Name).page().map { it.name },
)
assertEquals(
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
dao.artistsByNameReversed(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
dao
.artistPages(sourceId, 0, "", descending = true, sortNameMode = SortNameMode.Name)
.page()
.map { it.name },
)
assertEquals(
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.NonLatin.name).page().map { it.name },
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.NonLatin).page().map { it.name },
)
assertEquals(
listOf("Aaa", "Beatles", "Zzz"),
dao.albumsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
dao.albumPages(sourceId, 0, "", sortNameMode = SortNameMode.Name).page().map { it.name },
)
assertEquals(
listOf("Aaa", "Zzz", "Beatles"),
dao.albumsByArtist(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
dao
.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, sortNameMode = SortNameMode.Name)
.page()
.map { it.name },
)
assertEquals(
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
dao
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name))
.page()
.artistsPaging(
artistsPageQuery(sourceId, 0, "", ArtistSort.Name, false, false, SortNameMode.Name),
).page()
.map { it.name },
)
assertEquals(
listOf("Aaa", "Beatles", "Zzz"),
dao
.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false, SortNameMode.Name))
.albumsPaging(
albumsPageQuery(sourceId, 0, "", AlbumSort.Name, false, false, SortNameMode.Name),
).page()
.map { it.name },
)
}
@Test
fun downloadedSortsHonourTheSortNamePreference() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "ar-1", "Beatles", albumCount = 1).copy(sortName = "Beatles, The"),
artist(sourceId, "ar-2", "Zebra", albumCount = 1).copy(sortName = "Aaa"),
),
)
dao.upsertAlbums(
listOf(
album(sourceId, "al-1", "Beatles", year = null, starred = null)
.copy(sortName = "Beatles, The", albumArtistSort = "Beatles, The", albumArtist = "Beatles"),
album(sourceId, "al-2", "Zebra", year = null, starred = null)
.copy(
sortName = "Aaa",
albumArtistSort = "Aaa",
albumArtist = "Zebra",
artistId = "ar-2",
),
),
)
dao.upsertSongs(
listOf(
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1"),
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-2"),
),
)
listOf("s1", "s2").forEach {
db.downloadDao().upsert(SongDownload(sourceId, it, DownloadStatus.Completed))
}
assertEquals(
listOf("Zebra", "Beatles"),
dao
.artistPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.SortName)
.page()
.map { it.name },
)
assertEquals(
listOf("Beatles", "Zebra"),
dao
.artistPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.Name)
.page()
.map { it.name },
)
assertEquals(
listOf("Zebra", "Beatles"),
dao
.albumPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.SortName)
.page()
.map { it.name },
)
assertEquals(
listOf("Beatles", "Zebra"),
dao
.albumPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.Name)
.page()
.map { it.name },
)
@@ -1507,11 +1769,11 @@ class LibraryDaoTest {
),
)
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.artistPages(sourceId, 0, "utada").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.artistPages(sourceId, 0, "ut").page().map { it.name })
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name })
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name })
assertEquals(listOf("First Love"), dao.albumPages(sourceId, 0, "first").page().map { it.name })
assertEquals(listOf("First Love"), dao.albumPages(sourceId, 0, "fi").page().map { it.name })
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
}
@@ -1,11 +1,7 @@
package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
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.model.Album
import com.subtracks.data.model.Artist
@@ -27,12 +23,7 @@ class LibraryHomeDaoTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
}
@After
@@ -0,0 +1,109 @@
package com.subtracks.data.db
import androidx.paging.PagingSource
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.SortNameMode
internal fun LibraryDao.albumPages(
sourceId: Long,
starredFilter: Int = 0,
search: String = "",
sort: AlbumSort = AlbumSort.Name,
descending: Boolean = false,
downloaded: Boolean = false,
sortNameMode: SortNameMode = SortNameMode.SortName,
): PagingSource<Int, Album> =
if (downloaded) {
downloadedAlbumsPaging(
albumsPageQuery(
sourceId = sourceId,
starredFilter = starredFilter,
search = search,
sort = sort,
descending = descending,
downloaded = true,
sortNameMode = sortNameMode,
),
)
} else {
albumsPaging(
albumsPageQuery(
sourceId = sourceId,
starredFilter = starredFilter,
search = search,
sort = sort,
descending = descending,
downloaded = false,
sortNameMode = sortNameMode,
),
)
}
internal fun LibraryDao.artistPages(
sourceId: Long,
starredFilter: Int = 0,
search: String = "",
sort: ArtistSort = ArtistSort.Name,
descending: Boolean = false,
downloaded: Boolean = false,
sortNameMode: SortNameMode = SortNameMode.SortName,
): PagingSource<Int, Artist> =
if (downloaded) {
downloadedArtistsPaging(
artistsPageQuery(
sourceId = sourceId,
starredFilter = starredFilter,
search = search,
sort = sort,
descending = descending,
downloaded = true,
sortNameMode = sortNameMode,
),
)
} else {
artistsPaging(
artistsPageQuery(
sourceId = sourceId,
starredFilter = starredFilter,
search = search,
sort = sort,
descending = descending,
downloaded = false,
sortNameMode = sortNameMode,
),
)
}
internal fun LibraryDao.playlistPages(
sourceId: Long,
search: String = "",
sort: PlaylistSort = PlaylistSort.Name,
descending: Boolean = false,
downloaded: Boolean = false,
): PagingSource<Int, Playlist> =
if (downloaded) {
downloadedPlaylistsPaging(
playlistsPageQuery(
sourceId = sourceId,
search = search,
sort = sort,
descending = descending,
downloaded = true,
),
)
} else {
playlistsPaging(
playlistsPageQuery(
sourceId = sourceId,
search = search,
sort = sort,
descending = descending,
downloaded = false,
),
)
}
@@ -1,12 +1,9 @@
package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.room3.immediateTransaction
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.model.Album
import com.subtracks.data.model.Artist
@@ -14,6 +11,9 @@ import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
import com.subtracks.data.sync.SyncService
@@ -37,14 +37,8 @@ class QueryTimingMeasurementTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(driver)
.addCallback(ArtistSearchTriggerOptimization)
.build()
db = createTestDatabase(driver)
}
@After
@@ -110,28 +104,37 @@ class QueryTimingMeasurementTest {
private suspend fun measureReads() {
val dao = db.libraryDao()
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
timed("albums first page (added)") { dao.albumsByRecentlyAdded(1, 0, "").firstPage() }
timed("albums first page (starred)") { dao.albumsByStarred(1, 0, "").firstPage() }
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
timed("albums first page (name)") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).firstPage() }
timed("albums first page (name, downloaded)") {
dao.albumPages(1, 0, "", sort = AlbumSort.Name, downloaded = true).firstPage()
}
timed("albums first page (artist)") { dao.albumPages(1, 0, "", sort = AlbumSort.Artist).firstPage() }
timed("albums first page (year)") { dao.albumPages(1, 0, "", sort = AlbumSort.Year).firstPage() }
timed("albums first page (added)") { dao.albumPages(1, 0, "", sort = AlbumSort.Added).firstPage() }
timed("albums first page (starred)") { dao.albumPages(1, 0, "", sort = AlbumSort.Starred).firstPage() }
timed("albums first page (frequent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Frequent).firstPage() }
timed("albums first page (recent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Recent).firstPage() }
timed("artists first page (name)") { dao.artistPages(1, 0, "", sort = ArtistSort.Name).firstPage() }
timed("artists first page (name, downloaded)") {
dao.artistPages(1, 0, "", sort = ArtistSort.Name, downloaded = true).firstPage()
}
timed("artists first page (frequent)") { dao.artistPages(1, 0, "", sort = ArtistSort.Frequent).firstPage() }
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
timed("artist name") { dao.artistName(1, "ar0").first() }
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
timed("artists downloaded filter") { dao.artistPages(1, 0, "", downloaded = true).firstPage() }
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
timed("albums search short") { dao.albumsByName(1, 0, "al").firstPage() }
timed("playlists first page (name)") { dao.playlistPages(1, "", sort = PlaylistSort.Name).firstPage() }
timed("playlists first page (name, downloaded)") {
dao.playlistPages(1, "", sort = PlaylistSort.Name, downloaded = true).firstPage()
}
timed("albums deep page") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).deepPage(1000) }
timed("albums search FTS") { dao.albumPages(1, 0, "Album 1", sort = AlbumSort.Name).firstPage() }
timed("albums search short") { dao.albumPages(1, 0, "al", sort = AlbumSort.Name).firstPage() }
timed("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
@@ -0,0 +1,16 @@
package com.subtracks.data.db
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.SQLiteDriver
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
fun createTestDatabase(driver: SQLiteDriver = BundledSQLiteDriver()): SubtracksDatabase {
val context = ApplicationProvider.getApplicationContext<Context>()
return Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(driver)
.addSubtracksDatabaseCallbacks()
.build()
}
@@ -3,13 +3,12 @@ package com.subtracks.data.repo
import android.content.Context
import android.content.res.Resources
import android.net.Uri
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkFetcher
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
@@ -84,11 +83,7 @@ class DownloadRepositoryTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
@@ -4,12 +4,11 @@ import android.content.Context
import android.content.res.Resources
import androidx.paging.PagingDataEvent
import androidx.paging.PagingDataPresenter
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.Album
import com.subtracks.data.model.ArtistBio
@@ -63,11 +62,7 @@ class LibraryRepositoryTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
@@ -208,6 +203,42 @@ class LibraryRepositoryTest {
}
}
@Test
fun theDownloadedAlbumGridOnlyPresentsDownloadedRows() =
runBlocking {
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 1), seedAlbum(sourceId, 2)))
db.libraryDao().upsertSongs(
listOf(
seedSong(sourceId).copy(id = "s1", albumId = "al-0001"),
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
),
)
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
val presenter = RecordingPresenter(presenterDispatcher)
val job =
launch {
repository
.albums(
sourceId = sourceId,
sort = AlbumSort.Name,
descending = false,
starred = StarredFilter.Any,
search = "",
downloaded = true,
).collect { presenter.collectFrom(it) }
}
try {
awaitRefreshes(presenter, 1)
assertEquals(1, presented(presenter, presenterDispatcher))
} finally {
job.cancelAndJoin()
presenterDispatcher.close()
}
}
@Test
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
runBlocking {
@@ -1,13 +1,10 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.PendingActionKind
import com.subtracks.data.model.Source
import com.subtracks.data.source.ServerActionSink
@@ -82,12 +79,7 @@ class PendingServerActionSinkTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
delegate = RecordingSink()
@@ -1,12 +1,9 @@
package com.subtracks.data.repo
import android.content.Context
import androidx.room3.Room
import androidx.room3.useReaderConnection
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.db.createTestDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
@@ -26,12 +23,7 @@ class QueueKeysetTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
repository = QueueRepository(db)
}
@@ -1,13 +1,10 @@
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.db.createTestDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Playlist
@@ -33,12 +30,7 @@ class QueueRepositoryTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
repository = QueueRepository(db)
}
@@ -2,13 +2,12 @@ package com.subtracks.data.repo
import android.content.Context
import android.content.res.Resources
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.artistImageKey
@@ -58,11 +57,7 @@ class SourceRepositoryTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
prefs = fakeUserPreferences()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -314,7 +309,7 @@ class SourceRepositoryTest {
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
}
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null }!! }
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
withTimeout(TEST_TIMEOUT_MS) {
@@ -2,13 +2,12 @@ package com.subtracks.data.sync
import android.content.Context
import android.content.res.Resources
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
@@ -55,11 +54,7 @@ class SyncManagerTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
@@ -1,16 +1,15 @@
package com.subtracks.data.sync
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
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.LoggingSQLiteDriver
import com.subtracks.data.db.SqlExecution
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.artistsDefaultQuery
import com.subtracks.data.db.artistPages
import com.subtracks.data.db.artistsPageQuery
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.ArtistRef
@@ -43,12 +42,7 @@ class SyncServiceTest {
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
}
@After
@@ -75,7 +69,7 @@ class SyncServiceTest {
2,
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.size,
)
@@ -98,7 +92,7 @@ class SyncServiceTest {
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.map { it.id },
)
@@ -172,7 +166,7 @@ class SyncServiceTest {
"Old",
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.single()
.name,
@@ -185,7 +179,7 @@ class SyncServiceTest {
"New",
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.single()
.name,
@@ -1107,16 +1101,16 @@ class SyncServiceTest {
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.map { it.id },
)
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
val query = artistsPageQuery(1, 0, "alpha", ArtistSort.Name, false, false)
assertEquals(
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "alpha")
.artistPages(1, 0, "alpha")
.allRows()
.map { it.id },
)
@@ -1203,7 +1197,7 @@ class SyncServiceTest {
listOf("a1", "combo"),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.map { it.id },
)
@@ -1216,7 +1210,7 @@ class SyncServiceTest {
listOf("a1"),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.map { it.id },
)
@@ -1434,7 +1428,7 @@ class SyncServiceTest {
SyncService(db, source).sync()
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
val filtered = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
assertEquals(listOf("A", "B"), filtered.map { it.name })
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
@@ -1538,7 +1532,7 @@ class SyncServiceTest {
albums.size.toLong(),
db
.libraryDao()
.artistsByName(1, 0, "")
.artistPages(1, 0, "")
.allRows()
.first { it.id == "a2" }
.albumCount,
@@ -1780,7 +1774,7 @@ class SyncServiceTest {
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
}
val downloadedArtists = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
val downloadedArtists = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
for (artist in downloadedArtists) {
assertEquals(
"${artist.id} downloaded albums",
@@ -1845,13 +1839,8 @@ class SyncServiceTest {
@Test
fun anIdenticalSyncSkipsTheAggregateQueries() =
runTest {
val context = ApplicationProvider.getApplicationContext<Context>()
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
val loggingDb =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
.build()
val loggingDb = createTestDatabase(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
try {
loggingDb.sourcesDao().upsertSource(
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
@@ -3,9 +3,7 @@ package com.subtracks.playback
import android.content.Context
import android.content.res.Resources
import androidx.media3.common.PlaybackException
import androidx.room3.Room
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.R
@@ -14,6 +12,7 @@ import com.subtracks.UiMessage
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
@@ -83,11 +82,7 @@ class PlaybackControllerTest {
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
resources = context.resources
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
prefs = fakeUserPreferences()
networkMode = MutableStateFlow(NetworkMode.Wifi)
sources =
@@ -348,18 +343,75 @@ class PlaybackControllerTest {
}
@Test
fun togglingPlayOnARestoredQueuePreparesBeforePlaying() {
fun restoringAPreviousQueuePreparesTheSessionWithoutPlaying() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect()
await { handle.operations.contains("prepare") }
assertFalse(handle.operations.contains("play"))
assertFalse(handle.isIdle)
assertFalse(handle.playWhenReady)
assertFalse(controller.state.value.isPlaying)
}
@Test
fun aPrepareFailureBeforePlaybackIsNotReported() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect()
await { handle.operations.contains("prepare") }
handle.fail(UiMessage(R.string.playback_error_network))
assertTrue(messages.isEmpty())
controller.togglePlayPause()
await { handle.operations.contains("play") }
handle.fail(UiMessage(R.string.playback_error_network))
await { messages.isNotEmpty() }
assertEquals(1, messages.size)
}
@Test
fun restoringAnEmptyQueueDoesNotPrepare() {
seedAlbum(3, sourceId = 1)
controller.connect()
await { controller.ready.value }
assertTrue(handle.items.isEmpty())
assertFalse(handle.operations.contains("prepare"))
}
@Test
fun restoringAnOfflineQueueWithoutDownloadsDoesNotPrepare() {
seedAlbum(3, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
setOffline()
controller.connect()
await { controller.ready.value }
assertTrue(handle.items.isEmpty())
assertFalse(handle.operations.contains("prepare"))
}
@Test
fun togglingPlayOnARestoredQueuePlaysThePreparedWindow() {
seedAlbum(5, sourceId = 1)
runBlocking { queues.replace(listOf(queues.albumEntry(1, "al1"))) }
controller.connect()
await { handle.items.isNotEmpty() }
await { handle.operations.contains("prepare") }
handle.operations.clear()
controller.togglePlayPause()
await { handle.operations.contains("play") }
assertEquals(listOf("prepare", "play"), handle.operations)
assertEquals(listOf("play"), handle.operations)
}
@Test
@@ -0,0 +1,79 @@
package com.subtracks.ui
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.OkHttpClient
import java.io.File
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
class ViewModelRepositoryFixture {
val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
init {
Dispatchers.setMain(dispatcher)
}
val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
val db = createTestDatabase()
val artworkStore = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
val downloadEngine = FakeDownloadEngine()
val downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
val sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artworkStore, scope = repoScope)
val downloadRepository =
DownloadRepository(
db,
sourceRepository,
downloadEngine,
downloadsDir,
artworkStore = artworkStore,
artworkFetcher = { ByteArray(0) },
scope = repoScope,
)
val queueRepository = QueueRepository(db)
val controller =
PlaybackController(
sourceRepository,
queueRepository,
FakePlayerConnection(FakePlayerHandle()),
downloadRepository,
scope = controllerScope,
)
val libraryRepository =
LibraryRepository(
db,
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
scope = repoScope,
)
fun close() {
cancelAndJoinBlocking(controllerScope, repoScope)
db.close()
Dispatchers.resetMain()
dispatcher.close()
}
}
@@ -6,8 +6,13 @@ import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadList
import com.subtracks.ui.theme.SubtracksTheme
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.emptyFlow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -55,6 +60,115 @@ class DeleteDownloadsDialogTest {
composeRule.onNodeWithText("512 B", substring = true).assertIsDisplayed()
}
@Test
fun sharedWorkflowLooksUpBytesAndConfirmsTheRequestedTargetOnce() {
val actions = mutableListOf<Triple<DownloadList, String, BulkDownloadAction>>()
lateinit var controller: PendingDownloadDeleteController
renderWorkflow(
downloadedBytes = { list, refId ->
assertEquals(DownloadList.Album, list)
assertEquals("al1", refId)
1_500_000
},
onAction = { list, refId, action -> actions += Triple(list, refId, action) },
onController = { controller = it },
)
composeRule.runOnIdle {
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
}
composeRule.waitUntil { controller.pending != null }
composeRule.onNodeWithText("Kid A", substring = true).assertIsDisplayed()
composeRule.onNodeWithText("MB", substring = true).assertIsDisplayed()
composeRule.onNodeWithText("Delete").performClick()
composeRule.runOnIdle {
assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Delete)), actions)
assertNull(controller.pending)
}
}
@Test
fun sharedWorkflowCancelDoesNotConfirm() {
val actions = mutableListOf<BulkDownloadAction>()
lateinit var controller: PendingDownloadDeleteController
renderWorkflow(
onAction = { _, _, action -> actions += action },
onController = { controller = it },
)
composeRule.runOnIdle {
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
}
composeRule.waitUntil { controller.pending != null }
composeRule.onNodeWithText("Cancel").performClick()
composeRule.runOnIdle {
assertEquals(emptyList<BulkDownloadAction>(), actions)
assertNull(controller.pending)
}
}
@Test
fun sharedWorkflowDismissesWithTheContextMenu() {
val dismissals = MutableSharedFlow<Unit>(replay = 1)
lateinit var controller: PendingDownloadDeleteController
renderWorkflow(dismissals = dismissals, onController = { controller = it })
composeRule.runOnIdle {
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
}
composeRule.waitUntil { controller.pending != null }
composeRule.runOnIdle { dismissals.tryEmit(Unit) }
composeRule.waitUntil { controller.pending == null }
composeRule.onNodeWithText("Delete").assertDoesNotExist()
}
@Test
fun sharedWorkflowDispatchesNonDeleteActionsImmediately() {
val actions = mutableListOf<Triple<DownloadList, String, BulkDownloadAction>>()
var lookups = 0
lateinit var controller: PendingDownloadDeleteController
renderWorkflow(
downloadedBytes = { _, _ ->
lookups++
0
},
onAction = { list, refId, action -> actions += Triple(list, refId, action) },
onController = { controller = it },
)
composeRule.runOnIdle {
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Download) { list, refId, action ->
actions += Triple(list, refId, action)
}
}
composeRule.runOnIdle {
assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Download)), actions)
assertEquals(0, lookups)
assertNull(controller.pending)
}
}
private fun renderWorkflow(
dismissals: kotlinx.coroutines.flow.Flow<Unit> = emptyFlow(),
downloadedBytes: suspend (DownloadList, String) -> Long = { _, _ -> 0 },
onAction: (DownloadList, String, BulkDownloadAction) -> Unit = { _, _, _ -> },
onController: (PendingDownloadDeleteController) -> Unit,
) {
composeRule.setContent {
SubtracksTheme {
val controller = rememberPendingDownloadDelete(dismissals, downloadedBytes)
onController(controller)
PendingDownloadDeleteConfirmation(controller) { pending ->
onAction(pending.list, pending.refId, BulkDownloadAction.Delete)
}
}
}
}
private fun render(
bytes: Long = 0,
onConfirm: () -> Unit = {},
@@ -1,40 +1,16 @@
package com.subtracks.ui.downloads
import android.content.Context
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import com.subtracks.ui.ViewModelRepositoryFixture
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
@@ -42,61 +18,23 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class DownloadsViewModelTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase
private lateinit var sourceRepository: SourceRepository
private lateinit var downloadRepository: DownloadRepository
private lateinit var controller: PlaybackController
private lateinit var fixture: ViewModelRepositoryFixture
private val db get() = fixture.db
private val sourceRepository get() = fixture.sourceRepository
private val downloadRepository get() = fixture.downloadRepository
private val controller get() = fixture.controller
private lateinit var viewModel: DownloadsViewModel
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
private var sourceId: Long = 0
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
downloadRepository =
DownloadRepository(
db,
sourceRepository,
FakeDownloadEngine(),
File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artwork,
artworkFetcher = { ByteArray(0) },
scope = repoScope,
)
controller =
PlaybackController(
sourceRepository,
QueueRepository(db),
FakePlayerConnection(FakePlayerHandle()),
downloadRepository,
scope = controllerScope,
)
fixture = ViewModelRepositoryFixture()
viewModel =
DownloadsViewModel(
LibraryRepository(
db,
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
scope = repoScope,
),
fixture.libraryRepository,
downloadRepository,
controller,
"Unknown artist",
@@ -107,10 +45,7 @@ class DownloadsViewModelTest {
@After
fun tearDown() {
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
cancelAndJoinBlocking(controllerScope, repoScope)
db.close()
Dispatchers.resetMain()
dispatcher.close()
fixture.close()
}
@Test
@@ -5,14 +5,13 @@ import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Song
@@ -81,11 +80,7 @@ class NowPlayingRouteTest {
private fun setUpState() {
val context = ApplicationProvider.getApplicationContext<Context>()
val artworkStore = ArtworkStore(File(context.cacheDir, "art"))
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
val sources = SourceRepository(db, OkHttpClient(), prefs, artworkStore, scope = repoScope)
val queues = QueueRepository(db)
downloads =
@@ -2,14 +2,13 @@ package com.subtracks.ui.playback
import android.content.Context
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.AlbumSongItem
@@ -68,11 +67,7 @@ class QueueViewModelTest {
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
sources =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = repoScope)
queues = QueueRepository(db)
@@ -1,43 +1,19 @@
package com.subtracks.ui.search
import android.content.Context
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.FakePlayerConnection
import com.subtracks.playback.FakePlayerHandle
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import com.subtracks.ui.ViewModelRepositoryFixture
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -45,70 +21,24 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.concurrent.Executors
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class SearchViewModelTest {
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
private lateinit var db: SubtracksDatabase
private lateinit var sourceRepository: SourceRepository
private lateinit var fixture: ViewModelRepositoryFixture
private val db get() = fixture.db
private val sourceRepository get() = fixture.sourceRepository
private lateinit var viewModel: SearchViewModel
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
val downloads =
DownloadRepository(
db,
sourceRepository,
FakeDownloadEngine(),
File(context.cacheDir, "downloads-${System.nanoTime()}"),
artworkStore = artwork,
artworkFetcher = { ByteArray(0) },
scope = repoScope,
)
val controller =
PlaybackController(
sourceRepository,
QueueRepository(db),
FakePlayerConnection(FakePlayerHandle()),
downloads,
scope = controllerScope,
)
viewModel =
SearchViewModel(
LibraryRepository(
db,
sourceRepository,
NetworkServerActionSink(sourceRepository),
fakeUserPreferences(),
scope = repoScope,
),
sourceRepository,
controller,
)
fixture = ViewModelRepositoryFixture()
viewModel = SearchViewModel(fixture.libraryRepository, sourceRepository, fixture.controller)
}
@After
fun tearDown() {
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
cancelAndJoinBlocking(controllerScope, repoScope)
db.close()
Dispatchers.resetMain()
dispatcher.close()
fixture.close()
}
@Test
@@ -2,13 +2,12 @@ package com.subtracks.ui.settings
import android.content.Context
import androidx.lifecycle.viewModelScope
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.db.createTestDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.download.FakeDownloadEngine
import com.subtracks.data.prefs.fakeUserPreferences
@@ -57,11 +56,7 @@ class AddSourceViewModelTest {
fun setUp() {
Dispatchers.setMain(dispatcher)
val context = ApplicationProvider.getApplicationContext<Context>()
db =
Room
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
.setDriver(BundledSQLiteDriver())
.build()
db = createTestDatabase()
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
downloadRepository =
+2
View File
@@ -9,3 +9,5 @@ android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
mavenCentralMirrorUrl=https://maven-central.storage-download.googleapis.com/maven2/
+7
View File
@@ -1,4 +1,5 @@
pluginManagement {
val mavenCentralMirrorUrl = providers.gradleProperty("mavenCentralMirrorUrl").get()
repositories {
google {
content {
@@ -7,6 +8,9 @@ pluginManagement {
includeGroupByRegex("androidx.*")
}
}
maven(mavenCentralMirrorUrl) {
name = "MavenCentralMirror"
}
mavenCentral()
gradlePluginPortal()
}
@@ -16,6 +20,9 @@ dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
maven(providers.gradleProperty("mavenCentralMirrorUrl").get()) {
name = "MavenCentralMirror"
}
mavenCentral()
}
}
+17 -11
View File
@@ -12,11 +12,15 @@ export const LIB = ".integration/music"
export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT]
const MUSIC = [
{ id: 197, name: "Ugress - Retroconnaissance EP", songs: 4 }
{ id: 199, name: "Ugress - Kosmonaut", songs: 4 }
{ id: 321, name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
{ id: "7GZYpfn8wypaTR00LWeNI4", name: "Ugress - Retroconnaissance", songs: 4 }
{ id: "5gMe1SlZZzuOMkRVWJaodV", name: "Ugress - Kosmonaut", songs: 4 }
{ id: "0JnRLI5lo4eNU1pOePqfcG", name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
]
const MUSIC_DOWNLOAD = "https://demo.navidrome.org/rest/download.view?u=demo&p=demo&v=1.16.1&c=subtracks-test&id="
const MUSIC_ART = "https://demo.navidrome.org/rest/getCoverArt.view?u=demo&p=demo&v=1.16.1&c=subtracks-test&id="
# Keep in step with TestServers.kt.
export def test-servers [] {
[
@@ -33,17 +37,18 @@ def song-count [dir: string] {
}
export def ensure-music [] {
if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs }) {
if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs and ($"($LIB)/($m.id)/folder.jpg" | path exists) }) {
print $"using test music in ($LIB)"
return
}
print "downloading test music from demo.subsonic.org (one-time)"
print "downloading test music from demo.navidrome.org (one-time)"
mkdir $LIB
let tmp = (^mktemp -d | str trim)
for m in $MUSIC {
print $"($m.name)..."
^curl -fsS -o $"($tmp)/($m.id).zip" $"http://demo.subsonic.org/rest/download.view?u=guest1&p=guest&v=1.13.0&c=subtracks-test&id=($m.id)"
^unzip -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)"
^curl -fsS -o $"($tmp)/($m.id).zip" $"($MUSIC_DOWNLOAD)($m.id)"
^unzip -j -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)"
^curl -fsS -o $"($LIB)/($m.id)/folder.jpg" $"($MUSIC_ART)($m.id)"
}
rm -rf $tmp
}
@@ -163,8 +168,9 @@ export def start-prune [work: string] {
let music = ($dir | path join "music")
mkdir ($dir | path join "data")
mkdir $music
^cp -r $"((pwd) | path join $LIB)/197" $music
^cp -r $"((pwd) | path join $LIB)/199" $music
for m in ($MUSIC | first 2) {
^cp -r $"((pwd) | path join $LIB)/($m.id)" $music
}
let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $PRUNE_PORT --musicfolder $music --datafolder ($dir | path join "data") --loglevel warn out+err> ($dir | path join "navidrome.log") })
let ready = (try {
@@ -198,12 +204,12 @@ export def prepare-fixtures [server: record] {
let albums = (poll "albums" {||
let items = (sub-get $base $user $pass "getAlbumList2" [["type" "newest"]] | get albumList2.album)
if (["Kosmonaut" "Retroconnaissance EP" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null }
if (["Kosmonaut" "Retroconnaissance" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null }
})
let ugress = (named-id $artists "Ugress")
let kosmo = (named-id $albums "Kosmonaut")
let retro = (named-id $albums "Retroconnaissance EP")
let retro = (named-id $albums "Retroconnaissance")
let dunno = (named-id $albums "I Don't Know What I'm Doing")
poll "album songs" {||