mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
Compare commits
11
Commits
446a35d26e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11276c2e1f | ||
|
|
eb55858cc4 | ||
|
|
95ad993996 | ||
|
|
11abd4afbd | ||
|
|
69f41b3459 | ||
|
|
09baaeb77d | ||
|
|
484cc55385 | ||
|
|
599924bdd0 | ||
|
|
d6ff3ee086 | ||
|
|
b2a1716be2 | ||
|
|
51792f836b |
@@ -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
|
||||||
|
|||||||
@@ -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
-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),
|
||||||
|
|||||||
+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)
|
||||||
|
|||||||
@@ -84,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'"
|
|
||||||
|
|
||||||
internal 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 " +
|
||||||
@@ -222,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" +
|
||||||
@@ -260,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, " +
|
||||||
@@ -338,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 {
|
||||||
@@ -370,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)
|
||||||
@@ -1002,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(
|
||||||
@@ -1049,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)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ AND (:search = ''
|
|||||||
|
|
||||||
// The downloaded list counts the albums that actually have a download so the number under an artist
|
// The downloaded list counts the albums that actually have a download so the number under an artist
|
||||||
// matches the albums its detail screen lists.
|
// matches the albums its detail screen lists.
|
||||||
internal const val ARTIST_COLUMNS_DOWNLOADED = """
|
internal val ARTIST_COLUMNS_DOWNLOADED = """
|
||||||
artists.sourceId, artists.id, artists.name,
|
artists.sourceId, artists.id, artists.name,
|
||||||
(SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount,
|
(SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount,
|
||||||
$ARTIST_COLUMNS_TAIL
|
$ARTIST_COLUMNS_TAIL
|
||||||
|
|||||||
@@ -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>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,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)
|
return null
|
||||||
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
|
||||||
ids.addAll(entryIds.subList(from, to))
|
|
||||||
}
|
|
||||||
return ids
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -895,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 {
|
||||||
@@ -990,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
|
||||||
@@ -1357,6 +1357,24 @@ class LibraryDaoTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val plan = plan(ALBUMS_FOR_ARTIST)
|
val plan = plan(ALBUMS_FOR_ARTIST)
|
||||||
|
|
||||||
|
assertArtistCreditIndexes(plan)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||||
assertTrue(
|
assertTrue(
|
||||||
plan,
|
plan,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -40,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,17 +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.artistPages
|
import com.subtracks.data.db.artistPages
|
||||||
import com.subtracks.data.db.artistsPageQuery
|
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
|
||||||
@@ -44,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
|
||||||
@@ -1846,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/
|
||||||
|
|||||||
+3
-2
@@ -1,4 +1,5 @@
|
|||||||
pluginManagement {
|
pluginManagement {
|
||||||
|
val mavenCentralMirrorUrl = providers.gradleProperty("mavenCentralMirrorUrl").get()
|
||||||
repositories {
|
repositories {
|
||||||
google {
|
google {
|
||||||
content {
|
content {
|
||||||
@@ -7,7 +8,7 @@ pluginManagement {
|
|||||||
includeGroupByRegex("androidx.*")
|
includeGroupByRegex("androidx.*")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
maven("https://maven-central.storage-download.googleapis.com/maven2/") {
|
maven(mavenCentralMirrorUrl) {
|
||||||
name = "MavenCentralMirror"
|
name = "MavenCentralMirror"
|
||||||
}
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@@ -19,7 +20,7 @@ dependencyResolutionManagement {
|
|||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
maven("https://maven-central.storage-download.googleapis.com/maven2/") {
|
maven(providers.gradleProperty("mavenCentralMirrorUrl").get()) {
|
||||||
name = "MavenCentralMirror"
|
name = "MavenCentralMirror"
|
||||||
}
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
Reference in New Issue
Block a user