mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11276c2e1f | ||
|
|
eb55858cc4 | ||
|
|
95ad993996 | ||
|
|
11abd4afbd | ||
|
|
69f41b3459 | ||
|
|
09baaeb77d | ||
|
|
484cc55385 | ||
|
|
599924bdd0 | ||
|
|
d6ff3ee086 | ||
|
|
b2a1716be2 | ||
|
|
51792f836b | ||
|
|
446a35d26e | ||
|
|
b18e0891f6 | ||
|
|
c53f1aef06 | ||
|
|
87e0609c05 | ||
|
|
c3a5382c27 | ||
|
|
e8cbb93714 | ||
|
|
6f1e05d15a | ||
|
|
c165a6cc88 | ||
|
|
18c24ca566 | ||
|
|
8d6f7e2341 | ||
|
|
29935b7bee | ||
|
|
4bfe67ec19 | ||
|
|
060a5f036f | ||
|
|
c169549ef4 |
@@ -40,6 +40,11 @@ runs:
|
|||||||
path: ~/.gradle/caches
|
path: ~/.gradle/caches
|
||||||
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
restore-keys: gradle-v2-
|
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
|
- id: signing
|
||||||
if: inputs.sign == 'true'
|
if: inputs.sign == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -50,5 +50,5 @@ jobs:
|
|||||||
- uses: actions/cache@v6
|
- uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: .integration/music
|
path: .integration/music
|
||||||
key: integration-music-v1
|
key: integration-music-v3
|
||||||
- run: nix develop .#integration --command nu tools/integration-test.nu
|
- run: nix develop .#integration --command nu tools/integration-test.nu
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("debug").res.srcDir("src/nightly/res")
|
getByName("debug").res.directories.add("src/nightly/res")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -181,6 +181,8 @@ val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
|||||||
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
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")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||||
|
|||||||
+2
-2
@@ -56,7 +56,7 @@ class SubsonicSourceIntegrationTest(
|
|||||||
assertNotNull(kosmo.starred)
|
assertNotNull(kosmo.starred)
|
||||||
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
|
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)
|
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 }
|
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
.sorted()
|
.sorted()
|
||||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
|
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
|
||||||
|
|
||||||
val bradAlbums =
|
val bradAlbums =
|
||||||
albums
|
albums
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
|
|||||||
.albums()
|
.albums()
|
||||||
.toList()
|
.toList()
|
||||||
.flatten()
|
.flatten()
|
||||||
.first { it.name == "Retroconnaissance EP" }
|
.first { it.name == "Retroconnaissance" }
|
||||||
|
|
||||||
source.setStar(StarType.Album, album.id, true)
|
source.setStar(StarType.Album, album.id, true)
|
||||||
try {
|
try {
|
||||||
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
|
|||||||
.songs()
|
.songs()
|
||||||
.toList()
|
.toList()
|
||||||
.flatten()
|
.flatten()
|
||||||
.first { it.album == "Retroconnaissance EP" }
|
.first { it.album == "Retroconnaissance" }
|
||||||
|
|
||||||
source.setStar(StarType.Song, song.id, true)
|
source.setStar(StarType.Song, song.id, true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
TestServers.all.forEachIndexed { index, server ->
|
TestServers.all.forEachIndexed { index, server ->
|
||||||
val id = index + 1L
|
val id = index + 1L
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||||
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
|
|||||||
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
||||||
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
||||||
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
|
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)")
|
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val REMOVED_ALBUM_DIR = "197"
|
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
|
||||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
|
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
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 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.model.Source
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
|
|||||||
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
servers.forEach { server ->
|
servers.forEach { server ->
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
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 android.content.Context
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
|
import androidx.room3.RoomDatabase
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
@@ -11,7 +12,9 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
|||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(driver)
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
.addCallback(ArtistSearchTriggerOptimization)
|
.addSubtracksDatabaseCallbacks()
|
||||||
.addCallback(SqliteLifecycleLogging)
|
|
||||||
.build()
|
.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.Upsert
|
||||||
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||||
import com.subtracks.data.model.Album
|
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.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.ArtistBio
|
import com.subtracks.data.model.ArtistBio
|
||||||
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.PlaylistSearch
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.Song
|
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 com.subtracks.data.model.Source
|
||||||
import kotlinx.coroutines.flow.Flow
|
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 " +
|
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
|
||||||
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
|
"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 = "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_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
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 =
|
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"
|
"(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 = "$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 =
|
internal const val ARTIST_CREDITED =
|
||||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
"(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 " +
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
"WHERE album_artists.sourceId = :sourceId"
|
"WHERE album_artists.sourceId = :sourceId"
|
||||||
|
|
||||||
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
|
internal val ALBUMS_FOR_ARTIST =
|
||||||
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 =
|
|
||||||
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
"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 " +
|
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
"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) " +
|
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||||
"WHERE artistId = :artistId AND creditType = 'album'"
|
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||||
|
|
||||||
// Download visibility follows the credit path: an album credit includes the album when any song is
|
internal val DOWNLOADED_ARTIST_ALBUMS =
|
||||||
// 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 =
|
|
||||||
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
"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 =
|
private const val ARTIST_COVER_RANKS_TAIL =
|
||||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||||
@@ -236,19 +106,19 @@ private const val ARTIST_COVER_RANKS_TAIL =
|
|||||||
"deduped AS (SELECT artistId, coverArt, " +
|
"deduped AS (SELECT artistId, coverArt, " +
|
||||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
"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, " +
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||||
ARTIST_COVER_RANKS_TAIL
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
|
private val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||||
"credits AS (SELECT artistId, albumId, " +
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||||
ARTIST_COVER_RANKS_TAIL
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
internal const val ARTIST_ART_UPDATE_SQL =
|
internal val ARTIST_ART_UPDATE_SQL =
|
||||||
"WITH $ARTIST_COVER_RANKS, " +
|
"WITH $ARTIST_COVER_RANKS, " +
|
||||||
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
"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" +
|
"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 " +
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
"(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 " +
|
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||||
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
"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) " +
|
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||||
"WHERE artistId IN (:artistIds)), " +
|
"WHERE artistId IN (:artistIds)), " +
|
||||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
"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 " +
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER =
|
internal const val ARTIST_COLUMNS_TAIL =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"artists.downloadedCoverArts, artists.playCount, artists.played, artists.songCount, " +
|
||||||
"AND $ARTIST_CREDITED " +
|
"artists.sortName, artists.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 =
|
internal const val ARTIST_COLUMNS =
|
||||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
"artists.sourceId, artists.id, artists.name, artists.albumCount, $ARTIST_COLUMNS_TAIL"
|
||||||
"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 "
|
|
||||||
|
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
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"
|
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 =
|
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"
|
"(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 = "$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 =
|
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"
|
"(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 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 = "name COLLATE NOCASE, id"
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
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"
|
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 " +
|
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
|
||||||
"WHERE song_search MATCH '\"' || replace(: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
|
@Dao
|
||||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||||
interface LibraryDao {
|
interface LibraryDao {
|
||||||
@@ -426,54 +287,37 @@ interface LibraryDao {
|
|||||||
)
|
)
|
||||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||||
|
|
||||||
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
|
@RawQuery
|
||||||
// queries are separate because their visibility follows the credit path.
|
suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int
|
||||||
@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)
|
|
||||||
|
|
||||||
@Query(ARTIST_ART_UPDATE_SQL)
|
suspend fun recomputeArtistAggregates(sourceId: Long) {
|
||||||
suspend fun recomputeArtistArt(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(
|
fun artistCoverArts(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
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(
|
suspend fun recomputeDownloadedArtistArt(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistIds: List<String>,
|
artistIds: List<String>,
|
||||||
)
|
) {
|
||||||
|
recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds))
|
||||||
|
}
|
||||||
|
|
||||||
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||||
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||||
@@ -804,245 +648,27 @@ interface LibraryDao {
|
|||||||
@RawQuery(observedEntities = [Playlist::class])
|
@RawQuery(observedEntities = [Playlist::class])
|
||||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
|
// These observe every table the statements read; the sets match what Room inferred for the
|
||||||
fun albumsByName(
|
// old downloaded @Query methods, so download and library writes invalidate as before.
|
||||||
sourceId: Long,
|
@RawQuery(observedEntities = [Album::class, Song::class, SongDownload::class, AlbumSearch::class])
|
||||||
starredFilter: Int,
|
fun downloadedAlbumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
sortMode: String = "",
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
@RawQuery(
|
||||||
fun albumsByNameReversed(
|
observedEntities = [
|
||||||
sourceId: Long,
|
Album::class,
|
||||||
starredFilter: Int,
|
Song::class,
|
||||||
search: String,
|
SongDownload::class,
|
||||||
downloadedFilter: Int = 0,
|
AlbumArtistRef::class,
|
||||||
sortMode: String = "",
|
SongArtistRef::class,
|
||||||
): PagingSource<Int, Album>
|
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")
|
@RawQuery(observedEntities = [PlaylistSong::class, SongDownload::class, Playlist::class, PlaylistSearch::class])
|
||||||
fun albumsByArtist(
|
fun downloadedPlaylistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
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>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
@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>
|
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
@@ -1276,17 +902,21 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Flow<List<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(
|
fun albumsForArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
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(
|
fun albumCreditAlbumIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
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")
|
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||||
fun discs(
|
fun discs(
|
||||||
@@ -1323,36 +953,16 @@ interface LibraryDao {
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
): PagingSource<Int, PlaylistSongItem>
|
): PagingSource<Int, PlaylistSongItem>
|
||||||
|
|
||||||
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
|
@RawQuery(
|
||||||
@Query(
|
observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class],
|
||||||
"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",
|
|
||||||
)
|
)
|
||||||
|
fun albumsForArtistDownloaded(query: RoomRawQuery): Flow<List<Album>>
|
||||||
|
|
||||||
fun albumsForArtistDownloaded(
|
fun albumsForArtistDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
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.PlaylistSort
|
||||||
import com.subtracks.data.prefs.SortNameMode
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
|
|
||||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
|
||||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
// join, and their paging sources do not observe download writes (including progress updates).
|
||||||
internal const val ALBUMS_FILTER_BASE =
|
internal const val ALBUMS_FILTER = """
|
||||||
"FROM albums WHERE sourceId = ? " +
|
FROM albums WHERE sourceId = :sourceId
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"AND (? = '' " +
|
AND (:search = ''
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||||
"OR instr(lower(albumArtist), lower(?)) > 0 " +
|
OR instr(lower(albumArtist), lower(:search)) > 0
|
||||||
"OR instr(lower(sortName), lower(?)) > 0 " +
|
OR instr(lower(sortName), lower(:search)) > 0
|
||||||
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
|
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
|
||||||
|
"""
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER_BASE =
|
internal const val ALBUMS_FILTER_DOWNLOADED = """
|
||||||
"FROM artists WHERE sourceId = ? " +
|
FROM albums WHERE sourceId = :sourceId
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
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 songs
|
||||||
"AND (? = '' " +
|
JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')
|
||||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
AND (:search = ''
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||||
"OR instr(lower(sortName), lower(?)) > 0))) "
|
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 =
|
internal const val ARTISTS_FILTER = """
|
||||||
"FROM playlists WHERE sourceId = ? " +
|
FROM artists WHERE sourceId = :sourceId
|
||||||
"AND (? = '' " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
AND $ARTIST_CREDITED
|
||||||
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
AND (:search = ''
|
||||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
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,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
downloaded: Boolean,
|
||||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
): RoomRawQuery {
|
): RoomRawQuery =
|
||||||
val order = albumOrder(sort, descending, sortNameMode)
|
rawQuery(
|
||||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
sql = "SELECT * ${if (downloaded) ALBUMS_FILTER_DOWNLOADED else ALBUMS_FILTER} ORDER BY ${albumOrder(
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
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(
|
internal fun rediscoverAlbumsQuery(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -57,70 +169,47 @@ internal fun rediscoverAlbumsQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
cutoff: Long,
|
cutoff: Long,
|
||||||
): RoomRawQuery =
|
): RoomRawQuery =
|
||||||
RoomRawQuery(
|
rawQuery(
|
||||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
sql =
|
||||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
|
||||||
) { stmt ->
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
bindings =
|
||||||
stmt.bindLong(13, cutoff)
|
mapOf(
|
||||||
}
|
"sourceId" to sourceId,
|
||||||
|
"starredFilter" to starredFilter,
|
||||||
|
"search" to search,
|
||||||
|
"cutoff" to cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
private fun SQLiteStatement.bindAlbumFilters(
|
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
|
||||||
sourceId: Long,
|
// index, so the SQL can keep its names and only the first-appearance order needs binding.
|
||||||
starredFilter: Int,
|
internal fun rawQuery(
|
||||||
search: String,
|
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)
|
when (value) {
|
||||||
bindInt(2, starredFilter)
|
null -> bindNull(index)
|
||||||
bindInt(3, starredFilter)
|
is Int -> bindLong(index, value.toLong())
|
||||||
bindInt(4, starredFilter)
|
is Long -> bindLong(index, value)
|
||||||
bindText(5, search)
|
is String -> bindText(index, value)
|
||||||
bindText(6, search)
|
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -214,14 +214,34 @@ interface QueueDao {
|
|||||||
): List<String>
|
): List<String>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)",
|
"WHERE ranked.sourceId = :sourceId AND ranked.albumId = :albumId " +
|
||||||
|
"AND target.sourceId = :sourceId AND target.albumId = :albumId AND target.id = :songId " +
|
||||||
|
"AND ((ranked.disc IS NULL AND target.disc IS NOT NULL) OR ranked.disc < target.disc OR " +
|
||||||
|
"(ranked.disc IS target.disc AND ((ranked.track IS NULL AND target.track IS NOT NULL) OR ranked.track < target.track OR " +
|
||||||
|
"(ranked.track IS target.track AND ranked.id < target.id))))) END",
|
||||||
)
|
)
|
||||||
suspend fun songsByIds(
|
suspend fun albumRank(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
ids: List<String>,
|
albumId: String,
|
||||||
): List<AlbumSongItem>
|
songId: String,
|
||||||
|
): Long?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT CASE WHEN firstPosition IS NULL THEN NULL ELSE " +
|
||||||
|
"(SELECT COUNT(*) - 1 FROM playlist_songs " +
|
||||||
|
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||||
|
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND position <= firstPosition) END " +
|
||||||
|
"FROM (SELECT MIN(position) AS firstPosition FROM playlist_songs " +
|
||||||
|
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||||
|
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND songId = :songId)",
|
||||||
|
)
|
||||||
|
suspend fun playlistRank(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
songId: String,
|
||||||
|
): Long?
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
||||||
suspend fun genreLength(
|
suspend fun genreLength(
|
||||||
@@ -243,17 +263,6 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.id FROM song_genres " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
|
||||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
|
||||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
|
||||||
)
|
|
||||||
suspend fun genreSongIds(
|
|
||||||
sourceId: Long,
|
|
||||||
genre: String,
|
|
||||||
): List<String>
|
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
||||||
suspend fun downloadedLength(sourceId: Long): Long
|
suspend fun downloadedLength(sourceId: Long): Long
|
||||||
|
|
||||||
@@ -272,12 +281,6 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.id FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL " +
|
|
||||||
"ORDER BY starred DESC, sortName COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
suspend fun starredSongIds(sourceId: Long): List<String>
|
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
@@ -290,12 +293,4 @@ interface QueueDao {
|
|||||||
offset: Long,
|
offset: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
|
||||||
"SELECT songs.id FROM song_downloads d " +
|
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
|
||||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
|
||||||
)
|
|
||||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import androidx.paging.PagingSource
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.db.albumsDefaultQuery
|
import com.subtracks.data.db.albumsPageQuery
|
||||||
import com.subtracks.data.db.artistsDefaultQuery
|
import com.subtracks.data.db.artistsPageQuery
|
||||||
import com.subtracks.data.db.playlistsDefaultQuery
|
import com.subtracks.data.db.playlistsPageQuery
|
||||||
import com.subtracks.data.db.rediscoverAlbumsQuery
|
import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
@@ -195,68 +195,17 @@ class LibraryRepository(
|
|||||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(40, enablePlaceholders = true) {
|
pager(40, enablePlaceholders = true) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
albumsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
starredFilter = starred.ordinal,
|
||||||
when (sort) {
|
search = search,
|
||||||
AlbumSort.Name -> {
|
sort = sort,
|
||||||
if (descending) {
|
descending = descending,
|
||||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
downloaded = downloaded,
|
||||||
} else {
|
sortNameMode = mode,
|
||||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
)
|
||||||
}
|
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,52 +220,17 @@ class LibraryRepository(
|
|||||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(60) {
|
pager(60) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
artistsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
starredFilter = starred.ordinal,
|
||||||
when (sort) {
|
search = search,
|
||||||
ArtistSort.Name -> {
|
sort = sort,
|
||||||
if (descending) {
|
descending = descending,
|
||||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
downloaded = downloaded,
|
||||||
} else {
|
sortNameMode = mode,
|
||||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
)
|
||||||
}
|
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,36 +243,15 @@ class LibraryRepository(
|
|||||||
): Flow<PagingData<Playlist>> =
|
): Flow<PagingData<Playlist>> =
|
||||||
pager(40) {
|
pager(40) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
|
playlistsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
search = search,
|
||||||
when (sort) {
|
sort = sort,
|
||||||
PlaylistSort.Name -> {
|
descending = descending,
|
||||||
if (descending) {
|
downloaded = downloaded,
|
||||||
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
|
)
|
||||||
} else {
|
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun album(
|
fun album(
|
||||||
@@ -467,10 +360,7 @@ class LibraryRepository(
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long {
|
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
|
||||||
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
|
|
||||||
return if (index < 0) 0L else index.toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playlist(
|
fun playlist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -209,6 +209,202 @@ data class Reanchored(
|
|||||||
val changed: Boolean,
|
val changed: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private interface QueueKindResolver {
|
||||||
|
fun supportsKeyset(key: OrderKey): Boolean = false
|
||||||
|
|
||||||
|
suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
): List<SongItem>
|
||||||
|
|
||||||
|
suspend fun rowsFrom(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
): List<SongItem>? = null
|
||||||
|
|
||||||
|
suspend fun rowsBefore(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
): List<SongItem>? = null
|
||||||
|
|
||||||
|
suspend fun rank(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
): Long? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val queueKindResolvers =
|
||||||
|
mapOf<QueueKind, QueueKindResolver>(
|
||||||
|
QueueKind.Song to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.songLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
|
||||||
|
override suspend fun rank(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
|
||||||
|
},
|
||||||
|
QueueKind.Playlist to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override fun supportsKeyset(key: OrderKey) = key.position != null
|
||||||
|
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.playlistLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
|
||||||
|
override suspend fun rowsFrom(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||||
|
|
||||||
|
override suspend fun rowsBefore(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||||
|
|
||||||
|
override suspend fun rank(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
) = dao.playlistRank(entry.sourceId, entry.refId, songId)
|
||||||
|
},
|
||||||
|
QueueKind.Album to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
|
||||||
|
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.albumLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
|
||||||
|
override suspend fun rowsFrom(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||||
|
|
||||||
|
override suspend fun rowsBefore(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
key: OrderKey,
|
||||||
|
skip: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||||
|
|
||||||
|
override suspend fun rank(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
songId: String,
|
||||||
|
) = dao.albumRank(entry.sourceId, entry.refId, songId)
|
||||||
|
},
|
||||||
|
QueueKind.Artist to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.artistLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
},
|
||||||
|
QueueKind.Genre to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.genreLength(entry.sourceId, entry.refId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||||
|
},
|
||||||
|
QueueKind.Downloaded to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.downloadedLength(entry.sourceId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||||
|
},
|
||||||
|
QueueKind.Starred to
|
||||||
|
object : QueueKindResolver {
|
||||||
|
override suspend fun length(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
) = dao.starredLength(entry.sourceId)
|
||||||
|
|
||||||
|
override suspend fun rows(
|
||||||
|
dao: com.subtracks.data.db.QueueDao,
|
||||||
|
entry: QueueEntry,
|
||||||
|
offset: Long,
|
||||||
|
limit: Int,
|
||||||
|
) = dao.starredSongs(entry.sourceId, offset, limit)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
|
||||||
|
|
||||||
class QueueRepository(
|
class QueueRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
) {
|
) {
|
||||||
@@ -216,12 +412,6 @@ class QueueRepository(
|
|||||||
|
|
||||||
private val queueVersion = AtomicLong()
|
private val queueVersion = AtomicLong()
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cachedFlatIds: List<String>? = null
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var cachedFlatVersion = -1L
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var shuffleRemoved: ShuffleState? = null
|
private var shuffleRemoved: ShuffleState? = null
|
||||||
|
|
||||||
@@ -235,6 +425,9 @@ class QueueRepository(
|
|||||||
internal var offsetSeeks = 0L
|
internal var offsetSeeks = 0L
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
internal var resolvedRowCount = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
private val cursorMutex = Mutex()
|
private val cursorMutex = Mutex()
|
||||||
|
|
||||||
fun playlistEntry(
|
fun playlistEntry(
|
||||||
@@ -388,7 +581,6 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun invalidateLibraryCache() {
|
fun invalidateLibraryCache() {
|
||||||
cachedFlatIds = null
|
|
||||||
lengthCache.clear()
|
lengthCache.clear()
|
||||||
keyCache.clear()
|
keyCache.clear()
|
||||||
shuffleRemoved = null
|
shuffleRemoved = null
|
||||||
@@ -409,8 +601,8 @@ class QueueRepository(
|
|||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long? {
|
): Long? {
|
||||||
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
|
val upIndex = flatIndexOf(snapshot.upNext, songId)
|
||||||
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
|
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
|
||||||
return combinedContextIndexOf(snapshot, songId)
|
return combinedContextIndexOf(snapshot, songId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,9 +610,8 @@ class QueueRepository(
|
|||||||
snapshot: QueueSnapshot,
|
snapshot: QueueSnapshot,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Long? {
|
): Long? {
|
||||||
val flat = flatIds(snapshot).indexOf(songId)
|
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
|
||||||
if (flat < 0) return null
|
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
|
||||||
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun range(
|
suspend fun range(
|
||||||
@@ -633,7 +824,6 @@ class QueueRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
cachedFlatIds = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
||||||
@@ -650,34 +840,40 @@ class QueueRepository(
|
|||||||
queueVersion.incrementAndGet()
|
queueVersion.incrementAndGet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
|
private suspend fun flatIndexOf(
|
||||||
val cached = cachedFlatIds
|
entries: List<ResolvedQueueEntry>,
|
||||||
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
|
songId: String,
|
||||||
val ids = idsFor(snapshot.entries)
|
): Long? {
|
||||||
cachedFlatIds = ids
|
var start = 0L
|
||||||
cachedFlatVersion = snapshot.version
|
for (resolved in entries) {
|
||||||
return ids
|
val entry = resolved.entry
|
||||||
|
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
|
||||||
|
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
|
||||||
|
return start + rank - entry.offset
|
||||||
|
}
|
||||||
|
val fallback = fallbackRank(entry, resolved.length, songId)
|
||||||
|
if (fallback != null) return start + fallback - entry.offset
|
||||||
|
start += resolved.length
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
|
private suspend fun fallbackRank(
|
||||||
val ids = ArrayList<String>()
|
entry: QueueEntry,
|
||||||
for (entry in resolved) {
|
length: Long,
|
||||||
val ref = entry.entry
|
songId: String,
|
||||||
val entryIds =
|
): Long? {
|
||||||
when (ref.kind) {
|
var offset = entry.offset
|
||||||
QueueKind.Song -> listOf(ref.refId)
|
val end = entry.offset + length
|
||||||
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
while (offset < end) {
|
||||||
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
|
||||||
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
|
val batch = rows(entry, offset, limit)
|
||||||
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
|
val index = batch.indexOfFirst { it.song.id == songId }
|
||||||
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
|
if (index >= 0) return offset + index
|
||||||
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
|
if (batch.size < limit) return null
|
||||||
}
|
offset += batch.size
|
||||||
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
|
|
||||||
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
|
||||||
ids.addAll(entryIds.subList(from, to))
|
|
||||||
}
|
}
|
||||||
return ids
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun contextPlayRange(
|
private suspend fun contextPlayRange(
|
||||||
@@ -688,21 +884,11 @@ class QueueRepository(
|
|||||||
if (first > last) return emptyList()
|
if (first > last) return emptyList()
|
||||||
val items = mutableListOf<QueueWindowItem>()
|
val items = mutableListOf<QueueWindowItem>()
|
||||||
if (snapshot.shuffled) {
|
if (snapshot.shuffled) {
|
||||||
val sourceId =
|
|
||||||
snapshot.entries
|
|
||||||
.firstOrNull()
|
|
||||||
?.entry
|
|
||||||
?.sourceId ?: return emptyList()
|
|
||||||
val ids = flatIds(snapshot)
|
|
||||||
val requested = mutableListOf<Pair<Long, String>>()
|
|
||||||
for (play in first..last) {
|
for (play in first..last) {
|
||||||
val flat = snapshot.flatContext(play) ?: continue
|
val located = snapshot.locateContext(play) ?: continue
|
||||||
val id = ids.getOrNull(flat.toInt()) ?: continue
|
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
|
||||||
requested += play to id
|
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
|
||||||
}
|
}
|
||||||
if (requested.isEmpty()) return emptyList()
|
|
||||||
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
|
|
||||||
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
|
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
var start = 0L
|
var start = 0L
|
||||||
@@ -757,27 +943,20 @@ class QueueRepository(
|
|||||||
): List<SongItem> {
|
): List<SongItem> {
|
||||||
if (entry.kind == QueueKind.Song) {
|
if (entry.kind == QueueKind.Song) {
|
||||||
offsetSeeks++
|
offsetSeeks++
|
||||||
return dao.song(entry.sourceId, entry.refId, offset, limit)
|
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
|
||||||
}
|
}
|
||||||
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
||||||
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||||
if (anchored != null) {
|
if (anchored != null) {
|
||||||
keysetSeeks++
|
keysetSeeks++
|
||||||
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
||||||
|
resolvedRowCount += anchored.size
|
||||||
return anchored
|
return anchored
|
||||||
}
|
}
|
||||||
offsetSeeks++
|
offsetSeeks++
|
||||||
val fresh =
|
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
|
||||||
when (entry.kind) {
|
|
||||||
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
|
||||||
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
|
||||||
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
|
||||||
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
|
||||||
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
|
|
||||||
QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit)
|
|
||||||
QueueKind.Song -> emptyList()
|
|
||||||
}
|
|
||||||
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
||||||
|
resolvedRowCount += fresh.size
|
||||||
return fresh
|
return fresh
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -810,7 +989,7 @@ class QueueRepository(
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
cursor: KeyCursor,
|
cursor: KeyCursor,
|
||||||
): List<SongItem>? {
|
): List<SongItem>? {
|
||||||
if (!cursor.key.seekable(entry.kind)) return null
|
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
|
||||||
val from = cursor.ordinal
|
val from = cursor.ordinal
|
||||||
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
||||||
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
||||||
@@ -836,43 +1015,14 @@ class QueueRepository(
|
|||||||
key: OrderKey,
|
key: OrderKey,
|
||||||
skip: Long,
|
skip: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<SongItem> =
|
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
|
||||||
when (entry.kind) {
|
|
||||||
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
|
||||||
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
|
||||||
QueueKind.Artist -> emptyList()
|
|
||||||
QueueKind.Genre -> emptyList()
|
|
||||||
QueueKind.Downloaded -> emptyList()
|
|
||||||
QueueKind.Starred -> emptyList()
|
|
||||||
QueueKind.Song -> emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun rowsBefore(
|
private suspend fun rowsBefore(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
key: OrderKey,
|
key: OrderKey,
|
||||||
skip: Long,
|
skip: Long,
|
||||||
limit: Int,
|
limit: Int,
|
||||||
): List<SongItem> =
|
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
|
||||||
when (entry.kind) {
|
|
||||||
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
|
||||||
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
|
||||||
QueueKind.Artist -> emptyList()
|
|
||||||
QueueKind.Genre -> emptyList()
|
|
||||||
QueueKind.Downloaded -> emptyList()
|
|
||||||
QueueKind.Starred -> emptyList()
|
|
||||||
QueueKind.Song -> emptyList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun OrderKey.seekable(kind: QueueKind): Boolean =
|
|
||||||
when (kind) {
|
|
||||||
QueueKind.Playlist -> position != null
|
|
||||||
QueueKind.Album -> disc != null && track != null
|
|
||||||
QueueKind.Artist -> false
|
|
||||||
QueueKind.Genre -> false
|
|
||||||
QueueKind.Downloaded -> false
|
|
||||||
QueueKind.Starred -> false
|
|
||||||
QueueKind.Song -> false
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun lengthOf(
|
private suspend fun lengthOf(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
@@ -897,36 +1047,7 @@ class QueueRepository(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||||
val total =
|
val total = resolverFor(kind).length(dao, this)
|
||||||
when (kind) {
|
|
||||||
QueueKind.Song -> {
|
|
||||||
dao.songLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Playlist -> {
|
|
||||||
dao.playlistLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Album -> {
|
|
||||||
dao.albumLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Artist -> {
|
|
||||||
dao.artistLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Genre -> {
|
|
||||||
dao.genreLength(sourceId, refId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Downloaded -> {
|
|
||||||
dao.downloadedLength(sourceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
QueueKind.Starred -> {
|
|
||||||
dao.starredLength(sourceId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val available = (total - offset).coerceAtLeast(0)
|
val available = (total - offset).coerceAtLeast(0)
|
||||||
return count?.coerceAtMost(available) ?: available
|
return count?.coerceAtMost(available) ?: available
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class SyncService(
|
|||||||
source.artists().collect { batch ->
|
source.artists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
changed =
|
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
|
changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
artistsSynced += batch.size
|
artistsSynced += batch.size
|
||||||
@@ -137,7 +137,7 @@ class SyncService(
|
|||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
val resolved = connection.resolveSongSortNames(batch)
|
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 ->
|
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -148,7 +148,7 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
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 ->
|
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -159,7 +159,7 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
|
||||||
(connection.totalChanges() != before).also { changed ->
|
(connection.totalChanges() != before).also { changed ->
|
||||||
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ class SyncService(
|
|||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
source.playlists().collect { batch ->
|
source.playlists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
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)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
playlistsSynced += batch.size
|
playlistsSynced += batch.size
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ class SyncService(
|
|||||||
val counts = HashMap<String, Long>()
|
val counts = HashMap<String, Long>()
|
||||||
source.playlistSongs(playlistIds).collect { batch ->
|
source.playlistSongs(playlistIds).collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
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 }
|
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||||
playlistSongsSynced += batch.size
|
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.
|
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
||||||
private suspend fun <T> write(
|
private suspend fun <T> write(
|
||||||
table: String,
|
table: String,
|
||||||
columns: List<String>,
|
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
preserved: Set<String> = emptySet(),
|
preserved: Set<String> = emptySet(),
|
||||||
markStale: Boolean = false,
|
markStale: Boolean = false,
|
||||||
values: (T) -> List<Any?>,
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
connection.upsertChanged(table, columns, key, rows, preserved, values)
|
connection.upsertChanged(table, key, rows, preserved, columns)
|
||||||
(connection.totalChanges() != before).also { changed ->
|
(connection.totalChanges() != before).also { changed ->
|
||||||
if (changed && markStale) connection.markCreditsStale(source.id)
|
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||||
}
|
}
|
||||||
@@ -309,8 +308,8 @@ class SyncService(
|
|||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
val resolved = connection.resolveAlbumSortNames(albums)
|
val resolved = connection.resolveAlbumSortNames(albums)
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
connection.upsertChanged("albums", primaryKey, resolved) { it.columns() }
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
connection.upsertChanged("discs", discKey, discs) { it.columns() }
|
||||||
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||||
albums.forEach { album ->
|
albums.forEach { album ->
|
||||||
statement.bind(1, album.sourceId)
|
statement.bind(1, album.sourceId)
|
||||||
@@ -321,7 +320,7 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
|
||||||
(connection.totalChanges() != before).also { changed ->
|
(connection.totalChanges() != before).also { changed ->
|
||||||
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||||
}
|
}
|
||||||
@@ -438,58 +437,8 @@ private fun discHash(
|
|||||||
|
|
||||||
private val primaryKey = listOf("sourceId", "id")
|
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 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> =
|
private fun Song.genreNames(): List<String> =
|
||||||
genres
|
genres
|
||||||
.flatMap { it.genreParts() }
|
.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.
|
// 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 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 songGenreKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
|
||||||
|
|
||||||
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
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 songArtistKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
|
||||||
|
|
||||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||||
|
|
||||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
|
private fun Artist.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
private fun Album.values() =
|
"sourceId" to sourceId,
|
||||||
listOf<Any?>(
|
"id" to id,
|
||||||
sourceId,
|
"name" to name,
|
||||||
id,
|
"albumCount" to albumCount,
|
||||||
artistId,
|
"starred" to starred,
|
||||||
name,
|
"coverArt" to coverArt,
|
||||||
albumArtist,
|
"artistImageUrl" to artistImageUrl,
|
||||||
created,
|
"sortName" to sortName,
|
||||||
coverArt,
|
|
||||||
genre,
|
|
||||||
year,
|
|
||||||
starred,
|
|
||||||
songCount,
|
|
||||||
ArtistRefsCodec.encode(artistRefs),
|
|
||||||
sortName,
|
|
||||||
albumArtistSort,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
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() =
|
private fun Disc.columns(): List<Pair<String, Any?>> =
|
||||||
listOf<Any?>(
|
listOf(
|
||||||
sourceId,
|
"sourceId" to sourceId,
|
||||||
id,
|
"albumId" to albumId,
|
||||||
albumId,
|
"disc" to disc,
|
||||||
artistId,
|
"title" to title,
|
||||||
title,
|
)
|
||||||
album,
|
|
||||||
artist,
|
private fun Song.columns(): List<Pair<String, Any?>> =
|
||||||
duration,
|
listOf(
|
||||||
track,
|
"sourceId" to sourceId,
|
||||||
disc,
|
"id" to id,
|
||||||
starred,
|
"albumId" to albumId,
|
||||||
created,
|
"artistId" to artistId,
|
||||||
playCount,
|
"title" to title,
|
||||||
played,
|
"album" to album,
|
||||||
trackGain,
|
"artist" to artist,
|
||||||
albumGain,
|
"duration" to duration,
|
||||||
trackPeak,
|
"track" to track,
|
||||||
albumPeak,
|
"disc" to disc,
|
||||||
ArtistRefsCodec.encode(artistRefs),
|
"starred" to starred,
|
||||||
sortName,
|
"created" to created,
|
||||||
artistSort,
|
"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>> =
|
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 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(
|
private suspend fun <T> PooledConnection.upsertChanged(
|
||||||
table: String,
|
table: String,
|
||||||
columns: List<String>,
|
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
preserved: Set<String> = emptySet(),
|
preserved: Set<String> = emptySet(),
|
||||||
values: (T) -> List<Any?>,
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
val columnNames = columns(rows.first()).map { it.first }
|
||||||
val updates = columns.filterNot { it in key || it in preserved }
|
val updates = columnNames.filterNot { it in key || it in preserved }
|
||||||
val sql =
|
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 " +
|
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||||
updates.joinToString { "$it = excluded.$it" } +
|
updates.joinToString { "$it = excluded.$it" } +
|
||||||
" WHERE " +
|
" WHERE " +
|
||||||
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
||||||
usePrepared(sql) { statement ->
|
usePrepared(sql) { statement ->
|
||||||
rows.forEach { row ->
|
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.step()
|
||||||
statement.reset()
|
statement.reset()
|
||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
|
|||||||
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.DownloadList
|
import com.subtracks.data.model.DownloadList
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class PendingDownloadDelete(
|
data class PendingDownloadDelete(
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -18,6 +29,70 @@ data class PendingDownloadDelete(
|
|||||||
val bytes: Long,
|
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
|
@Composable
|
||||||
fun DeleteDownloadsDialog(
|
fun DeleteDownloadsDialog(
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
|
import com.subtracks.data.model.DownloadList
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
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.ArtistChooserSheet
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.HeroDetailScaffold
|
import com.subtracks.ui.components.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.HeroHeader
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
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.ArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -74,21 +74,16 @@ fun AlbumDetailRoute(
|
|||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
|
||||||
var showArtistChooser by remember { mutableStateOf(false) }
|
var showArtistChooser by remember { mutableStateOf(false) }
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
DismissOnRequest(dismissals) { showArtistChooser = false }
|
||||||
pendingDelete = null
|
|
||||||
showArtistChooser = false
|
|
||||||
}
|
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||||
if (action == BulkDownloadAction.Delete) {
|
pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested ->
|
||||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
viewModel.onDownloadAction(requested)
|
||||||
} else {
|
|
||||||
viewModel.onDownloadAction(action)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val actions =
|
val actions =
|
||||||
@@ -141,13 +136,8 @@ fun AlbumDetailRoute(
|
|||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { bytes ->
|
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||||
name = album?.name.orEmpty(),
|
|
||||||
bytes = bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showArtistChooser) {
|
if (showArtistChooser) {
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ import androidx.compose.runtime.mutableFloatStateOf
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
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.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.CoverArt
|
import com.subtracks.ui.components.CoverArt
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.ListDownloadIndicator
|
import com.subtracks.ui.components.ListDownloadIndicator
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.PendingDownloadDelete
|
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||||
import com.subtracks.ui.components.bulkRef
|
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.components.statusBarScrim
|
import com.subtracks.ui.components.statusBarScrim
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
@@ -130,7 +128,6 @@ import com.subtracks.ui.theme.rememberArtworkColors
|
|||||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -217,26 +214,13 @@ fun ArtistDetailRoute(
|
|||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
|
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
|
||||||
val scope = rememberCoroutineScope()
|
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
val actions =
|
val actions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||||
bulkDownload = { target, action ->
|
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setStar = setStar,
|
setStar = setStar,
|
||||||
viewAlbum = onViewAlbum,
|
viewAlbum = onViewAlbum,
|
||||||
)
|
)
|
||||||
@@ -267,13 +251,8 @@ fun ArtistDetailRoute(
|
|||||||
onShuffle = viewModel::shuffle,
|
onShuffle = viewModel::shuffle,
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { pending ->
|
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||||
name = pending.name,
|
|
||||||
bytes = pending.bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ import androidx.compose.runtime.mutableIntStateOf
|
|||||||
import androidx.compose.runtime.mutableStateMapOf
|
import androidx.compose.runtime.mutableStateMapOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
@@ -126,13 +125,12 @@ import com.subtracks.data.source.StarType
|
|||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.ArtistArt
|
import com.subtracks.ui.components.ArtistArt
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
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.SearchField
|
||||||
import com.subtracks.ui.components.bulkRef
|
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||||
import com.subtracks.ui.components.statusBarScrim
|
import com.subtracks.ui.components.statusBarScrim
|
||||||
import com.subtracks.ui.home.HomeRoute
|
import com.subtracks.ui.home.HomeRoute
|
||||||
import com.subtracks.ui.home.HomeSection
|
import com.subtracks.ui.home.HomeSection
|
||||||
@@ -143,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
|
|||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -278,26 +275,13 @@ fun LibraryRoute(
|
|||||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
||||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||||
val downloads = downloadsState?.value ?: emptyMap()
|
val downloads = downloadsState?.value ?: emptyMap()
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
|
||||||
val itemActions =
|
val itemActions =
|
||||||
ItemActions(
|
ItemActions(
|
||||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||||
bulkDownload = { target, action ->
|
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setStar = setStar,
|
setStar = setStar,
|
||||||
viewAlbum = onViewAlbum,
|
viewAlbum = onViewAlbum,
|
||||||
viewArtist = onViewArtist,
|
viewArtist = onViewArtist,
|
||||||
@@ -377,13 +361,8 @@ fun LibraryRoute(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { pending ->
|
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||||
name = pending.name,
|
|
||||||
bytes = pending.bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
@@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.BulkDownloadAction
|
import com.subtracks.data.model.BulkDownloadAction
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
|
import com.subtracks.data.model.DownloadList
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType
|
|||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.HeroDetailScaffold
|
import com.subtracks.ui.components.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.HeroHeader
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
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.ArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -60,16 +60,12 @@ fun PlaylistDetailRoute(
|
|||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||||
val scope = rememberCoroutineScope()
|
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() }
|
||||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
|
||||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||||
if (action == BulkDownloadAction.Delete) {
|
pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested ->
|
||||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
viewModel.onDownloadAction(requested)
|
||||||
} else {
|
|
||||||
viewModel.onDownloadAction(action)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val actions =
|
val actions =
|
||||||
@@ -104,13 +100,8 @@ fun PlaylistDetailRoute(
|
|||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||||
)
|
)
|
||||||
|
|
||||||
pendingDelete?.let { bytes ->
|
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||||
DeleteDownloadsDialog(
|
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||||
name = playlist?.name.orEmpty(),
|
|
||||||
bytes = bytes,
|
|
||||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
|
||||||
onDismiss = { pendingDelete = null },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.ArtworkSeed
|
import com.subtracks.data.model.ArtworkSeed
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
@@ -22,12 +18,7 @@ class ArtworkSeedDaoTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useReaderConnection
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
@@ -39,12 +35,7 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -67,17 +58,17 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma"),
|
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(
|
assertEquals(
|
||||||
listOf("Gamma", "Beta", "Alpha"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma"),
|
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
|
@Test
|
||||||
@@ -95,14 +86,14 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beta", "Gamma", "Alpha"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha", "Gamma", "Beta"),
|
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("Gamma"), dao.albumPages(sourceId, starredFilter = 0, search = "gam").page().map { it.name })
|
||||||
assertEquals(listOf("Beta"), dao.albumsByName(sourceId, starredFilter = 2, search = "").page().map { it.name })
|
assertEquals(listOf("Beta"), dao.albumPages(sourceId, starredFilter = 2, search = "").page().map { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -148,19 +139,19 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
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(
|
assertEquals(
|
||||||
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
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(
|
assertEquals(
|
||||||
listOf("Two", "One", "Three"),
|
listOf("Two", "One", "Three"),
|
||||||
dao.albumsByFrequent(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Two", "One", "Three"),
|
listOf("Two", "One", "Three"),
|
||||||
dao.albumsByRecent(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Many", "Few"),
|
listOf("Many", "Few"),
|
||||||
dao.artistsByFrequent(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Many", "Few"),
|
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 =
|
val pairs =
|
||||||
listOf(
|
listOf(
|
||||||
dao.albumsByName(sourceId, 0, "") to dao.albumsByNameReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "") to dao.albumPages(sourceId, 0, "", descending = true),
|
||||||
dao.albumsByArtist(sourceId, 0, "") to dao.albumsByArtistReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist) to
|
||||||
dao.albumsByYear(sourceId, 0, "") to dao.albumsByYearReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, descending = true),
|
||||||
dao.albumsByRecentlyAdded(sourceId, 0, "") to dao.albumsByRecentlyAddedReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Year) to
|
||||||
dao.albumsByFrequent(sourceId, 0, "") to dao.albumsByFrequentReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Year, descending = true),
|
||||||
dao.albumsByRecent(sourceId, 0, "") to dao.albumsByRecentReversed(sourceId, 0, ""),
|
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) {
|
for ((base, reversed) in pairs) {
|
||||||
val ids = base.page().map { it.id }
|
val ids = base.page().map { it.id }
|
||||||
@@ -303,23 +299,23 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
val artistPairs =
|
val artistPairs =
|
||||||
listOf(
|
listOf(
|
||||||
dao.artistsByName(sourceId, 0, "").page().map { it.id } to
|
dao.artistPages(sourceId, 0, "").page().map { it.id } to
|
||||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.id },
|
dao.artistPages(sourceId, 0, "", descending = true).page().map { it.id },
|
||||||
dao.artistsByAlbumCount(sourceId, 0, "").page().map { it.id } to
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.AlbumCount).page().map { it.id } to
|
||||||
dao.artistsByAlbumCountReversed(sourceId, 0, "").page().map { it.id },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.AlbumCount, descending = true).page().map { it.id },
|
||||||
dao.artistsByFrequent(sourceId, 0, "").page().map { it.id } to
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent).page().map { it.id } to
|
||||||
dao.artistsByFrequentReversed(sourceId, 0, "").page().map { it.id },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent, descending = true).page().map { it.id },
|
||||||
dao.artistsByRecent(sourceId, 0, "").page().map { it.id } to
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent).page().map { it.id } to
|
||||||
dao.artistsByRecentReversed(sourceId, 0, "").page().map { it.id },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent, descending = true).page().map { it.id },
|
||||||
)
|
)
|
||||||
val playlistPairs =
|
val playlistPairs =
|
||||||
listOf(
|
listOf(
|
||||||
dao.playlistsByName(sourceId, "").page().map { it.id } to
|
dao.playlistPages(sourceId, "").page().map { it.id } to
|
||||||
dao.playlistsByNameReversed(sourceId, "").page().map { it.id },
|
dao.playlistPages(sourceId, "", descending = true).page().map { it.id },
|
||||||
dao.playlistsByAdded(sourceId, "").page().map { it.id } to
|
dao.playlistPages(sourceId, "", sort = PlaylistSort.Added).page().map { it.id } to
|
||||||
dao.playlistsByAddedReversed(sourceId, "").page().map { it.id },
|
dao.playlistPages(sourceId, "", sort = PlaylistSort.Added, descending = true).page().map { it.id },
|
||||||
dao.playlistsByUpdated(sourceId, "").page().map { it.id } to
|
dao.playlistPages(sourceId, "", sort = PlaylistSort.Updated).page().map { it.id } to
|
||||||
dao.playlistsByUpdatedReversed(sourceId, "").page().map { it.id },
|
dao.playlistPages(sourceId, "", sort = PlaylistSort.Updated, descending = true).page().map { it.id },
|
||||||
)
|
)
|
||||||
for ((base, reversed) in artistPairs + playlistPairs) {
|
for ((base, reversed) in artistPairs + playlistPairs) {
|
||||||
assertEquals(base.reversed(), reversed)
|
assertEquals(base.reversed(), reversed)
|
||||||
@@ -357,9 +353,11 @@ class LibraryDaoTest {
|
|||||||
ALBUM_ORDER_BY_RECENT_REVERSED to "index_albums_recent",
|
ALBUM_ORDER_BY_RECENT_REVERSED to "index_albums_recent",
|
||||||
)
|
)
|
||||||
for ((order, index) in orders) {
|
for ((order, index) in orders) {
|
||||||
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
for (filter in listOf(ALBUMS_FILTER, ALBUMS_FILTER_DOWNLOADED)) {
|
||||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
val plan = plan("SELECT * $filter ORDER BY $order LIMIT 20 OFFSET 0")
|
||||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
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",
|
ARTIST_ORDER_BY_RECENT_REVERSED to "index_artists_recent",
|
||||||
)
|
)
|
||||||
for ((order, index) in orders) {
|
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"))
|
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
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
|
@Test
|
||||||
fun playlistOrdersAreIndexBacked() =
|
fun playlistOrdersAreIndexBacked() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -401,9 +436,18 @@ class LibraryDaoTest {
|
|||||||
PLAYLIST_ORDER_BY_UPDATED_REVERSED to "index_playlists_updated",
|
PLAYLIST_ORDER_BY_UPDATED_REVERSED to "index_playlists_updated",
|
||||||
)
|
)
|
||||||
for ((order, index) in orders) {
|
for ((order, index) in orders) {
|
||||||
val plan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
val defaultPlan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
assertTrue(defaultPlan, defaultPlan.contains("USING INDEX $index"))
|
||||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
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(
|
assertEquals(
|
||||||
listOf("Downloaded"),
|
listOf("Downloaded"),
|
||||||
dao.albumsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
|
dao.albumPages(sourceId, 0, "", downloaded = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("ar-1"),
|
listOf("ar-1"),
|
||||||
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.id },
|
dao.artistPages(sourceId, 0, "", downloaded = true).page().map { it.id },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("pl-1"),
|
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
|
@Test
|
||||||
@@ -628,7 +740,7 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
dao.recomputeArtistArt(sourceId)
|
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)
|
assertEquals("own-art", artists.getValue("ar-own").coverArt)
|
||||||
assertNull(artists.getValue("ar-album").coverArt)
|
assertNull(artists.getValue("ar-album").coverArt)
|
||||||
@@ -648,7 +760,7 @@ class LibraryDaoTest {
|
|||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("album-ref-art"),
|
listOf("album-ref-art"),
|
||||||
dao
|
dao
|
||||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
|
.artistPages(sourceId, 0, "", ArtistSort.Name, false)
|
||||||
.page()
|
.page()
|
||||||
.first { it.id == "ar-album-ref" }
|
.first { it.id == "ar-album-ref" }
|
||||||
.coverArts,
|
.coverArts,
|
||||||
@@ -774,6 +886,26 @@ class LibraryDaoTest {
|
|||||||
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
|
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
|
@Test
|
||||||
fun artistNameUpdatesKeepTheSearchIndexValid() =
|
fun artistNameUpdatesKeepTheSearchIndexValid() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -869,18 +1001,7 @@ class LibraryDaoTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val plan = plan(ALBUM_CREDIT_ALBUM_IDS)
|
val plan = plan(ALBUM_CREDIT_ALBUM_IDS)
|
||||||
|
|
||||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
assertArtistCreditIndexes(plan)
|
||||||
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"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -904,7 +1025,7 @@ class LibraryDaoTest {
|
|||||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Album Artist"),
|
listOf("Album Artist"),
|
||||||
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
|
dao.artistPages(sourceId, 0, "", downloaded = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Album Artist"),
|
listOf("Album Artist"),
|
||||||
@@ -913,7 +1034,7 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
|
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("Album Artist", "Guest"), filtered.map { it.name })
|
||||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||||
|
|
||||||
@@ -961,14 +1082,14 @@ class LibraryDaoTest {
|
|||||||
)
|
)
|
||||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
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(1, filtered.size)
|
||||||
assertEquals(1L, filtered.first().albumCount)
|
assertEquals(1L, filtered.first().albumCount)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
5L,
|
5L,
|
||||||
dao
|
dao
|
||||||
.artistsByName(sourceId, 0, "")
|
.artistPages(sourceId, 0, "")
|
||||||
.page()
|
.page()
|
||||||
.first { it.id == "ar-1" }
|
.first { it.id == "ar-1" }
|
||||||
.albumCount,
|
.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
|
// 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.
|
// 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("ar-1", "ar-2"), filtered.map { it.id })
|
||||||
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
|
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
|
||||||
}
|
}
|
||||||
@@ -1036,7 +1157,7 @@ class LibraryDaoTest {
|
|||||||
)
|
)
|
||||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
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(1, filtered.size)
|
||||||
assertEquals(1L, filtered.first().songCount)
|
assertEquals(1L, filtered.first().songCount)
|
||||||
assertEquals("austin", filtered.first().owner)
|
assertEquals("austin", filtered.first().owner)
|
||||||
@@ -1045,7 +1166,7 @@ class LibraryDaoTest {
|
|||||||
assertEquals(
|
assertEquals(
|
||||||
5L,
|
5L,
|
||||||
dao
|
dao
|
||||||
.playlistsByName(sourceId, "")
|
.playlistPages(sourceId, "")
|
||||||
.page()
|
.page()
|
||||||
.first { it.id == "pl-1" }
|
.first { it.id == "pl-1" }
|
||||||
.songCount,
|
.songCount,
|
||||||
@@ -1064,11 +1185,11 @@ class LibraryDaoTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "amm").page().map { it.name })
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "GAM").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "GAM").page().map { it.name })
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "ga").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "ga").page().map { it.name })
|
||||||
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, 0, "Else").page().map { it.name })
|
assertEquals(listOf("Alpha"), dao.albumPages(sourceId, 0, "Else").page().map { it.name })
|
||||||
assertTrue(dao.albumsByName(sourceId, 0, "zzz").page().isEmpty())
|
assertTrue(dao.albumPages(sourceId, 0, "zzz").page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1077,17 +1198,17 @@ class LibraryDaoTest {
|
|||||||
val sourceId = source()
|
val sourceId = source()
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null)))
|
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)
|
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)))
|
dao.upsertAlbums(listOf(album(sourceId, "a", "Delta", year = null, starred = null)))
|
||||||
assertTrue(dao.albumsByName(sourceId, 0, "gam").page().isEmpty())
|
assertTrue(dao.albumPages(sourceId, 0, "gam").page().isEmpty())
|
||||||
assertEquals(listOf("Delta"), dao.albumsByName(sourceId, 0, "elt").page().map { it.name })
|
assertEquals(listOf("Delta"), dao.albumPages(sourceId, 0, "elt").page().map { it.name })
|
||||||
|
|
||||||
dao.deleteAlbums(sourceId, listOf("a"))
|
dao.deleteAlbums(sourceId, listOf("a"))
|
||||||
assertTrue(dao.albumsByName(sourceId, 0, "elt").page().isEmpty())
|
assertTrue(dao.albumPages(sourceId, 0, "elt").page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1101,8 +1222,8 @@ class LibraryDaoTest {
|
|||||||
val second = source()
|
val second = source()
|
||||||
dao.upsertAlbums(listOf(album(second, "a", "Beta", year = null, starred = null)))
|
dao.upsertAlbums(listOf(album(second, "a", "Beta", year = null, starred = null)))
|
||||||
|
|
||||||
assertEquals(listOf("Beta"), dao.albumsByName(second, 0, "eta").page().map { it.name })
|
assertEquals(listOf("Beta"), dao.albumPages(second, 0, "eta").page().map { it.name })
|
||||||
assertTrue(dao.albumsByName(second, 0, "gam").page().isEmpty())
|
assertTrue(dao.albumPages(second, 0, "gam").page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1113,10 +1234,10 @@ class LibraryDaoTest {
|
|||||||
dao.upsertArtists(listOf(artist(sourceId, "r1", "The Beatles", albumCount = 1)))
|
dao.upsertArtists(listOf(artist(sourceId, "r1", "The Beatles", albumCount = 1)))
|
||||||
dao.upsertPlaylists(listOf(playlist(sourceId, "l1", "Road Trip", created = 0, changed = 0)))
|
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("r1"), dao.artistPages(sourceId, 0, "eat").page().map { it.id })
|
||||||
assertEquals(listOf("l1"), dao.playlistsByName(sourceId, "rip").page().map { it.id })
|
assertEquals(listOf("l1"), dao.playlistPages(sourceId, "rip").page().map { it.id })
|
||||||
assertTrue(dao.artistsByName(sourceId, 0, "rip").page().isEmpty())
|
assertTrue(dao.artistPages(sourceId, 0, "rip").page().isEmpty())
|
||||||
assertTrue(dao.playlistsByName(sourceId, "eat").page().isEmpty())
|
assertTrue(dao.playlistPages(sourceId, "eat").page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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(
|
assertEquals(
|
||||||
listOf("Say \"Hello\""),
|
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()
|
val dao = db.libraryDao()
|
||||||
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null).copy(albumArtist = null)))
|
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
|
@Test
|
||||||
@@ -1236,25 +1357,48 @@ class LibraryDaoTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val plan = plan(ALBUMS_FOR_ARTIST)
|
val plan = plan(ALBUMS_FOR_ARTIST)
|
||||||
|
|
||||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
assertArtistCreditIndexes(plan)
|
||||||
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): 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 ->
|
db.useReaderConnection { connection ->
|
||||||
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
||||||
val details = mutableListOf<String>()
|
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")
|
details.joinToString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1274,35 +1418,35 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma"),
|
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(
|
assertEquals(
|
||||||
listOf("Gamma", "Beta", "Alpha"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma"),
|
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(
|
assertEquals(
|
||||||
listOf("Gamma", "Beta", "Alpha"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha"),
|
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(
|
assertEquals(
|
||||||
listOf("Gamma"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha"),
|
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(
|
assertEquals(
|
||||||
listOf("Beta", "Gamma"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta"),
|
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(
|
assertEquals(
|
||||||
listOf("Beta", "Alpha"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta"),
|
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(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta"),
|
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(
|
assertEquals(
|
||||||
listOf("Beta", "Alpha"),
|
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")))
|
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null).copy(albumId = "a")))
|
||||||
|
|
||||||
val defaultInvalidated = AtomicBoolean(false)
|
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.registerInvalidatedCallback { defaultInvalidated.set(true) }
|
||||||
default.page()
|
default.page()
|
||||||
|
|
||||||
val downloadedInvalidated = AtomicBoolean(false)
|
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.registerInvalidatedCallback { downloadedInvalidated.set(true) }
|
||||||
downloaded.page()
|
downloaded.page()
|
||||||
|
|
||||||
@@ -1373,6 +1517,52 @@ class LibraryDaoTest {
|
|||||||
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
|
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
|
@Test
|
||||||
fun sortNamesDriveArtistAndAlbumOrder() =
|
fun sortNamesDriveArtistAndAlbumOrder() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -1395,27 +1585,27 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("宇多田ヒカル", "あいみょん"),
|
listOf("宇多田ヒカル", "あいみょん"),
|
||||||
dao.artistsByName(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "").page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("あいみょん", "宇多田ヒカル"),
|
listOf("あいみょん", "宇多田ヒカル"),
|
||||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "", descending = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Zzz", "Aaa"),
|
listOf("Zzz", "Aaa"),
|
||||||
dao.albumsByName(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "").page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aaa", "Zzz"),
|
listOf("Aaa", "Zzz"),
|
||||||
dao.albumsByNameReversed(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", descending = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aaa", "Zzz"),
|
listOf("Aaa", "Zzz"),
|
||||||
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Zzz", "Aaa"),
|
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(
|
assertEquals(
|
||||||
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
|
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(
|
assertEquals(
|
||||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
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(
|
assertEquals(
|
||||||
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
|
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(
|
assertEquals(
|
||||||
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
|
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(
|
assertEquals(
|
||||||
listOf("Aaa", "Beatles", "Zzz"),
|
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(
|
assertEquals(
|
||||||
listOf("Aaa", "Zzz", "Beatles"),
|
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(
|
assertEquals(
|
||||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||||
dao
|
dao
|
||||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name))
|
.artistsPaging(
|
||||||
.page()
|
artistsPageQuery(sourceId, 0, "", ArtistSort.Name, false, false, SortNameMode.Name),
|
||||||
|
).page()
|
||||||
.map { it.name },
|
.map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aaa", "Beatles", "Zzz"),
|
listOf("Aaa", "Beatles", "Zzz"),
|
||||||
dao
|
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()
|
.page()
|
||||||
.map { it.name },
|
.map { it.name },
|
||||||
)
|
)
|
||||||
@@ -1507,11 +1769,11 @@ class LibraryDaoTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
|
assertEquals(listOf("宇多田ヒカル"), dao.artistPages(sourceId, 0, "utada").page().map { it.name })
|
||||||
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").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("宇多田ヒカル"), 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.albumPages(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, "fi").page().map { it.name })
|
||||||
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().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 })
|
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
@@ -27,12 +23,7 @@ class LibraryHomeDaoTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@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
|
package com.subtracks.data.db
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.immediateTransaction
|
import androidx.room3.immediateTransaction
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
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.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.Source
|
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.MusicSource
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.sync.SyncService
|
import com.subtracks.data.sync.SyncService
|
||||||
@@ -37,14 +37,8 @@ class QueryTimingMeasurementTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||||
db =
|
db = createTestDatabase(driver)
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(driver)
|
|
||||||
.addCallback(ArtistSearchTriggerOptimization)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -110,28 +104,37 @@ class QueryTimingMeasurementTest {
|
|||||||
|
|
||||||
private suspend fun measureReads() {
|
private suspend fun measureReads() {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
|
timed("albums first page (name)") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).firstPage() }
|
||||||
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
|
timed("albums first page (name, downloaded)") {
|
||||||
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
|
dao.albumPages(1, 0, "", sort = AlbumSort.Name, downloaded = true).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 (artist)") { dao.albumPages(1, 0, "", sort = AlbumSort.Artist).firstPage() }
|
||||||
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
|
timed("albums first page (year)") { dao.albumPages(1, 0, "", sort = AlbumSort.Year).firstPage() }
|
||||||
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
timed("albums first page (added)") { dao.albumPages(1, 0, "", sort = AlbumSort.Added).firstPage() }
|
||||||
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
timed("albums first page (starred)") { dao.albumPages(1, 0, "", sort = AlbumSort.Starred).firstPage() }
|
||||||
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").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 albums") { dao.albumsForArtist(1, "ar0").first() }
|
||||||
timed("artist name") { dao.artistName(1, "ar0").first() }
|
timed("artist name") { dao.artistName(1, "ar0").first() }
|
||||||
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
||||||
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
||||||
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
||||||
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
|
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 statuses") { db.downloadDao().artistStatuses(1).first() }
|
||||||
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
||||||
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
|
timed("playlists first page (name)") { dao.playlistPages(1, "", sort = PlaylistSort.Name).firstPage() }
|
||||||
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
timed("playlists first page (name, downloaded)") {
|
||||||
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
|
dao.playlistPages(1, "", sort = PlaylistSort.Name, downloaded = true).firstPage()
|
||||||
timed("albums search short") { dao.albumsByName(1, 0, "al").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("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
|
||||||
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
||||||
timed("home most played") { dao.mostPlayedAlbums(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.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkFetcher
|
import com.subtracks.data.download.ArtworkFetcher
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
@@ -84,11 +83,7 @@ class DownloadRepositoryTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
resources = context.resources
|
resources = context.resources
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
|
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import android.content.Context
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.paging.PagingDataEvent
|
import androidx.paging.PagingDataEvent
|
||||||
import androidx.paging.PagingDataPresenter
|
import androidx.paging.PagingDataPresenter
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.ArtistBio
|
import com.subtracks.data.model.ArtistBio
|
||||||
@@ -63,11 +62,7 @@ class LibraryRepositoryTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
resources = context.resources
|
resources = context.resources
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sourceRepository =
|
sourceRepository =
|
||||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
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
|
@Test
|
||||||
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package com.subtracks.data.repo
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.PendingActionKind
|
import com.subtracks.data.model.PendingActionKind
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
@@ -82,12 +79,7 @@ class PendingServerActionSinkTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
||||||
delegate = RecordingSink()
|
delegate = RecordingSink()
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useReaderConnection
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -26,12 +23,7 @@ class QueueKeysetTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
repository = QueueRepository(db)
|
repository = QueueRepository(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +150,18 @@ class QueueKeysetTest {
|
|||||||
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
|
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scanningAnAlbumWithNullDiscAndTrackForwardFallsBackToOffsetRows() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.replace(listOf(repository.albumEntry(1, "mixed")))
|
||||||
|
val snapshot = repository.snapshot()
|
||||||
|
|
||||||
|
val resolved = (0 until snapshot.size).map { repository.itemAt(snapshot, it)?.song?.id }
|
||||||
|
|
||||||
|
assertEquals(listOf("n1", "n2", "s1", "s2"), resolved)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -268,6 +272,22 @@ class QueueKeysetTest {
|
|||||||
assertTrue(ids.all { it in expected })
|
assertTrue(ids.all { it in expected })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePlaylistWindowQuerySeeksThePositionIndex() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
val plan =
|
||||||
|
queryPlan(
|
||||||
|
"SELECT songs.*, playlist_songs.position AS playlistPosition, albums.coverArt AS coverArt " +
|
||||||
|
"FROM playlist_songs JOIN songs ON songs.sourceId = playlist_songs.sourceId " +
|
||||||
|
"AND songs.id = playlist_songs.songId LEFT JOIN albums ON albums.sourceId = songs.sourceId " +
|
||||||
|
"AND albums.id = songs.albumId WHERE playlist_songs.sourceId = 1 " +
|
||||||
|
"AND playlist_songs.playlistId = 'pl1' ORDER BY playlist_songs.position LIMIT 51 OFFSET 100000",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(plan.joinToString("\n"), plan.any { it.contains("sqlite_autoindex_playlist_songs_1") })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useReaderConnection
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.DownloadStatus
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
@@ -33,12 +30,7 @@ class QueueRepositoryTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
repository = QueueRepository(db)
|
repository = QueueRepository(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,6 +727,32 @@ class QueueRepositoryTest {
|
|||||||
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
|
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffledLargeContextWindowOnlyResolvesRequestedRows() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
db.libraryDao().upsertPlaylists(listOf(playlist("huge", "Huge")))
|
||||||
|
db.useWriterConnection { transactor ->
|
||||||
|
transactor.usePrepared(
|
||||||
|
"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 199999) " +
|
||||||
|
"INSERT INTO playlist_songs SELECT 1, 'huge', 's1', x FROM n",
|
||||||
|
) { statement ->
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repository.snapshotAfter(listOf(repository.playlistEntry(1, "huge")))
|
||||||
|
repository.setShuffle(42L)
|
||||||
|
val snapshot = repository.snapshot()
|
||||||
|
|
||||||
|
val window = repository.window(snapshot, center = 100_000, radius = 25)
|
||||||
|
|
||||||
|
assertEquals(200_000L, snapshot.contextSize)
|
||||||
|
assertEquals(51, window.size)
|
||||||
|
assertEquals(51L, repository.resolvedRowCount)
|
||||||
|
assertEquals((99_975L..100_025L).toList(), window.map { it.position })
|
||||||
|
assertTrue(window.all { it.item.song.id == "s1" })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun shuffledRangeResolvesEntriesInPositionOrder() =
|
fun shuffledRangeResolvesEntriesInPositionOrder() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -859,6 +877,55 @@ class QueueRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contextIndexUsesTheFirstDuplicateOrdinalAcrossSparsePositions() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||||
|
db.libraryDao().upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "duplicates", "s2", 10),
|
||||||
|
PlaylistSong(1, "duplicates", "s1", 20),
|
||||||
|
PlaylistSong(1, "duplicates", "s2", 30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "duplicates")))
|
||||||
|
|
||||||
|
assertEquals(0L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||||
|
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contextIndexFindsADuplicateInsideTheEntryRange() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||||
|
db.libraryDao().upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "duplicates", "s2", 10),
|
||||||
|
PlaylistSong(1, "duplicates", "s1", 20),
|
||||||
|
PlaylistSong(1, "duplicates", "s2", 30),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val entry = repository.playlistEntry(1, "duplicates").copy(rangeStart = 1, rangeEnd = 2)
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(entry))
|
||||||
|
|
||||||
|
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun contextIndexFallsBackToBoundedRowsForGenre() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
insertGenre("s1", "Rock")
|
||||||
|
insertGenre("s3", "Rock")
|
||||||
|
insertGenre("s4", "Rock")
|
||||||
|
val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock")))
|
||||||
|
|
||||||
|
assertEquals(2L, repository.combinedContextIndexOf(snapshot, "s4"))
|
||||||
|
assertTrue(repository.resolvedRowCount <= QUEUE_CHUNK)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
|
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ package com.subtracks.data.repo
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.TEST_TIMEOUT_MS
|
import com.subtracks.TEST_TIMEOUT_MS
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.artistImageKey
|
import com.subtracks.data.model.artistImageKey
|
||||||
@@ -58,11 +57,7 @@ class SourceRepositoryTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
resources = context.resources
|
resources = context.resources
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||||
prefs = fakeUserPreferences()
|
prefs = fakeUserPreferences()
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
@@ -314,7 +309,7 @@ class SourceRepositoryTest {
|
|||||||
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
|
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 =
|
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||||
withTimeout(TEST_TIMEOUT_MS) {
|
withTimeout(TEST_TIMEOUT_MS) {
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ package com.subtracks.data.sync
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.TEST_TIMEOUT_MS
|
import com.subtracks.TEST_TIMEOUT_MS
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
@@ -55,11 +54,7 @@ class SyncManagerTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
resources = context.resources
|
resources = context.resources
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sourceRepository =
|
sourceRepository =
|
||||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.LoggingSQLiteDriver
|
import com.subtracks.data.db.LoggingSQLiteDriver
|
||||||
import com.subtracks.data.db.SqlExecution
|
import com.subtracks.data.db.SqlExecution
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
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.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
@@ -43,12 +42,7 @@ class SyncServiceTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -75,7 +69,7 @@ class SyncServiceTest {
|
|||||||
2,
|
2,
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.size,
|
.size,
|
||||||
)
|
)
|
||||||
@@ -98,7 +92,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -172,7 +166,7 @@ class SyncServiceTest {
|
|||||||
"Old",
|
"Old",
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.single()
|
.single()
|
||||||
.name,
|
.name,
|
||||||
@@ -185,7 +179,7 @@ class SyncServiceTest {
|
|||||||
"New",
|
"New",
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.single()
|
.single()
|
||||||
.name,
|
.name,
|
||||||
@@ -1107,16 +1101,16 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
|
val query = artistsPageQuery(1, 0, "alpha", ArtistSort.Name, false, false)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "alpha")
|
.artistPages(1, 0, "alpha")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1203,7 +1197,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1", "combo"),
|
listOf("a1", "combo"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1216,7 +1210,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1434,7 +1428,7 @@ class SyncServiceTest {
|
|||||||
SyncService(db, source).sync()
|
SyncService(db, source).sync()
|
||||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
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("A", "B"), filtered.map { it.name })
|
||||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||||
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
||||||
@@ -1538,7 +1532,7 @@ class SyncServiceTest {
|
|||||||
albums.size.toLong(),
|
albums.size.toLong(),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.first { it.id == "a2" }
|
.first { it.id == "a2" }
|
||||||
.albumCount,
|
.albumCount,
|
||||||
@@ -1780,7 +1774,7 @@ class SyncServiceTest {
|
|||||||
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
|
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) {
|
for (artist in downloadedArtists) {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"${artist.id} downloaded albums",
|
"${artist.id} downloaded albums",
|
||||||
@@ -1845,13 +1839,8 @@ class SyncServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
fun anIdenticalSyncSkipsTheAggregateQueries() =
|
fun anIdenticalSyncSkipsTheAggregateQueries() =
|
||||||
runTest {
|
runTest {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
||||||
val loggingDb =
|
val loggingDb = createTestDatabase(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
loggingDb.sourcesDao().upsertSource(
|
loggingDb.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
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.Context
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
@@ -14,6 +12,7 @@ import com.subtracks.UiMessage
|
|||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
@@ -83,11 +82,7 @@ class PlaybackControllerTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
resources = context.resources
|
resources = context.resources
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
prefs = fakeUserPreferences()
|
prefs = fakeUserPreferences()
|
||||||
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
||||||
sources =
|
sources =
|
||||||
|
|||||||
@@ -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.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
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 com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -55,6 +60,115 @@ class DeleteDownloadsDialogTest {
|
|||||||
composeRule.onNodeWithText("512 B", substring = true).assertIsDisplayed()
|
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(
|
private fun render(
|
||||||
bytes: Long = 0,
|
bytes: Long = 0,
|
||||||
onConfirm: () -> Unit = {},
|
onConfirm: () -> Unit = {},
|
||||||
|
|||||||
@@ -1,40 +1,16 @@
|
|||||||
package com.subtracks.ui.downloads
|
package com.subtracks.ui.downloads
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.viewModelScope
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
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.DownloadList
|
||||||
import com.subtracks.data.model.DownloadStatus
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.ui.ViewModelRepositoryFixture
|
||||||
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.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
@@ -42,61 +18,23 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class DownloadsViewModelTest {
|
class DownloadsViewModelTest {
|
||||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
private lateinit var fixture: ViewModelRepositoryFixture
|
||||||
|
private val db get() = fixture.db
|
||||||
private lateinit var db: SubtracksDatabase
|
private val sourceRepository get() = fixture.sourceRepository
|
||||||
private lateinit var sourceRepository: SourceRepository
|
private val downloadRepository get() = fixture.downloadRepository
|
||||||
private lateinit var downloadRepository: DownloadRepository
|
private val controller get() = fixture.controller
|
||||||
private lateinit var controller: PlaybackController
|
|
||||||
private lateinit var viewModel: DownloadsViewModel
|
private lateinit var viewModel: DownloadsViewModel
|
||||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
|
||||||
private var sourceId: Long = 0
|
private var sourceId: Long = 0
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(dispatcher)
|
fixture = ViewModelRepositoryFixture()
|
||||||
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,
|
|
||||||
)
|
|
||||||
viewModel =
|
viewModel =
|
||||||
DownloadsViewModel(
|
DownloadsViewModel(
|
||||||
LibraryRepository(
|
fixture.libraryRepository,
|
||||||
db,
|
|
||||||
sourceRepository,
|
|
||||||
NetworkServerActionSink(sourceRepository),
|
|
||||||
fakeUserPreferences(),
|
|
||||||
scope = repoScope,
|
|
||||||
),
|
|
||||||
downloadRepository,
|
downloadRepository,
|
||||||
controller,
|
controller,
|
||||||
"Unknown artist",
|
"Unknown artist",
|
||||||
@@ -107,10 +45,7 @@ class DownloadsViewModelTest {
|
|||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
|
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
|
||||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
fixture.close()
|
||||||
db.close()
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
dispatcher.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import androidx.activity.ComponentActivity
|
|||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
import androidx.compose.ui.test.onAllNodesWithTag
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
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.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.TEST_TIMEOUT_MS
|
import com.subtracks.TEST_TIMEOUT_MS
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -81,11 +80,7 @@ class NowPlayingRouteTest {
|
|||||||
private fun setUpState() {
|
private fun setUpState() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
val artworkStore = ArtworkStore(File(context.cacheDir, "art"))
|
val artworkStore = ArtworkStore(File(context.cacheDir, "art"))
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
val sources = SourceRepository(db, OkHttpClient(), prefs, artworkStore, scope = repoScope)
|
val sources = SourceRepository(db, OkHttpClient(), prefs, artworkStore, scope = repoScope)
|
||||||
val queues = QueueRepository(db)
|
val queues = QueueRepository(db)
|
||||||
downloads =
|
downloads =
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ package com.subtracks.ui.playback
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.TEST_TIMEOUT_MS
|
import com.subtracks.TEST_TIMEOUT_MS
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
@@ -68,11 +67,7 @@ class QueueViewModelTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(dispatcher)
|
Dispatchers.setMain(dispatcher)
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
sources =
|
sources =
|
||||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = repoScope)
|
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = repoScope)
|
||||||
queues = QueueRepository(db)
|
queues = QueueRepository(db)
|
||||||
|
|||||||
@@ -1,43 +1,19 @@
|
|||||||
package com.subtracks.ui.search
|
package com.subtracks.ui.search
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.lifecycle.viewModelScope
|
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 androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
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.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.ui.ViewModelRepositoryFixture
|
||||||
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.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -45,70 +21,24 @@ import org.junit.Assert.assertTrue
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
|
||||||
import java.util.concurrent.Executors
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class SearchViewModelTest {
|
class SearchViewModelTest {
|
||||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
private lateinit var fixture: ViewModelRepositoryFixture
|
||||||
|
private val db get() = fixture.db
|
||||||
private lateinit var db: SubtracksDatabase
|
private val sourceRepository get() = fixture.sourceRepository
|
||||||
private lateinit var sourceRepository: SourceRepository
|
|
||||||
private lateinit var viewModel: SearchViewModel
|
private lateinit var viewModel: SearchViewModel
|
||||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(dispatcher)
|
fixture = ViewModelRepositoryFixture()
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
viewModel = SearchViewModel(fixture.libraryRepository, sourceRepository, fixture.controller)
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
|
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
|
||||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
fixture.close()
|
||||||
db.close()
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
dispatcher.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ package com.subtracks.ui.settings
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
import com.subtracks.data.download.FakeDownloadEngine
|
import com.subtracks.data.download.FakeDownloadEngine
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
@@ -57,11 +56,7 @@ class AddSourceViewModelTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
Dispatchers.setMain(dispatcher)
|
Dispatchers.setMain(dispatcher)
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
db =
|
db = createTestDatabase()
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||||
downloadRepository =
|
downloadRepository =
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ android.useAndroidX=true
|
|||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
mavenCentralMirrorUrl=https://maven-central.storage-download.googleapis.com/maven2/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pluginManagement {
|
pluginManagement {
|
||||||
|
val mavenCentralMirrorUrl = providers.gradleProperty("mavenCentralMirrorUrl").get()
|
||||||
repositories {
|
repositories {
|
||||||
google {
|
google {
|
||||||
content {
|
content {
|
||||||
@@ -7,6 +8,9 @@ pluginManagement {
|
|||||||
includeGroupByRegex("androidx.*")
|
includeGroupByRegex("androidx.*")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maven(mavenCentralMirrorUrl) {
|
||||||
|
name = "MavenCentralMirror"
|
||||||
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
@@ -16,6 +20,9 @@ dependencyResolutionManagement {
|
|||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
maven(providers.gradleProperty("mavenCentralMirrorUrl").get()) {
|
||||||
|
name = "MavenCentralMirror"
|
||||||
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ export const LIB = ".integration/music"
|
|||||||
export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT]
|
export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT]
|
||||||
|
|
||||||
const MUSIC = [
|
const MUSIC = [
|
||||||
{ id: 197, name: "Ugress - Retroconnaissance EP", songs: 4 }
|
{ id: "7GZYpfn8wypaTR00LWeNI4", name: "Ugress - Retroconnaissance", songs: 4 }
|
||||||
{ id: 199, name: "Ugress - Kosmonaut", songs: 4 }
|
{ id: "5gMe1SlZZzuOMkRVWJaodV", name: "Ugress - Kosmonaut", songs: 4 }
|
||||||
{ id: 321, name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
|
{ 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.
|
# Keep in step with TestServers.kt.
|
||||||
export def test-servers [] {
|
export def test-servers [] {
|
||||||
[
|
[
|
||||||
@@ -33,17 +37,18 @@ def song-count [dir: string] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export def ensure-music [] {
|
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)"
|
print $"using test music in ($LIB)"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
print "downloading test music from demo.subsonic.org (one-time)"
|
print "downloading test music from demo.navidrome.org (one-time)"
|
||||||
mkdir $LIB
|
mkdir $LIB
|
||||||
let tmp = (^mktemp -d | str trim)
|
let tmp = (^mktemp -d | str trim)
|
||||||
for m in $MUSIC {
|
for m in $MUSIC {
|
||||||
print $"($m.name)..."
|
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)"
|
^curl -fsS -o $"($tmp)/($m.id).zip" $"($MUSIC_DOWNLOAD)($m.id)"
|
||||||
^unzip -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($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
|
rm -rf $tmp
|
||||||
}
|
}
|
||||||
@@ -163,8 +168,9 @@ export def start-prune [work: string] {
|
|||||||
let music = ($dir | path join "music")
|
let music = ($dir | path join "music")
|
||||||
mkdir ($dir | path join "data")
|
mkdir ($dir | path join "data")
|
||||||
mkdir $music
|
mkdir $music
|
||||||
^cp -r $"((pwd) | path join $LIB)/197" $music
|
for m in ($MUSIC | first 2) {
|
||||||
^cp -r $"((pwd) | path join $LIB)/199" $music
|
^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 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 {
|
let ready = (try {
|
||||||
@@ -198,12 +204,12 @@ export def prepare-fixtures [server: record] {
|
|||||||
|
|
||||||
let albums = (poll "albums" {||
|
let albums = (poll "albums" {||
|
||||||
let items = (sub-get $base $user $pass "getAlbumList2" [["type" "newest"]] | get albumList2.album)
|
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 ugress = (named-id $artists "Ugress")
|
||||||
let kosmo = (named-id $albums "Kosmonaut")
|
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")
|
let dunno = (named-id $albums "I Don't Know What I'm Doing")
|
||||||
|
|
||||||
poll "album songs" {||
|
poll "album songs" {||
|
||||||
|
|||||||
Reference in New Issue
Block a user