mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-15 00:42:54 +01:00
Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b92c5313b | ||
|
|
d76f236f59 | ||
|
|
50c4dfce03 | ||
|
|
746446be86 | ||
|
|
df8cf73385 | ||
|
|
97e165e8c2 | ||
|
|
eb4d537437 | ||
|
|
31e4596860 | ||
|
|
b95b8c3569 | ||
|
|
e4549f5fa1 | ||
|
|
e2c3f31c26 | ||
|
|
2766affa65 | ||
|
|
4d5328d8a0 | ||
|
|
97e700a1ab | ||
|
|
5fadc21455 | ||
|
|
64ec3265b7 | ||
|
|
9514bc501a | ||
|
|
35f4f9178b | ||
|
|
e4d97f7aba | ||
|
|
cc94dfc458 | ||
|
|
79915d6c7a | ||
|
|
4e72b1f38d | ||
|
|
ed19ca2ece | ||
|
|
481f12c892 | ||
|
|
544b442f4e | ||
|
|
705c93053f | ||
|
|
ffc61bdbd2 | ||
|
|
3de80dacdd | ||
|
|
95c8a2a915 | ||
|
|
92e5bb45c9 | ||
|
|
2afad4fa0d | ||
|
|
4e51f906fe | ||
|
|
548bf39136 | ||
|
|
acc820e263 | ||
|
|
4b5d02f17b | ||
|
|
fd0eb0a9a0 | ||
|
|
01ccc6475d | ||
|
|
a9738c6311 | ||
|
|
767c3db1ae | ||
|
|
018f30a3d8 | ||
|
|
762639cab5 | ||
|
|
94c7fba536 | ||
|
|
ddc6811c34 | ||
|
|
04dba5ef76 | ||
|
|
0448842f60 | ||
|
|
b9085af44c | ||
|
|
44e2eb7ae8 | ||
|
|
f43bc877f0 | ||
|
|
d9af47323a | ||
|
|
02404ef562 | ||
|
|
d05fb6fc07 | ||
|
|
2bbff7e477 | ||
|
|
e94b365495 | ||
|
|
d62bff5ace | ||
|
|
29335f7ddf | ||
|
|
11276c2e1f | ||
|
|
eb55858cc4 | ||
|
|
95ad993996 | ||
|
|
11abd4afbd | ||
|
|
69f41b3459 | ||
|
|
09baaeb77d | ||
|
|
484cc55385 | ||
|
|
599924bdd0 | ||
|
|
d6ff3ee086 | ||
|
|
b2a1716be2 | ||
|
|
51792f836b | ||
|
|
446a35d26e | ||
|
|
b18e0891f6 | ||
|
|
c53f1aef06 | ||
|
|
87e0609c05 | ||
|
|
c3a5382c27 | ||
|
|
e8cbb93714 | ||
|
|
6f1e05d15a | ||
|
|
c165a6cc88 | ||
|
|
18c24ca566 | ||
|
|
8d6f7e2341 | ||
|
|
29935b7bee | ||
|
|
4bfe67ec19 | ||
|
|
060a5f036f | ||
|
|
c169549ef4 |
@@ -40,6 +40,11 @@ runs:
|
|||||||
path: ~/.gradle/caches
|
path: ~/.gradle/caches
|
||||||
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||||
restore-keys: gradle-v2-
|
restore-keys: gradle-v2-
|
||||||
|
- uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: ~/.m2/repository/org/robolectric
|
||||||
|
key: robolectric-m2-v1-${{ hashFiles('gradle/libs.versions.toml', 'app/src/test/resources/robolectric.properties') }}
|
||||||
|
restore-keys: robolectric-m2-v1-
|
||||||
- id: signing
|
- id: signing
|
||||||
if: inputs.sign == 'true'
|
if: inputs.sign == 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -50,5 +50,5 @@ jobs:
|
|||||||
- uses: actions/cache@v6
|
- uses: actions/cache@v6
|
||||||
with:
|
with:
|
||||||
path: .integration/music
|
path: .integration/music
|
||||||
key: integration-music-v1
|
key: integration-music-v3
|
||||||
- run: nix develop .#integration --command nu tools/integration-test.nu
|
- run: nix develop .#integration --command nu tools/integration-test.nu
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
getByName("debug").res.srcDir("src/nightly/res")
|
getByName("debug").res.directories.add("src/nightly/res")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -181,6 +181,8 @@ val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
|||||||
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
|
// Robolectric fetches its Android SDK jar itself, outside Gradle's repositories.
|
||||||
|
systemProperty("robolectric.dependency.repo.url", providers.gradleProperty("mavenCentralMirrorUrl").get())
|
||||||
if (name.endsWith("UnitTest")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||||
|
|||||||
+2092
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -56,7 +56,7 @@ class SubsonicSourceIntegrationTest(
|
|||||||
assertNotNull(kosmo.starred)
|
assertNotNull(kosmo.starred)
|
||||||
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
|
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
|
||||||
|
|
||||||
assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred)
|
assertNull(albums.first { it.name == "Retroconnaissance" }.starred)
|
||||||
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
|
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ class SubsonicSourceIntegrationTest(
|
|||||||
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
|
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
|
||||||
.map { it.name }
|
.map { it.name }
|
||||||
.sorted()
|
.sorted()
|
||||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
|
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
|
||||||
|
|
||||||
val bradAlbums =
|
val bradAlbums =
|
||||||
albums
|
albums
|
||||||
|
|||||||
+2
-2
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
|
|||||||
.albums()
|
.albums()
|
||||||
.toList()
|
.toList()
|
||||||
.flatten()
|
.flatten()
|
||||||
.first { it.name == "Retroconnaissance EP" }
|
.first { it.name == "Retroconnaissance" }
|
||||||
|
|
||||||
source.setStar(StarType.Album, album.id, true)
|
source.setStar(StarType.Album, album.id, true)
|
||||||
try {
|
try {
|
||||||
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
|
|||||||
.songs()
|
.songs()
|
||||||
.toList()
|
.toList()
|
||||||
.flatten()
|
.flatten()
|
||||||
.first { it.album == "Retroconnaissance EP" }
|
.first { it.album == "Retroconnaissance" }
|
||||||
|
|
||||||
source.setStar(StarType.Song, song.id, true)
|
source.setStar(StarType.Song, song.id, true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
TestServers.all.forEachIndexed { index, server ->
|
TestServers.all.forEachIndexed { index, server ->
|
||||||
val id = index + 1L
|
val id = index + 1L
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||||
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
|
|||||||
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
||||||
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
||||||
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
|
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
|
||||||
@@ -93,7 +84,7 @@ class PruneSyncIntegrationTest {
|
|||||||
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
|
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val REMOVED_ALBUM_DIR = "197"
|
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
|
||||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
|
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-11
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.source.subsonic.TestServers
|
import com.subtracks.data.source.subsonic.TestServers
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
|
|||||||
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
servers.forEach { server ->
|
servers.forEach { server ->
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
val db = createTestDatabase()
|
||||||
val db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
try {
|
try {
|
||||||
db.sourcesDao().upsertSource(
|
db.sourcesDao().upsertSource(
|
||||||
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.room3.RoomRawQuery
|
||||||
|
|
||||||
|
internal val CREDITED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery())
|
||||||
|
internal val DOWNLOADED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true))
|
||||||
|
internal val DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST =
|
||||||
|
albumCreditRows(AlbumCreditQuery(downloaded = true, artistScoped = true, selectAlbums = true))
|
||||||
|
internal val DOWNLOADED_COVER_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true, coverOnly = true))
|
||||||
|
internal val ARTIST_COVER_CREDITS = albumCreditRows(AlbumCreditQuery(coverOnly = true))
|
||||||
|
internal val ARTIST_COVER_CREDITS_FOR_ARTIST = albumCreditRows(AlbumCreditQuery(coverOnly = true, artistScoped = true))
|
||||||
|
|
||||||
|
private data class AlbumCreditQuery(
|
||||||
|
val artistScoped: Boolean = false,
|
||||||
|
val coverOnly: Boolean = false,
|
||||||
|
val downloaded: Boolean = false,
|
||||||
|
val selectAlbums: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun albumCreditRows(query: AlbumCreditQuery): String =
|
||||||
|
if (query.selectAlbums) {
|
||||||
|
listOf(
|
||||||
|
primaryAlbumCredits(query),
|
||||||
|
albumRelationCredits(query),
|
||||||
|
trackRelationCredits(query),
|
||||||
|
trackCredits(query),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
primaryAlbumCredits(query),
|
||||||
|
albumRelationCredits(query),
|
||||||
|
trackCredits(query),
|
||||||
|
trackRelationCredits(query),
|
||||||
|
)
|
||||||
|
}.joinToString(if (query.downloaded) " UNION " else " UNION ALL ")
|
||||||
|
|
||||||
|
private fun primaryAlbumCredits(query: AlbumCreditQuery): String =
|
||||||
|
buildString {
|
||||||
|
append(
|
||||||
|
when {
|
||||||
|
query.selectAlbums -> "SELECT albums.* FROM albums "
|
||||||
|
query.downloaded -> "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums "
|
||||||
|
else -> "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType FROM albums "
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append("WHERE albums.sourceId = :sourceId AND ${query.directArtist("albums.artistId")} ")
|
||||||
|
append(query.coverFilter())
|
||||||
|
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||||
|
}.trim()
|
||||||
|
|
||||||
|
private fun albumRelationCredits(query: AlbumCreditQuery): String =
|
||||||
|
buildString {
|
||||||
|
append(
|
||||||
|
when {
|
||||||
|
query.selectAlbums -> "SELECT albums.* FROM album_artists "
|
||||||
|
query.downloaded -> "SELECT albums.id, album_artists.artistId FROM album_artists "
|
||||||
|
else -> "SELECT album_artists.artistId, albums.id, 'album' FROM album_artists "
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append("JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId ")
|
||||||
|
append("WHERE album_artists.sourceId = :sourceId ")
|
||||||
|
if (query.artistScoped) append("AND album_artists.artistId = :artistId ")
|
||||||
|
append(query.coverFilter())
|
||||||
|
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||||
|
}.trim()
|
||||||
|
|
||||||
|
private fun trackCredits(query: AlbumCreditQuery): String =
|
||||||
|
buildString {
|
||||||
|
append(
|
||||||
|
when {
|
||||||
|
query.selectAlbums -> "SELECT albums.* FROM songs "
|
||||||
|
query.downloaded -> "SELECT songs.albumId, songs.artistId FROM songs "
|
||||||
|
else -> "SELECT songs.artistId, songs.albumId, 'track' FROM songs "
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append(SONG_ALBUM_JOIN)
|
||||||
|
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||||
|
append("WHERE songs.sourceId = :sourceId AND ${query.directArtist("songs.artistId")} ")
|
||||||
|
append(query.coverFilter())
|
||||||
|
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||||
|
}.trim()
|
||||||
|
|
||||||
|
private fun trackRelationCredits(query: AlbumCreditQuery): String =
|
||||||
|
buildString {
|
||||||
|
append(
|
||||||
|
when {
|
||||||
|
query.selectAlbums -> "SELECT albums.* FROM song_artists "
|
||||||
|
query.downloaded -> "SELECT songs.albumId, song_artists.artistId FROM song_artists "
|
||||||
|
else -> "SELECT song_artists.artistId, songs.albumId, 'track' FROM song_artists "
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append("JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId ")
|
||||||
|
append(SONG_ALBUM_JOIN)
|
||||||
|
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||||
|
append("WHERE song_artists.sourceId = :sourceId ")
|
||||||
|
if (query.artistScoped) append("AND song_artists.artistId = :artistId ")
|
||||||
|
append(query.coverFilter())
|
||||||
|
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||||
|
}.trim()
|
||||||
|
|
||||||
|
private fun AlbumCreditQuery.directArtist(column: String): String = if (artistScoped) "$column = :artistId" else "$column IS NOT NULL"
|
||||||
|
|
||||||
|
private fun AlbumCreditQuery.coverFilter(): String = if (coverOnly) "AND albums.coverArt IS NOT NULL " else ""
|
||||||
|
|
||||||
|
private const val SONG_ALBUM_JOIN = "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId "
|
||||||
|
|
||||||
|
private const val TRACK_DOWNLOAD_JOIN = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id "
|
||||||
|
|
||||||
|
private const val ALBUM_DOWNLOADED =
|
||||||
|
"EXISTS (SELECT 1 FROM songs " +
|
||||||
|
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||||
|
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')"
|
||||||
|
|
||||||
|
internal fun downloadedArtistArtQuery(
|
||||||
|
sourceId: Long,
|
||||||
|
artistIds: List<String>,
|
||||||
|
): RoomRawQuery {
|
||||||
|
val bindings = mutableMapOf<String, Any?>("sourceId" to sourceId)
|
||||||
|
val placeholders =
|
||||||
|
artistIds
|
||||||
|
.mapIndexed { index, artistId ->
|
||||||
|
val name = "artistId$index"
|
||||||
|
bindings[name] = artistId
|
||||||
|
":$name"
|
||||||
|
}.ifEmpty { listOf("NULL") }
|
||||||
|
return rawQuery(DOWNLOADED_ARTIST_ART_UPDATE_SQL.replace(":artistIds", placeholders.joinToString(",")), bindings)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.subtracks.data.db
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
|
import androidx.room3.RoomDatabase
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
@@ -11,7 +12,9 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
|||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(driver)
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
.addCallback(ArtistSearchTriggerOptimization)
|
.addSubtracksDatabaseCallbacks()
|
||||||
.addCallback(SqliteLifecycleLogging)
|
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun RoomDatabase.Builder<SubtracksDatabase>.addSubtracksDatabaseCallbacks(): RoomDatabase.Builder<SubtracksDatabase> =
|
||||||
|
addCallback(ArtistSearchTriggerOptimization).addCallback(SqliteLifecycleLogging)
|
||||||
|
|||||||
@@ -56,6 +56,27 @@ interface DownloadDao {
|
|||||||
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
||||||
fun downloads(sourceId: Long): Flow<List<SongDownload>>
|
fun downloads(sourceId: Long): Flow<List<SongDownload>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
||||||
|
suspend fun downloadsOnce(sourceId: Long): List<SongDownload>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT d.* 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 completedDownloads(sourceId: Long): List<SongDownload>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT d.songId FROM song_downloads d " +
|
||||||
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
|
"WHERE d.sourceId = :sourceId AND d.songId IN (:songIds)",
|
||||||
|
)
|
||||||
|
suspend fun queueVisibleSongIds(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
): List<String>
|
||||||
|
|
||||||
@Query("SELECT * FROM song_downloads WHERE status IN ('Queued', 'Running') AND (engineId IS NOT NULL OR sourceId = :sourceId)")
|
@Query("SELECT * FROM song_downloads WHERE status IN ('Queued', 'Running') AND (engineId IS NOT NULL OR sourceId = :sourceId)")
|
||||||
fun activeDownloads(sourceId: Long): Flow<List<SongDownload>>
|
fun activeDownloads(sourceId: Long): Flow<List<SongDownload>>
|
||||||
|
|
||||||
@@ -161,7 +182,7 @@ interface DownloadDao {
|
|||||||
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
"SELECT d.sourceId AS sourceId, s.id AS songRow, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||||
"al.artistId AS albumArtistId, " +
|
"al.artistId AS albumArtistId, " +
|
||||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||||
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
"ar.id AS artistRow, ar.coverArt AS artistCoverArt, " +
|
||||||
|
|||||||
@@ -10,15 +10,21 @@ import androidx.room3.Transaction
|
|||||||
import androidx.room3.Upsert
|
import androidx.room3.Upsert
|
||||||
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.AlbumArtistRef
|
||||||
|
import com.subtracks.data.model.AlbumSearch
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.ArtistBio
|
import com.subtracks.data.model.ArtistBio
|
||||||
|
import com.subtracks.data.model.ArtistSearch
|
||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.model.PlaylistSearch
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongArtistRef
|
||||||
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -36,20 +42,6 @@ internal const val DOWNLOADED_SONG =
|
|||||||
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
|
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
|
||||||
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
|
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
|
||||||
|
|
||||||
internal const val ALBUMS_FILTER =
|
|
||||||
"FROM albums WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
|
||||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
|
|
||||||
"AND (:search = '' " +
|
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
|
||||||
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
|
||||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
|
||||||
|
|
||||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||||
@@ -67,13 +59,7 @@ internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sort
|
|||||||
|
|
||||||
private const val ALBUM_NAME_SORT_KEY_MODE =
|
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
private const val ALBUM_ARTIST_SORT_KEY_MODE =
|
|
||||||
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
|
|
||||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
|
||||||
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
|
|
||||||
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
|
|
||||||
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
|
||||||
|
|
||||||
internal const val ARTIST_CREDITED =
|
internal const val ARTIST_CREDITED =
|
||||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||||
@@ -98,134 +84,18 @@ internal const val CREDITED_SONG_ROWS =
|
|||||||
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||||
"WHERE album_artists.sourceId = :sourceId"
|
"WHERE album_artists.sourceId = :sourceId"
|
||||||
|
|
||||||
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
|
internal val ALBUMS_FOR_ARTIST =
|
||||||
internal const val CREDITED_ALBUM_ROWS =
|
|
||||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
|
||||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
|
||||||
"FROM album_artists JOIN albums " +
|
|
||||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM song_artists JOIN songs " +
|
|
||||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId"
|
|
||||||
|
|
||||||
internal const val ALBUMS_FOR_ARTIST =
|
|
||||||
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
||||||
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||||
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||||
|
|
||||||
internal const val ALBUM_CREDIT_ALBUM_IDS =
|
internal val ALBUM_CREDIT_ALBUM_IDS =
|
||||||
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||||
"WHERE artistId = :artistId AND creditType = 'album'"
|
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||||
|
|
||||||
// Download visibility follows the credit path: an album credit includes the album when any song is
|
internal val DOWNLOADED_ARTIST_ALBUMS =
|
||||||
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
|
||||||
internal const val DOWNLOADED_ALBUM_ROWS =
|
|
||||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
|
||||||
|
|
||||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
|
||||||
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
||||||
|
|
||||||
private const val DOWNLOADED_COVER_ALBUM_ROWS =
|
|
||||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
|
||||||
"AND sd.status = 'Completed' " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
|
|
||||||
|
|
||||||
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
|
|
||||||
private const val ARTIST_COVER_CREDITS =
|
|
||||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
|
||||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
|
||||||
"AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
|
||||||
"FROM album_artists JOIN albums " +
|
|
||||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM song_artists JOIN songs " +
|
|
||||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
|
|
||||||
|
|
||||||
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
|
|
||||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
|
||||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
|
||||||
"AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
|
||||||
"FROM album_artists JOIN albums " +
|
|
||||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
|
||||||
"AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
|
|
||||||
"UNION ALL " +
|
|
||||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
|
||||||
"FROM song_artists JOIN songs " +
|
|
||||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
|
||||||
"AND albums.coverArt IS NOT NULL"
|
|
||||||
|
|
||||||
private const val ARTIST_COVER_RANKS_TAIL =
|
private const val ARTIST_COVER_RANKS_TAIL =
|
||||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||||
@@ -236,19 +106,19 @@ private const val ARTIST_COVER_RANKS_TAIL =
|
|||||||
"deduped AS (SELECT artistId, coverArt, " +
|
"deduped AS (SELECT artistId, coverArt, " +
|
||||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||||
|
|
||||||
private const val ARTIST_COVER_RANKS =
|
private val ARTIST_COVER_RANKS =
|
||||||
"credits AS (SELECT artistId, albumId, " +
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||||
ARTIST_COVER_RANKS_TAIL
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
|
private val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||||
"credits AS (SELECT artistId, albumId, " +
|
"credits AS (SELECT artistId, albumId, " +
|
||||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||||
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||||
ARTIST_COVER_RANKS_TAIL
|
ARTIST_COVER_RANKS_TAIL
|
||||||
|
|
||||||
internal const val ARTIST_ART_UPDATE_SQL =
|
internal val ARTIST_ART_UPDATE_SQL =
|
||||||
"WITH $ARTIST_COVER_RANKS, " +
|
"WITH $ARTIST_COVER_RANKS, " +
|
||||||
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||||
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||||
@@ -274,11 +144,11 @@ internal const val ARTIST_ART_UPDATE_SQL =
|
|||||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||||
|
|
||||||
internal const val ARTIST_COVER_ARTS_SQL =
|
internal val ARTIST_COVER_ARTS_SQL =
|
||||||
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||||
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||||
|
|
||||||
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
internal val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||||
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||||
"WHERE artistId IN (:artistIds)), " +
|
"WHERE artistId IN (:artistIds)), " +
|
||||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||||
@@ -300,33 +170,13 @@ internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
|||||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||||
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER =
|
internal const val ARTIST_COLUMNS_TAIL =
|
||||||
"FROM artists WHERE sourceId = :sourceId " +
|
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, " +
|
||||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
"artists.downloadedCoverArts, artists.playCount, artists.played, artists.songCount, " +
|
||||||
"AND $ARTIST_CREDITED " +
|
"artists.sortName, artists.credited"
|
||||||
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
|
||||||
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
|
||||||
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
|
||||||
"AND (:search = '' " +
|
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
|
||||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
|
||||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
|
||||||
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
|
||||||
|
|
||||||
internal const val ARTIST_COLUMNS =
|
internal const val ARTIST_COLUMNS =
|
||||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
"artists.sourceId, artists.id, artists.name, artists.albumCount, $ARTIST_COLUMNS_TAIL"
|
||||||
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
|
||||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
|
|
||||||
|
|
||||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
|
||||||
// an artist matches the albums its detail screen lists.
|
|
||||||
internal const val ARTISTS_SELECT =
|
|
||||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
|
||||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
|
||||||
") ELSE artists.albumCount END AS albumCount, " +
|
|
||||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
|
||||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
|
|
||||||
|
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
||||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||||
@@ -340,33 +190,11 @@ internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS L
|
|||||||
private const val ARTIST_NAME_SORT_KEY_MODE =
|
private const val ARTIST_NAME_SORT_KEY_MODE =
|
||||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
||||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
|
|
||||||
|
|
||||||
private const val SONG_NAME_SORT_KEY_MODE =
|
private const val SONG_NAME_SORT_KEY_MODE =
|
||||||
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
||||||
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
||||||
|
|
||||||
internal const val PLAYLISTS_FILTER =
|
|
||||||
"FROM playlists WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId " +
|
|
||||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')) " +
|
|
||||||
"AND (:search = '' " +
|
|
||||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
|
||||||
"WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
|
||||||
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
|
|
||||||
|
|
||||||
// With the Downloaded filter on, count the songs that actually have a download so the number under
|
|
||||||
// a playlist matches the songs its detail screen lists.
|
|
||||||
internal const val PLAYLISTS_SELECT =
|
|
||||||
"SELECT playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt, " +
|
|
||||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
|
||||||
"SELECT COUNT(DISTINCT ps.songId) FROM playlist_songs ps " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
|
||||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
|
||||||
") ELSE playlists.songCount END AS songCount, " +
|
|
||||||
"playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
|
|
||||||
|
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||||
internal const val PLAYLIST_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
|
internal const val PLAYLIST_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
|
||||||
@@ -394,6 +222,39 @@ internal const val SONG_SEARCH_FILTER =
|
|||||||
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
|
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
|
||||||
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))"
|
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))"
|
||||||
|
|
||||||
|
internal val ARTIST_AGGREGATES_SQL =
|
||||||
|
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||||
|
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||||
|
"totals AS (" +
|
||||||
|
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||||
|
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||||
|
"FROM credited GROUP BY artistId" +
|
||||||
|
"), album_totals AS (" +
|
||||||
|
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||||
|
"FROM credited_albums GROUP BY artistId" +
|
||||||
|
") " +
|
||||||
|
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||||
|
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||||
|
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||||
|
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||||
|
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||||
|
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||||
|
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||||
|
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||||
|
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||||
|
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||||
|
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||||
|
"WHERE artists.sourceId = :sourceId) c " +
|
||||||
|
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||||
|
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||||
|
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||||
|
"OR artists.credited != c.credited)"
|
||||||
|
|
||||||
|
internal val ALBUMS_FOR_ARTIST_DOWNLOADED =
|
||||||
|
"SELECT * FROM ($DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST) ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||||
interface LibraryDao {
|
interface LibraryDao {
|
||||||
@@ -426,54 +287,37 @@ interface LibraryDao {
|
|||||||
)
|
)
|
||||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||||
|
|
||||||
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
|
@RawQuery
|
||||||
// queries are separate because their visibility follows the credit path.
|
suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int
|
||||||
@Query(
|
|
||||||
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
|
||||||
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
|
||||||
"totals AS (" +
|
|
||||||
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
|
||||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
|
||||||
"FROM credited GROUP BY artistId" +
|
|
||||||
"), album_totals AS (" +
|
|
||||||
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
|
||||||
"FROM credited_albums GROUP BY artistId" +
|
|
||||||
") " +
|
|
||||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
|
||||||
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
|
||||||
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
|
||||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
|
||||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
|
||||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
|
||||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
|
||||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
|
||||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
|
||||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
|
||||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
|
||||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
|
||||||
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
|
||||||
"WHERE artists.sourceId = :sourceId) c " +
|
|
||||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
|
||||||
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
|
||||||
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
|
||||||
"OR artists.credited != c.credited)",
|
|
||||||
)
|
|
||||||
suspend fun recomputeArtistAggregates(sourceId: Long)
|
|
||||||
|
|
||||||
@Query(ARTIST_ART_UPDATE_SQL)
|
suspend fun recomputeArtistAggregates(sourceId: Long) {
|
||||||
suspend fun recomputeArtistArt(sourceId: Long)
|
recomputeArtistAggregates(rawQuery(ARTIST_AGGREGATES_SQL, mapOf("sourceId" to sourceId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@RawQuery
|
||||||
|
suspend fun recomputeArtistArt(query: RoomRawQuery): Int
|
||||||
|
|
||||||
|
suspend fun recomputeArtistArt(sourceId: Long) {
|
||||||
|
recomputeArtistArt(rawQuery(ARTIST_ART_UPDATE_SQL, mapOf("sourceId" to sourceId)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||||
|
fun artistCoverArts(query: RoomRawQuery): Flow<List<String>>
|
||||||
|
|
||||||
@Query(ARTIST_COVER_ARTS_SQL)
|
|
||||||
fun artistCoverArts(
|
fun artistCoverArts(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<String>>
|
): Flow<List<String>> = artistCoverArts(rawQuery(ARTIST_COVER_ARTS_SQL, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||||
|
|
||||||
|
@RawQuery
|
||||||
|
suspend fun recomputeDownloadedArtistArt(query: RoomRawQuery): Int
|
||||||
|
|
||||||
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
|
||||||
suspend fun recomputeDownloadedArtistArt(
|
suspend fun recomputeDownloadedArtistArt(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistIds: List<String>,
|
artistIds: List<String>,
|
||||||
)
|
) {
|
||||||
|
recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds))
|
||||||
|
}
|
||||||
|
|
||||||
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||||
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||||
@@ -698,7 +542,7 @@ interface LibraryDao {
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
id: String,
|
id: String,
|
||||||
starred: Long?,
|
starred: Long?,
|
||||||
)
|
): Int
|
||||||
|
|
||||||
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
|
@Query("SELECT id FROM artists WHERE sourceId = :sourceId")
|
||||||
suspend fun artistIds(sourceId: Long): List<String>
|
suspend fun artistIds(sourceId: Long): List<String>
|
||||||
@@ -785,7 +629,7 @@ interface LibraryDao {
|
|||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
position: Long,
|
position: Long,
|
||||||
)
|
): Int
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"DELETE FROM playlist_songs WHERE sourceId = :sourceId " +
|
"DELETE FROM playlist_songs WHERE sourceId = :sourceId " +
|
||||||
@@ -804,245 +648,27 @@ interface LibraryDao {
|
|||||||
@RawQuery(observedEntities = [Playlist::class])
|
@RawQuery(observedEntities = [Playlist::class])
|
||||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
|
// These observe every table the statements read; the sets match what Room inferred for the
|
||||||
fun albumsByName(
|
// old downloaded @Query methods, so download and library writes invalidate as before.
|
||||||
sourceId: Long,
|
@RawQuery(observedEntities = [Album::class, Song::class, SongDownload::class, AlbumSearch::class])
|
||||||
starredFilter: Int,
|
fun downloadedAlbumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
sortMode: String = "",
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
@RawQuery(
|
||||||
fun albumsByNameReversed(
|
observedEntities = [
|
||||||
sourceId: Long,
|
Album::class,
|
||||||
starredFilter: Int,
|
Song::class,
|
||||||
search: String,
|
SongDownload::class,
|
||||||
downloadedFilter: Int = 0,
|
AlbumArtistRef::class,
|
||||||
sortMode: String = "",
|
SongArtistRef::class,
|
||||||
): PagingSource<Int, Album>
|
Artist::class,
|
||||||
|
Source::class,
|
||||||
|
ArtistSearch::class,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fun downloadedArtistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
|
@RawQuery(observedEntities = [PlaylistSong::class, SongDownload::class, Playlist::class, PlaylistSearch::class])
|
||||||
fun albumsByArtist(
|
fun downloadedPlaylistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
sortMode: String = "",
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
|
|
||||||
fun albumsByArtistReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
sortMode: String = "",
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
|
||||||
fun albumsByYear(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
|
|
||||||
fun albumsByYearReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
|
|
||||||
fun albumsByRecentlyAdded(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
|
|
||||||
fun albumsByRecentlyAddedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
|
|
||||||
fun albumsByStarred(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
|
|
||||||
fun albumsByStarredReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT")
|
|
||||||
fun albumsByFrequent(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT_REVERSED")
|
|
||||||
fun albumsByFrequentReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT")
|
|
||||||
fun albumsByRecent(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT_REVERSED")
|
|
||||||
fun albumsByRecentReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Album>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE")
|
|
||||||
fun artistsByName(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
sortMode: String = "",
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
|
||||||
fun artistsByNameReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
sortMode: String = "",
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
|
||||||
fun artistsByAlbumCount(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
|
|
||||||
fun artistsByAlbumCountReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
|
|
||||||
fun artistsByStarred(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
|
|
||||||
fun artistsByStarredReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT")
|
|
||||||
fun artistsByFrequent(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT_REVERSED")
|
|
||||||
fun artistsByFrequentReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT")
|
|
||||||
fun artistsByRecent(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT_REVERSED")
|
|
||||||
fun artistsByRecentReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Artist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
|
|
||||||
fun playlistsByName(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
|
|
||||||
fun playlistsByNameReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
|
|
||||||
fun playlistsByAdded(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
|
|
||||||
fun playlistsByAddedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
|
|
||||||
fun playlistsByUpdated(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
|
|
||||||
fun playlistsByUpdatedReversed(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
downloadedFilter: Int = 0,
|
|
||||||
): PagingSource<Int, Playlist>
|
|
||||||
|
|
||||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
||||||
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||||
@@ -1276,17 +902,21 @@ interface LibraryDao {
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Flow<List<String>>
|
): Flow<List<String>>
|
||||||
|
|
||||||
@Query(ALBUMS_FOR_ARTIST)
|
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||||
|
fun albumsForArtist(query: RoomRawQuery): Flow<List<Album>>
|
||||||
|
|
||||||
fun albumsForArtist(
|
fun albumsForArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>> = albumsForArtist(rawQuery(ALBUMS_FOR_ARTIST, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||||
|
|
||||||
|
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||||
|
fun albumCreditAlbumIds(query: RoomRawQuery): Flow<List<String>>
|
||||||
|
|
||||||
@Query(ALBUM_CREDIT_ALBUM_IDS)
|
|
||||||
fun albumCreditAlbumIds(
|
fun albumCreditAlbumIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<String>>
|
): Flow<List<String>> = albumCreditAlbumIds(rawQuery(ALBUM_CREDIT_ALBUM_IDS, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||||
|
|
||||||
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||||
fun discs(
|
fun discs(
|
||||||
@@ -1323,36 +953,16 @@ interface LibraryDao {
|
|||||||
playlistId: String,
|
playlistId: String,
|
||||||
): PagingSource<Int, PlaylistSongItem>
|
): PagingSource<Int, PlaylistSongItem>
|
||||||
|
|
||||||
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
|
@RawQuery(
|
||||||
@Query(
|
observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class],
|
||||||
"SELECT * FROM (" +
|
|
||||||
"SELECT albums.* FROM albums " +
|
|
||||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM album_artists " +
|
|
||||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
|
||||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
|
||||||
"AND EXISTS (SELECT 1 FROM songs " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
|
||||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM song_artists " +
|
|
||||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
|
||||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
|
||||||
"UNION " +
|
|
||||||
"SELECT albums.* FROM songs " +
|
|
||||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
|
||||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
|
||||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
|
||||||
)
|
)
|
||||||
|
fun albumsForArtistDownloaded(query: RoomRawQuery): Flow<List<Album>>
|
||||||
|
|
||||||
fun albumsForArtistDownloaded(
|
fun albumsForArtistDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
artistId: String,
|
artistId: String,
|
||||||
): Flow<List<Album>>
|
): Flow<List<Album>> =
|
||||||
|
albumsForArtistDownloaded(
|
||||||
|
rawQuery(ALBUMS_FOR_ARTIST_DOWNLOADED, mapOf("sourceId" to sourceId, "artistId" to artistId)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,49 +7,161 @@ import com.subtracks.data.prefs.ArtistSort
|
|||||||
import com.subtracks.data.prefs.PlaylistSort
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.prefs.SortNameMode
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
|
|
||||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
|
||||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
// join, and their paging sources do not observe download writes (including progress updates).
|
||||||
internal const val ALBUMS_FILTER_BASE =
|
internal const val ALBUMS_FILTER = """
|
||||||
"FROM albums WHERE sourceId = ? " +
|
FROM albums WHERE sourceId = :sourceId
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"AND (? = '' " +
|
AND (:search = ''
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||||
"OR instr(lower(albumArtist), lower(?)) > 0 " +
|
OR instr(lower(albumArtist), lower(:search)) > 0
|
||||||
"OR instr(lower(sortName), lower(?)) > 0 " +
|
OR instr(lower(sortName), lower(:search)) > 0
|
||||||
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
|
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
|
||||||
|
"""
|
||||||
|
|
||||||
internal const val ARTISTS_FILTER_BASE =
|
internal const val ALBUMS_FILTER_DOWNLOADED = """
|
||||||
"FROM artists WHERE sourceId = ? " +
|
FROM albums WHERE sourceId = :sourceId
|
||||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"AND $ARTIST_CREDITED " +
|
AND EXISTS (SELECT 1 FROM songs
|
||||||
"AND (? = '' " +
|
JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')
|
||||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
AND (:search = ''
|
||||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||||
"OR instr(lower(sortName), lower(?)) > 0))) "
|
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||||
|
OR instr(lower(albumArtist), lower(:search)) > 0
|
||||||
|
OR instr(lower(sortName), lower(:search)) > 0
|
||||||
|
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
|
||||||
|
"""
|
||||||
|
|
||||||
internal const val PLAYLISTS_FILTER_BASE =
|
internal const val ARTISTS_FILTER = """
|
||||||
"FROM playlists WHERE sourceId = ? " +
|
FROM artists WHERE sourceId = :sourceId
|
||||||
"AND (? = '' " +
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
AND $ARTIST_CREDITED
|
||||||
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
AND (:search = ''
|
||||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
|
||||||
|
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||||
|
OR instr(lower(sortName), lower(:search)) > 0)))
|
||||||
|
"""
|
||||||
|
|
||||||
internal fun albumsDefaultQuery(
|
internal const val ARTISTS_FILTER_DOWNLOADED = """
|
||||||
|
FROM artists WHERE sourceId = :sourceId
|
||||||
|
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||||
|
AND $ARTIST_CREDITED
|
||||||
|
AND EXISTS (SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl
|
||||||
|
WHERE dl.id = artists.id AND dl.status = 'Completed')
|
||||||
|
AND (:search = ''
|
||||||
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
|
||||||
|
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||||
|
OR instr(lower(sortName), lower(:search)) > 0)))
|
||||||
|
"""
|
||||||
|
|
||||||
|
// The downloaded list counts the albums that actually have a download so the number under an artist
|
||||||
|
// matches the albums its detail screen lists.
|
||||||
|
internal val ARTIST_COLUMNS_DOWNLOADED = """
|
||||||
|
artists.sourceId, artists.id, artists.name,
|
||||||
|
(SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount,
|
||||||
|
$ARTIST_COLUMNS_TAIL
|
||||||
|
"""
|
||||||
|
|
||||||
|
internal const val PLAYLISTS_FILTER = """
|
||||||
|
FROM playlists WHERE sourceId = :sourceId
|
||||||
|
AND (:search = ''
|
||||||
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
|
||||||
|
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
|
||||||
|
"""
|
||||||
|
|
||||||
|
internal const val PLAYLISTS_FILTER_DOWNLOADED = """
|
||||||
|
FROM playlists WHERE sourceId = :sourceId
|
||||||
|
AND EXISTS (SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId
|
||||||
|
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')
|
||||||
|
AND (:search = ''
|
||||||
|
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
|
||||||
|
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||||
|
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
|
||||||
|
"""
|
||||||
|
|
||||||
|
// The downloaded list counts the songs that actually have a download so the number under a
|
||||||
|
// playlist matches the songs its detail screen lists.
|
||||||
|
internal const val PLAYLISTS_COLUMNS_DOWNLOADED = """
|
||||||
|
playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt,
|
||||||
|
(SELECT COUNT(DISTINCT ps.songId)
|
||||||
|
FROM playlist_songs ps
|
||||||
|
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed'
|
||||||
|
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id
|
||||||
|
) AS songCount,
|
||||||
|
playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared
|
||||||
|
"""
|
||||||
|
|
||||||
|
internal fun albumsPageQuery(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
sort: AlbumSort,
|
sort: AlbumSort,
|
||||||
descending: Boolean,
|
descending: Boolean,
|
||||||
|
downloaded: Boolean,
|
||||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
): RoomRawQuery {
|
): RoomRawQuery =
|
||||||
val order = albumOrder(sort, descending, sortNameMode)
|
rawQuery(
|
||||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
sql = "SELECT * ${if (downloaded) ALBUMS_FILTER_DOWNLOADED else ALBUMS_FILTER} ORDER BY ${albumOrder(
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
sort,
|
||||||
}
|
descending,
|
||||||
}
|
sortNameMode,
|
||||||
|
)}",
|
||||||
|
bindings =
|
||||||
|
mapOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"starredFilter" to starredFilter,
|
||||||
|
"search" to search,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun artistsPageQuery(
|
||||||
|
sourceId: Long,
|
||||||
|
starredFilter: Int,
|
||||||
|
search: String,
|
||||||
|
sort: ArtistSort,
|
||||||
|
descending: Boolean,
|
||||||
|
downloaded: Boolean,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
|
): RoomRawQuery =
|
||||||
|
rawQuery(
|
||||||
|
sql =
|
||||||
|
"SELECT ${if (downloaded) ARTIST_COLUMNS_DOWNLOADED else ARTIST_COLUMNS} " +
|
||||||
|
"${if (downloaded) ARTISTS_FILTER_DOWNLOADED else ARTISTS_FILTER} " +
|
||||||
|
"ORDER BY ${artistOrder(sort, descending, sortNameMode)}",
|
||||||
|
bindings =
|
||||||
|
mapOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"starredFilter" to starredFilter,
|
||||||
|
"search" to search,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun playlistsPageQuery(
|
||||||
|
sourceId: Long,
|
||||||
|
search: String,
|
||||||
|
sort: PlaylistSort,
|
||||||
|
descending: Boolean,
|
||||||
|
downloaded: Boolean,
|
||||||
|
): RoomRawQuery =
|
||||||
|
rawQuery(
|
||||||
|
sql =
|
||||||
|
"SELECT ${if (downloaded) PLAYLISTS_COLUMNS_DOWNLOADED else "*"} " +
|
||||||
|
"${if (downloaded) PLAYLISTS_FILTER_DOWNLOADED else PLAYLISTS_FILTER} " +
|
||||||
|
"ORDER BY ${playlistOrder(sort, descending)}",
|
||||||
|
bindings =
|
||||||
|
mapOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"search" to search,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
internal fun rediscoverAlbumsQuery(
|
internal fun rediscoverAlbumsQuery(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -57,70 +169,47 @@ internal fun rediscoverAlbumsQuery(
|
|||||||
search: String,
|
search: String,
|
||||||
cutoff: Long,
|
cutoff: Long,
|
||||||
): RoomRawQuery =
|
): RoomRawQuery =
|
||||||
RoomRawQuery(
|
rawQuery(
|
||||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
sql =
|
||||||
|
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
|
||||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||||
) { stmt ->
|
bindings =
|
||||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
mapOf(
|
||||||
stmt.bindLong(13, cutoff)
|
"sourceId" to sourceId,
|
||||||
|
"starredFilter" to starredFilter,
|
||||||
|
"search" to search,
|
||||||
|
"cutoff" to cutoff,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
|
||||||
|
// index, so the SQL can keep its names and only the first-appearance order needs binding.
|
||||||
|
internal fun rawQuery(
|
||||||
|
sql: String,
|
||||||
|
bindings: Map<String, Any?>,
|
||||||
|
): RoomRawQuery {
|
||||||
|
val values =
|
||||||
|
Regex(":(\\w+)")
|
||||||
|
.findAll(sql)
|
||||||
|
.map { it.groupValues[1] }
|
||||||
|
.distinct()
|
||||||
|
.map(bindings::getValue)
|
||||||
|
.toList()
|
||||||
|
return RoomRawQuery(sql) { statement ->
|
||||||
|
values.forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun SQLiteStatement.bindAlbumFilters(
|
private fun SQLiteStatement.bind(
|
||||||
sourceId: Long,
|
index: Int,
|
||||||
starredFilter: Int,
|
value: Any?,
|
||||||
search: String,
|
|
||||||
) {
|
) {
|
||||||
bindLong(1, sourceId)
|
when (value) {
|
||||||
bindInt(2, starredFilter)
|
null -> bindNull(index)
|
||||||
bindInt(3, starredFilter)
|
is Int -> bindLong(index, value.toLong())
|
||||||
bindInt(4, starredFilter)
|
is Long -> bindLong(index, value)
|
||||||
bindText(5, search)
|
is String -> bindText(index, value)
|
||||||
bindText(6, search)
|
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
|
||||||
bindText(7, search)
|
|
||||||
bindText(8, search)
|
|
||||||
bindText(9, search)
|
|
||||||
bindText(10, search)
|
|
||||||
bindText(11, search)
|
|
||||||
bindText(12, search)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun artistsDefaultQuery(
|
|
||||||
sourceId: Long,
|
|
||||||
starredFilter: Int,
|
|
||||||
search: String,
|
|
||||||
sort: ArtistSort,
|
|
||||||
descending: Boolean,
|
|
||||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
|
||||||
): RoomRawQuery {
|
|
||||||
val order = artistOrder(sort, descending, sortNameMode)
|
|
||||||
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
|
||||||
stmt.bindLong(1, sourceId)
|
|
||||||
stmt.bindInt(2, starredFilter)
|
|
||||||
stmt.bindInt(3, starredFilter)
|
|
||||||
stmt.bindInt(4, starredFilter)
|
|
||||||
stmt.bindText(5, search)
|
|
||||||
stmt.bindText(6, search)
|
|
||||||
stmt.bindText(7, search)
|
|
||||||
stmt.bindText(8, search)
|
|
||||||
stmt.bindText(9, search)
|
|
||||||
stmt.bindText(10, search)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun playlistsDefaultQuery(
|
|
||||||
sourceId: Long,
|
|
||||||
search: String,
|
|
||||||
sort: PlaylistSort,
|
|
||||||
descending: Boolean,
|
|
||||||
): RoomRawQuery {
|
|
||||||
val order = playlistOrder(sort, descending)
|
|
||||||
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
|
||||||
stmt.bindLong(1, sourceId)
|
|
||||||
stmt.bindText(2, search)
|
|
||||||
stmt.bindText(3, search)
|
|
||||||
stmt.bindText(4, search)
|
|
||||||
stmt.bindText(5, search)
|
|
||||||
stmt.bindText(6, search)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -730,6 +730,37 @@ val MIGRATION_37_38 =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val MIGRATION_38_39 =
|
||||||
|
object : Migration(38, 39) {
|
||||||
|
override suspend fun migrate(connection: SQLiteConnection) {
|
||||||
|
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originKind` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originRefId` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originOccurrence` INTEGER")
|
||||||
|
connection.execSQL("ALTER TABLE `queue_entries` ADD COLUMN `originEntryId` INTEGER")
|
||||||
|
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originKind` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originRefId` TEXT")
|
||||||
|
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originOccurrence` INTEGER")
|
||||||
|
connection.execSQL("ALTER TABLE `up_next_entries` ADD COLUMN `originEntryId` INTEGER")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `pending_sync_origins` (`sourceId` INTEGER NOT NULL, `kind` TEXT NOT NULL, " +
|
||||||
|
"`refId` TEXT NOT NULL, PRIMARY KEY(`sourceId`, `kind`, `refId`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_pending_sync_origins_sourceId` ON `pending_sync_origins` (`sourceId`)")
|
||||||
|
connection.execSQL(
|
||||||
|
"CREATE TABLE `song_downloads_new` (`sourceId` INTEGER NOT NULL, `songId` TEXT NOT NULL, `status` TEXT NOT NULL, " +
|
||||||
|
"`engineId` INTEGER, `bytes` INTEGER NOT NULL DEFAULT 0, `total` INTEGER NOT NULL DEFAULT 0, `error` TEXT, " +
|
||||||
|
"`downloadedAt` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`sourceId`, `songId`), " +
|
||||||
|
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
)
|
||||||
|
connection.execSQL("INSERT INTO `song_downloads_new` SELECT * FROM `song_downloads`")
|
||||||
|
connection.execSQL("DROP TABLE `song_downloads`")
|
||||||
|
connection.execSQL("ALTER TABLE `song_downloads_new` RENAME TO `song_downloads`")
|
||||||
|
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_sourceId` ON `song_downloads` (`sourceId`)")
|
||||||
|
connection.execSQL("CREATE INDEX IF NOT EXISTS `index_song_downloads_engineId` ON `song_downloads` (`engineId`)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
internal fun artistSearchTriggersOptimized(connection: SQLiteConnection): Boolean {
|
||||||
val triggers = mutableListOf<String>()
|
val triggers = mutableListOf<String>()
|
||||||
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name IN (?, ?)").use { statement ->
|
||||||
@@ -808,4 +839,5 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_35_36,
|
MIGRATION_35_36,
|
||||||
MIGRATION_36_37,
|
MIGRATION_36_37,
|
||||||
MIGRATION_37_38,
|
MIGRATION_37_38,
|
||||||
|
MIGRATION_38_39,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.room3.Insert
|
|||||||
import androidx.room3.OnConflictStrategy
|
import androidx.room3.OnConflictStrategy
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.PendingSyncOrigin
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
import com.subtracks.data.model.QueueEntry
|
import com.subtracks.data.model.QueueEntry
|
||||||
@@ -22,11 +23,13 @@ private const val ALBUM_SONGS_SQL = ALBUM_SONGS_SELECT + ALBUM_SONGS_ORDER
|
|||||||
private const val ARTIST_SONG_IDS =
|
private const val ARTIST_SONG_IDS =
|
||||||
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
"(SELECT songId FROM ($CREDITED_SONG_ROWS) WHERE artistId = :artistId)"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_SELECT =
|
private const val ARTIST_SONGS_FROM =
|
||||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
"FROM songs " +
|
||||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
"WHERE songs.sourceId = :sourceId AND songs.id IN $ARTIST_SONG_IDS"
|
||||||
|
|
||||||
|
private const val ARTIST_SONGS_SELECT = "SELECT songs.*, albums.coverArt AS coverArt $ARTIST_SONGS_FROM"
|
||||||
|
|
||||||
private const val ARTIST_SONGS_ORDER =
|
private const val ARTIST_SONGS_ORDER =
|
||||||
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
" ORDER BY albums.id IS NULL, albums.year DESC, albums.sortName COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id"
|
||||||
|
|
||||||
@@ -37,6 +40,12 @@ private const val SONG_SQL =
|
|||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface QueueDao {
|
interface QueueDao {
|
||||||
|
@Query("SELECT * FROM pending_sync_origins WHERE sourceId = :sourceId")
|
||||||
|
suspend fun pendingSyncOrigins(sourceId: Long): List<PendingSyncOrigin>
|
||||||
|
|
||||||
|
@Query("DELETE FROM pending_sync_origins WHERE sourceId = :sourceId")
|
||||||
|
suspend fun clearPendingSyncOrigins(sourceId: Long)
|
||||||
|
|
||||||
@Query("DELETE FROM queue_entries")
|
@Query("DELETE FROM queue_entries")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|
||||||
@@ -174,6 +183,17 @@ interface QueueDao {
|
|||||||
limit: Int,
|
limit: Int,
|
||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT songRank FROM (" +
|
||||||
|
"SELECT songs.id AS songId, ROW_NUMBER() OVER ($ARTIST_SONGS_ORDER) - 1 AS songRank $ARTIST_SONGS_FROM" +
|
||||||
|
") WHERE songId = :songId ORDER BY songRank LIMIT 1",
|
||||||
|
)
|
||||||
|
suspend fun artistRank(
|
||||||
|
sourceId: Long,
|
||||||
|
artistId: String,
|
||||||
|
songId: String,
|
||||||
|
): Long?
|
||||||
|
|
||||||
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
@Query("$SONG_SQL LIMIT :limit OFFSET :offset")
|
||||||
suspend fun song(
|
suspend fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -214,14 +234,72 @@ 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 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 playlist_songs.songId = :songId AND playlist_songs.position < :position",
|
||||||
|
)
|
||||||
|
suspend fun playlistOccurrenceRank(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
songId: String,
|
||||||
|
position: Long,
|
||||||
|
): Long
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT playlist_songs.position 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 playlist_songs.songId = :songId ORDER BY playlist_songs.position LIMIT 1 OFFSET :occurrence",
|
||||||
|
)
|
||||||
|
suspend fun playlistPositionForOccurrence(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
songId: String,
|
||||||
|
occurrence: Long,
|
||||||
|
): Long?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"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 playlist_songs.position <= :position",
|
||||||
|
)
|
||||||
|
suspend fun playlistRankAtPosition(
|
||||||
|
sourceId: Long,
|
||||||
|
playlistId: String,
|
||||||
|
position: Long,
|
||||||
|
): 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(
|
||||||
@@ -244,17 +322,24 @@ interface QueueDao {
|
|||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM song_genres " +
|
"SELECT songRank FROM (" +
|
||||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
"SELECT songs.id AS songId, " +
|
||||||
|
"ROW_NUMBER() OVER (ORDER BY songs.sortName COLLATE NOCASE, songs.id) - 1 AS songRank " +
|
||||||
|
"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" +
|
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre" +
|
||||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
") WHERE songId = :songId ORDER BY songRank LIMIT 1",
|
||||||
)
|
)
|
||||||
suspend fun genreSongIds(
|
suspend fun genreRank(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
genre: String,
|
genre: String,
|
||||||
): List<String>
|
songId: String,
|
||||||
|
): Long?
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
@Query(
|
||||||
|
"SELECT COUNT(*) FROM song_downloads d " +
|
||||||
|
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed'",
|
||||||
|
)
|
||||||
suspend fun downloadedLength(sourceId: Long): Long
|
suspend fun downloadedLength(sourceId: Long): Long
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL")
|
@Query("SELECT COUNT(*) FROM songs WHERE sourceId = :sourceId AND starred IS NOT NULL")
|
||||||
@@ -272,12 +357,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 " +
|
||||||
@@ -292,10 +371,27 @@ interface QueueDao {
|
|||||||
): List<AlbumSongItem>
|
): List<AlbumSongItem>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT songs.id FROM song_downloads d " +
|
"SELECT songRank FROM (" +
|
||||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
"SELECT songs.id AS songId, " +
|
||||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
"ROW_NUMBER() OVER (ORDER BY songs.starred DESC, songs.sortName COLLATE NOCASE, songs.id) - 1 AS songRank " +
|
||||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
"FROM songs WHERE songs.sourceId = :sourceId AND songs.starred IS NOT NULL" +
|
||||||
|
") WHERE songId = :songId ORDER BY songRank LIMIT 1",
|
||||||
)
|
)
|
||||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
suspend fun starredRank(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
): Long?
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"SELECT songRank FROM (" +
|
||||||
|
"SELECT songs.id AS songId, " +
|
||||||
|
"ROW_NUMBER() OVER (ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id) - 1 AS songRank " +
|
||||||
|
"FROM song_downloads d JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||||
|
"WHERE d.sourceId = :sourceId AND d.status = 'Completed'" +
|
||||||
|
") WHERE songId = :songId ORDER BY songRank LIMIT 1",
|
||||||
|
)
|
||||||
|
suspend fun downloadedRank(
|
||||||
|
sourceId: Long,
|
||||||
|
songId: String,
|
||||||
|
): Long?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.subtracks.data.model.DownloadErrorConverter
|
|||||||
import com.subtracks.data.model.DownloadStatusConverter
|
import com.subtracks.data.model.DownloadStatusConverter
|
||||||
import com.subtracks.data.model.PendingAction
|
import com.subtracks.data.model.PendingAction
|
||||||
import com.subtracks.data.model.PendingActionKindConverter
|
import com.subtracks.data.model.PendingActionKindConverter
|
||||||
|
import com.subtracks.data.model.PendingSyncOrigin
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSearch
|
import com.subtracks.data.model.PlaylistSearch
|
||||||
@@ -56,8 +57,9 @@ import com.subtracks.data.model.UpNextEntry
|
|||||||
ArtworkSeed::class,
|
ArtworkSeed::class,
|
||||||
SongDownload::class,
|
SongDownload::class,
|
||||||
PendingAction::class,
|
PendingAction::class,
|
||||||
|
PendingSyncOrigin::class,
|
||||||
],
|
],
|
||||||
version = 38,
|
version = 39,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(
|
@ColumnTypeConverters(
|
||||||
|
|||||||
@@ -38,12 +38,6 @@ enum class DownloadError(
|
|||||||
childColumns = ["sourceId"],
|
childColumns = ["sourceId"],
|
||||||
onDelete = ForeignKey.CASCADE,
|
onDelete = ForeignKey.CASCADE,
|
||||||
),
|
),
|
||||||
ForeignKey(
|
|
||||||
entity = Song::class,
|
|
||||||
parentColumns = ["sourceId", "id"],
|
|
||||||
childColumns = ["sourceId", "songId"],
|
|
||||||
onDelete = ForeignKey.CASCADE,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
indices = [Index("sourceId"), Index("engineId")],
|
indices = [Index("sourceId"), Index("engineId")],
|
||||||
)
|
)
|
||||||
@@ -109,6 +103,7 @@ data class DownloadedSong(
|
|||||||
|
|
||||||
data class DownloadArtwork(
|
data class DownloadArtwork(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
|
val songRow: String?,
|
||||||
val albumId: String?,
|
val albumId: String?,
|
||||||
val artistId: String?,
|
val artistId: String?,
|
||||||
val albumArtistId: String?,
|
val albumArtistId: String?,
|
||||||
@@ -119,7 +114,7 @@ data class DownloadArtwork(
|
|||||||
val albumArtistCoverArt: String?,
|
val albumArtistCoverArt: String?,
|
||||||
) {
|
) {
|
||||||
val hasMissingLibraryRow: Boolean
|
val hasMissingLibraryRow: Boolean
|
||||||
get() = (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
get() = songRow == null || (albumId != null && albumRow == null) || (artistId != null && artistRow == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SourceCoverArt(
|
data class SourceCoverArt(
|
||||||
|
|||||||
@@ -448,6 +448,31 @@ data class DiscKey(
|
|||||||
|
|
||||||
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred }
|
enum class QueueKind { Playlist, Album, Song, Artist, Genre, Downloaded, Starred }
|
||||||
|
|
||||||
|
data class QueueOrigin(
|
||||||
|
val kind: QueueKind,
|
||||||
|
val sourceId: Long,
|
||||||
|
val refId: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "pending_sync_origins",
|
||||||
|
primaryKeys = ["sourceId", "kind", "refId"],
|
||||||
|
foreignKeys = [
|
||||||
|
ForeignKey(
|
||||||
|
entity = Source::class,
|
||||||
|
parentColumns = ["id"],
|
||||||
|
childColumns = ["sourceId"],
|
||||||
|
onDelete = ForeignKey.CASCADE,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
indices = [Index("sourceId")],
|
||||||
|
)
|
||||||
|
data class PendingSyncOrigin(
|
||||||
|
val sourceId: Long,
|
||||||
|
val kind: QueueKind,
|
||||||
|
val refId: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "queue_entries",
|
tableName = "queue_entries",
|
||||||
foreignKeys = [
|
foreignKeys = [
|
||||||
@@ -468,10 +493,16 @@ data class QueueEntry(
|
|||||||
val refId: String,
|
val refId: String,
|
||||||
val rangeStart: Long? = null,
|
val rangeStart: Long? = null,
|
||||||
val rangeEnd: Long? = null,
|
val rangeEnd: Long? = null,
|
||||||
|
val originKind: QueueKind? = null,
|
||||||
|
val originRefId: String? = null,
|
||||||
|
val originOccurrence: Long? = null,
|
||||||
|
val originEntryId: Long? = null,
|
||||||
) {
|
) {
|
||||||
val offset: Long get() = rangeStart ?: 0
|
val offset: Long get() = rangeStart ?: 0
|
||||||
|
|
||||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||||
|
|
||||||
|
val origin: QueueOrigin get() = QueueOrigin(originKind ?: kind, sourceId, originRefId ?: refId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
@@ -494,16 +525,45 @@ data class UpNextEntry(
|
|||||||
val refId: String,
|
val refId: String,
|
||||||
val rangeStart: Long? = null,
|
val rangeStart: Long? = null,
|
||||||
val rangeEnd: Long? = null,
|
val rangeEnd: Long? = null,
|
||||||
|
val originKind: QueueKind? = null,
|
||||||
|
val originRefId: String? = null,
|
||||||
|
val originOccurrence: Long? = null,
|
||||||
|
val originEntryId: Long? = null,
|
||||||
) {
|
) {
|
||||||
val offset: Long get() = rangeStart ?: 0
|
val offset: Long get() = rangeStart ?: 0
|
||||||
|
|
||||||
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
val count: Long? get() = rangeEnd?.let { (it - offset + 1).coerceAtLeast(0) }
|
||||||
|
|
||||||
fun toQueueEntry(): QueueEntry = QueueEntry(id, position, sourceId, kind, refId, rangeStart, rangeEnd)
|
fun toQueueEntry(): QueueEntry =
|
||||||
|
QueueEntry(
|
||||||
|
id,
|
||||||
|
position,
|
||||||
|
sourceId,
|
||||||
|
kind,
|
||||||
|
refId,
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
originKind,
|
||||||
|
originRefId,
|
||||||
|
originOccurrence,
|
||||||
|
originEntryId,
|
||||||
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun from(entry: QueueEntry) =
|
fun from(entry: QueueEntry) =
|
||||||
UpNextEntry(entry.id, entry.position, entry.sourceId, entry.kind, entry.refId, entry.rangeStart, entry.rangeEnd)
|
UpNextEntry(
|
||||||
|
entry.id,
|
||||||
|
entry.position,
|
||||||
|
entry.sourceId,
|
||||||
|
entry.kind,
|
||||||
|
entry.refId,
|
||||||
|
entry.rangeStart,
|
||||||
|
entry.rangeEnd,
|
||||||
|
entry.originKind,
|
||||||
|
entry.originRefId,
|
||||||
|
entry.originOccurrence,
|
||||||
|
entry.originEntryId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ const val MAX_REPLAY_GAIN_PREAMP_DB = 15f
|
|||||||
|
|
||||||
enum class SyncMode { Off, WifiOnly, Always }
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
|
enum class SongTapBehavior { InOrder, KeepShuffle, Shuffle }
|
||||||
|
|
||||||
enum class ReplayGainMode { Off, Track, Album, Auto }
|
enum class ReplayGainMode { Off, Track, Album, Auto }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
@@ -129,6 +131,19 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val syncModeKey = stringPreferencesKey("sync_mode")
|
private val syncModeKey = stringPreferencesKey("sync_mode")
|
||||||
|
|
||||||
|
fun songTapBehavior(): Flow<SongTapBehavior> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[songTapBehaviorKey]?.let { stored ->
|
||||||
|
SongTapBehavior.entries.firstOrNull { it.name == stored }
|
||||||
|
} ?: SongTapBehavior.InOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSongTapBehavior(behavior: SongTapBehavior) {
|
||||||
|
store.edit { prefs -> prefs[songTapBehaviorKey] = behavior.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val songTapBehaviorKey = stringPreferencesKey("song_tap_behavior")
|
||||||
|
|
||||||
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
||||||
|
|
||||||
suspend fun setLastSyncAt(value: Long) {
|
suspend fun setLastSyncAt(value: Long) {
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import com.subtracks.data.model.DownloadList
|
|||||||
import com.subtracks.data.model.DownloadStatus
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.DownloadedSong
|
import com.subtracks.data.model.DownloadedSong
|
||||||
import com.subtracks.data.model.ListDownloadStatus
|
import com.subtracks.data.model.ListDownloadStatus
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.artistImageKey
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
|
import com.subtracks.data.repo.QueueResetPoint
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
@@ -48,6 +51,19 @@ import kotlinx.coroutines.withTimeoutOrNull
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
private fun SongDownload.sameResetRelevantState(other: SongDownload): Boolean =
|
||||||
|
sourceId == other.sourceId && songId == other.songId && status == other.status && engineId == other.engineId &&
|
||||||
|
total == other.total && error == other.error && downloadedAt == other.downloadedAt
|
||||||
|
|
||||||
|
private fun sameResetRelevantEmission(
|
||||||
|
previous: Pair<Long?, Map<String, SongDownload>>?,
|
||||||
|
sourceId: Long?,
|
||||||
|
rows: Map<String, SongDownload>,
|
||||||
|
): Boolean {
|
||||||
|
if (previous == null || previous.first != sourceId || previous.second.size != rows.size) return false
|
||||||
|
return previous.second.all { (songId, row) -> rows[songId]?.let { row.sameResetRelevantState(it) } == true }
|
||||||
|
}
|
||||||
|
|
||||||
class DownloadRepository(
|
class DownloadRepository(
|
||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
@@ -55,6 +71,7 @@ class DownloadRepository(
|
|||||||
private val downloadsDir: File,
|
private val downloadsDir: File,
|
||||||
private val artworkStore: ArtworkStore,
|
private val artworkStore: ArtworkStore,
|
||||||
private val artworkFetcher: ArtworkFetcher,
|
private val artworkFetcher: ArtworkFetcher,
|
||||||
|
private val queueRepository: QueueRepository,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher),
|
||||||
@@ -67,6 +84,10 @@ class DownloadRepository(
|
|||||||
private var started = false
|
private var started = false
|
||||||
|
|
||||||
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
private val statesFlow = MutableStateFlow<Map<String, SongDownload>>(emptyMap())
|
||||||
|
private val completedBySource = mutableMapOf<Long, List<Pair<String, Long>>>()
|
||||||
|
private val completedRowsBySource = mutableMapOf<Long, Map<String, Long>>()
|
||||||
|
private val completedBaselineMutex = Mutex()
|
||||||
|
private val pendingResetPoints = mutableMapOf<Long, QueueResetPoint>()
|
||||||
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
private val attemptedBios = ConcurrentHashMap.newKeySet<Pair<Long, String>>()
|
||||||
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
private val failedArtistImages = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
@@ -76,9 +97,66 @@ class DownloadRepository(
|
|||||||
downloadsDir.mkdirs()
|
downloadsDir.mkdirs()
|
||||||
File(downloadsDir, NO_MEDIA).createNewFile()
|
File(downloadsDir, NO_MEDIA).createNewFile()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
downloadStates().collect { rows ->
|
var resetPoint: QueueResetPoint? = null
|
||||||
statesFlow.value = rows
|
var reconciled: Pair<Long?, Map<String, SongDownload>>? = null
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
if (resetPoint == null) resetPoint = queueRepository.captureResetPoint()
|
||||||
|
downloadStates().collect { (sourceId, emittedRows) ->
|
||||||
|
statesFlow.value = emittedRows
|
||||||
|
val unchanged = sameResetRelevantEmission(reconciled, sourceId, emittedRows)
|
||||||
|
val pending =
|
||||||
|
unchanged && sourceId != null &&
|
||||||
|
completedBaselineMutex.withLock { sourceId in pendingResetPoints }
|
||||||
|
if (unchanged && !pending) {
|
||||||
loaded.complete(Unit)
|
loaded.complete(Unit)
|
||||||
|
return@collect
|
||||||
|
}
|
||||||
|
var rows = emittedRows
|
||||||
|
if (sourceId != null) {
|
||||||
|
mutex.withLock {
|
||||||
|
rows = db.downloadDao().downloadsOnce(sourceId).associateBy { it.songId }
|
||||||
|
statesFlow.value = rows
|
||||||
|
val completed = completedDownloads(sourceId)
|
||||||
|
val completedRows = completedDownloadKeys(rows)
|
||||||
|
completedBaselineMutex.withLock {
|
||||||
|
val previous = completedBySource[sourceId]
|
||||||
|
val previousRows = completedRowsBySource[sourceId]
|
||||||
|
val pending = pendingResetPoints[sourceId]
|
||||||
|
when {
|
||||||
|
pending != null -> {
|
||||||
|
if (previous == null || completed != previous) {
|
||||||
|
queueRepository.resetChangedOrigins(
|
||||||
|
setOf(QueueOrigin(QueueKind.Downloaded, sourceId, "")),
|
||||||
|
pending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pendingResetPoints.remove(sourceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
previous != null && completed != previous && completedRows != previousRows -> {
|
||||||
|
queueRepository.resetChangedOrigins(
|
||||||
|
setOf(QueueOrigin(QueueKind.Downloaded, sourceId, "")),
|
||||||
|
resetPoint!!,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completedBySource[sourceId] = completed
|
||||||
|
completedRowsBySource[sourceId] = completedRows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetPoint = queueRepository.captureResetPoint()
|
||||||
|
reconciled = sourceId to rows
|
||||||
|
loaded.complete(Unit)
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("download", "state reconciliation failed; retrying", failure)
|
||||||
|
loaded.complete(Unit)
|
||||||
|
delay(POLL_MS)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -96,6 +174,47 @@ class DownloadRepository(
|
|||||||
|
|
||||||
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
|
fun states(): StateFlow<Map<String, SongDownload>> = statesFlow
|
||||||
|
|
||||||
|
internal suspend fun captureForReset(sourceId: Long) =
|
||||||
|
completedBaselineMutex.withLock {
|
||||||
|
if (sourceId !in pendingResetPoints) pendingResetPoints[sourceId] = queueRepository.captureResetPoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun captureForReset(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
) {
|
||||||
|
if (songIds.isEmpty()) return
|
||||||
|
val queueVisible =
|
||||||
|
songIds.asSequence().chunked(DELETE_CHUNK).any { ids ->
|
||||||
|
db.downloadDao().queueVisibleSongIds(sourceId, ids).isNotEmpty()
|
||||||
|
}
|
||||||
|
if (queueVisible) {
|
||||||
|
captureForReset(sourceId)
|
||||||
|
} else {
|
||||||
|
completedBaselineMutex.withLock {
|
||||||
|
if (sourceId !in pendingResetPoints) completedBySource[sourceId] = completedDownloads(sourceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureCompletedBaselines(rows: List<SongDownload>) =
|
||||||
|
completedBaselineMutex.withLock {
|
||||||
|
rows.groupBy { it.sourceId }.forEach { (sourceId, sourceRows) ->
|
||||||
|
if (sourceId !in completedBySource && sourceId !in pendingResetPoints) {
|
||||||
|
completedBySource[sourceId] = completedDownloads(sourceId)
|
||||||
|
completedRowsBySource[sourceId] = completedDownloadKeys(sourceRows.associateBy { it.songId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun completedDownloads(sourceId: Long): List<Pair<String, Long>> =
|
||||||
|
db.downloadDao().completedDownloads(sourceId).map { it.songId to it.downloadedAt }
|
||||||
|
|
||||||
|
private fun completedDownloadKeys(rows: Map<String, SongDownload>): Map<String, Long> =
|
||||||
|
rows.values
|
||||||
|
.filter { it.status == DownloadStatus.Completed }
|
||||||
|
.associate { it.songId to it.downloadedAt }
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
fun activeDownloads(): Flow<List<SongDownload>> =
|
fun activeDownloads(): Flow<List<SongDownload>> =
|
||||||
sourceRepository.activeSourceId().flatMapLatest { sourceId ->
|
sourceRepository.activeSourceId().flatMapLatest { sourceId ->
|
||||||
@@ -207,6 +326,16 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun remove(
|
||||||
|
sourceId: Long,
|
||||||
|
songIds: Collection<String>,
|
||||||
|
) = withContext(dispatcher) {
|
||||||
|
val ids = songIds.toHashSet()
|
||||||
|
mutex.withLock {
|
||||||
|
removeRows(db.downloadDao().all().filter { it.sourceId == sourceId && it.songId in ids })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun cancel(
|
suspend fun cancel(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songIds: Collection<String>,
|
songIds: Collection<String>,
|
||||||
@@ -351,6 +480,7 @@ class DownloadRepository(
|
|||||||
val engineIds = rows.mapNotNull { it.engineId }
|
val engineIds = rows.mapNotNull { it.engineId }
|
||||||
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
||||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||||
|
captureForReset(sourceId, group.filter { it.status == DownloadStatus.Completed }.map { it.songId })
|
||||||
group.forEach { file(sourceId, it.songId).delete() }
|
group.forEach { file(sourceId, it.songId).delete() }
|
||||||
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
group.map { it.songId }.chunked(DELETE_CHUNK).forEach { db.downloadDao().deleteSongs(sourceId, it) }
|
||||||
if (group.any { it.status == DownloadStatus.Completed }) {
|
if (group.any { it.status == DownloadStatus.Completed }) {
|
||||||
@@ -371,12 +501,9 @@ class DownloadRepository(
|
|||||||
suspend fun removeSource(sourceId: Long) =
|
suspend fun removeSource(sourceId: Long) =
|
||||||
withContext(dispatcher) {
|
withContext(dispatcher) {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
db
|
val rows = db.downloadDao().all().filter { it.sourceId == sourceId }
|
||||||
.downloadDao()
|
captureForReset(sourceId, rows.filter { it.status == DownloadStatus.Completed }.map { it.songId })
|
||||||
.all()
|
rows.mapNotNull { it.engineId }.let(engine::cancel)
|
||||||
.filter { it.sourceId == sourceId }
|
|
||||||
.mapNotNull { it.engineId }
|
|
||||||
.let(engine::cancel)
|
|
||||||
db.downloadDao().deleteSource(sourceId)
|
db.downloadDao().deleteSource(sourceId)
|
||||||
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
db.libraryDao().clearDownloadedArtistArt(sourceId)
|
||||||
dir(sourceId).deleteRecursively()
|
dir(sourceId).deleteRecursively()
|
||||||
@@ -388,6 +515,7 @@ class DownloadRepository(
|
|||||||
val promoted =
|
val promoted =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val rows = db.downloadDao().all()
|
val rows = db.downloadDao().all()
|
||||||
|
ensureCompletedBaselines(rows)
|
||||||
reconcileRows(rows)
|
reconcileRows(rows)
|
||||||
sweep(rows)
|
sweep(rows)
|
||||||
ensurePolling()
|
ensurePolling()
|
||||||
@@ -397,11 +525,16 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun downloadStates(): Flow<Map<String, SongDownload>> =
|
private fun downloadStates(): Flow<Pair<Long?, Map<String, SongDownload>>> =
|
||||||
sourceRepository
|
sourceRepository
|
||||||
.activeSourceId()
|
.activeSourceId()
|
||||||
.flatMapLatest { sourceId -> if (sourceId == null) flowOf(emptyList()) else db.downloadDao().downloads(sourceId) }
|
.flatMapLatest { sourceId ->
|
||||||
.map { rows -> rows.associateBy { it.songId } }
|
if (sourceId == null) {
|
||||||
|
flowOf(null to emptyMap())
|
||||||
|
} else {
|
||||||
|
db.downloadDao().downloads(sourceId).map { rows -> sourceId to rows.associateBy { it.songId } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun alreadyDownloadingOrDownloaded(
|
private suspend fun alreadyDownloadingOrDownloaded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
@@ -585,6 +718,7 @@ class DownloadRepository(
|
|||||||
songId: String,
|
songId: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val row = db.downloadDao().find(sourceId, songId) ?: return false
|
val row = db.downloadDao().find(sourceId, songId) ?: return false
|
||||||
|
if (row.status == DownloadStatus.Completed) captureForReset(sourceId, listOf(songId))
|
||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().delete(sourceId, songId)
|
db.downloadDao().delete(sourceId, songId)
|
||||||
@@ -668,6 +802,9 @@ class DownloadRepository(
|
|||||||
row.downloadedAt
|
row.downloadedAt
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
val completedBefore = row.takeIf { it.status == DownloadStatus.Completed }?.let { it.songId to it.downloadedAt }
|
||||||
|
val completedAfter = updated.takeIf { it.status == DownloadStatus.Completed }?.let { it.songId to it.downloadedAt }
|
||||||
|
if (completedBefore != completedAfter) captureForReset(row.sourceId, listOf(row.songId))
|
||||||
if (updated != row) db.downloadDao().upsert(updated)
|
if (updated != row) db.downloadDao().upsert(updated)
|
||||||
if (updated.status != row.status &&
|
if (updated.status != row.status &&
|
||||||
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
|
(updated.status == DownloadStatus.Completed || row.status == DownloadStatus.Completed)
|
||||||
@@ -688,6 +825,7 @@ class DownloadRepository(
|
|||||||
row: SongDownload,
|
row: SongDownload,
|
||||||
error: DownloadError,
|
error: DownloadError,
|
||||||
) {
|
) {
|
||||||
|
if (row.status == DownloadStatus.Completed) captureForReset(row.sourceId, listOf(row.songId))
|
||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(row.sourceId, row.songId).delete()
|
file(row.sourceId, row.songId).delete()
|
||||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import androidx.paging.PagingSource
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.db.albumsDefaultQuery
|
import com.subtracks.data.db.albumsPageQuery
|
||||||
import com.subtracks.data.db.artistsDefaultQuery
|
import com.subtracks.data.db.artistsPageQuery
|
||||||
import com.subtracks.data.db.playlistsDefaultQuery
|
import com.subtracks.data.db.playlistsPageQuery
|
||||||
import com.subtracks.data.db.rediscoverAlbumsQuery
|
import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
@@ -19,6 +19,8 @@ import com.subtracks.data.model.ArtistBio
|
|||||||
import com.subtracks.data.model.Disc
|
import com.subtracks.data.model.Disc
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.prefs.AlbumSort
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
import com.subtracks.data.prefs.ArtistSort
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
@@ -50,6 +52,7 @@ class LibraryRepository(
|
|||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
private val userPreferences: UserPreferences,
|
private val userPreferences: UserPreferences,
|
||||||
|
private val queueRepository: QueueRepository,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||||
) {
|
) {
|
||||||
@@ -195,68 +198,17 @@ class LibraryRepository(
|
|||||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(40, enablePlaceholders = true) {
|
pager(40, enablePlaceholders = true) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
albumsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
starredFilter = starred.ordinal,
|
||||||
when (sort) {
|
search = search,
|
||||||
AlbumSort.Name -> {
|
sort = sort,
|
||||||
if (descending) {
|
descending = descending,
|
||||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
downloaded = downloaded,
|
||||||
} else {
|
sortNameMode = mode,
|
||||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
)
|
||||||
}
|
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Artist -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
|
||||||
} else {
|
|
||||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Year -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Added -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Starred -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Frequent -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlbumSort.Recent -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,52 +223,17 @@ class LibraryRepository(
|
|||||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||||
pager(60) {
|
pager(60) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
artistsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
starredFilter = starred.ordinal,
|
||||||
when (sort) {
|
search = search,
|
||||||
ArtistSort.Name -> {
|
sort = sort,
|
||||||
if (descending) {
|
descending = descending,
|
||||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
downloaded = downloaded,
|
||||||
} else {
|
sortNameMode = mode,
|
||||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
)
|
||||||
}
|
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
|
||||||
}
|
|
||||||
|
|
||||||
ArtistSort.AlbumCount -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ArtistSort.Starred -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ArtistSort.Frequent -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ArtistSort.Recent -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,36 +246,15 @@ class LibraryRepository(
|
|||||||
): Flow<PagingData<Playlist>> =
|
): Flow<PagingData<Playlist>> =
|
||||||
pager(40) {
|
pager(40) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
if (!downloaded) {
|
val query =
|
||||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
|
playlistsPageQuery(
|
||||||
} else {
|
sourceId = sourceId,
|
||||||
val downloadedFilter = 1
|
search = search,
|
||||||
when (sort) {
|
sort = sort,
|
||||||
PlaylistSort.Name -> {
|
descending = descending,
|
||||||
if (descending) {
|
downloaded = downloaded,
|
||||||
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
|
)
|
||||||
} else {
|
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
|
||||||
dao.playlistsByName(sourceId, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaylistSort.Added -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.playlistsByAdded(sourceId, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaylistSort.Updated -> {
|
|
||||||
if (descending) {
|
|
||||||
dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
|
|
||||||
} else {
|
|
||||||
dao.playlistsByUpdated(sourceId, search, downloadedFilter)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun album(
|
fun album(
|
||||||
@@ -467,10 +363,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,
|
||||||
@@ -562,10 +455,19 @@ class LibraryRepository(
|
|||||||
starred: Long?,
|
starred: Long?,
|
||||||
) {
|
) {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
|
if (type == StarType.Song) {
|
||||||
|
val previous = song(sourceId, id).first()?.starred
|
||||||
|
if (previous == starred) return
|
||||||
|
val point = queueRepository.captureResetPoint()
|
||||||
|
if (dao.setSongStar(sourceId, id, starred) > 0) {
|
||||||
|
queueRepository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Starred, sourceId, "")), point)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
when (type) {
|
when (type) {
|
||||||
StarType.Song -> dao.setSongStar(sourceId, id, starred)
|
|
||||||
StarType.Album -> dao.setAlbumStar(sourceId, id, starred)
|
StarType.Album -> dao.setAlbumStar(sourceId, id, starred)
|
||||||
StarType.Artist -> dao.setArtistStar(sourceId, id, starred)
|
StarType.Artist -> dao.setArtistStar(sourceId, id, starred)
|
||||||
|
StarType.Song -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ package com.subtracks.data.sync
|
|||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
@@ -86,13 +87,18 @@ class SyncManager(
|
|||||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
Log.i("sync", "started (source ${source.id})")
|
Log.i("sync", "started (source ${source.id})")
|
||||||
serverActions.flush()
|
serverActions.flush()
|
||||||
val summary = SyncService(db, source).sync()
|
val resetPoint = queueRepository.captureResetPoint()
|
||||||
|
val service = SyncService(db, source)
|
||||||
|
val completed = service.sync()
|
||||||
|
val pending = db.queueDao().pendingSyncOrigins(source.id).map { QueueOrigin(it.kind, it.sourceId, it.refId) }
|
||||||
|
val changedOrigins = pending.toSet() + completed.changedOrigins
|
||||||
Log.i(
|
Log.i(
|
||||||
"sync",
|
"sync",
|
||||||
"finished in ${elapsedMs(startedAt)}ms: " +
|
"finished in ${elapsedMs(startedAt)}ms: " +
|
||||||
"${summary.artists} artists, ${summary.albums} albums, ${summary.songs} songs, " +
|
"${completed.artists} artists, ${completed.albums} albums, ${completed.songs} songs, " +
|
||||||
"${summary.playlists} playlists, ${summary.playlistSongs} playlist tracks, ${summary.pruned} pruned",
|
"${completed.playlists} playlists, ${completed.playlistSongs} playlist tracks, ${completed.pruned} pruned",
|
||||||
)
|
)
|
||||||
|
queueRepository.resetChangedOriginsAndClearPendingOrigins(changedOrigins, resetPoint, source.id)
|
||||||
SyncStatus.Success
|
SyncStatus.Success
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
@@ -112,7 +118,6 @@ class SyncManager(
|
|||||||
if (!silent) showMessage(message)
|
if (!silent) showMessage(message)
|
||||||
SyncStatus.Failed(message)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
|
||||||
_status.value = result
|
_status.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import com.subtracks.data.model.Disc
|
|||||||
import com.subtracks.data.model.DiscKey
|
import com.subtracks.data.model.DiscKey
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongArtistRef
|
import com.subtracks.data.model.SongArtistRef
|
||||||
import com.subtracks.data.model.SongGenre
|
import com.subtracks.data.model.SongGenre
|
||||||
@@ -30,6 +32,27 @@ class SyncService(
|
|||||||
private var playlistsSynced = 0
|
private var playlistsSynced = 0
|
||||||
private var playlistSongsSynced = 0
|
private var playlistSongsSynced = 0
|
||||||
private var pruned = 0
|
private var pruned = 0
|
||||||
|
private val committedOrigins = mutableSetOf<QueueOrigin>()
|
||||||
|
private var transactionOrigins: MutableSet<QueueOrigin>? = null
|
||||||
|
private val changedOrigins =
|
||||||
|
object : AbstractMutableSet<QueueOrigin>() {
|
||||||
|
override val size: Int get() = committedOrigins.size
|
||||||
|
|
||||||
|
override fun iterator(): MutableIterator<QueueOrigin> = committedOrigins.iterator()
|
||||||
|
|
||||||
|
override fun add(element: QueueOrigin): Boolean = (transactionOrigins ?: committedOrigins).add(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> commitOrigins(block: suspend () -> T): T {
|
||||||
|
check(transactionOrigins == null)
|
||||||
|
val pending = mutableSetOf<QueueOrigin>()
|
||||||
|
transactionOrigins = pending
|
||||||
|
return try {
|
||||||
|
block().also { committedOrigins.addAll(pending) }
|
||||||
|
} finally {
|
||||||
|
transactionOrigins = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun sync(): SyncSummary {
|
suspend fun sync(): SyncSummary {
|
||||||
val sources = db.sourcesDao()
|
val sources = db.sourcesDao()
|
||||||
@@ -51,6 +74,7 @@ class SyncService(
|
|||||||
playlists = playlistsSynced,
|
playlists = playlistsSynced,
|
||||||
playlistSongs = playlistSongsSynced,
|
playlistSongs = playlistSongsSynced,
|
||||||
pruned = pruned,
|
pruned = pruned,
|
||||||
|
changedOrigins = changedOrigins.toSet(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,13 +85,29 @@ class SyncService(
|
|||||||
source.artists().collect { batch ->
|
source.artists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
changed =
|
changed =
|
||||||
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
|
write(
|
||||||
|
"artists",
|
||||||
|
primaryKey,
|
||||||
|
batch,
|
||||||
|
preserved = setOf("albumCount"),
|
||||||
|
markStale = true,
|
||||||
|
onChanged = { changedOrigins += origin(QueueKind.Artist, it.id) },
|
||||||
|
) { it.columns() } ||
|
||||||
changed
|
changed
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
artistsSynced += batch.size
|
artistsSynced += batch.size
|
||||||
}
|
}
|
||||||
val prunedBefore = pruned
|
val prunedBefore = pruned
|
||||||
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
pruned +=
|
||||||
|
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, {
|
||||||
|
it.forEach { id -> changedOrigins += origin(QueueKind.Artist, id) }
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
library.deleteArtists(source.id, it)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
return changed || pruned > prunedBefore
|
return changed || pruned > prunedBefore
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +132,18 @@ class SyncService(
|
|||||||
pruneStaleIds(
|
pruneStaleIds(
|
||||||
seenAlbums,
|
seenAlbums,
|
||||||
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
{ library.albumIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
{ library.deleteAlbums(source.id, it) },
|
{
|
||||||
|
it.forEach { id -> changedOrigins += origin(QueueKind.Album, id) }
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
val artistIds = connection.albumArtistIds(it.toSet()).values.flatten()
|
||||||
|
val trackArtistIds = connection.songArtistIds(it.toSet()).values.flatten()
|
||||||
|
changedOrigins += (artistIds + trackArtistIds).map { id -> origin(QueueKind.Artist, id) }
|
||||||
|
library.deleteAlbums(source.id, it)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
)
|
)
|
||||||
pruned +=
|
pruned +=
|
||||||
@@ -117,7 +168,9 @@ class SyncService(
|
|||||||
pruneStaleIds(
|
pruneStaleIds(
|
||||||
seen,
|
seen,
|
||||||
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
{ library.songIdsAfter(source.id, it, PRUNE_PAGE) },
|
||||||
{ library.deleteSongs(source.id, it) },
|
{
|
||||||
|
deleteSongsAndRecordOrigins(it)
|
||||||
|
},
|
||||||
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
markStale = { db.sourcesDao().setCreditsStale(source.id, true) },
|
||||||
)
|
)
|
||||||
return changed || pruned > prunedBefore
|
return changed || pruned > prunedBefore
|
||||||
@@ -133,11 +186,17 @@ class SyncService(
|
|||||||
batch.flatMap { song ->
|
batch.flatMap { song ->
|
||||||
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
song.artistPositions().map { (position, ref) -> SongArtistRef(song.sourceId, song.id, position, ref.id, ref.name) }
|
||||||
}
|
}
|
||||||
return db.useWriterConnection { connection ->
|
return commitOrigins {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
|
val previousStates = connection.storedSongSyncStates(batch.map { it.id })
|
||||||
|
val albumArtists = connection.albumArtistIds(batch.mapNotNull { it.albumId }.toSet())
|
||||||
val resolved = connection.resolveSongSortNames(batch)
|
val resolved = connection.resolveSongSortNames(batch)
|
||||||
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
val nextStates = resolved.associate { it.id to it.syncState(albumArtists) }
|
||||||
|
val downloadedIds = connection.completedDownloadIds(batch.map { it.id })
|
||||||
|
val changedSongIds = mutableSetOf<String>()
|
||||||
|
connection.upsertChanged("songs", primaryKey, resolved, onChanged = { changedSongIds += it.id }) { it.columns() }
|
||||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -148,7 +207,7 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
connection.upsertChanged("song_genres", songGenreKey, genres) { it.columns() }
|
||||||
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||||
batch.forEach { song ->
|
batch.forEach { song ->
|
||||||
statement.bind(1, song.sourceId)
|
statement.bind(1, song.sourceId)
|
||||||
@@ -159,9 +218,168 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
|
||||||
(connection.totalChanges() != before).also { changed ->
|
connection.recordPlaylistOrigins(batch.filter { previousStates[it.id] == null }.map { it.id })
|
||||||
|
batch.forEach { song ->
|
||||||
|
val previous = previousStates[song.id]
|
||||||
|
val next = nextStates.getValue(song.id)
|
||||||
|
if (song.id in changedSongIds || previous != next) {
|
||||||
|
recordSongOrigins(previous, next)
|
||||||
|
}
|
||||||
|
if (song.id in downloadedIds && (previous == null || previous.sortName != next.sortName)) {
|
||||||
|
changedOrigins += origin(QueueKind.Downloaded, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val changed = connection.totalChanges() != before
|
||||||
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteSongsAndRecordOrigins(ids: Collection<String>) {
|
||||||
|
ids.forEach { changedOrigins += origin(QueueKind.Song, it) }
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
val previousStates = connection.storedSongSyncStates(ids.toList())
|
||||||
|
connection.recordPlaylistOrigins(previousStates.keys)
|
||||||
|
previousStates.values.forEach { recordSongOrigins(it, null) }
|
||||||
|
if (connection.completedDownloadIds(ids.toList()).isNotEmpty()) changedOrigins += origin(QueueKind.Downloaded, "")
|
||||||
|
db.libraryDao().deleteSongs(source.id, ids)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.recordPlaylistOrigins(songIds: Collection<String>) {
|
||||||
|
readSourceIds(
|
||||||
|
songIds,
|
||||||
|
{ "SELECT playlistId FROM playlist_songs WHERE sourceId = ? AND songId IN ($it)" },
|
||||||
|
) { statement ->
|
||||||
|
changedOrigins += origin(QueueKind.Playlist, statement.getText(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.recordSongOrigins(
|
||||||
|
previous: SongSyncState?,
|
||||||
|
next: SongSyncState?,
|
||||||
|
) {
|
||||||
|
val states = listOfNotNull(previous, next)
|
||||||
|
states.mapTo(changedOrigins) { origin(QueueKind.Song, it.id) }
|
||||||
|
states.mapNotNullTo(changedOrigins) { it.albumId?.let { id -> origin(QueueKind.Album, id) } }
|
||||||
|
states.flatMapTo(changedOrigins) { state -> state.artistIds.map { origin(QueueKind.Artist, it) } }
|
||||||
|
states.flatMapTo(changedOrigins) { state -> state.genres.map { origin(QueueKind.Genre, it) } }
|
||||||
|
if (states.any { it.starred }) changedOrigins += origin(QueueKind.Starred, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.storedSongSyncStates(ids: List<String>): Map<String, SongSyncState> {
|
||||||
|
val states = mutableMapOf<String, MutableSongSyncState>()
|
||||||
|
readSourceIds(
|
||||||
|
ids,
|
||||||
|
{ "SELECT id, albumId, artistId, sortName, starred FROM songs WHERE sourceId = ? AND id IN ($it)" },
|
||||||
|
) { statement ->
|
||||||
|
val state = MutableSongSyncState()
|
||||||
|
state.albumId = if (statement.isNull(1)) null else statement.getText(1)
|
||||||
|
if (!statement.isNull(2)) state.artistIds += statement.getText(2)
|
||||||
|
state.sortName = statement.getText(3)
|
||||||
|
state.starred = !statement.isNull(4)
|
||||||
|
states[statement.getText(0)] = state
|
||||||
|
}
|
||||||
|
readSourceIds(ids, { "SELECT songId, artistId FROM song_artists WHERE sourceId = ? AND songId IN ($it)" }) { statement ->
|
||||||
|
states[statement.getText(0)]?.artistIds?.plusAssign(statement.getText(1))
|
||||||
|
}
|
||||||
|
readSourceIds(ids, { "SELECT songId, genre FROM song_genres WHERE sourceId = ? AND songId IN ($it)" }) { statement ->
|
||||||
|
states[statement.getText(0)]?.genres?.plusAssign(statement.getText(1))
|
||||||
|
}
|
||||||
|
val albumArtists = albumArtistIds(states.values.mapNotNull { it.albumId }.toSet())
|
||||||
|
return states.mapValues { (id, state) ->
|
||||||
|
SongSyncState(
|
||||||
|
id = id,
|
||||||
|
albumId = state.albumId,
|
||||||
|
artistIds = state.artistIds + state.albumId?.let { albumArtists[it].orEmpty() }.orEmpty(),
|
||||||
|
genres = state.genres,
|
||||||
|
starred = state.starred,
|
||||||
|
sortName = state.sortName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.completedDownloadIds(ids: List<String>): Set<String> {
|
||||||
|
val result = mutableSetOf<String>()
|
||||||
|
readSourceIds(
|
||||||
|
ids,
|
||||||
|
{ "SELECT songId FROM song_downloads WHERE sourceId = ? AND status = 'Completed' AND songId IN ($it)" },
|
||||||
|
) { statement ->
|
||||||
|
result += statement.getText(0)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.albumArtistIds(albumIds: Set<String>): Map<String, Set<String>> {
|
||||||
|
val artists = mutableMapOf<String, MutableSet<String>>()
|
||||||
|
readSourceIds(albumIds, { "SELECT id, artistId FROM albums WHERE sourceId = ? AND artistId IS NOT NULL AND id IN ($it)" }) {
|
||||||
|
artists.getOrPut(it.getText(0)) { mutableSetOf() } += it.getText(1)
|
||||||
|
}
|
||||||
|
readSourceIds(albumIds, { "SELECT albumId, artistId FROM album_artists WHERE sourceId = ? AND albumId IN ($it)" }) {
|
||||||
|
artists.getOrPut(it.getText(0)) { mutableSetOf() } += it.getText(1)
|
||||||
|
}
|
||||||
|
return artists
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.songArtistIds(albumIds: Set<String>): Map<String, Set<String>> {
|
||||||
|
val result = mutableMapOf<String, MutableSet<String>>()
|
||||||
|
readSourceIds(albumIds, {
|
||||||
|
"SELECT albumId, artistId FROM songs WHERE sourceId = ? AND artistId IS NOT NULL AND albumId IN ($it)"
|
||||||
|
}) { statement ->
|
||||||
|
result.getOrPut(statement.getText(0)) { mutableSetOf() } += statement.getText(1)
|
||||||
|
}
|
||||||
|
albumIds.chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
val placeholders = chunk.joinToString(",") { "?" }
|
||||||
|
usePrepared(
|
||||||
|
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||||
|
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||||
|
"WHERE song_artists.sourceId = ? AND songs.albumId IN ($placeholders)",
|
||||||
|
) { statement ->
|
||||||
|
statement.bindLong(1, source.id)
|
||||||
|
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||||
|
while (statement.step()) result.getOrPut(statement.getText(0)) { mutableSetOf() } += statement.getText(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.readSourceIds(
|
||||||
|
ids: Collection<String>,
|
||||||
|
sql: (placeholders: String) -> String,
|
||||||
|
row: (SQLiteStatement) -> Unit,
|
||||||
|
) {
|
||||||
|
ids.chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
usePrepared(sql(chunk.joinToString(",") { "?" })) { statement ->
|
||||||
|
statement.bindLong(1, source.id)
|
||||||
|
chunk.forEachIndexed { index, id -> statement.bindText(index + 2, id) }
|
||||||
|
while (statement.step()) row(statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun origin(
|
||||||
|
kind: QueueKind,
|
||||||
|
refId: String,
|
||||||
|
) = QueueOrigin(kind, source.id, refId)
|
||||||
|
|
||||||
|
private suspend fun PooledConnection.persistPendingOrigins() {
|
||||||
|
val origins = transactionOrigins.orEmpty()
|
||||||
|
origins.chunked(SQL_VARIABLES).forEach { chunk ->
|
||||||
|
usePrepared("INSERT OR IGNORE INTO pending_sync_origins (sourceId, kind, refId) VALUES (?, ?, ?)") { statement ->
|
||||||
|
chunk.forEach { origin ->
|
||||||
|
statement.bindLong(1, origin.sourceId)
|
||||||
|
statement.bindText(2, origin.kind.name)
|
||||||
|
statement.bindText(3, origin.refId)
|
||||||
|
statement.step()
|
||||||
|
statement.reset()
|
||||||
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,11 +474,20 @@ class SyncService(
|
|||||||
val seen = HashedIds()
|
val seen = HashedIds()
|
||||||
source.playlists().collect { batch ->
|
source.playlists().collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
write("playlists", primaryKey, batch, onChanged = { changedOrigins += origin(QueueKind.Playlist, it.id) }) { it.columns() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
batch.forEach { seen.add(idHash(it.id)) }
|
||||||
playlistsSynced += batch.size
|
playlistsSynced += batch.size
|
||||||
}
|
}
|
||||||
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
pruned +=
|
||||||
|
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, {
|
||||||
|
it.forEach { id -> changedOrigins += origin(QueueKind.Playlist, id) }
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
library.deletePlaylists(source.id, it)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncPlaylistSongs() {
|
private suspend fun syncPlaylistSongs() {
|
||||||
@@ -269,30 +496,53 @@ class SyncService(
|
|||||||
val counts = HashMap<String, Long>()
|
val counts = HashMap<String, Long>()
|
||||||
source.playlistSongs(playlistIds).collect { batch ->
|
source.playlistSongs(playlistIds).collect { batch ->
|
||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
write("playlist_songs", playlistSongKey, batch, onChanged = { changedOrigins += origin(QueueKind.Playlist, it.playlistId) }) {
|
||||||
|
it.columns()
|
||||||
|
}
|
||||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||||
playlistSongsSynced += batch.size
|
playlistSongsSynced += batch.size
|
||||||
}
|
}
|
||||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
playlistIds.forEach { playlistId ->
|
||||||
|
deletePlaylistSongsFrom(playlistId, counts[playlistId] ?: 0L)
|
||||||
|
}
|
||||||
library.deleteOrphanPlaylistSongs(source.id)
|
library.deleteOrphanPlaylistSongs(source.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun deletePlaylistSongsFrom(
|
||||||
|
playlistId: String,
|
||||||
|
position: Long,
|
||||||
|
): Unit =
|
||||||
|
commitOrigins {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
val changed = db.libraryDao().deletePlaylistSongsFrom(source.id, playlistId, position) > 0
|
||||||
|
if (changed) {
|
||||||
|
changedOrigins += origin(QueueKind.Playlist, playlistId)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
||||||
private suspend fun <T> write(
|
private suspend fun <T> write(
|
||||||
table: String,
|
table: String,
|
||||||
columns: List<String>,
|
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
preserved: Set<String> = emptySet(),
|
preserved: Set<String> = emptySet(),
|
||||||
markStale: Boolean = false,
|
markStale: Boolean = false,
|
||||||
values: (T) -> List<Any?>,
|
onChanged: (T) -> Unit = {},
|
||||||
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
|
commitOrigins {
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
connection.upsertChanged(table, columns, key, rows, preserved, values)
|
connection.upsertChanged(table, key, rows, preserved, onChanged, columns)
|
||||||
(connection.totalChanges() != before).also { changed ->
|
val changed = connection.totalChanges() != before
|
||||||
if (changed && markStale) connection.markCreditsStale(source.id)
|
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,12 +555,29 @@ class SyncService(
|
|||||||
albums.flatMap { album ->
|
albums.flatMap { album ->
|
||||||
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
album.artistPositions().map { (position, ref) -> AlbumArtistRef(album.sourceId, album.id, position, ref.id, ref.name) }
|
||||||
}
|
}
|
||||||
return db.useWriterConnection { connection ->
|
return commitOrigins {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
val before = connection.totalChanges()
|
val before = connection.totalChanges()
|
||||||
|
val previousArtists = connection.albumArtistIds(albums.map { it.id }.toSet())
|
||||||
|
val previousTrackArtists = connection.songArtistIds(albums.map { it.id }.toSet())
|
||||||
|
val changedAlbums = mutableSetOf<String>()
|
||||||
val resolved = connection.resolveAlbumSortNames(albums)
|
val resolved = connection.resolveAlbumSortNames(albums)
|
||||||
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
connection.upsertChanged(
|
||||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
"albums",
|
||||||
|
primaryKey,
|
||||||
|
resolved,
|
||||||
|
onChanged = {
|
||||||
|
changedAlbums += it.id
|
||||||
|
changedOrigins += origin(QueueKind.Album, it.id)
|
||||||
|
},
|
||||||
|
) { it.columns() }
|
||||||
|
connection.upsertChanged(
|
||||||
|
"discs",
|
||||||
|
discKey,
|
||||||
|
discs,
|
||||||
|
onChanged = { changedOrigins += origin(QueueKind.Album, it.albumId) },
|
||||||
|
) { it.columns() }
|
||||||
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||||
albums.forEach { album ->
|
albums.forEach { album ->
|
||||||
statement.bind(1, album.sourceId)
|
statement.bind(1, album.sourceId)
|
||||||
@@ -321,9 +588,21 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
|
||||||
(connection.totalChanges() != before).also { changed ->
|
albums.forEach { album ->
|
||||||
|
val previous = previousArtists[album.id].orEmpty()
|
||||||
|
val current = (album.artistPositions().map { it.second.id } + album.artistId).filterNotNull().toSet()
|
||||||
|
if (album.id in changedAlbums || previous != current) {
|
||||||
|
changedOrigins += (previous + current).map { origin(QueueKind.Artist, it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changedAlbums.forEach { albumId ->
|
||||||
|
changedOrigins += previousTrackArtists[albumId].orEmpty().map { origin(QueueKind.Artist, it) }
|
||||||
|
}
|
||||||
|
val changed = connection.totalChanges() != before
|
||||||
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +610,8 @@ class SyncService(
|
|||||||
|
|
||||||
private suspend fun deleteStaleDiscs(stale: Collection<DiscKey>) {
|
private suspend fun deleteStaleDiscs(stale: Collection<DiscKey>) {
|
||||||
stale.chunked(DELETE_CHUNK).forEach { chunk ->
|
stale.chunked(DELETE_CHUNK).forEach { chunk ->
|
||||||
|
commitOrigins {
|
||||||
|
chunk.forEach { changedOrigins += origin(QueueKind.Album, it.albumId) }
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
|
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
|
||||||
@@ -343,6 +624,8 @@ class SyncService(
|
|||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
connection.persistPendingOrigins()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -362,7 +645,7 @@ class SyncService(
|
|||||||
if (ids.isEmpty()) return pruned
|
if (ids.isEmpty()) return pruned
|
||||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||||
if (stale.isNotEmpty()) markStale?.invoke()
|
if (stale.isNotEmpty()) markStale?.invoke()
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
stale.chunked(DELETE_CHUNK).forEach { commitOrigins { delete(it) } }
|
||||||
pruned += stale.size
|
pruned += stale.size
|
||||||
if (ids.size < PRUNE_PAGE) return pruned
|
if (ids.size < PRUNE_PAGE) return pruned
|
||||||
afterId = ids.last()
|
afterId = ids.last()
|
||||||
@@ -398,8 +681,26 @@ data class SyncSummary(
|
|||||||
val playlists: Int,
|
val playlists: Int,
|
||||||
val playlistSongs: Int,
|
val playlistSongs: Int,
|
||||||
val pruned: Int,
|
val pruned: Int,
|
||||||
|
val changedOrigins: Set<QueueOrigin> = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class SongSyncState(
|
||||||
|
val id: String,
|
||||||
|
val albumId: String?,
|
||||||
|
val artistIds: Set<String>,
|
||||||
|
val genres: Set<String>,
|
||||||
|
val starred: Boolean,
|
||||||
|
val sortName: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private class MutableSongSyncState {
|
||||||
|
var albumId: String? = null
|
||||||
|
var sortName: String = ""
|
||||||
|
val artistIds = mutableSetOf<String>()
|
||||||
|
val genres = mutableSetOf<String>()
|
||||||
|
var starred = false
|
||||||
|
}
|
||||||
|
|
||||||
private const val DELETE_CHUNK = 500
|
private const val DELETE_CHUNK = 500
|
||||||
private const val SQL_VARIABLES = 500
|
private const val SQL_VARIABLES = 500
|
||||||
private const val PRUNE_PAGE = 500
|
private const val PRUNE_PAGE = 500
|
||||||
@@ -438,58 +739,8 @@ private fun discHash(
|
|||||||
|
|
||||||
private val primaryKey = listOf("sourceId", "id")
|
private val primaryKey = listOf("sourceId", "id")
|
||||||
|
|
||||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
|
|
||||||
|
|
||||||
private val albumColumns =
|
|
||||||
listOf(
|
|
||||||
"sourceId",
|
|
||||||
"id",
|
|
||||||
"artistId",
|
|
||||||
"name",
|
|
||||||
"albumArtist",
|
|
||||||
"created",
|
|
||||||
"coverArt",
|
|
||||||
"genre",
|
|
||||||
"year",
|
|
||||||
"starred",
|
|
||||||
"songCount",
|
|
||||||
"artists",
|
|
||||||
"sortName",
|
|
||||||
"albumArtistSort",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||||
|
|
||||||
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
|
|
||||||
|
|
||||||
private val songColumns =
|
|
||||||
listOf(
|
|
||||||
"sourceId",
|
|
||||||
"id",
|
|
||||||
"albumId",
|
|
||||||
"artistId",
|
|
||||||
"title",
|
|
||||||
"album",
|
|
||||||
"artist",
|
|
||||||
"duration",
|
|
||||||
"track",
|
|
||||||
"disc",
|
|
||||||
"starred",
|
|
||||||
"created",
|
|
||||||
"playCount",
|
|
||||||
"played",
|
|
||||||
"trackGain",
|
|
||||||
"albumGain",
|
|
||||||
"trackPeak",
|
|
||||||
"albumPeak",
|
|
||||||
"artists",
|
|
||||||
"sortName",
|
|
||||||
"artistSort",
|
|
||||||
)
|
|
||||||
|
|
||||||
private val playlistColumns =
|
|
||||||
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
|
|
||||||
|
|
||||||
private fun Song.genreNames(): List<String> =
|
private fun Song.genreNames(): List<String> =
|
||||||
genres
|
genres
|
||||||
.flatMap { it.genreParts() }
|
.flatMap { it.genreParts() }
|
||||||
@@ -497,74 +748,110 @@ private fun Song.genreNames(): List<String> =
|
|||||||
.map { part -> id3v1Genre(part) ?: part }
|
.map { part -> id3v1Genre(part) ?: part }
|
||||||
.distinct()
|
.distinct()
|
||||||
|
|
||||||
|
private fun Song.syncState(albumArtists: Map<String, Set<String>>): SongSyncState {
|
||||||
|
val artistIds = artistPositions().mapTo(mutableSetOf()) { it.second.id }
|
||||||
|
this.artistId?.let(artistIds::add)
|
||||||
|
albumId?.let { artistIds += albumArtists[it].orEmpty() }
|
||||||
|
return SongSyncState(
|
||||||
|
id = id,
|
||||||
|
albumId = albumId,
|
||||||
|
artistIds = artistIds,
|
||||||
|
genres = genreNames().toSet(),
|
||||||
|
starred = starred != null,
|
||||||
|
sortName = sortName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
|
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
|
||||||
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
|
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
|
||||||
|
|
||||||
private val songGenreKey = listOf("sourceId", "songId", "position")
|
private val songGenreKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
|
||||||
|
|
||||||
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||||
|
|
||||||
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
|
|
||||||
|
|
||||||
private val songArtistKey = listOf("sourceId", "songId", "position")
|
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||||
|
|
||||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
|
||||||
|
|
||||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||||
|
|
||||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
|
private fun Artist.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
private fun Album.values() =
|
"sourceId" to sourceId,
|
||||||
listOf<Any?>(
|
"id" to id,
|
||||||
sourceId,
|
"name" to name,
|
||||||
id,
|
"albumCount" to albumCount,
|
||||||
artistId,
|
"starred" to starred,
|
||||||
name,
|
"coverArt" to coverArt,
|
||||||
albumArtist,
|
"artistImageUrl" to artistImageUrl,
|
||||||
created,
|
"sortName" to sortName,
|
||||||
coverArt,
|
|
||||||
genre,
|
|
||||||
year,
|
|
||||||
starred,
|
|
||||||
songCount,
|
|
||||||
ArtistRefsCodec.encode(artistRefs),
|
|
||||||
sortName,
|
|
||||||
albumArtistSort,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
|
private fun Album.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"id" to id,
|
||||||
|
"artistId" to artistId,
|
||||||
|
"name" to name,
|
||||||
|
"albumArtist" to albumArtist,
|
||||||
|
"created" to created,
|
||||||
|
"coverArt" to coverArt,
|
||||||
|
"genre" to genre,
|
||||||
|
"year" to year,
|
||||||
|
"starred" to starred,
|
||||||
|
"songCount" to songCount,
|
||||||
|
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||||
|
"sortName" to sortName,
|
||||||
|
"albumArtistSort" to albumArtistSort,
|
||||||
|
)
|
||||||
|
|
||||||
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
|
private fun AlbumArtistRef.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"albumId" to albumId,
|
||||||
|
"position" to position,
|
||||||
|
"artistId" to artistId,
|
||||||
|
"name" to name,
|
||||||
|
)
|
||||||
|
|
||||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
private fun SongArtistRef.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"songId" to songId,
|
||||||
|
"position" to position,
|
||||||
|
"artistId" to artistId,
|
||||||
|
"name" to name,
|
||||||
|
)
|
||||||
|
|
||||||
private fun Song.values() =
|
private fun Disc.columns(): List<Pair<String, Any?>> =
|
||||||
listOf<Any?>(
|
listOf(
|
||||||
sourceId,
|
"sourceId" to sourceId,
|
||||||
id,
|
"albumId" to albumId,
|
||||||
albumId,
|
"disc" to disc,
|
||||||
artistId,
|
"title" to title,
|
||||||
title,
|
)
|
||||||
album,
|
|
||||||
artist,
|
private fun Song.columns(): List<Pair<String, Any?>> =
|
||||||
duration,
|
listOf(
|
||||||
track,
|
"sourceId" to sourceId,
|
||||||
disc,
|
"id" to id,
|
||||||
starred,
|
"albumId" to albumId,
|
||||||
created,
|
"artistId" to artistId,
|
||||||
playCount,
|
"title" to title,
|
||||||
played,
|
"album" to album,
|
||||||
trackGain,
|
"artist" to artist,
|
||||||
albumGain,
|
"duration" to duration,
|
||||||
trackPeak,
|
"track" to track,
|
||||||
albumPeak,
|
"disc" to disc,
|
||||||
ArtistRefsCodec.encode(artistRefs),
|
"starred" to starred,
|
||||||
sortName,
|
"created" to created,
|
||||||
artistSort,
|
"playCount" to playCount,
|
||||||
|
"played" to played,
|
||||||
|
"trackGain" to trackGain,
|
||||||
|
"albumGain" to albumGain,
|
||||||
|
"trackPeak" to trackPeak,
|
||||||
|
"albumPeak" to albumPeak,
|
||||||
|
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||||
|
"sortName" to sortName,
|
||||||
|
"artistSort" to artistSort,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
|
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
|
||||||
@@ -576,33 +863,59 @@ private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.po
|
|||||||
|
|
||||||
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||||
|
|
||||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
private fun SongGenre.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"songId" to songId,
|
||||||
|
"position" to position,
|
||||||
|
"genre" to genre,
|
||||||
|
)
|
||||||
|
|
||||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
|
private fun Playlist.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"id" to id,
|
||||||
|
"name" to name,
|
||||||
|
"comment" to comment,
|
||||||
|
"coverArt" to coverArt,
|
||||||
|
"songCount" to songCount,
|
||||||
|
"created" to created,
|
||||||
|
"changed" to changed,
|
||||||
|
"duration" to duration,
|
||||||
|
"owner" to owner,
|
||||||
|
"shared" to shared,
|
||||||
|
)
|
||||||
|
|
||||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
private fun PlaylistSong.columns(): List<Pair<String, Any?>> =
|
||||||
|
listOf(
|
||||||
|
"sourceId" to sourceId,
|
||||||
|
"playlistId" to playlistId,
|
||||||
|
"songId" to songId,
|
||||||
|
"position" to position,
|
||||||
|
)
|
||||||
|
|
||||||
private suspend fun <T> PooledConnection.upsertChanged(
|
private suspend fun <T> PooledConnection.upsertChanged(
|
||||||
table: String,
|
table: String,
|
||||||
columns: List<String>,
|
|
||||||
key: List<String>,
|
key: List<String>,
|
||||||
rows: List<T>,
|
rows: List<T>,
|
||||||
preserved: Set<String> = emptySet(),
|
preserved: Set<String> = emptySet(),
|
||||||
values: (T) -> List<Any?>,
|
onChanged: (T) -> Unit = {},
|
||||||
|
columns: (T) -> List<Pair<String, Any?>>,
|
||||||
) {
|
) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
val columnNames = columns(rows.first()).map { it.first }
|
||||||
val updates = columns.filterNot { it in key || it in preserved }
|
val updates = columnNames.filterNot { it in key || it in preserved }
|
||||||
val sql =
|
val sql =
|
||||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
"INSERT INTO $table (${columnNames.joinToString()}) VALUES (${columnNames.joinToString { "?" }}) " +
|
||||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||||
updates.joinToString { "$it = excluded.$it" } +
|
updates.joinToString { "$it = excluded.$it" } +
|
||||||
" WHERE " +
|
" WHERE " +
|
||||||
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" } +
|
||||||
|
" RETURNING 1"
|
||||||
usePrepared(sql) { statement ->
|
usePrepared(sql) { statement ->
|
||||||
rows.forEach { row ->
|
rows.forEach { row ->
|
||||||
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) }
|
||||||
statement.step()
|
if (statement.step()) onChanged(row)
|
||||||
statement.reset()
|
statement.reset()
|
||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ fun appModule(
|
|||||||
scope = get(),
|
scope = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single { LibraryRepository(get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
single { LibraryRepository(get(), get(), get(), get(), get(), toast, scope = get(named("io"))) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||||
single {
|
single {
|
||||||
@@ -120,6 +120,7 @@ fun appModule(
|
|||||||
downloadsDir = downloadsRoot(context),
|
downloadsDir = downloadsRoot(context),
|
||||||
artworkStore = get(),
|
artworkStore = get(),
|
||||||
artworkFetcher = get(),
|
artworkFetcher = get(),
|
||||||
|
queueRepository = get(),
|
||||||
showMessage = toast,
|
showMessage = toast,
|
||||||
dispatcher = Dispatchers.IO,
|
dispatcher = Dispatchers.IO,
|
||||||
scope = get(named("io")),
|
scope = get(named("io")),
|
||||||
@@ -128,7 +129,7 @@ fun appModule(
|
|||||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||||
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
single { PlaybackController(get(), get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||||
single(createdAtStart = true) {
|
single(createdAtStart = true) {
|
||||||
val playback = get<PlaybackController>()
|
val playback = get<PlaybackController>()
|
||||||
Scrobbler(
|
Scrobbler(
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import com.subtracks.UiMessage
|
|||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
import com.subtracks.data.model.ArtistRefsCodec
|
import com.subtracks.data.model.ArtistRefsCodec
|
||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
|
import com.subtracks.data.repo.QueueResetItem
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.log.Log
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
@@ -222,7 +225,10 @@ class Media3PlayerHandle(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun toQueueItem(mediaItem: MediaItem): QueueItem =
|
private fun toQueueItem(mediaItem: MediaItem): QueueItem = queueItemFromMediaItem(mediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun queueItemFromMediaItem(mediaItem: MediaItem): QueueItem =
|
||||||
QueueItem(
|
QueueItem(
|
||||||
id = mediaItem.mediaId,
|
id = mediaItem.mediaId,
|
||||||
title =
|
title =
|
||||||
@@ -238,6 +244,29 @@ class Media3PlayerHandle(
|
|||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
replayGain = mediaItem.replayGain(),
|
replayGain = mediaItem.replayGain(),
|
||||||
|
resetItem = mediaItem.resetItem(),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun MediaItem.resetItem(): QueueResetItem? {
|
||||||
|
val extras = mediaMetadata.extras ?: return null
|
||||||
|
val kind = extras.getString(EXTRA_RESET_KIND)?.let { runCatching { QueueKind.valueOf(it) }.getOrNull() } ?: return null
|
||||||
|
val refId = extras.getString(EXTRA_RESET_REF_ID) ?: return null
|
||||||
|
val songId = extras.getString(EXTRA_RESET_SONG_ID) ?: return null
|
||||||
|
if (!extras.containsKey(EXTRA_RESET_SOURCE_ID) ||
|
||||||
|
!extras.containsKey(EXTRA_RESET_POSITION) ||
|
||||||
|
!extras.containsKey(EXTRA_RESET_ENTRY_OCCURRENCE) ||
|
||||||
|
!extras.containsKey(EXTRA_RESET_OCCURRENCE)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return QueueResetItem(
|
||||||
|
origin = QueueOrigin(kind, extras.getLong(EXTRA_RESET_SOURCE_ID), refId),
|
||||||
|
songId = songId,
|
||||||
|
position = extras.getLong(EXTRA_RESET_POSITION),
|
||||||
|
upNext = extras.getBoolean(EXTRA_RESET_UP_NEXT),
|
||||||
|
entryOccurrence = extras.getInt(EXTRA_RESET_ENTRY_OCCURRENCE),
|
||||||
|
occurrence = extras.getLong(EXTRA_RESET_OCCURRENCE),
|
||||||
|
originEntryId = extras.takeIf { it.containsKey(EXTRA_RESET_ORIGIN_ENTRY_ID) }?.getLong(EXTRA_RESET_ORIGIN_ENTRY_ID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +284,15 @@ private const val EXTRA_REPLAY_GAIN_ALBUM_GAIN = "replayGainAlbumGain"
|
|||||||
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
private const val EXTRA_REPLAY_GAIN_TRACK_PEAK = "replayGainTrackPeak"
|
||||||
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
private const val EXTRA_REPLAY_GAIN_ALBUM_PEAK = "replayGainAlbumPeak"
|
||||||
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
|
private const val EXTRA_ALBUM_CONTEXT = "albumContext"
|
||||||
|
private const val EXTRA_RESET_KIND = "resetKind"
|
||||||
|
private const val EXTRA_RESET_SOURCE_ID = "resetSourceId"
|
||||||
|
private const val EXTRA_RESET_REF_ID = "resetRefId"
|
||||||
|
private const val EXTRA_RESET_SONG_ID = "resetSongId"
|
||||||
|
private const val EXTRA_RESET_POSITION = "resetPosition"
|
||||||
|
private const val EXTRA_RESET_UP_NEXT = "resetUpNext"
|
||||||
|
private const val EXTRA_RESET_ENTRY_OCCURRENCE = "resetEntryOccurrence"
|
||||||
|
private const val EXTRA_RESET_OCCURRENCE = "resetOccurrence"
|
||||||
|
private const val EXTRA_RESET_ORIGIN_ENTRY_ID = "resetOriginEntryId"
|
||||||
|
|
||||||
internal fun MediaItem?.replayGain(): ReplayGain {
|
internal fun MediaItem?.replayGain(): ReplayGain {
|
||||||
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
val extras = this?.mediaMetadata?.extras ?: return ReplayGain()
|
||||||
@@ -303,6 +341,17 @@ internal fun queueMediaItem(
|
|||||||
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
putGain(EXTRA_REPLAY_GAIN_TRACK_PEAK, item.replayGain.trackPeak)
|
||||||
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
putGain(EXTRA_REPLAY_GAIN_ALBUM_PEAK, item.replayGain.albumPeak)
|
||||||
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
|
putBoolean(EXTRA_ALBUM_CONTEXT, item.albumContext)
|
||||||
|
item.resetItem?.let { reset ->
|
||||||
|
putString(EXTRA_RESET_KIND, reset.origin.kind.name)
|
||||||
|
putLong(EXTRA_RESET_SOURCE_ID, reset.origin.sourceId)
|
||||||
|
putString(EXTRA_RESET_REF_ID, reset.origin.refId)
|
||||||
|
putString(EXTRA_RESET_SONG_ID, reset.songId)
|
||||||
|
putLong(EXTRA_RESET_POSITION, reset.position)
|
||||||
|
putBoolean(EXTRA_RESET_UP_NEXT, reset.upNext)
|
||||||
|
putInt(EXTRA_RESET_ENTRY_OCCURRENCE, reset.entryOccurrence)
|
||||||
|
putLong(EXTRA_RESET_OCCURRENCE, reset.occurrence)
|
||||||
|
reset.originEntryId?.let { putLong(EXTRA_RESET_ORIGIN_ENTRY_ID, it) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
).build()
|
).build()
|
||||||
val builder =
|
val builder =
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class DownloadsViewModel(
|
|||||||
fun deleteSongs(songIds: List<String>) {
|
fun deleteSongs(songIds: List<String>) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val source = sourceId.first()
|
val source = sourceId.first()
|
||||||
songIds.forEach { downloadRepository.remove(source, it) }
|
downloadRepository.remove(source, songIds)
|
||||||
playbackController.refreshMediaItems()
|
playbackController.refreshMediaItems()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ data class QueueRow(
|
|||||||
val position: Long,
|
val position: Long,
|
||||||
val song: SongItem,
|
val song: SongItem,
|
||||||
val upNext: Boolean = false,
|
val upNext: Boolean = false,
|
||||||
|
val resetVersion: Long = 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Both must match: the position alone can transiently point at the row above while the list
|
// Both must match: the position alone can transiently point at the row above while the list
|
||||||
@@ -96,6 +97,11 @@ internal fun QueueRow.isPlaying(
|
|||||||
currentSongId: String?,
|
currentSongId: String?,
|
||||||
): Boolean = position == currentPosition && song.song.id == currentSongId
|
): Boolean = position == currentPosition && song.song.id == currentSongId
|
||||||
|
|
||||||
|
private data class ReorderRollback(
|
||||||
|
val before: List<Long>,
|
||||||
|
val after: List<Long>,
|
||||||
|
)
|
||||||
|
|
||||||
class QueueViewModel(
|
class QueueViewModel(
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
@@ -114,11 +120,16 @@ class QueueViewModel(
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
private var reorderPending = false
|
private var reorderPending = false
|
||||||
|
private var olderLoadPending = false
|
||||||
|
private var dragRollback: ReorderRollback? = null
|
||||||
|
|
||||||
private var nextId = 0L
|
private var nextId = 0L
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
private fun newRow(item: QueueWindowItem) = QueueRow(nextId++, item.position, item.item, item.upNext)
|
private fun newRow(
|
||||||
|
item: QueueWindowItem,
|
||||||
|
resetVersion: Long,
|
||||||
|
) = QueueRow(nextId++, item.position, item.item, item.upNext, resetVersion)
|
||||||
|
|
||||||
private fun visible(item: QueueWindowItem): Boolean = !sourceRepository.offline.value || downloads.localUri(item.item.song.id) != null
|
private fun visible(item: QueueWindowItem): Boolean = !sourceRepository.offline.value || downloads.localUri(item.item.song.id) != null
|
||||||
|
|
||||||
@@ -128,10 +139,11 @@ class QueueViewModel(
|
|||||||
private fun rowFor(
|
private fun rowFor(
|
||||||
item: QueueWindowItem,
|
item: QueueWindowItem,
|
||||||
reused: Map<String, MutableList<Long>>,
|
reused: Map<String, MutableList<Long>>,
|
||||||
|
resetVersion: Long,
|
||||||
): QueueRow {
|
): QueueRow {
|
||||||
val ids = reused[item.item.song.id]
|
val ids = reused[item.item.song.id]
|
||||||
val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++
|
val id = if (!ids.isNullOrEmpty()) ids.removeAt(0) else nextId++
|
||||||
return QueueRow(id, item.position, item.item, item.upNext)
|
return QueueRow(id, item.position, item.item, item.upNext, resetVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun open() {
|
fun open() {
|
||||||
@@ -147,7 +159,9 @@ class QueueViewModel(
|
|||||||
}?.coerceIn(0, snapshot.size - 1) ?: 0L
|
}?.coerceIn(0, snapshot.size - 1) ?: 0L
|
||||||
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
val start = (cursor - OLDER_LOAD).coerceAtLeast(0)
|
||||||
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(snapshot.size - 1)
|
val end = (start + QUEUE_CHUNK - 1).coerceAtMost(snapshot.size - 1)
|
||||||
rows.addAll(queueRepository.range(snapshot, start, end).filter(::visible).map { rowFor(it, reused) })
|
rows.addAll(
|
||||||
|
queueRepository.range(snapshot, start, end).filter(::visible).map { rowFor(it, reused, snapshot.resetVersion) },
|
||||||
|
)
|
||||||
initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0)
|
initialIndex = rows.indexOfFirst { it.position == cursor }.coerceAtLeast(0)
|
||||||
}
|
}
|
||||||
ready = true
|
ready = true
|
||||||
@@ -167,12 +181,21 @@ class QueueViewModel(
|
|||||||
rows.clear()
|
rows.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val start = (rows.minOfOrNull { it.position } ?: 0L).coerceIn(0, snapshot.size - 1)
|
val firstPosition = rows.minOfOrNull { it.position } ?: OLDER_LOAD.toLong()
|
||||||
|
val start = (firstPosition - OLDER_LOAD).coerceAtLeast(0)
|
||||||
val rawEnd = (rows.maxOfOrNull { it.position } ?: (start + QUEUE_CHUNK - 1)).coerceIn(start, snapshot.size - 1)
|
val rawEnd = (rows.maxOfOrNull { it.position } ?: (start + QUEUE_CHUNK - 1)).coerceIn(start, snapshot.size - 1)
|
||||||
val end = rawEnd.coerceAtMost(start + MAX_SCAN_POSITIONS - 1)
|
val end = rawEnd.coerceAtMost(start + MAX_SCAN_POSITIONS - 1)
|
||||||
val loaded = queueRepository.range(snapshot, start, end).filter(::visible)
|
val loaded = queueRepository.range(snapshot, start, end).filter(::visible)
|
||||||
val reused = reusedIds()
|
val reused = reusedIds()
|
||||||
val updated = loaded.map { rowFor(it, reused) }
|
val scanned = loaded.map { rowFor(it, reused, snapshot.resetVersion) }
|
||||||
|
val firstRow = rows.minByOrNull { it.position }
|
||||||
|
val windowStart =
|
||||||
|
if (firstRow == null || olderLoadPending) {
|
||||||
|
start
|
||||||
|
} else {
|
||||||
|
scanned.firstOrNull { it.id == firstRow.id && it.position < firstPosition }?.position ?: firstPosition
|
||||||
|
}
|
||||||
|
val updated = scanned.filter { it.position >= windowStart }
|
||||||
if (end >= rawEnd) {
|
if (end >= rawEnd) {
|
||||||
if (updated.size == rows.size) {
|
if (updated.size == rows.size) {
|
||||||
updated.forEachIndexed { index, row -> if (rows[index] != row) rows[index] = row }
|
updated.forEachIndexed { index, row -> if (rows[index] != row) rows[index] = row }
|
||||||
@@ -181,20 +204,20 @@ class QueueViewModel(
|
|||||||
rows.addAll(updated)
|
rows.addAll(updated)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Only the head of a sparse offline window was resolved; keep the tail on screen,
|
|
||||||
// dropping any tail row whose id was reused for a head row.
|
|
||||||
val usedIds = updated.mapTo(HashSet()) { it.id }
|
val usedIds = updated.mapTo(HashSet()) { it.id }
|
||||||
val tail = rows.filter { it.position > end && it.id !in usedIds }
|
val unresolvedTail = rows.filter { it.position > end && it.id !in usedIds }
|
||||||
rows.clear()
|
rows.clear()
|
||||||
rows.addAll(updated)
|
rows.addAll(updated)
|
||||||
rows.addAll(tail)
|
rows.addAll(unresolvedTail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadOlder() {
|
fun loadOlder() {
|
||||||
val top = rows.minOfOrNull { it.position } ?: return
|
val top = rows.minOfOrNull { it.position } ?: return
|
||||||
if (top <= 0L) return
|
if (top <= 0L) return
|
||||||
|
olderLoadPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val first = rows.minOfOrNull { it.position } ?: return@withLock
|
val first = rows.minOfOrNull { it.position } ?: return@withLock
|
||||||
if (first <= 0L) return@withLock
|
if (first <= 0L) return@withLock
|
||||||
@@ -207,9 +230,12 @@ class QueueViewModel(
|
|||||||
end = start - 1
|
end = start - 1
|
||||||
}
|
}
|
||||||
if (collected.isEmpty()) return@withLock
|
if (collected.isEmpty()) return@withLock
|
||||||
rows.addAll(0, collected.map(::newRow))
|
rows.addAll(0, collected.map { newRow(it, snapshot.resetVersion) })
|
||||||
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(rows.size - 1)
|
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(rows.size - 1)
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
olderLoadPending = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +254,7 @@ class QueueViewModel(
|
|||||||
start = to + 1
|
start = to + 1
|
||||||
}
|
}
|
||||||
if (collected.isEmpty()) return@withLock
|
if (collected.isEmpty()) return@withLock
|
||||||
rows.addAll(collected.map(::newRow))
|
rows.addAll(collected.map { newRow(it, snapshot.resetVersion) })
|
||||||
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(0)
|
while (rows.size > QUEUE_WINDOW_ROWS) rows.removeAt(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,24 +267,68 @@ class QueueViewModel(
|
|||||||
if (fromIndex == toIndex) return
|
if (fromIndex == toIndex) return
|
||||||
if (fromIndex !in rows.indices || toIndex !in rows.indices) return
|
if (fromIndex !in rows.indices || toIndex !in rows.indices) return
|
||||||
if (rows[fromIndex].upNext != rows[toIndex].upNext) return
|
if (rows[fromIndex].upNext != rows[toIndex].upNext) return
|
||||||
|
if (rows[fromIndex].resetVersion != rows[toIndex].resetVersion) return
|
||||||
|
val before = dragRollback?.before ?: rows.map { it.id }
|
||||||
rows.add(toIndex, rows.removeAt(fromIndex))
|
rows.add(toIndex, rows.removeAt(fromIndex))
|
||||||
|
dragRollback = ReorderRollback(before, rows.map { it.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
fun play(position: Long) {
|
fun play(position: Long) {
|
||||||
viewModelScope.launch { playbackController.playAt(position) }
|
val resetVersion = rows.firstOrNull { it.position == position }?.resetVersion ?: return
|
||||||
|
viewModelScope.launch { playbackController.playAt(position, resetVersion) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rollbackReorder(edit: ReorderRollback?) {
|
||||||
|
if (edit == null || rows.map { it.id } != edit.after) return
|
||||||
|
val current = rows.associateBy { it.id }
|
||||||
|
rows.clear()
|
||||||
|
rows.addAll(edit.before.map { current.getValue(it) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rejectMove(
|
||||||
|
edit: ReorderRollback?,
|
||||||
|
onComplete: (Boolean) -> Unit = {},
|
||||||
|
): Boolean {
|
||||||
|
viewModelScope.launch {
|
||||||
|
mutex.withLock {
|
||||||
|
rollbackReorder(edit)
|
||||||
|
reconcileLocked()
|
||||||
|
}
|
||||||
|
onComplete(false)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelMove() {
|
||||||
|
val edit = dragRollback ?: return
|
||||||
|
dragRollback = null
|
||||||
|
rejectMove(edit)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun move(
|
fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
|
targetResetVersion: Long? = null,
|
||||||
|
onComplete: (Boolean) -> Unit = {},
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (from == to) return false
|
val edit = dragRollback
|
||||||
if (reorderPending) return false
|
dragRollback = null
|
||||||
|
if (from == to || reorderPending) return rejectMove(edit, onComplete)
|
||||||
|
val resetVersion = rows.firstOrNull { it.position == from }?.resetVersion ?: return rejectMove(edit, onComplete)
|
||||||
|
val targetResetVersion = targetResetVersion ?: rows.firstOrNull { it.position == to }?.resetVersion ?: resetVersion
|
||||||
|
if (resetVersion != targetResetVersion) return rejectMove(edit, onComplete)
|
||||||
|
val expectedContentVersion = queueRepository.contentVersion()
|
||||||
reorderPending = true
|
reorderPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
playbackController.move(from, to)
|
val applied =
|
||||||
mutex.withLock { reconcileLocked() }
|
mutex.withLock {
|
||||||
|
val applied = playbackController.move(from, to, resetVersion, expectedContentVersion)
|
||||||
|
if (!applied) rollbackReorder(edit)
|
||||||
|
reconcileLocked()
|
||||||
|
applied
|
||||||
|
}
|
||||||
|
onComplete(applied)
|
||||||
} finally {
|
} finally {
|
||||||
reorderPending = false
|
reorderPending = false
|
||||||
}
|
}
|
||||||
@@ -266,14 +336,38 @@ class QueueViewModel(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(position: Long) {
|
fun remove(
|
||||||
|
position: Long,
|
||||||
|
onComplete: (Boolean) -> Unit = {},
|
||||||
|
) {
|
||||||
|
val expected = rows.firstOrNull { it.position == position }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
val applied =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val index = rows.indexOfFirst { it.position == position }
|
if (expected == null) {
|
||||||
if (index >= 0) rows.removeAt(index)
|
|
||||||
playbackController.removeAt(position)
|
|
||||||
reconcileLocked()
|
reconcileLocked()
|
||||||
|
return@withLock false
|
||||||
}
|
}
|
||||||
|
val index =
|
||||||
|
rows.indexOfFirst {
|
||||||
|
it.id == expected.id && it.position == expected.position && it.resetVersion == expected.resetVersion
|
||||||
|
}
|
||||||
|
if (index < 0 || (playbackController.state.value.shuffle && !rows[index].upNext)) {
|
||||||
|
reconcileLocked()
|
||||||
|
return@withLock false
|
||||||
|
}
|
||||||
|
val applied = playbackController.removeAt(expected.position, expected.resetVersion)
|
||||||
|
if (applied) {
|
||||||
|
val currentIndex =
|
||||||
|
rows.indexOfFirst {
|
||||||
|
it.id == expected.id && it.position == expected.position && it.resetVersion == expected.resetVersion
|
||||||
|
}
|
||||||
|
if (currentIndex >= 0) rows.removeAt(currentIndex)
|
||||||
|
}
|
||||||
|
reconcileLocked()
|
||||||
|
applied
|
||||||
|
}
|
||||||
|
onComplete(applied)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,9 +412,10 @@ fun QueueRoute(
|
|||||||
coverArt = sourceRepository::coverArt,
|
coverArt = sourceRepository::coverArt,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onPlay = viewModel::play,
|
onPlay = viewModel::play,
|
||||||
onRemove = viewModel::remove,
|
onRemove = { position, onComplete -> viewModel.remove(position, onComplete) },
|
||||||
onReorder = viewModel::reorder,
|
onReorder = viewModel::reorder,
|
||||||
onMove = viewModel::move,
|
onMove = { move, onComplete -> viewModel.move(move.from, move.to, move.resetVersion, onComplete) },
|
||||||
|
onCancelMove = viewModel::cancelMove,
|
||||||
onLoadOlder = viewModel::loadOlder,
|
onLoadOlder = viewModel::loadOlder,
|
||||||
onLoadNewer = viewModel::loadNewer,
|
onLoadNewer = viewModel::loadNewer,
|
||||||
onUndo = viewModel::undo,
|
onUndo = viewModel::undo,
|
||||||
@@ -338,9 +433,10 @@ fun QueueScreen(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long) -> Unit,
|
onRemove: (Long, (Boolean) -> Unit) -> Unit,
|
||||||
onReorder: (Int, Int) -> Unit,
|
onReorder: (Int, Int) -> Unit,
|
||||||
onMove: (Long, Long) -> Boolean,
|
onMove: (QueueMove, (Boolean) -> Unit) -> Unit,
|
||||||
|
onCancelMove: () -> Unit,
|
||||||
onLoadOlder: () -> Unit,
|
onLoadOlder: () -> Unit,
|
||||||
onLoadNewer: () -> Unit,
|
onLoadNewer: () -> Unit,
|
||||||
onUndo: () -> Unit,
|
onUndo: () -> Unit,
|
||||||
@@ -480,19 +576,24 @@ fun QueueScreen(
|
|||||||
Modifier.draggableHandle(
|
Modifier.draggableHandle(
|
||||||
onDragStarted = { dragId = row.id },
|
onDragStarted = { dragId = row.id },
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
dragId?.let { id ->
|
val move = dragId?.let { id -> rows.dropMoveFor(id) }
|
||||||
rows.dropMoveFor(id)?.let { move ->
|
if (move == null) {
|
||||||
if (onMove(move.from, move.to)) showUndo(queueReordered)
|
onCancelMove()
|
||||||
}
|
} else {
|
||||||
|
onMove(move) { applied -> if (applied) showUndo(queueReordered) }
|
||||||
}
|
}
|
||||||
dragId = null
|
dragId = null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onPlay(row.position) },
|
onClick = { onPlay(row.position) },
|
||||||
onRemove = {
|
onRemove =
|
||||||
onRemove(row.position)
|
if (enabled) {
|
||||||
showUndo(queueRemoved)
|
{
|
||||||
|
onRemove(row.position) { applied -> if (applied) showUndo(queueRemoved) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -557,9 +658,10 @@ internal fun dropTarget(
|
|||||||
return (before - if (before > from) 1 else 0) + 1
|
return (before - if (before > from) 1 else 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
internal data class QueueMove(
|
data class QueueMove(
|
||||||
val from: Long,
|
val from: Long,
|
||||||
val to: Long,
|
val to: Long,
|
||||||
|
val resetVersion: Long? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
||||||
@@ -567,7 +669,8 @@ internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
|||||||
if (index < 0) return null
|
if (index < 0) return null
|
||||||
val from = this[index].position
|
val from = this[index].position
|
||||||
val to = dropTarget(this, index, from)
|
val to = dropTarget(this, index, from)
|
||||||
return if (to == from) null else QueueMove(from, to)
|
val resetVersion = this.getOrNull(index + 1)?.resetVersion ?: this.getOrNull(index - 1)?.resetVersion ?: this[index].resetVersion
|
||||||
|
return if (to == from) null else QueueMove(from, to, resetVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("ModifierParameter")
|
@Suppress("ModifierParameter")
|
||||||
@@ -579,7 +682,7 @@ private fun QueueRowItem(
|
|||||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||||
dragHandle: Modifier?,
|
dragHandle: Modifier?,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
SongRow(
|
SongRow(
|
||||||
song = row.song.song,
|
song = row.song.song,
|
||||||
@@ -588,6 +691,7 @@ private fun QueueRowItem(
|
|||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (onRemove != null) {
|
||||||
IconButton(onClick = onRemove) {
|
IconButton(onClick = onRemove) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Rounded.Close,
|
imageVector = Icons.Rounded.Close,
|
||||||
@@ -595,6 +699,7 @@ private fun QueueRowItem(
|
|||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (dragHandle != null) {
|
if (dragHandle != null) {
|
||||||
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
|
Box(modifier = Modifier.size(40.dp).then(dragHandle), contentAlignment = Alignment.Center) {
|
||||||
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.queue_reorder))
|
Icon(imageVector = Icons.Rounded.DragHandle, contentDescription = stringResource(R.string.queue_reorder))
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
|||||||
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
import com.subtracks.data.prefs.MAX_REPLAY_GAIN_PREAMP_DB
|
||||||
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
import com.subtracks.data.prefs.MIN_REPLAY_GAIN_PREAMP_DB
|
||||||
import com.subtracks.data.prefs.ReplayGainMode
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import com.subtracks.data.prefs.SongTapBehavior
|
||||||
import com.subtracks.data.prefs.SortNameMode
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
@@ -103,6 +104,7 @@ private enum class SettingsDialog {
|
|||||||
ReplayGain,
|
ReplayGain,
|
||||||
ReplayGainPreamp,
|
ReplayGainPreamp,
|
||||||
SortNames,
|
SortNames,
|
||||||
|
SongTap,
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -131,6 +133,7 @@ fun SettingsRoute(
|
|||||||
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
val replayGainMode by viewModel.replayGainMode.collectAsStateWithLifecycle()
|
||||||
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
val replayGainPreamp by viewModel.replayGainPreamp.collectAsStateWithLifecycle()
|
||||||
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
val sortNameMode by viewModel.sortNameMode.collectAsStateWithLifecycle()
|
||||||
|
val songTapBehavior by viewModel.songTapBehavior.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
@@ -147,6 +150,7 @@ fun SettingsRoute(
|
|||||||
replayGainMode = replayGainMode,
|
replayGainMode = replayGainMode,
|
||||||
replayGainPreamp = replayGainPreamp,
|
replayGainPreamp = replayGainPreamp,
|
||||||
sortNameMode = sortNameMode,
|
sortNameMode = sortNameMode,
|
||||||
|
songTapBehavior = songTapBehavior,
|
||||||
keepScreenOn = keepScreenOn,
|
keepScreenOn = keepScreenOn,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
verboseLogging = verboseLogging,
|
verboseLogging = verboseLogging,
|
||||||
@@ -163,6 +167,7 @@ fun SettingsRoute(
|
|||||||
onReplayGainModeChange = viewModel::setReplayGainMode,
|
onReplayGainModeChange = viewModel::setReplayGainMode,
|
||||||
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
onReplayGainPreampChange = viewModel::setReplayGainPreamp,
|
||||||
onSortNameModeChange = viewModel::setSortNameMode,
|
onSortNameModeChange = viewModel::setSortNameMode,
|
||||||
|
onSongTapBehaviorChange = viewModel::setSongTapBehavior,
|
||||||
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
onKeepScreenOnChange = viewModel::setKeepScreenOn,
|
||||||
onOfflineChange = viewModel::setOfflineMode,
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||||
@@ -211,6 +216,7 @@ fun SettingsScreen(
|
|||||||
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
replayGainMode: ReplayGainMode = ReplayGainMode.Auto,
|
||||||
replayGainPreamp: Float = 0f,
|
replayGainPreamp: Float = 0f,
|
||||||
sortNameMode: SortNameMode = SortNameMode.Name,
|
sortNameMode: SortNameMode = SortNameMode.Name,
|
||||||
|
songTapBehavior: SongTapBehavior = SongTapBehavior.InOrder,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
@@ -224,6 +230,7 @@ fun SettingsScreen(
|
|||||||
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
onReplayGainModeChange: (ReplayGainMode) -> Unit = {},
|
||||||
onReplayGainPreampChange: (Float) -> Unit = {},
|
onReplayGainPreampChange: (Float) -> Unit = {},
|
||||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||||
|
onSongTapBehaviorChange: (SongTapBehavior) -> Unit = {},
|
||||||
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
onKeepScreenOnChange: (Boolean) -> Unit = {},
|
||||||
onOfflineChange: (Boolean) -> Unit = {},
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||||
@@ -421,6 +428,14 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_music_options_song_tap_title)) },
|
||||||
|
supportingContent = { Text(songTapBehaviorLabel(LocalResources.current, songTapBehavior)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SongTap },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
item { SectionHeader(stringResource(R.string.downloads_title)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -627,6 +642,14 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SongTap -> {
|
||||||
|
SongTapBehaviorDialog(
|
||||||
|
selected = songTapBehavior,
|
||||||
|
onSelect = onSongTapBehaviorChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
@@ -826,6 +849,49 @@ private fun SortNameModeDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun songTapBehaviorLabel(
|
||||||
|
resources: Resources,
|
||||||
|
behavior: SongTapBehavior,
|
||||||
|
): String =
|
||||||
|
when (behavior) {
|
||||||
|
SongTapBehavior.InOrder -> resources.getString(R.string.settings_music_options_song_tap_in_order)
|
||||||
|
SongTapBehavior.KeepShuffle -> resources.getString(R.string.settings_music_options_song_tap_keep_shuffle)
|
||||||
|
SongTapBehavior.Shuffle -> resources.getString(R.string.settings_music_options_song_tap_shuffle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SongTapBehaviorDialog(
|
||||||
|
selected: SongTapBehavior,
|
||||||
|
onSelect: (SongTapBehavior) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_music_options_song_tap_title)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.settings_music_options_song_tap_prompt),
|
||||||
|
options = SongTapBehavior.entries.map { it to songTapBehaviorLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ReplayGainModeDialog(
|
private fun ReplayGainModeDialog(
|
||||||
selected: ReplayGainMode,
|
selected: ReplayGainMode,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.Source
|
|||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
import com.subtracks.data.prefs.ReplayGainMode
|
import com.subtracks.data.prefs.ReplayGainMode
|
||||||
|
import com.subtracks.data.prefs.SongTapBehavior
|
||||||
import com.subtracks.data.prefs.SortNameMode
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.data.prefs.SyncMode
|
import com.subtracks.data.prefs.SyncMode
|
||||||
@@ -82,6 +83,11 @@ class SettingsViewModel(
|
|||||||
.sortNameMode()
|
.sortNameMode()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SortNameMode.Name)
|
||||||
|
|
||||||
|
val songTapBehavior: StateFlow<SongTapBehavior> =
|
||||||
|
userPreferences
|
||||||
|
.songTapBehavior()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SongTapBehavior.InOrder)
|
||||||
|
|
||||||
val keepScreenOn: StateFlow<Boolean> =
|
val keepScreenOn: StateFlow<Boolean> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.keepScreenOn()
|
.keepScreenOn()
|
||||||
@@ -153,4 +159,8 @@ class SettingsViewModel(
|
|||||||
fun setSortNameMode(mode: SortNameMode) {
|
fun setSortNameMode(mode: SortNameMode) {
|
||||||
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
|
viewModelScope.launch { userPreferences.setSortNameMode(mode) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSongTapBehavior(behavior: SongTapBehavior) {
|
||||||
|
viewModelScope.launch { userPreferences.setSongTapBehavior(behavior) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,4 +47,18 @@
|
|||||||
<string name="resources_album_name">专辑</string>
|
<string name="resources_album_name">专辑</string>
|
||||||
<string name="resources_artist_name">歌手</string>
|
<string name="resources_artist_name">歌手</string>
|
||||||
<string name="resources_playlist_name">播放列表</string>
|
<string name="resources_playlist_name">播放列表</string>
|
||||||
|
<string name="controls_shuffle_play">随机播放</string>
|
||||||
|
<string name="controls_play">播放</string>
|
||||||
|
<string name="controls_pause">暂停</string>
|
||||||
|
<string name="controls_next">下一首</string>
|
||||||
|
<string name="controls_previous">上一首</string>
|
||||||
|
<string name="controls_queue">队列</string>
|
||||||
|
<string name="controls_more">更多</string>
|
||||||
|
<string name="navigation_back">背 景</string>
|
||||||
|
<string name="settings_servers_actions_add">添加服务器</string>
|
||||||
|
<string name="settings_servers_actions_delete">删除服务器</string>
|
||||||
|
<string name="settings_servers_actions_edit">修改服务器</string>
|
||||||
|
<string name="settings_servers_actions_save">保存并同步</string>
|
||||||
|
<string name="settings_servers_fields_address">服务器地址</string>
|
||||||
|
<string name="settings_servers_name">服务器</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -83,6 +83,11 @@
|
|||||||
<string name="settings_music_name">Playback</string>
|
<string name="settings_music_name">Playback</string>
|
||||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||||
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
<string name="settings_music_options_scrobble_title">Scrobble to server</string>
|
||||||
|
<string name="settings_music_options_song_tap_in_order">Play without shuffling</string>
|
||||||
|
<string name="settings_music_options_song_tap_keep_shuffle">Keep current shuffle mode</string>
|
||||||
|
<string name="settings_music_options_song_tap_prompt">When tapping a song in a list</string>
|
||||||
|
<string name="settings_music_options_song_tap_shuffle">Play and shuffle</string>
|
||||||
|
<string name="settings_music_options_song_tap_title">Song tap behavior</string>
|
||||||
<string name="settings_replay_gain_mode_album">Album gain</string>
|
<string name="settings_replay_gain_mode_album">Album gain</string>
|
||||||
<string name="settings_replay_gain_mode_auto">Auto</string>
|
<string name="settings_replay_gain_mode_auto">Auto</string>
|
||||||
<string name="settings_replay_gain_mode_off">Off</string>
|
<string name="settings_replay_gain_mode_off">Off</string>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.data.model.DownloadStatus
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.data.model.SongDownload
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class DownloadDaoTest {
|
||||||
|
private lateinit var db: SubtracksDatabase
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
db = createTestDatabase()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
db.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun completedDownloadsUseTheDownloadedQueueVisibilityAndOrder() =
|
||||||
|
runTest {
|
||||||
|
val sourceId =
|
||||||
|
db.sourcesDao().insertSource(
|
||||||
|
Source(name = "source", address = "http://localhost", isActive = true, createdAt = 0),
|
||||||
|
)
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
song(sourceId, "s1", "Zulu"),
|
||||||
|
song(sourceId, "s2", "Alpha"),
|
||||||
|
song(sourceId, "s3", "Running"),
|
||||||
|
song(sourceId, "s4", "Pruned"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s3", DownloadStatus.Running, downloadedAt = 2))
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s4", DownloadStatus.Completed, downloadedAt = 3))
|
||||||
|
db.libraryDao().deleteSongs(sourceId, listOf("s4"))
|
||||||
|
|
||||||
|
assertEquals(listOf("s2", "s1"), db.downloadDao().completedDownloads(sourceId).map { it.songId })
|
||||||
|
assertEquals(
|
||||||
|
setOf("s1", "s2", "s3"),
|
||||||
|
db.downloadDao().queueVisibleSongIds(sourceId, listOf("s1", "s2", "s3", "s4")).toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun song(
|
||||||
|
sourceId: Long,
|
||||||
|
id: String,
|
||||||
|
sortName: String,
|
||||||
|
) = Song(
|
||||||
|
sourceId = sourceId,
|
||||||
|
id = id,
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = sortName,
|
||||||
|
sortName = sortName,
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useReaderConnection
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
@@ -39,12 +35,7 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -67,17 +58,17 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma"),
|
listOf("Alpha", "Beta", "Gamma"),
|
||||||
dao.albumsByName(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
dao.albumPages(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Gamma", "Beta", "Alpha"),
|
listOf("Gamma", "Beta", "Alpha"),
|
||||||
dao.albumsByNameReversed(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
dao.albumPages(sourceId, starredFilter = 0, search = "", descending = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma"),
|
listOf("Alpha", "Beta", "Gamma"),
|
||||||
dao.albumsByYear(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Year).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredFilter = 1, search = "").page().map { it.name })
|
assertEquals(listOf("Alpha"), dao.albumPages(sourceId, starredFilter = 1, search = "").page().map { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -95,14 +86,14 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beta", "Gamma", "Alpha"),
|
listOf("Beta", "Gamma", "Alpha"),
|
||||||
dao.albumsByRecentlyAdded(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Added).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Gamma", "Beta"),
|
listOf("Alpha", "Gamma", "Beta"),
|
||||||
dao.albumsByStarred(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Starred).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredFilter = 0, search = "gam").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, starredFilter = 0, search = "gam").page().map { it.name })
|
||||||
assertEquals(listOf("Beta"), dao.albumsByName(sourceId, starredFilter = 2, search = "").page().map { it.name })
|
assertEquals(listOf("Beta"), dao.albumPages(sourceId, starredFilter = 2, search = "").page().map { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -148,19 +139,19 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
||||||
dao.albumsByStarred(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Starred).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
||||||
dao.albumsByStarredReversed(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Starred, descending = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
||||||
dao.artistsByStarred(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Starred).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
||||||
dao.artistsByStarredReversed(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Starred, descending = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,19 +197,19 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Two", "One", "Three"),
|
listOf("Two", "One", "Three"),
|
||||||
dao.albumsByFrequent(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Two", "One", "Three"),
|
listOf("Two", "One", "Three"),
|
||||||
dao.albumsByRecent(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Many", "Few"),
|
listOf("Many", "Few"),
|
||||||
dao.artistsByFrequent(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Many", "Few"),
|
listOf("Many", "Few"),
|
||||||
dao.artistsByRecent(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent).page().map { it.name },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,12 +259,17 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
val pairs =
|
val pairs =
|
||||||
listOf(
|
listOf(
|
||||||
dao.albumsByName(sourceId, 0, "") to dao.albumsByNameReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "") to dao.albumPages(sourceId, 0, "", descending = true),
|
||||||
dao.albumsByArtist(sourceId, 0, "") to dao.albumsByArtistReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist) to
|
||||||
dao.albumsByYear(sourceId, 0, "") to dao.albumsByYearReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, descending = true),
|
||||||
dao.albumsByRecentlyAdded(sourceId, 0, "") to dao.albumsByRecentlyAddedReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Year) to
|
||||||
dao.albumsByFrequent(sourceId, 0, "") to dao.albumsByFrequentReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Year, descending = true),
|
||||||
dao.albumsByRecent(sourceId, 0, "") to dao.albumsByRecentReversed(sourceId, 0, ""),
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Added) to
|
||||||
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Added, descending = true),
|
||||||
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent) to
|
||||||
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent, descending = true),
|
||||||
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent) to
|
||||||
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent, descending = true),
|
||||||
)
|
)
|
||||||
for ((base, reversed) in pairs) {
|
for ((base, reversed) in pairs) {
|
||||||
val ids = base.page().map { it.id }
|
val ids = base.page().map { it.id }
|
||||||
@@ -303,23 +299,23 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
val artistPairs =
|
val artistPairs =
|
||||||
listOf(
|
listOf(
|
||||||
dao.artistsByName(sourceId, 0, "").page().map { it.id } to
|
dao.artistPages(sourceId, 0, "").page().map { it.id } to
|
||||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.id },
|
dao.artistPages(sourceId, 0, "", descending = true).page().map { it.id },
|
||||||
dao.artistsByAlbumCount(sourceId, 0, "").page().map { it.id } to
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.AlbumCount).page().map { it.id } to
|
||||||
dao.artistsByAlbumCountReversed(sourceId, 0, "").page().map { it.id },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.AlbumCount, descending = true).page().map { it.id },
|
||||||
dao.artistsByFrequent(sourceId, 0, "").page().map { it.id } to
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent).page().map { it.id } to
|
||||||
dao.artistsByFrequentReversed(sourceId, 0, "").page().map { it.id },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent, descending = true).page().map { it.id },
|
||||||
dao.artistsByRecent(sourceId, 0, "").page().map { it.id } to
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent).page().map { it.id } to
|
||||||
dao.artistsByRecentReversed(sourceId, 0, "").page().map { it.id },
|
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent, descending = true).page().map { it.id },
|
||||||
)
|
)
|
||||||
val playlistPairs =
|
val playlistPairs =
|
||||||
listOf(
|
listOf(
|
||||||
dao.playlistsByName(sourceId, "").page().map { it.id } to
|
dao.playlistPages(sourceId, "").page().map { it.id } to
|
||||||
dao.playlistsByNameReversed(sourceId, "").page().map { it.id },
|
dao.playlistPages(sourceId, "", descending = true).page().map { it.id },
|
||||||
dao.playlistsByAdded(sourceId, "").page().map { it.id } to
|
dao.playlistPages(sourceId, "", sort = PlaylistSort.Added).page().map { it.id } to
|
||||||
dao.playlistsByAddedReversed(sourceId, "").page().map { it.id },
|
dao.playlistPages(sourceId, "", sort = PlaylistSort.Added, descending = true).page().map { it.id },
|
||||||
dao.playlistsByUpdated(sourceId, "").page().map { it.id } to
|
dao.playlistPages(sourceId, "", sort = PlaylistSort.Updated).page().map { it.id } to
|
||||||
dao.playlistsByUpdatedReversed(sourceId, "").page().map { it.id },
|
dao.playlistPages(sourceId, "", sort = PlaylistSort.Updated, descending = true).page().map { it.id },
|
||||||
)
|
)
|
||||||
for ((base, reversed) in artistPairs + playlistPairs) {
|
for ((base, reversed) in artistPairs + playlistPairs) {
|
||||||
assertEquals(base.reversed(), reversed)
|
assertEquals(base.reversed(), reversed)
|
||||||
@@ -357,11 +353,13 @@ class LibraryDaoTest {
|
|||||||
ALBUM_ORDER_BY_RECENT_REVERSED to "index_albums_recent",
|
ALBUM_ORDER_BY_RECENT_REVERSED to "index_albums_recent",
|
||||||
)
|
)
|
||||||
for ((order, index) in orders) {
|
for ((order, index) in orders) {
|
||||||
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
for (filter in listOf(ALBUMS_FILTER, ALBUMS_FILTER_DOWNLOADED)) {
|
||||||
|
val plan = plan("SELECT * $filter ORDER BY $order LIMIT 20 OFFSET 0")
|
||||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistOrdersAreIndexBacked() =
|
fun artistOrdersAreIndexBacked() =
|
||||||
@@ -382,12 +380,49 @@ class LibraryDaoTest {
|
|||||||
ARTIST_ORDER_BY_RECENT_REVERSED to "index_artists_recent",
|
ARTIST_ORDER_BY_RECENT_REVERSED to "index_artists_recent",
|
||||||
)
|
)
|
||||||
for ((order, index) in orders) {
|
for ((order, index) in orders) {
|
||||||
val plan = plan("SELECT * $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloadedArtistOrdersUseTheIndexesTheirSortColumnsHave() =
|
||||||
|
runTest {
|
||||||
|
val orders =
|
||||||
|
listOf(
|
||||||
|
"${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name",
|
||||||
|
"${artistNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_artists_name",
|
||||||
|
"${artistNameSortKey(SortNameMode.Name)}, id" to "index_artists_name_plain",
|
||||||
|
"${artistNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_artists_name_plain",
|
||||||
|
ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount",
|
||||||
|
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
|
||||||
|
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
|
||||||
|
ARTIST_ORDER_BY_STARRED_REVERSED to "index_artists_starred",
|
||||||
|
ARTIST_ORDER_BY_FREQUENT to "index_artists_frequent",
|
||||||
|
ARTIST_ORDER_BY_FREQUENT_REVERSED to "index_artists_frequent",
|
||||||
|
ARTIST_ORDER_BY_RECENT to "index_artists_recent",
|
||||||
|
ARTIST_ORDER_BY_RECENT_REVERSED to "index_artists_recent",
|
||||||
|
)
|
||||||
|
for ((order, index) in orders) {
|
||||||
|
val plan =
|
||||||
|
plan(
|
||||||
|
"SELECT $ARTIST_COLUMNS_DOWNLOADED $ARTISTS_FILTER_DOWNLOADED " +
|
||||||
|
"ORDER BY $order LIMIT 20 OFFSET 0",
|
||||||
|
topLevelOnly = true,
|
||||||
|
)
|
||||||
|
if (order == ARTIST_ORDER_BY_ALBUM_COUNT || order == ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED) {
|
||||||
|
// The displayed count comes from song_downloads, so this order cannot use
|
||||||
|
// index_artists_albumCount; the hand-written statement sorted in memory too.
|
||||||
|
assertTrue("$order\n$plan", plan.contains("USING INDEX index_artists_sourceId"))
|
||||||
|
assertTrue("$order\n$plan", plan.contains("USE TEMP B-TREE FOR ORDER BY"))
|
||||||
|
} else {
|
||||||
|
assertTrue("$order\n$plan", plan.contains("USING INDEX $index"))
|
||||||
|
assertFalse("$order\n$plan", plan.contains("USE TEMP B-TREE FOR ORDER BY"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun playlistOrdersAreIndexBacked() =
|
fun playlistOrdersAreIndexBacked() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -401,9 +436,18 @@ class LibraryDaoTest {
|
|||||||
PLAYLIST_ORDER_BY_UPDATED_REVERSED to "index_playlists_updated",
|
PLAYLIST_ORDER_BY_UPDATED_REVERSED to "index_playlists_updated",
|
||||||
)
|
)
|
||||||
for ((order, index) in orders) {
|
for ((order, index) in orders) {
|
||||||
val plan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
val defaultPlan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
assertTrue(defaultPlan, defaultPlan.contains("USING INDEX $index"))
|
||||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
assertFalse(defaultPlan, defaultPlan.contains("TEMP B-TREE"))
|
||||||
|
|
||||||
|
val downloadedPlan =
|
||||||
|
plan(
|
||||||
|
"SELECT $PLAYLISTS_COLUMNS_DOWNLOADED $PLAYLISTS_FILTER_DOWNLOADED " +
|
||||||
|
"ORDER BY $order LIMIT 20 OFFSET 0",
|
||||||
|
topLevelOnly = true,
|
||||||
|
)
|
||||||
|
assertTrue(downloadedPlan, downloadedPlan.contains("USING INDEX $index"))
|
||||||
|
assertFalse(downloadedPlan, downloadedPlan.contains("USE TEMP B-TREE FOR ORDER BY"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,17 +500,85 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Downloaded"),
|
listOf("Downloaded"),
|
||||||
dao.albumsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
|
dao.albumPages(sourceId, 0, "", downloaded = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("ar-1"),
|
listOf("ar-1"),
|
||||||
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.id },
|
dao.artistPages(sourceId, 0, "", downloaded = true).page().map { it.id },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("pl-1"),
|
listOf("pl-1"),
|
||||||
dao.playlistsByName(sourceId, "", downloadedFilter = 1).page().map { it.id },
|
dao.playlistPages(sourceId, "", downloaded = true).page().map { it.id },
|
||||||
)
|
)
|
||||||
assertEquals(3, dao.albumsByName(sourceId, 0, "").page().size)
|
assertEquals(3, dao.albumPages(sourceId, 0, "").page().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloadedFiltersHonourSearchAndStarred() =
|
||||||
|
runTest {
|
||||||
|
val sourceId = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
dao.upsertArtists(
|
||||||
|
listOf(
|
||||||
|
artist(sourceId, "ar-1", "Artist", albumCount = 2).copy(starred = 5),
|
||||||
|
artist(sourceId, "ar-2", "Other", albumCount = 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dao.upsertAlbums(
|
||||||
|
listOf(
|
||||||
|
album(sourceId, "al-1", "Downloaded One", year = 2000, starred = 5),
|
||||||
|
album(sourceId, "al-2", "Downloaded Two", year = 2001, starred = null),
|
||||||
|
album(sourceId, "al-3", "Missing", year = 2002, starred = 5),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dao.upsertSongs(
|
||||||
|
listOf(
|
||||||
|
song(sourceId, "s1", "One", starred = null),
|
||||||
|
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2"),
|
||||||
|
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-2"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dao.upsertPlaylists(
|
||||||
|
listOf(
|
||||||
|
playlist(sourceId, "pl-1", "Has download", created = 0, changed = 0),
|
||||||
|
playlist(sourceId, "pl-2", "No download", created = 1, changed = 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dao.upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(sourceId, "pl-1", "s1", 0),
|
||||||
|
PlaylistSong(sourceId, "pl-2", "s2", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("Downloaded One"),
|
||||||
|
dao.albumPages(sourceId, 0, "one", downloaded = true).page().map { it.name },
|
||||||
|
)
|
||||||
|
assertTrue(dao.albumPages(sourceId, 0, "missing", downloaded = true).page().isEmpty())
|
||||||
|
assertEquals(
|
||||||
|
listOf("Downloaded One"),
|
||||||
|
dao.albumPages(sourceId, 1, "", downloaded = true).page().map { it.name },
|
||||||
|
)
|
||||||
|
assertTrue(dao.albumPages(sourceId, 2, "", downloaded = true).page().isEmpty())
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("ar-1"),
|
||||||
|
dao.artistPages(sourceId, 0, "art", downloaded = true).page().map { it.id },
|
||||||
|
)
|
||||||
|
assertTrue(dao.artistPages(sourceId, 0, "other", downloaded = true).page().isEmpty())
|
||||||
|
assertEquals(
|
||||||
|
listOf("ar-1"),
|
||||||
|
dao.artistPages(sourceId, 1, "", downloaded = true).page().map { it.id },
|
||||||
|
)
|
||||||
|
assertTrue(dao.artistPages(sourceId, 2, "", downloaded = true).page().isEmpty())
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("pl-1"),
|
||||||
|
dao.playlistPages(sourceId, "has", downloaded = true).page().map { it.id },
|
||||||
|
)
|
||||||
|
assertTrue(dao.playlistPages(sourceId, "no", downloaded = true).page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -628,7 +740,7 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
dao.recomputeArtistArt(sourceId)
|
dao.recomputeArtistArt(sourceId)
|
||||||
|
|
||||||
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
|
val artists = dao.artistPages(sourceId, 0, "").page().associateBy { it.id }
|
||||||
|
|
||||||
assertEquals("own-art", artists.getValue("ar-own").coverArt)
|
assertEquals("own-art", artists.getValue("ar-own").coverArt)
|
||||||
assertNull(artists.getValue("ar-album").coverArt)
|
assertNull(artists.getValue("ar-album").coverArt)
|
||||||
@@ -648,7 +760,7 @@ class LibraryDaoTest {
|
|||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("album-ref-art"),
|
listOf("album-ref-art"),
|
||||||
dao
|
dao
|
||||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
|
.artistPages(sourceId, 0, "", ArtistSort.Name, false)
|
||||||
.page()
|
.page()
|
||||||
.first { it.id == "ar-album-ref" }
|
.first { it.id == "ar-album-ref" }
|
||||||
.coverArts,
|
.coverArts,
|
||||||
@@ -774,6 +886,26 @@ class LibraryDaoTest {
|
|||||||
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
|
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun freshDatabaseOptimizesArtistSearchTriggers() =
|
||||||
|
runTest {
|
||||||
|
val triggers =
|
||||||
|
db.useReaderConnection { connection ->
|
||||||
|
connection.usePrepared(
|
||||||
|
"SELECT sql FROM sqlite_master WHERE type = 'trigger' " +
|
||||||
|
"AND name IN ('room_fts_content_sync_artist_search_BEFORE_UPDATE', " +
|
||||||
|
"'room_fts_content_sync_artist_search_AFTER_UPDATE')",
|
||||||
|
) { statement ->
|
||||||
|
buildList {
|
||||||
|
while (statement.step()) add(statement.getText(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(2, triggers.size)
|
||||||
|
assertTrue(triggers.all { it.contains("UPDATE OF `name`, `sortName`") })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistNameUpdatesKeepTheSearchIndexValid() =
|
fun artistNameUpdatesKeepTheSearchIndexValid() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -869,18 +1001,7 @@ class LibraryDaoTest {
|
|||||||
runTest {
|
runTest {
|
||||||
val plan = plan(ALBUM_CREDIT_ALBUM_IDS)
|
val plan = plan(ALBUM_CREDIT_ALBUM_IDS)
|
||||||
|
|
||||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
assertArtistCreditIndexes(plan)
|
||||||
assertTrue(
|
|
||||||
plan,
|
|
||||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
|
||||||
)
|
|
||||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
|
||||||
assertTrue(
|
|
||||||
plan,
|
|
||||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
|
||||||
)
|
|
||||||
assertFalse(plan, plan.contains("SCAN albums"))
|
|
||||||
assertFalse(plan, plan.contains("SCAN songs"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -904,7 +1025,7 @@ class LibraryDaoTest {
|
|||||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Album Artist"),
|
listOf("Album Artist"),
|
||||||
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
|
dao.artistPages(sourceId, 0, "", downloaded = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Album Artist"),
|
listOf("Album Artist"),
|
||||||
@@ -913,7 +1034,7 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
|
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
|
||||||
|
|
||||||
val filtered = dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page()
|
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true).page()
|
||||||
assertEquals(listOf("Album Artist", "Guest"), filtered.map { it.name })
|
assertEquals(listOf("Album Artist", "Guest"), filtered.map { it.name })
|
||||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||||
|
|
||||||
@@ -961,14 +1082,14 @@ class LibraryDaoTest {
|
|||||||
)
|
)
|
||||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||||
|
|
||||||
val filtered = dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page()
|
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true).page()
|
||||||
assertEquals(1, filtered.size)
|
assertEquals(1, filtered.size)
|
||||||
assertEquals(1L, filtered.first().albumCount)
|
assertEquals(1L, filtered.first().albumCount)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
5L,
|
5L,
|
||||||
dao
|
dao
|
||||||
.artistsByName(sourceId, 0, "")
|
.artistPages(sourceId, 0, "")
|
||||||
.page()
|
.page()
|
||||||
.first { it.id == "ar-1" }
|
.first { it.id == "ar-1" }
|
||||||
.albumCount,
|
.albumCount,
|
||||||
@@ -1006,7 +1127,7 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
// ar-1 has two downloaded albums, ar-2 one, so the downloaded order is ar-1 first even
|
// ar-1 has two downloaded albums, ar-2 one, so the downloaded order is ar-1 first even
|
||||||
// though the synced counts would put ar-2 first.
|
// though the synced counts would put ar-2 first.
|
||||||
val filtered = dao.artistsByAlbumCount(sourceId, 0, "", downloadedFilter = 1).page()
|
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true, sort = ArtistSort.AlbumCount).page()
|
||||||
assertEquals(listOf("ar-1", "ar-2"), filtered.map { it.id })
|
assertEquals(listOf("ar-1", "ar-2"), filtered.map { it.id })
|
||||||
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
|
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
|
||||||
}
|
}
|
||||||
@@ -1036,7 +1157,7 @@ class LibraryDaoTest {
|
|||||||
)
|
)
|
||||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||||
|
|
||||||
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
|
val filtered = dao.playlistPages(sourceId, "", downloaded = true).page()
|
||||||
assertEquals(1, filtered.size)
|
assertEquals(1, filtered.size)
|
||||||
assertEquals(1L, filtered.first().songCount)
|
assertEquals(1L, filtered.first().songCount)
|
||||||
assertEquals("austin", filtered.first().owner)
|
assertEquals("austin", filtered.first().owner)
|
||||||
@@ -1045,7 +1166,7 @@ class LibraryDaoTest {
|
|||||||
assertEquals(
|
assertEquals(
|
||||||
5L,
|
5L,
|
||||||
dao
|
dao
|
||||||
.playlistsByName(sourceId, "")
|
.playlistPages(sourceId, "")
|
||||||
.page()
|
.page()
|
||||||
.first { it.id == "pl-1" }
|
.first { it.id == "pl-1" }
|
||||||
.songCount,
|
.songCount,
|
||||||
@@ -1064,11 +1185,11 @@ class LibraryDaoTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "amm").page().map { it.name })
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "GAM").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "GAM").page().map { it.name })
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "ga").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "ga").page().map { it.name })
|
||||||
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, 0, "Else").page().map { it.name })
|
assertEquals(listOf("Alpha"), dao.albumPages(sourceId, 0, "Else").page().map { it.name })
|
||||||
assertTrue(dao.albumsByName(sourceId, 0, "zzz").page().isEmpty())
|
assertTrue(dao.albumPages(sourceId, 0, "zzz").page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1077,17 +1198,17 @@ class LibraryDaoTest {
|
|||||||
val sourceId = source()
|
val sourceId = source()
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null)))
|
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null)))
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "gam").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "gam").page().map { it.name })
|
||||||
|
|
||||||
dao.setAlbumStar(sourceId, "a", 7)
|
dao.setAlbumStar(sourceId, "a", 7)
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "gam").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "gam").page().map { it.name })
|
||||||
|
|
||||||
dao.upsertAlbums(listOf(album(sourceId, "a", "Delta", year = null, starred = null)))
|
dao.upsertAlbums(listOf(album(sourceId, "a", "Delta", year = null, starred = null)))
|
||||||
assertTrue(dao.albumsByName(sourceId, 0, "gam").page().isEmpty())
|
assertTrue(dao.albumPages(sourceId, 0, "gam").page().isEmpty())
|
||||||
assertEquals(listOf("Delta"), dao.albumsByName(sourceId, 0, "elt").page().map { it.name })
|
assertEquals(listOf("Delta"), dao.albumPages(sourceId, 0, "elt").page().map { it.name })
|
||||||
|
|
||||||
dao.deleteAlbums(sourceId, listOf("a"))
|
dao.deleteAlbums(sourceId, listOf("a"))
|
||||||
assertTrue(dao.albumsByName(sourceId, 0, "elt").page().isEmpty())
|
assertTrue(dao.albumPages(sourceId, 0, "elt").page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1101,8 +1222,8 @@ class LibraryDaoTest {
|
|||||||
val second = source()
|
val second = source()
|
||||||
dao.upsertAlbums(listOf(album(second, "a", "Beta", year = null, starred = null)))
|
dao.upsertAlbums(listOf(album(second, "a", "Beta", year = null, starred = null)))
|
||||||
|
|
||||||
assertEquals(listOf("Beta"), dao.albumsByName(second, 0, "eta").page().map { it.name })
|
assertEquals(listOf("Beta"), dao.albumPages(second, 0, "eta").page().map { it.name })
|
||||||
assertTrue(dao.albumsByName(second, 0, "gam").page().isEmpty())
|
assertTrue(dao.albumPages(second, 0, "gam").page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1113,10 +1234,10 @@ class LibraryDaoTest {
|
|||||||
dao.upsertArtists(listOf(artist(sourceId, "r1", "The Beatles", albumCount = 1)))
|
dao.upsertArtists(listOf(artist(sourceId, "r1", "The Beatles", albumCount = 1)))
|
||||||
dao.upsertPlaylists(listOf(playlist(sourceId, "l1", "Road Trip", created = 0, changed = 0)))
|
dao.upsertPlaylists(listOf(playlist(sourceId, "l1", "Road Trip", created = 0, changed = 0)))
|
||||||
|
|
||||||
assertEquals(listOf("r1"), dao.artistsByName(sourceId, 0, "eat").page().map { it.id })
|
assertEquals(listOf("r1"), dao.artistPages(sourceId, 0, "eat").page().map { it.id })
|
||||||
assertEquals(listOf("l1"), dao.playlistsByName(sourceId, "rip").page().map { it.id })
|
assertEquals(listOf("l1"), dao.playlistPages(sourceId, "rip").page().map { it.id })
|
||||||
assertTrue(dao.artistsByName(sourceId, 0, "rip").page().isEmpty())
|
assertTrue(dao.artistPages(sourceId, 0, "rip").page().isEmpty())
|
||||||
assertTrue(dao.playlistsByName(sourceId, "eat").page().isEmpty())
|
assertTrue(dao.playlistPages(sourceId, "eat").page().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1131,10 +1252,10 @@ class LibraryDaoTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("Say \"Hello\""), dao.albumsByName(sourceId, 0, "\"Hel").page().map { it.name })
|
assertEquals(listOf("Say \"Hello\""), dao.albumPages(sourceId, 0, "\"Hel").page().map { it.name })
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Say \"Hello\""),
|
listOf("Say \"Hello\""),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "\"Hel", AlbumSort.Name, false)).page().map { it.name },
|
dao.albumPages(sourceId, 0, "\"Hel", AlbumSort.Name, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,7 +1266,7 @@ class LibraryDaoTest {
|
|||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null).copy(albumArtist = null)))
|
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null).copy(albumArtist = null)))
|
||||||
|
|
||||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
|
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "amm").page().map { it.name })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1236,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,
|
||||||
@@ -1250,11 +1389,16 @@ class LibraryDaoTest {
|
|||||||
assertFalse(plan, plan.contains("SCAN songs"))
|
assertFalse(plan, plan.contains("SCAN songs"))
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun plan(sql: String): String =
|
private suspend fun plan(
|
||||||
|
sql: String,
|
||||||
|
topLevelOnly: Boolean = false,
|
||||||
|
): String =
|
||||||
db.useReaderConnection { connection ->
|
db.useReaderConnection { connection ->
|
||||||
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
||||||
val details = mutableListOf<String>()
|
val details = mutableListOf<String>()
|
||||||
while (statement.step()) details += statement.getText(3)
|
while (statement.step()) {
|
||||||
|
if (!topLevelOnly || statement.getLong(1) == 0L) details += statement.getText(3)
|
||||||
|
}
|
||||||
details.joinToString("\n")
|
details.joinToString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1274,35 +1418,35 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma"),
|
listOf("Alpha", "Beta", "Gamma"),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false)).page().map { it.name },
|
dao.albumPages(sourceId, 0, "", AlbumSort.Name, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Gamma", "Beta", "Alpha"),
|
listOf("Gamma", "Beta", "Alpha"),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, true)).page().map { it.name },
|
dao.albumPages(sourceId, 0, "", AlbumSort.Name, true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta", "Gamma"),
|
listOf("Alpha", "Beta", "Gamma"),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Year, false)).page().map { it.name },
|
dao.albumPages(sourceId, 0, "", AlbumSort.Year, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Gamma", "Beta", "Alpha"),
|
listOf("Gamma", "Beta", "Alpha"),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Artist, false)).page().map { it.name },
|
dao.albumPages(sourceId, 0, "", AlbumSort.Artist, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha"),
|
listOf("Alpha"),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "alp", AlbumSort.Name, false)).page().map { it.name },
|
dao.albumPages(sourceId, 0, "alp", AlbumSort.Name, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Gamma"),
|
listOf("Gamma"),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "am", AlbumSort.Name, false)).page().map { it.name },
|
dao.albumPages(sourceId, 0, "am", AlbumSort.Name, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha"),
|
listOf("Alpha"),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 1, "", AlbumSort.Name, false)).page().map { it.name },
|
dao.albumPages(sourceId, 1, "", AlbumSort.Name, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beta", "Gamma"),
|
listOf("Beta", "Gamma"),
|
||||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 2, "", AlbumSort.Name, false)).page().map { it.name },
|
dao.albumPages(sourceId, 2, "", AlbumSort.Name, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1326,23 +1470,23 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta"),
|
listOf("Alpha", "Beta"),
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "a", ArtistSort.Name, false)).page().map { it.name },
|
dao.artistPages(sourceId, 0, "a", ArtistSort.Name, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beta", "Alpha"),
|
listOf("Beta", "Alpha"),
|
||||||
dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.AlbumCount, false)).page().map { it.name },
|
dao.artistPages(sourceId, 0, "", ArtistSort.AlbumCount, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta"),
|
listOf("Alpha", "Beta"),
|
||||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "a", PlaylistSort.Name, false)).page().map { it.name },
|
dao.playlistPages(sourceId, "a", PlaylistSort.Name, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Alpha", "Beta"),
|
listOf("Alpha", "Beta"),
|
||||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Added, false)).page().map { it.name },
|
dao.playlistPages(sourceId, "", PlaylistSort.Added, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beta", "Alpha"),
|
listOf("Beta", "Alpha"),
|
||||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Updated, false)).page().map { it.name },
|
dao.playlistPages(sourceId, "", PlaylistSort.Updated, false).page().map { it.name },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1355,12 +1499,12 @@ class LibraryDaoTest {
|
|||||||
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null).copy(albumId = "a")))
|
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null).copy(albumId = "a")))
|
||||||
|
|
||||||
val defaultInvalidated = AtomicBoolean(false)
|
val defaultInvalidated = AtomicBoolean(false)
|
||||||
val default = dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false))
|
val default = dao.albumPages(sourceId, 0, "", AlbumSort.Name, false)
|
||||||
default.registerInvalidatedCallback { defaultInvalidated.set(true) }
|
default.registerInvalidatedCallback { defaultInvalidated.set(true) }
|
||||||
default.page()
|
default.page()
|
||||||
|
|
||||||
val downloadedInvalidated = AtomicBoolean(false)
|
val downloadedInvalidated = AtomicBoolean(false)
|
||||||
val downloaded = dao.albumsByName(sourceId, 0, "", downloadedFilter = 1)
|
val downloaded = dao.albumPages(sourceId, 0, "", downloaded = true)
|
||||||
downloaded.registerInvalidatedCallback { downloadedInvalidated.set(true) }
|
downloaded.registerInvalidatedCallback { downloadedInvalidated.set(true) }
|
||||||
downloaded.page()
|
downloaded.page()
|
||||||
|
|
||||||
@@ -1373,6 +1517,52 @@ class LibraryDaoTest {
|
|||||||
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
|
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloadWritesInvalidateTheDownloadedArtistAndPlaylistPagingSources() =
|
||||||
|
runTest {
|
||||||
|
val sourceId = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 1)))
|
||||||
|
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Album", year = null, starred = null)))
|
||||||
|
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null)))
|
||||||
|
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "Playlist", created = 0, changed = 0)))
|
||||||
|
dao.upsertPlaylistSongs(listOf(PlaylistSong(sourceId, "pl-1", "s1", 0)))
|
||||||
|
|
||||||
|
val artistInvalidated = AtomicBoolean(false)
|
||||||
|
val artists = dao.artistPages(sourceId, downloaded = true)
|
||||||
|
artists.registerInvalidatedCallback { artistInvalidated.set(true) }
|
||||||
|
artists.page()
|
||||||
|
|
||||||
|
val playlistInvalidated = AtomicBoolean(false)
|
||||||
|
val playlists = dao.playlistPages(sourceId, downloaded = true)
|
||||||
|
playlists.registerInvalidatedCallback { playlistInvalidated.set(true) }
|
||||||
|
playlists.page()
|
||||||
|
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||||
|
|
||||||
|
awaitUntil("the downloaded artists observe download writes") { artistInvalidated.get() }
|
||||||
|
awaitUntil("the downloaded playlists observe download writes") { playlistInvalidated.get() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun songWritesInvalidateTheDownloadedArtistPagingSource() =
|
||||||
|
runTest {
|
||||||
|
val sourceId = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 1)))
|
||||||
|
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Album", year = null, starred = null)))
|
||||||
|
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null)))
|
||||||
|
|
||||||
|
val invalidated = AtomicBoolean(false)
|
||||||
|
val artists = dao.artistPages(sourceId, downloaded = true)
|
||||||
|
artists.registerInvalidatedCallback { invalidated.set(true) }
|
||||||
|
artists.page()
|
||||||
|
|
||||||
|
dao.upsertSongs(listOf(song(sourceId, "s2", "Two", starred = null)))
|
||||||
|
|
||||||
|
awaitUntil("the downloaded artists observe song writes") { invalidated.get() }
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun sortNamesDriveArtistAndAlbumOrder() =
|
fun sortNamesDriveArtistAndAlbumOrder() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -1395,27 +1585,27 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("宇多田ヒカル", "あいみょん"),
|
listOf("宇多田ヒカル", "あいみょん"),
|
||||||
dao.artistsByName(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "").page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("あいみょん", "宇多田ヒカル"),
|
listOf("あいみょん", "宇多田ヒカル"),
|
||||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.name },
|
dao.artistPages(sourceId, 0, "", descending = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Zzz", "Aaa"),
|
listOf("Zzz", "Aaa"),
|
||||||
dao.albumsByName(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "").page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aaa", "Zzz"),
|
listOf("Aaa", "Zzz"),
|
||||||
dao.albumsByNameReversed(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", descending = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aaa", "Zzz"),
|
listOf("Aaa", "Zzz"),
|
||||||
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Zzz", "Aaa"),
|
listOf("Zzz", "Aaa"),
|
||||||
dao.albumsByArtistReversed(sourceId, 0, "").page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, descending = true).page().map { it.name },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1445,39 +1635,111 @@ class LibraryDaoTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
|
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
|
||||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.SortName.name).page().map { it.name },
|
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.SortName).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.Name).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
|
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
|
||||||
dao.artistsByNameReversed(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
dao
|
||||||
|
.artistPages(sourceId, 0, "", descending = true, sortNameMode = SortNameMode.Name)
|
||||||
|
.page()
|
||||||
|
.map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
|
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
|
||||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.NonLatin.name).page().map { it.name },
|
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.NonLatin).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aaa", "Beatles", "Zzz"),
|
listOf("Aaa", "Beatles", "Zzz"),
|
||||||
dao.albumsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
dao.albumPages(sourceId, 0, "", sortNameMode = SortNameMode.Name).page().map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aaa", "Zzz", "Beatles"),
|
listOf("Aaa", "Zzz", "Beatles"),
|
||||||
dao.albumsByArtist(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
dao
|
||||||
|
.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, sortNameMode = SortNameMode.Name)
|
||||||
|
.page()
|
||||||
|
.map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||||
dao
|
dao
|
||||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name))
|
.artistsPaging(
|
||||||
.page()
|
artistsPageQuery(sourceId, 0, "", ArtistSort.Name, false, false, SortNameMode.Name),
|
||||||
|
).page()
|
||||||
.map { it.name },
|
.map { it.name },
|
||||||
)
|
)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("Aaa", "Beatles", "Zzz"),
|
listOf("Aaa", "Beatles", "Zzz"),
|
||||||
dao
|
dao
|
||||||
.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false, SortNameMode.Name))
|
.albumsPaging(
|
||||||
|
albumsPageQuery(sourceId, 0, "", AlbumSort.Name, false, false, SortNameMode.Name),
|
||||||
|
).page()
|
||||||
|
.map { it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloadedSortsHonourTheSortNamePreference() =
|
||||||
|
runTest {
|
||||||
|
val sourceId = source()
|
||||||
|
val dao = db.libraryDao()
|
||||||
|
dao.upsertArtists(
|
||||||
|
listOf(
|
||||||
|
artist(sourceId, "ar-1", "Beatles", albumCount = 1).copy(sortName = "Beatles, The"),
|
||||||
|
artist(sourceId, "ar-2", "Zebra", albumCount = 1).copy(sortName = "Aaa"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dao.upsertAlbums(
|
||||||
|
listOf(
|
||||||
|
album(sourceId, "al-1", "Beatles", year = null, starred = null)
|
||||||
|
.copy(sortName = "Beatles, The", albumArtistSort = "Beatles, The", albumArtist = "Beatles"),
|
||||||
|
album(sourceId, "al-2", "Zebra", year = null, starred = null)
|
||||||
|
.copy(
|
||||||
|
sortName = "Aaa",
|
||||||
|
albumArtistSort = "Aaa",
|
||||||
|
albumArtist = "Zebra",
|
||||||
|
artistId = "ar-2",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
dao.upsertSongs(
|
||||||
|
listOf(
|
||||||
|
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1"),
|
||||||
|
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-2"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
listOf("s1", "s2").forEach {
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, it, DownloadStatus.Completed))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf("Zebra", "Beatles"),
|
||||||
|
dao
|
||||||
|
.artistPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.SortName)
|
||||||
|
.page()
|
||||||
|
.map { it.name },
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("Beatles", "Zebra"),
|
||||||
|
dao
|
||||||
|
.artistPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.Name)
|
||||||
|
.page()
|
||||||
|
.map { it.name },
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("Zebra", "Beatles"),
|
||||||
|
dao
|
||||||
|
.albumPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.SortName)
|
||||||
|
.page()
|
||||||
|
.map { it.name },
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("Beatles", "Zebra"),
|
||||||
|
dao
|
||||||
|
.albumPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.Name)
|
||||||
.page()
|
.page()
|
||||||
.map { it.name },
|
.map { it.name },
|
||||||
)
|
)
|
||||||
@@ -1507,11 +1769,11 @@ class LibraryDaoTest {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
|
assertEquals(listOf("宇多田ヒカル"), dao.artistPages(sourceId, 0, "utada").page().map { it.name })
|
||||||
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name })
|
assertEquals(listOf("宇多田ヒカル"), dao.artistPages(sourceId, 0, "ut").page().map { it.name })
|
||||||
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
|
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
|
||||||
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name })
|
assertEquals(listOf("First Love"), dao.albumPages(sourceId, 0, "first").page().map { it.name })
|
||||||
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name })
|
assertEquals(listOf("First Love"), dao.albumPages(sourceId, 0, "fi").page().map { it.name })
|
||||||
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
|
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
|
||||||
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
|
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
@@ -27,12 +23,7 @@ class LibraryHomeDaoTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import com.subtracks.data.model.Album
|
||||||
|
import com.subtracks.data.model.Artist
|
||||||
|
import com.subtracks.data.model.Playlist
|
||||||
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
|
|
||||||
|
internal fun LibraryDao.albumPages(
|
||||||
|
sourceId: Long,
|
||||||
|
starredFilter: Int = 0,
|
||||||
|
search: String = "",
|
||||||
|
sort: AlbumSort = AlbumSort.Name,
|
||||||
|
descending: Boolean = false,
|
||||||
|
downloaded: Boolean = false,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
|
): PagingSource<Int, Album> =
|
||||||
|
if (downloaded) {
|
||||||
|
downloadedAlbumsPaging(
|
||||||
|
albumsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
starredFilter = starredFilter,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = true,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
albumsPaging(
|
||||||
|
albumsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
starredFilter = starredFilter,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = false,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun LibraryDao.artistPages(
|
||||||
|
sourceId: Long,
|
||||||
|
starredFilter: Int = 0,
|
||||||
|
search: String = "",
|
||||||
|
sort: ArtistSort = ArtistSort.Name,
|
||||||
|
descending: Boolean = false,
|
||||||
|
downloaded: Boolean = false,
|
||||||
|
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||||
|
): PagingSource<Int, Artist> =
|
||||||
|
if (downloaded) {
|
||||||
|
downloadedArtistsPaging(
|
||||||
|
artistsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
starredFilter = starredFilter,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = true,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
artistsPaging(
|
||||||
|
artistsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
starredFilter = starredFilter,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = false,
|
||||||
|
sortNameMode = sortNameMode,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun LibraryDao.playlistPages(
|
||||||
|
sourceId: Long,
|
||||||
|
search: String = "",
|
||||||
|
sort: PlaylistSort = PlaylistSort.Name,
|
||||||
|
descending: Boolean = false,
|
||||||
|
downloaded: Boolean = false,
|
||||||
|
): PagingSource<Int, Playlist> =
|
||||||
|
if (downloaded) {
|
||||||
|
downloadedPlaylistsPaging(
|
||||||
|
playlistsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
playlistsPaging(
|
||||||
|
playlistsPageQuery(
|
||||||
|
sourceId = sourceId,
|
||||||
|
search = search,
|
||||||
|
sort = sort,
|
||||||
|
descending = descending,
|
||||||
|
downloaded = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.SQLiteConnection
|
import androidx.sqlite.SQLiteConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.sqlite.execSQL
|
import androidx.sqlite.execSQL
|
||||||
|
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.PlaylistSong
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
|
import com.subtracks.data.repo.QueueRepository
|
||||||
|
import com.subtracks.data.repo.QueueResetPreparation
|
||||||
|
import com.subtracks.data.repo.removeAt
|
||||||
|
import com.subtracks.data.repo.replace
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -483,6 +493,138 @@ class MigrationsTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun migratedFragmentedDuplicateEntriesAreLeftUnresetWhenTheirIdentityIsUnknown() =
|
||||||
|
runTest {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val name = "migration-38-queue-${System.nanoTime()}.db"
|
||||||
|
val databaseFile = context.getDatabasePath(name)
|
||||||
|
databaseFile.parentFile?.mkdirs()
|
||||||
|
driver.open(databaseFile.absolutePath).use { migrated ->
|
||||||
|
applySchema(migrated, 38)
|
||||||
|
migrated.execSQL("PRAGMA user_version = 38")
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 'server', 'http://localhost', 1, 0)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO songs (sourceId, id, title) VALUES " +
|
||||||
|
"(1, 's1', 'One'), (1, 's2', 'Two'), (1, 's3', 'Three')",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO playlist_songs (sourceId, playlistId, songId, position) VALUES " +
|
||||||
|
"(1, 'p1', 's1', 0), (1, 'p1', 's2', 1), (1, 'p1', 's3', 2)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO queue_entries (position, sourceId, kind, refId, rangeStart, rangeEnd) VALUES " +
|
||||||
|
"(0, 1, 'Playlist', 'p1', 0, 0), (1, 1, 'Playlist', 'p1', 2, 2), " +
|
||||||
|
"(2, 1, 'Playlist', 'p1', 0, 0), (3, 1, 'Playlist', 'p1', 2, 2)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val db =
|
||||||
|
Room
|
||||||
|
.databaseBuilder<SubtracksDatabase>(context, name)
|
||||||
|
.setDriver(driver)
|
||||||
|
.addMigrations(MIGRATION_38_39)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
val repository = QueueRepository(db)
|
||||||
|
val point = repository.captureResetPoint()
|
||||||
|
|
||||||
|
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||||
|
|
||||||
|
val after = repository.snapshot()
|
||||||
|
assertEquals(4, after.entries.size)
|
||||||
|
assertEquals(listOf(null, null, null, null), after.entries.map { it.entry.originEntryId })
|
||||||
|
assertEquals(
|
||||||
|
listOf("s1", "s3", "s1", "s3"),
|
||||||
|
(0 until after.size).map { repository.itemAt(after, it)?.song?.id },
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.removeAt(after, 0)
|
||||||
|
repository.replace(after.entries.map { it.entry })
|
||||||
|
val undoPoint = repository.captureResetPoint()
|
||||||
|
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), undoPoint)
|
||||||
|
|
||||||
|
val undone = repository.snapshot()
|
||||||
|
assertEquals(4, undone.entries.size)
|
||||||
|
assertEquals(
|
||||||
|
listOf("s1", "s3", "s1", "s3"),
|
||||||
|
(0 until undone.size).map { repository.itemAt(undone, it)?.song?.id },
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aRetainedMigratedOriginStillRemapsAndPublishesWhenItsRowsChange() =
|
||||||
|
runTest {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val name = "migration-38-remap-${System.nanoTime()}.db"
|
||||||
|
databaseFile(context, name).parentFile?.mkdirs()
|
||||||
|
driver.open(databaseFile(context, name).absolutePath).use { migrated ->
|
||||||
|
applySchema(migrated, 38)
|
||||||
|
migrated.execSQL("PRAGMA user_version = 38")
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO sources (id, name, address, isActive, createdAt) VALUES (1, 'server', 'http://localhost', 1, 0)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO songs (sourceId, id, title) VALUES " +
|
||||||
|
"(1, 's0', 'Zero'), (1, 's1', 'One'), (1, 's2', 'Two'), (1, 's3', 'Three'), (1, 's4', 'Four')",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO playlist_songs (sourceId, playlistId, songId, position) VALUES " +
|
||||||
|
"(1, 'p1', 's1', 0), (1, 'p1', 's2', 1), (1, 'p1', 's3', 2), (1, 'p1', 's4', 3)",
|
||||||
|
)
|
||||||
|
migrated.execSQL(
|
||||||
|
"INSERT INTO queue_entries (position, sourceId, kind, refId, rangeStart, rangeEnd) VALUES " +
|
||||||
|
"(0, 1, 'Playlist', 'p1', 0, 1), (1, 1, 'Playlist', 'p1', 2, 3)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val db =
|
||||||
|
Room
|
||||||
|
.databaseBuilder<SubtracksDatabase>(context, name)
|
||||||
|
.setDriver(driver)
|
||||||
|
.addMigrations(MIGRATION_38_39)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
val repository = QueueRepository(db)
|
||||||
|
repository.setCursor(2)
|
||||||
|
val point = repository.captureResetPoint()
|
||||||
|
var resets = 0
|
||||||
|
repository.setResetListener {
|
||||||
|
resets++
|
||||||
|
QueueResetPreparation(it.snapshot)
|
||||||
|
}
|
||||||
|
db.libraryDao().deletePlaylistSongsFrom(1, "p1", 0)
|
||||||
|
db.libraryDao().upsertPlaylistSongs(
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "p1", "s0", 0),
|
||||||
|
PlaylistSong(1, "p1", "s1", 1),
|
||||||
|
PlaylistSong(1, "p1", "s2", 2),
|
||||||
|
PlaylistSong(1, "p1", "s3", 3),
|
||||||
|
PlaylistSong(1, "p1", "s4", 4),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), point)
|
||||||
|
|
||||||
|
val snapshot = repository.snapshot()
|
||||||
|
assertEquals(1, resets)
|
||||||
|
assertEquals(3L, repository.cursor())
|
||||||
|
assertEquals("s3", repository.itemAt(snapshot, repository.cursor())?.song?.id)
|
||||||
|
} finally {
|
||||||
|
db.close()
|
||||||
|
context.deleteDatabase(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun databaseFile(
|
||||||
|
context: Context,
|
||||||
|
name: String,
|
||||||
|
): File = context.getDatabasePath(name)
|
||||||
|
|
||||||
private fun text(
|
private fun text(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
sql: String,
|
sql: String,
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package com.subtracks.data.db
|
package com.subtracks.data.db
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.immediateTransaction
|
import androidx.room3.immediateTransaction
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
@@ -14,6 +11,9 @@ import com.subtracks.data.model.Playlist
|
|||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.prefs.AlbumSort
|
||||||
|
import com.subtracks.data.prefs.ArtistSort
|
||||||
|
import com.subtracks.data.prefs.PlaylistSort
|
||||||
import com.subtracks.data.source.MusicSource
|
import com.subtracks.data.source.MusicSource
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.sync.SyncService
|
import com.subtracks.data.sync.SyncService
|
||||||
@@ -37,14 +37,8 @@ class QueryTimingMeasurementTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||||
db =
|
db = createTestDatabase(driver)
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(driver)
|
|
||||||
.addCallback(ArtistSearchTriggerOptimization)
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -110,28 +104,37 @@ class QueryTimingMeasurementTest {
|
|||||||
|
|
||||||
private suspend fun measureReads() {
|
private suspend fun measureReads() {
|
||||||
val dao = db.libraryDao()
|
val dao = db.libraryDao()
|
||||||
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
|
timed("albums first page (name)") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).firstPage() }
|
||||||
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
|
timed("albums first page (name, downloaded)") {
|
||||||
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
|
dao.albumPages(1, 0, "", sort = AlbumSort.Name, downloaded = true).firstPage()
|
||||||
timed("albums first page (added)") { dao.albumsByRecentlyAdded(1, 0, "").firstPage() }
|
}
|
||||||
timed("albums first page (starred)") { dao.albumsByStarred(1, 0, "").firstPage() }
|
timed("albums first page (artist)") { dao.albumPages(1, 0, "", sort = AlbumSort.Artist).firstPage() }
|
||||||
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
|
timed("albums first page (year)") { dao.albumPages(1, 0, "", sort = AlbumSort.Year).firstPage() }
|
||||||
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
timed("albums first page (added)") { dao.albumPages(1, 0, "", sort = AlbumSort.Added).firstPage() }
|
||||||
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
timed("albums first page (starred)") { dao.albumPages(1, 0, "", sort = AlbumSort.Starred).firstPage() }
|
||||||
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
|
timed("albums first page (frequent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Frequent).firstPage() }
|
||||||
|
timed("albums first page (recent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Recent).firstPage() }
|
||||||
|
timed("artists first page (name)") { dao.artistPages(1, 0, "", sort = ArtistSort.Name).firstPage() }
|
||||||
|
timed("artists first page (name, downloaded)") {
|
||||||
|
dao.artistPages(1, 0, "", sort = ArtistSort.Name, downloaded = true).firstPage()
|
||||||
|
}
|
||||||
|
timed("artists first page (frequent)") { dao.artistPages(1, 0, "", sort = ArtistSort.Frequent).firstPage() }
|
||||||
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
|
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
|
||||||
timed("artist name") { dao.artistName(1, "ar0").first() }
|
timed("artist name") { dao.artistName(1, "ar0").first() }
|
||||||
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
||||||
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
||||||
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
||||||
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
|
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
|
||||||
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
|
timed("artists downloaded filter") { dao.artistPages(1, 0, "", downloaded = true).firstPage() }
|
||||||
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
|
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
|
||||||
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
||||||
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
|
timed("playlists first page (name)") { dao.playlistPages(1, "", sort = PlaylistSort.Name).firstPage() }
|
||||||
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
timed("playlists first page (name, downloaded)") {
|
||||||
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
|
dao.playlistPages(1, "", sort = PlaylistSort.Name, downloaded = true).firstPage()
|
||||||
timed("albums search short") { dao.albumsByName(1, 0, "al").firstPage() }
|
}
|
||||||
|
timed("albums deep page") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).deepPage(1000) }
|
||||||
|
timed("albums search FTS") { dao.albumPages(1, 0, "Album 1", sort = AlbumSort.Name).firstPage() }
|
||||||
|
timed("albums search short") { dao.albumPages(1, 0, "al", sort = AlbumSort.Name).firstPage() }
|
||||||
timed("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
|
timed("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
|
||||||
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
||||||
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
|
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.subtracks.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room3.Room
|
||||||
|
import androidx.sqlite.SQLiteDriver
|
||||||
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
|
||||||
|
fun createTestDatabase(driver: SQLiteDriver = BundledSQLiteDriver()): SubtracksDatabase {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
return Room
|
||||||
|
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||||
|
.setDriver(driver)
|
||||||
|
.addSubtracksDatabaseCallbacks()
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ class FakeDownloadEngine : DownloadEngine {
|
|||||||
var enqueueDelayMs = 0L
|
var enqueueDelayMs = 0L
|
||||||
var failEnqueueFor: String? = null
|
var failEnqueueFor: String? = null
|
||||||
var failCancelOnEmpty = false
|
var failCancelOnEmpty = false
|
||||||
|
var beforeDownload: ((Long) -> Unit)? = null
|
||||||
|
|
||||||
override fun enqueue(request: EngineRequest): Long {
|
override fun enqueue(request: EngineRequest): Long {
|
||||||
if (enqueueDelayMs > 0) Thread.sleep(enqueueDelayMs)
|
if (enqueueDelayMs > 0) Thread.sleep(enqueueDelayMs)
|
||||||
@@ -23,7 +24,10 @@ class FakeDownloadEngine : DownloadEngine {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun download(id: Long): EngineDownload? = downloads[id]
|
override fun download(id: Long): EngineDownload? {
|
||||||
|
beforeDownload?.invoke(id)
|
||||||
|
return downloads[id]
|
||||||
|
}
|
||||||
|
|
||||||
override fun cancel(ids: List<Long>) {
|
override fun cancel(ids: List<Long>) {
|
||||||
if (ids.isEmpty() && failCancelOnEmpty) throw IllegalArgumentException("no ids")
|
if (ids.isEmpty() && failCancelOnEmpty) throw IllegalArgumentException("no ids")
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ class UserPreferencesTest {
|
|||||||
file.delete()
|
file.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun songTapBehaviorDefaultsToInOrderAndRoundTrips() =
|
||||||
|
runTest {
|
||||||
|
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||||
|
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||||
|
val prefs = UserPreferences(store)
|
||||||
|
|
||||||
|
assertEquals(SongTapBehavior.InOrder, prefs.songTapBehavior().first())
|
||||||
|
|
||||||
|
prefs.setSongTapBehavior(SongTapBehavior.Shuffle)
|
||||||
|
|
||||||
|
assertEquals(SongTapBehavior.Shuffle, prefs.songTapBehavior().first())
|
||||||
|
|
||||||
|
store.edit { it[stringPreferencesKey("song_tap_behavior")] = "Bogus" }
|
||||||
|
|
||||||
|
assertEquals(SongTapBehavior.InOrder, prefs.songTapBehavior().first())
|
||||||
|
|
||||||
|
file.delete()
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theSelectedLibraryTabRoundTrips() =
|
fun theSelectedLibraryTabRoundTrips() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ 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.room3.immediateTransaction
|
||||||
|
import androidx.room3.useReaderConnection
|
||||||
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
|
import com.subtracks.data.db.LoggingSQLiteDriver
|
||||||
|
import com.subtracks.data.db.SqlExecution
|
||||||
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
|
||||||
@@ -22,6 +27,7 @@ import com.subtracks.data.model.DownloadStatus
|
|||||||
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.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
@@ -29,13 +35,16 @@ import com.subtracks.data.model.SubsonicSource
|
|||||||
import com.subtracks.data.model.artistImageKey
|
import com.subtracks.data.model.artistImageKey
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.asCoroutineDispatcher
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.mockwebserver.Dispatcher
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
@@ -53,8 +62,12 @@ import org.junit.Test
|
|||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.util.Collections
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class DownloadRepositoryTest {
|
class DownloadRepositoryTest {
|
||||||
@@ -62,6 +75,7 @@ class DownloadRepositoryTest {
|
|||||||
private lateinit var sources: SourceRepository
|
private lateinit var sources: SourceRepository
|
||||||
private lateinit var engine: FakeDownloadEngine
|
private lateinit var engine: FakeDownloadEngine
|
||||||
private lateinit var repository: DownloadRepository
|
private lateinit var repository: DownloadRepository
|
||||||
|
private lateinit var queues: QueueRepository
|
||||||
private lateinit var artwork: ArtworkStore
|
private lateinit var artwork: ArtworkStore
|
||||||
private lateinit var dir: File
|
private lateinit var dir: File
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
@@ -69,6 +83,7 @@ class DownloadRepositoryTest {
|
|||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
private lateinit var resources: Resources
|
private lateinit var resources: Resources
|
||||||
private val requestedArt = CopyOnWriteArrayList<String>()
|
private val requestedArt = CopyOnWriteArrayList<String>()
|
||||||
|
private val auxiliaryDbs = mutableListOf<SubtracksDatabase>()
|
||||||
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
private val artFetchedAfterTheEngineRequest = CopyOnWriteArrayList<Boolean>()
|
||||||
private var failArtworkFetch = false
|
private var failArtworkFetch = false
|
||||||
private val failingArtUrls = CopyOnWriteArrayList<String>()
|
private val failingArtUrls = CopyOnWriteArrayList<String>()
|
||||||
@@ -84,16 +99,13 @@ 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)
|
||||||
engine = FakeDownloadEngine()
|
engine = FakeDownloadEngine()
|
||||||
dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
|
dir = File(context.cacheDir, "downloads-${System.nanoTime()}")
|
||||||
|
queues = QueueRepository(db)
|
||||||
repository =
|
repository =
|
||||||
DownloadRepository(
|
DownloadRepository(
|
||||||
db,
|
db,
|
||||||
@@ -102,6 +114,7 @@ class DownloadRepositoryTest {
|
|||||||
dir,
|
dir,
|
||||||
artworkStore = artwork,
|
artworkStore = artwork,
|
||||||
artworkFetcher = fetcher,
|
artworkFetcher = fetcher,
|
||||||
|
queueRepository = queues,
|
||||||
showMessage = { messages += it.resolve(resources) },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
@@ -110,6 +123,7 @@ class DownloadRepositoryTest {
|
|||||||
@After
|
@After
|
||||||
fun tearDown() {
|
fun tearDown() {
|
||||||
cancelAndJoinBlocking(scope)
|
cancelAndJoinBlocking(scope)
|
||||||
|
auxiliaryDbs.forEach { it.close() }
|
||||||
db.close()
|
db.close()
|
||||||
dir.deleteRecursively()
|
dir.deleteRecursively()
|
||||||
}
|
}
|
||||||
@@ -318,14 +332,14 @@ class DownloadRepositoryTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aSongRemovedFromTheServerTakesItsDownloadRowWithIt() =
|
fun aSongRemovedFromTheServerKeepsItsDownloadRowForRecovery() =
|
||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
runBlocking { repository.download(1, "s1") }
|
runBlocking { repository.download(1, "s1") }
|
||||||
|
|
||||||
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
|
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
|
||||||
|
|
||||||
assertNull(row(1, "s1"))
|
assertEquals(DownloadStatus.Queued, row(1, "s1")?.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -366,6 +380,67 @@ class DownloadRepositoryTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removingASourceBeyondTheSqliteBindLimitDeletesAllDownloads() =
|
||||||
|
runTest {
|
||||||
|
val count = runBlocking { sqliteVariableLimit() }
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(1, "source", "http://localhost", isActive = true, createdAt = 0))
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.immediateTransaction {
|
||||||
|
connection.usePrepared(
|
||||||
|
"WITH RECURSIVE ids(id) AS (SELECT 0 UNION ALL SELECT id + 1 FROM ids WHERE id + 1 < ?) " +
|
||||||
|
"INSERT INTO songs (sourceId, id, title) SELECT 1, 's' || id, 'Song' FROM ids",
|
||||||
|
) { statement ->
|
||||||
|
statement.bindLong(1, count.toLong())
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
connection.usePrepared(
|
||||||
|
"WITH RECURSIVE ids(id) AS (SELECT 0 UNION ALL SELECT id + 1 FROM ids WHERE id + 1 < ?) " +
|
||||||
|
"INSERT INTO song_downloads (sourceId, songId, status, downloadedAt) " +
|
||||||
|
"SELECT 1, 's' || id, 'Completed', id FROM ids",
|
||||||
|
) { statement ->
|
||||||
|
statement.bindLong(1, count.toLong())
|
||||||
|
statement.step()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(count, allRows().size)
|
||||||
|
runBlocking { repository.removeSource(1) }
|
||||||
|
|
||||||
|
assertTrue(allRows().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteAllCapturesTheCurrentQueuePositionBeforeRemovingDownloads() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
seedSongs(3..3)
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1), queues.albumEntry(1, "al1")))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "audio")
|
||||||
|
writeFile(1, "s2", "audio")
|
||||||
|
repository.start()
|
||||||
|
await { repository.localUri("s1") != null && repository.localUri("s2") != null }
|
||||||
|
runBlocking { repository.awaitLoaded() }
|
||||||
|
runBlocking {
|
||||||
|
queues.setCursor(4)
|
||||||
|
queues.setPosition(42_000L)
|
||||||
|
}
|
||||||
|
val beforeReset = runBlocking { queues.snapshot().resetVersion }
|
||||||
|
|
||||||
|
runBlocking { repository.removeSource(1) }
|
||||||
|
await { runBlocking { queues.snapshot().resetVersion > beforeReset } }
|
||||||
|
|
||||||
|
assertEquals(2L, runBlocking { queues.cursor() })
|
||||||
|
assertEquals(42_000L, runBlocking { queues.cursorPositionMs() })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun reconcileDeletesFilesThatBelongToNoDownload() =
|
fun reconcileDeletesFilesThatBelongToNoDownload() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -618,6 +693,19 @@ class DownloadRepositoryTest {
|
|||||||
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
|
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun artworkSurvivesTheDownloadedSongGoingMissing() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking { repository.download(1, "s1") }
|
||||||
|
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
|
||||||
|
|
||||||
|
runBlocking { db.libraryDao().deleteSongs(1, listOf("s1")) }
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
|
||||||
|
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingTheLastDownloadOfAnAlbumRemovesItsArtwork() =
|
fun removingTheLastDownloadOfAnAlbumRemovesItsArtwork() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -1334,6 +1422,54 @@ class DownloadRepositoryTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun useLoggingDatabase(executions: MutableList<SqlExecution>) {
|
||||||
|
auxiliaryDbs += db
|
||||||
|
db = createTestDatabase(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||||
|
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
|
||||||
|
queues = QueueRepository(db)
|
||||||
|
useRepository(scope, Dispatchers.Default)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun useRepository(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
dispatcher: CoroutineDispatcher,
|
||||||
|
) {
|
||||||
|
repository =
|
||||||
|
DownloadRepository(
|
||||||
|
db,
|
||||||
|
sources,
|
||||||
|
engine,
|
||||||
|
dir,
|
||||||
|
artworkStore = artwork,
|
||||||
|
artworkFetcher = fetcher,
|
||||||
|
queueRepository = queues,
|
||||||
|
showMessage = { messages += it.resolve(resources) },
|
||||||
|
dispatcher = dispatcher,
|
||||||
|
scope = scope,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withBlockedCollector(block: (CountDownLatch) -> Unit) {
|
||||||
|
val releaseCollector = CountDownLatch(1)
|
||||||
|
val executor = Executors.newSingleThreadExecutor()
|
||||||
|
val dispatcher = executor.asCoroutineDispatcher()
|
||||||
|
val collectorScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||||
|
val collectorBlocked = CountDownLatch(1)
|
||||||
|
executor.execute {
|
||||||
|
collectorBlocked.countDown()
|
||||||
|
releaseCollector.await()
|
||||||
|
}
|
||||||
|
assertTrue(collectorBlocked.await(5, TimeUnit.SECONDS))
|
||||||
|
useRepository(collectorScope, Dispatchers.Default)
|
||||||
|
try {
|
||||||
|
block(releaseCollector)
|
||||||
|
} finally {
|
||||||
|
releaseCollector.countDown()
|
||||||
|
cancelAndJoinBlocking(collectorScope)
|
||||||
|
dispatcher.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
|
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
|
||||||
|
|
||||||
private fun seedSongs(tracks: IntRange) {
|
private fun seedSongs(tracks: IntRange) {
|
||||||
@@ -1359,6 +1495,664 @@ class DownloadRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun switchingSourcesDoesNotResetAnEditedDownloadedQueue() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(2, "other", "http://other/", isActive = false, createdAt = 1))
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(
|
||||||
|
2,
|
||||||
|
"o1",
|
||||||
|
"other-album",
|
||||||
|
"other-artist",
|
||||||
|
"Other",
|
||||||
|
"Other Album",
|
||||||
|
"Other Artist",
|
||||||
|
100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
db.downloadDao().upsert(SongDownload(2, "o1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
writeFile(2, "o1", "other")
|
||||||
|
repository.start()
|
||||||
|
await { repository.localUri("s1") != null }
|
||||||
|
runBlocking {
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1)))
|
||||||
|
queues.removeAt(queues.snapshot(), 1)
|
||||||
|
}
|
||||||
|
val edited = runBlocking { queues.snapshot().entries.map { it.entry } }
|
||||||
|
assertEquals(1, edited.size)
|
||||||
|
assertNotNull(edited.single().rangeStart)
|
||||||
|
|
||||||
|
runBlocking { sources.selectSource(2) }
|
||||||
|
await { repository.localUri("o1") != null }
|
||||||
|
|
||||||
|
assertEquals(edited, runBlocking { queues.snapshot().entries.map { it.entry } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aDownloadCompletedWhileItsSourceIsInactiveResetsOnReturn() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(2, "other", "http://other/", isActive = false, createdAt = 1))
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(1, "s3", "al1", "ar1", "Song 3", "Album", "Artist", 100, track = 3, disc = 1, starred = null, genre = null),
|
||||||
|
Song(
|
||||||
|
2,
|
||||||
|
"o1",
|
||||||
|
"other-album",
|
||||||
|
"other-artist",
|
||||||
|
"Other",
|
||||||
|
"Other Album",
|
||||||
|
"Other Artist",
|
||||||
|
100,
|
||||||
|
track = 1,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
db.downloadDao().upsert(SongDownload(2, "o1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
writeFile(2, "o1", "other")
|
||||||
|
repository.start()
|
||||||
|
await { repository.localUri("s1") != null }
|
||||||
|
runBlocking {
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1)))
|
||||||
|
queues.removeAt(queues.snapshot(), 1)
|
||||||
|
sources.selectSource(2)
|
||||||
|
}
|
||||||
|
await { repository.localUri("o1") != null }
|
||||||
|
assertNotNull(
|
||||||
|
runBlocking {
|
||||||
|
queues
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
repository.captureForReset(1)
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Completed, downloadedAt = 3))
|
||||||
|
writeFile(1, "s3", "three")
|
||||||
|
repository.reconcile()
|
||||||
|
sources.selectSource(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await { repository.localUri("s3") != null }
|
||||||
|
assertNull(
|
||||||
|
runBlocking {
|
||||||
|
queues
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun backfillingDownloadTimeResetsAnEditedDownloadedQueue() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 42, downloadedAt = 0))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
repository.start()
|
||||||
|
await { repository.localUri("s1") != null && repository.localUri("s2") != null }
|
||||||
|
runBlocking {
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1)))
|
||||||
|
queues.removeAt(queues.snapshot(), 0)
|
||||||
|
}
|
||||||
|
assertNotNull(
|
||||||
|
runBlocking {
|
||||||
|
queues
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
engine.complete(42, total = 10)
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
await { runBlocking { (row(1, "s1")?.downloadedAt ?: 0L) > 0L } }
|
||||||
|
Thread.sleep(200)
|
||||||
|
|
||||||
|
assertNull(
|
||||||
|
runBlocking {
|
||||||
|
queues
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFreshSourceResetsAnEditedDownloadedQueueOnTheSecondCompletion() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.start()
|
||||||
|
runBlocking {
|
||||||
|
repository.awaitLoaded()
|
||||||
|
repository.download(1, "s1")
|
||||||
|
}
|
||||||
|
var engineId = engine.requests.single().first
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
engine.complete(engineId, total = 10)
|
||||||
|
await { repository.localUri("s1") != null }
|
||||||
|
runBlocking {
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
repository.download(1, "s2")
|
||||||
|
}
|
||||||
|
val beforeReset = runBlocking { queues.snapshot().resetVersion }
|
||||||
|
engineId = engine.requests.last().first
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
engine.complete(engineId, total = 10)
|
||||||
|
|
||||||
|
await { runBlocking { queues.snapshot().resetVersion > beforeReset } }
|
||||||
|
|
||||||
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertNull(
|
||||||
|
snapshot.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertEquals(2L, snapshot.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun metadataOnlyUpdatesDoNotReuseAStaleResetPointForALaterCompletion() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
seedSongs(0..0)
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(
|
||||||
|
SongDownload(1, "s2", DownloadStatus.Completed, engineId = 42, bytes = 10, total = 10, downloadedAt = 1),
|
||||||
|
)
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, bytes = 10, total = 10, downloadedAt = 2))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
repository.start()
|
||||||
|
await { repository.localUri("s1") != null && repository.localUri("s2") != null }
|
||||||
|
runBlocking {
|
||||||
|
repository.awaitLoaded()
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 0, rangeEnd = 1)))
|
||||||
|
queues.setCursor(0)
|
||||||
|
}
|
||||||
|
engine.complete(42, total = 20)
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
await { row(1, "s2")?.total == 20L }
|
||||||
|
runBlocking {
|
||||||
|
queues.setCursor(1)
|
||||||
|
repository.download(1, "s0")
|
||||||
|
}
|
||||||
|
val beforeReset = runBlocking { queues.snapshot().resetVersion }
|
||||||
|
val engineId = engine.requests.single().first
|
||||||
|
engine.complete(engineId, total = 10)
|
||||||
|
writeFile(1, "s0", "zero")
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
|
||||||
|
await { runBlocking { queues.snapshot().resetVersion > beforeReset } }
|
||||||
|
|
||||||
|
assertEquals(2L, runBlocking { queues.cursor() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aMultiRowRecoveryKeepsTheResetPointFromBeforeTheWholeBatch() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
seedSongs(3..5)
|
||||||
|
runBlocking {
|
||||||
|
(1..5).forEach { track ->
|
||||||
|
val engineId =
|
||||||
|
if (track == 2) {
|
||||||
|
102L
|
||||||
|
} else if (track == 3) {
|
||||||
|
103L
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
db.downloadDao().upsert(
|
||||||
|
SongDownload(1, "s$track", DownloadStatus.Completed, engineId = engineId, downloadedAt = (6 - track).toLong()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(1..5).forEach { writeFile(1, "s$it", "audio$it") }
|
||||||
|
repository.start()
|
||||||
|
await { (1..5).all { repository.localUri("s$it") != null } }
|
||||||
|
runBlocking {
|
||||||
|
repository.awaitLoaded()
|
||||||
|
repository.reconcile()
|
||||||
|
}
|
||||||
|
val beforeReset =
|
||||||
|
runBlocking {
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 0, rangeEnd = 4)))
|
||||||
|
queues.setCursor(2)
|
||||||
|
queues.snapshot().resetVersion
|
||||||
|
}
|
||||||
|
val firstReset = CountDownLatch(1)
|
||||||
|
queues.setResetListener {
|
||||||
|
firstReset.countDown()
|
||||||
|
QueueResetPreparation(it.snapshot)
|
||||||
|
}
|
||||||
|
file(1, "s2").delete()
|
||||||
|
file(1, "s3").delete()
|
||||||
|
engine.beforeDownload = { engineId ->
|
||||||
|
if (engineId == 103L) firstReset.await(1, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
|
||||||
|
await { runBlocking { queues.snapshot().resetVersion > beforeReset && queues.snapshot().size == 3L } }
|
||||||
|
val (snapshot, cursor) = runBlocking { queues.snapshot() to queues.cursor() }
|
||||||
|
assertEquals(2L, cursor)
|
||||||
|
assertEquals("s5", runBlocking { queues.itemAt(snapshot, cursor)?.song?.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun startupRecoveryResetsAnEditedDownloadedQueueWhenAFileIsMissing() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 1)))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
|
||||||
|
repository.start()
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
|
||||||
|
await {
|
||||||
|
runBlocking {
|
||||||
|
queues
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart == null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aCapturedDeletionBeforeTheFirstEmissionResetsAnEditedDownloadedQueue() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 1)))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
val beforeReset = runBlocking { queues.snapshot().resetVersion }
|
||||||
|
withBlockedCollector { releaseCollector ->
|
||||||
|
repository.start()
|
||||||
|
runBlocking {
|
||||||
|
repository.remove(1, "s2")
|
||||||
|
repository.reconcile()
|
||||||
|
}
|
||||||
|
releaseCollector.countDown()
|
||||||
|
runBlocking { repository.awaitLoaded() }
|
||||||
|
|
||||||
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertEquals(beforeReset + 1, snapshot.resetVersion)
|
||||||
|
assertNull(
|
||||||
|
snapshot.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aCapturedCompletionBeforeTheFirstEmissionResetsAnEditedDownloadedQueue() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
val beforeReset = runBlocking { queues.snapshot().resetVersion }
|
||||||
|
withBlockedCollector { releaseCollector ->
|
||||||
|
repository.start()
|
||||||
|
runBlocking {
|
||||||
|
repository.captureForReset(1)
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
repository.reconcile()
|
||||||
|
}
|
||||||
|
releaseCollector.countDown()
|
||||||
|
runBlocking { repository.awaitLoaded() }
|
||||||
|
|
||||||
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertEquals(beforeReset + 1, snapshot.resetVersion)
|
||||||
|
assertNull(
|
||||||
|
snapshot.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertEquals(2L, snapshot.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aResetAfterAStaleEmissionObservesTheCompletedLocalFile() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Running, engineId = 42, total = 10))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
engine.running(42, bytes = 1, total = 10)
|
||||||
|
repository.start()
|
||||||
|
await {
|
||||||
|
repository.states().value["s1"]?.status == DownloadStatus.Running && repository.localUri("s2") != null
|
||||||
|
}
|
||||||
|
runBlocking {
|
||||||
|
repository.awaitLoaded()
|
||||||
|
repository.reconcile()
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
}
|
||||||
|
val reconciliationStarted = CountDownLatch(1)
|
||||||
|
val finishReconciliation = CountDownLatch(1)
|
||||||
|
val reconciliationTimedOut = AtomicReference(false)
|
||||||
|
engine.beforeDownload = { engineId ->
|
||||||
|
if (engineId == 42L) {
|
||||||
|
reconciliationStarted.countDown()
|
||||||
|
reconciliationTimedOut.set(!finishReconciliation.await(5, TimeUnit.SECONDS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val reset = CountDownLatch(1)
|
||||||
|
val localUriAtReset = AtomicReference<String?>("not reset")
|
||||||
|
queues.setResetListener {
|
||||||
|
localUriAtReset.set(repository.localUri("s1"))
|
||||||
|
reset.countDown()
|
||||||
|
QueueResetPreparation(it.snapshot)
|
||||||
|
}
|
||||||
|
val reconciling = launch(Dispatchers.IO) { repository.reconcile() }
|
||||||
|
assertTrue(reconciliationStarted.await(5, TimeUnit.SECONDS))
|
||||||
|
runBlocking {
|
||||||
|
val running = db.downloadDao().find(1, "s1")!!
|
||||||
|
db.downloadDao().upsert(running.copy(bytes = 2))
|
||||||
|
}
|
||||||
|
await { repository.states().value["s1"]?.bytes == 2L }
|
||||||
|
assertFalse(reconciliationTimedOut.get())
|
||||||
|
|
||||||
|
engine.complete(42, total = 10)
|
||||||
|
finishReconciliation.countDown()
|
||||||
|
reconciling.join()
|
||||||
|
|
||||||
|
assertTrue(reset.await(5, TimeUnit.SECONDS))
|
||||||
|
assertNotNull(localUriAtReset.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPrunedDownloadCompletingDoesNotResetAnEditedDownloadedQueue() =
|
||||||
|
runTest {
|
||||||
|
val beforeReset = preparePrunedDownload(DownloadStatus.Running)
|
||||||
|
writeFile(1, "s3", "three")
|
||||||
|
engine.complete(42, total = 10)
|
||||||
|
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
await { repository.states().value["s3"]?.status == DownloadStatus.Completed }
|
||||||
|
Thread.sleep(200)
|
||||||
|
|
||||||
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertEquals(beforeReset, snapshot.resetVersion)
|
||||||
|
assertEquals(
|
||||||
|
1L,
|
||||||
|
snapshot.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPrunedCompletedDownloadFailingDoesNotResetAnEditedDownloadedQueue() =
|
||||||
|
runTest {
|
||||||
|
val beforeReset = preparePrunedDownload(DownloadStatus.Completed)
|
||||||
|
file(1, "s3").delete()
|
||||||
|
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
await { repository.states().value["s3"]?.status == DownloadStatus.Failed }
|
||||||
|
Thread.sleep(200)
|
||||||
|
|
||||||
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertEquals(beforeReset, snapshot.resetVersion)
|
||||||
|
assertEquals(
|
||||||
|
1L,
|
||||||
|
snapshot.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removingAPrunedCompletedDownloadDoesNotResetAnEditedDownloadedQueue() =
|
||||||
|
runTest {
|
||||||
|
val beforeReset = preparePrunedDownload(DownloadStatus.Completed)
|
||||||
|
|
||||||
|
runBlocking { repository.remove(1, "s3") }
|
||||||
|
await { repository.states().value["s3"] == null }
|
||||||
|
Thread.sleep(200)
|
||||||
|
|
||||||
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertEquals(beforeReset, snapshot.resetVersion)
|
||||||
|
assertEquals(
|
||||||
|
1L,
|
||||||
|
snapshot.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTransientResetFailureIsRetriedWithTheOriginalResetPoint() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
repository.start()
|
||||||
|
runBlocking {
|
||||||
|
repository.awaitLoaded()
|
||||||
|
repository.reconcile()
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 0, rangeEnd = 1)))
|
||||||
|
queues.setCursor(1)
|
||||||
|
}
|
||||||
|
val beforeReset = runBlocking { queues.snapshot().resetVersion }
|
||||||
|
val attempts = CopyOnWriteArrayList<QueueResetPoint>()
|
||||||
|
val secondAttempt = CountDownLatch(1)
|
||||||
|
queues.setResetListener { reset ->
|
||||||
|
attempts += reset.point
|
||||||
|
if (attempts.size == 1) throw IllegalStateException("reset failed")
|
||||||
|
secondAttempt.countDown()
|
||||||
|
QueueResetPreparation(reset.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking { repository.remove(1, "s2") }
|
||||||
|
|
||||||
|
assertTrue(secondAttempt.await(5, TimeUnit.SECONDS))
|
||||||
|
await { runBlocking { queues.snapshot().resetVersion > beforeReset } }
|
||||||
|
assertEquals(2, attempts.size)
|
||||||
|
assertEquals(attempts[0], attempts[1])
|
||||||
|
val snapshot = runBlocking { queues.snapshot() }
|
||||||
|
assertNull(
|
||||||
|
snapshot.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertEquals(1L, snapshot.size)
|
||||||
|
assertEquals(0L, runBlocking { queues.cursor() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFirstEmissionResetFailureCompletesLoadingAndRetries() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
val beforeReset = runBlocking { queues.snapshot().resetVersion }
|
||||||
|
withBlockedCollector { releaseCollector ->
|
||||||
|
val attempts = CopyOnWriteArrayList<QueueResetPoint>()
|
||||||
|
val secondAttempt = CountDownLatch(1)
|
||||||
|
queues.setResetListener { reset ->
|
||||||
|
attempts += reset.point
|
||||||
|
if (attempts.size == 1) throw IllegalStateException("reset failed")
|
||||||
|
secondAttempt.countDown()
|
||||||
|
QueueResetPreparation(reset.snapshot)
|
||||||
|
}
|
||||||
|
repository.start()
|
||||||
|
runBlocking {
|
||||||
|
repository.captureForReset(1)
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
repository.reconcile()
|
||||||
|
}
|
||||||
|
releaseCollector.countDown()
|
||||||
|
|
||||||
|
runBlocking { withTimeout(5_000) { repository.awaitLoaded() } }
|
||||||
|
|
||||||
|
assertTrue(secondAttempt.await(5, TimeUnit.SECONDS))
|
||||||
|
await { runBlocking { queues.snapshot().resetVersion > beforeReset } }
|
||||||
|
assertEquals(2, attempts.size)
|
||||||
|
assertEquals(attempts[0], attempts[1])
|
||||||
|
assertNull(
|
||||||
|
runBlocking {
|
||||||
|
queues
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun byteOnlyProgressSkipsDownloadResetQueries() =
|
||||||
|
runTest {
|
||||||
|
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
||||||
|
useLoggingDatabase(executions)
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(
|
||||||
|
SongDownload(1, "s1", DownloadStatus.Completed, bytes = 1, total = 10, downloadedAt = 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
repository.start()
|
||||||
|
runBlocking { repository.awaitLoaded() }
|
||||||
|
await { repository.localUri("s1") != null }
|
||||||
|
synchronized(executions) { executions.clear() }
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val row = db.downloadDao().find(1, "s1")!!
|
||||||
|
db.downloadDao().upsert(row.copy(bytes = 2))
|
||||||
|
}
|
||||||
|
await { repository.states().value["s1"]?.bytes == 2L }
|
||||||
|
Thread.sleep(300)
|
||||||
|
|
||||||
|
val queries = synchronized(executions) { executions.toList() }
|
||||||
|
assertEquals(1, queries.count { it.sql == "SELECT * FROM song_downloads WHERE sourceId = ?" })
|
||||||
|
assertTrue(queries.none { it.sql.contains("ORDER BY d.downloadedAt DESC") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bulkRemoveSweepsArtworkOnce() =
|
||||||
|
runTest {
|
||||||
|
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
||||||
|
useLoggingDatabase(executions)
|
||||||
|
seedLibrary()
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
synchronized(executions) { executions.clear() }
|
||||||
|
|
||||||
|
runBlocking { repository.remove(1, listOf("s1", "s2")) }
|
||||||
|
|
||||||
|
val queries = synchronized(executions) { executions.toList() }
|
||||||
|
assertEquals(1, queries.count { it.sql.contains("AS albumRow") })
|
||||||
|
assertEquals(1, queries.count { it.sql.startsWith("SELECT DISTINCT ps.sourceId AS sourceId") })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun preparePrunedDownload(status: DownloadStatus): Long {
|
||||||
|
seedLibrary()
|
||||||
|
seedSongs(3..3)
|
||||||
|
runBlocking {
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 2))
|
||||||
|
db.downloadDao().upsert(
|
||||||
|
SongDownload(
|
||||||
|
1,
|
||||||
|
"s3",
|
||||||
|
status,
|
||||||
|
engineId = 42L.takeIf { status == DownloadStatus.Running },
|
||||||
|
downloadedAt = if (status == DownloadStatus.Completed) 3L else 0L,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
writeFile(1, "s1", "one")
|
||||||
|
writeFile(1, "s2", "two")
|
||||||
|
writeFile(1, "s3", "three")
|
||||||
|
if (status == DownloadStatus.Running) engine.running(42, bytes = 1, total = 10)
|
||||||
|
repository.start()
|
||||||
|
await {
|
||||||
|
repository.localUri("s1") != null &&
|
||||||
|
repository.localUri("s2") != null &&
|
||||||
|
(status != DownloadStatus.Completed || repository.localUri("s3") != null)
|
||||||
|
}
|
||||||
|
return runBlocking {
|
||||||
|
repository.awaitLoaded()
|
||||||
|
repository.reconcile()
|
||||||
|
queues.replace(listOf(queues.downloadedEntry(1).copy(rangeStart = 1)))
|
||||||
|
db.libraryDao().deleteSongs(1, listOf("s3"))
|
||||||
|
queues.invalidateLibraryCache()
|
||||||
|
queues.snapshot().resetVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun seedPlaylist(songIds: List<String>) {
|
private fun seedPlaylist(songIds: List<String>) {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
db.libraryDao().upsertPlaylists(listOf(Playlist(1, "pl1", "Playlist", null, null, songIds.size.toLong(), 0)))
|
db.libraryDao().upsertPlaylists(listOf(Playlist(1, "pl1", "Playlist", null, null, songIds.size.toLong(), 0)))
|
||||||
@@ -1456,6 +2250,18 @@ class DownloadRepositoryTest {
|
|||||||
|
|
||||||
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
|
private fun allRows(): List<SongDownload> = runBlocking { db.downloadDao().all() }
|
||||||
|
|
||||||
|
private suspend fun sqliteVariableLimit(): Int =
|
||||||
|
db.useReaderConnection { connection ->
|
||||||
|
connection.usePrepared("PRAGMA compile_options") { statement ->
|
||||||
|
var limit = 32_766
|
||||||
|
while (statement.step()) {
|
||||||
|
val option = statement.getText(0)
|
||||||
|
if (option.startsWith("MAX_VARIABLE_NUMBER=")) limit = option.substringAfter('=').toInt()
|
||||||
|
}
|
||||||
|
limit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun writeFile(
|
private fun writeFile(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -55,6 +54,7 @@ class LibraryRepositoryTest {
|
|||||||
private lateinit var db: SubtracksDatabase
|
private lateinit var db: SubtracksDatabase
|
||||||
private lateinit var sourceRepository: SourceRepository
|
private lateinit var sourceRepository: SourceRepository
|
||||||
private lateinit var repository: LibraryRepository
|
private lateinit var repository: LibraryRepository
|
||||||
|
private lateinit var queues: QueueRepository
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private val messages = ArrayList<String>()
|
private val messages = ArrayList<String>()
|
||||||
private lateinit var resources: Resources
|
private lateinit var resources: Resources
|
||||||
@@ -63,20 +63,18 @@ 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)
|
||||||
|
queues = QueueRepository(db)
|
||||||
repository =
|
repository =
|
||||||
LibraryRepository(
|
LibraryRepository(
|
||||||
db,
|
db,
|
||||||
sourceRepository,
|
sourceRepository,
|
||||||
NetworkServerActionSink(sourceRepository),
|
NetworkServerActionSink(sourceRepository),
|
||||||
fakeUserPreferences(),
|
fakeUserPreferences(),
|
||||||
|
queueRepository = queues,
|
||||||
showMessage = { messages += it.resolve(resources) },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
@@ -164,6 +162,26 @@ class LibraryRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun starringAMissingSongDoesNotResetTheStarredQueue() =
|
||||||
|
runBlocking {
|
||||||
|
withSource { server ->
|
||||||
|
val sourceId = sourceRepository.activeSourceIdOnce()!!
|
||||||
|
db.libraryDao().setSongStar(sourceId, "s1", 1)
|
||||||
|
queues.replace(listOf(queues.starredEntry(sourceId).copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
val before = queues.snapshot()
|
||||||
|
server.enqueue(ok())
|
||||||
|
|
||||||
|
assertTrue(repository.setStar(StarType.Song, "pruned-id", true).isSuccess)
|
||||||
|
|
||||||
|
val after = queues.snapshot()
|
||||||
|
assertEquals(before.resetVersion, after.resetVersion)
|
||||||
|
assertEquals(before.entries.map { it.entry }, after.entries.map { it.entry })
|
||||||
|
assertNull(db.libraryDao().songOnce(sourceId, "pruned-id"))
|
||||||
|
assertEquals("/rest/star.view", server.takeRequest().requestUrl!!.encodedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun albumSongOrdinalFollowsTheAlbumOrder() =
|
fun albumSongOrdinalFollowsTheAlbumOrder() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -208,6 +226,42 @@ class LibraryRepositoryTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDownloadedAlbumGridOnlyPresentsDownloadedRows() =
|
||||||
|
runBlocking {
|
||||||
|
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||||
|
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 1), seedAlbum(sourceId, 2)))
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
seedSong(sourceId).copy(id = "s1", albumId = "al-0001"),
|
||||||
|
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||||
|
|
||||||
|
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||||
|
val presenter = RecordingPresenter(presenterDispatcher)
|
||||||
|
val job =
|
||||||
|
launch {
|
||||||
|
repository
|
||||||
|
.albums(
|
||||||
|
sourceId = sourceId,
|
||||||
|
sort = AlbumSort.Name,
|
||||||
|
descending = false,
|
||||||
|
starred = StarredFilter.Any,
|
||||||
|
search = "",
|
||||||
|
downloaded = true,
|
||||||
|
).collect { presenter.collectFrom(it) }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
awaitRefreshes(presenter, 1)
|
||||||
|
assertEquals(1, presented(presenter, presenterDispatcher))
|
||||||
|
} finally {
|
||||||
|
job.cancelAndJoin()
|
||||||
|
presenterDispatcher.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.awaitUntil
|
import com.subtracks.awaitUntil
|
||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.PendingActionKind
|
import com.subtracks.data.model.PendingActionKind
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
@@ -82,12 +79,7 @@ class PendingServerActionSinkTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
||||||
delegate = RecordingSink()
|
delegate = RecordingSink()
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
package com.subtracks.data.repo
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useReaderConnection
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
import com.subtracks.data.model.Song
|
||||||
@@ -26,12 +23,7 @@ class QueueKeysetTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
repository = QueueRepository(db)
|
repository = QueueRepository(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +150,18 @@ class QueueKeysetTest {
|
|||||||
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
|
assertTrue("backward keyset seeks", repository.keysetSeeks > beforeBackward)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scanningAnAlbumWithNullDiscAndTrackForwardFallsBackToOffsetRows() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
repository.replace(listOf(repository.albumEntry(1, "mixed")))
|
||||||
|
val snapshot = repository.snapshot()
|
||||||
|
|
||||||
|
val resolved = (0 until snapshot.size).map { repository.itemAt(snapshot, it)?.song?.id }
|
||||||
|
|
||||||
|
assertEquals(listOf("n1", "n2", "s1", "s2"), resolved)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -268,6 +272,22 @@ class QueueKeysetTest {
|
|||||||
assertTrue(ids.all { it in expected })
|
assertTrue(ids.all { it in expected })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thePlaylistWindowQuerySeeksThePositionIndex() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
val plan =
|
||||||
|
queryPlan(
|
||||||
|
"SELECT songs.*, playlist_songs.position AS playlistPosition, albums.coverArt AS coverArt " +
|
||||||
|
"FROM playlist_songs JOIN songs ON songs.sourceId = playlist_songs.sourceId " +
|
||||||
|
"AND songs.id = playlist_songs.songId LEFT JOIN albums ON albums.sourceId = songs.sourceId " +
|
||||||
|
"AND albums.id = songs.albumId WHERE playlist_songs.sourceId = 1 " +
|
||||||
|
"AND playlist_songs.playlistId = 'pl1' ORDER BY playlist_songs.position LIMIT 51 OFFSET 100000",
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(plan.joinToString("\n"), plan.any { it.contains("sqlite_autoindex_playlist_songs_1") })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.subtracks.data.repo
|
||||||
|
|
||||||
|
import com.subtracks.data.model.QueueEntry
|
||||||
|
|
||||||
|
suspend fun QueueRepository.replace(
|
||||||
|
entries: List<QueueEntry>,
|
||||||
|
shuffleSeed: Long? = null,
|
||||||
|
upNext: List<QueueEntry> = emptyList(),
|
||||||
|
upNextAnchor: Long = 0,
|
||||||
|
) = withMutationLock {
|
||||||
|
replace(entries, shuffleSeed, upNext, upNextAnchor)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun QueueRepository.setShuffle(seed: Long?) =
|
||||||
|
withMutationLock {
|
||||||
|
setShuffle(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun QueueRepository.addUpNext(
|
||||||
|
currentPosition: Long,
|
||||||
|
entry: QueueEntry,
|
||||||
|
playNext: Boolean,
|
||||||
|
): Long = withMutationLock { addUpNext(currentPosition, entry, playNext) }
|
||||||
|
|
||||||
|
suspend fun QueueRepository.reanchorFor(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
target: Long,
|
||||||
|
): Reanchored = withMutationLock { reanchorFor(snapshot, target) }
|
||||||
|
|
||||||
|
suspend fun QueueRepository.clearUpNext() =
|
||||||
|
withMutationLock {
|
||||||
|
clearUpNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun QueueRepository.removeUpNextAt(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
index: Long,
|
||||||
|
): Boolean = withMutationLock { removeUpNextAt(snapshot, index) }
|
||||||
|
|
||||||
|
suspend fun QueueRepository.removeAt(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
position: Long,
|
||||||
|
): Boolean = withMutationLock { removeAt(snapshot, position) }
|
||||||
|
|
||||||
|
suspend fun QueueRepository.move(
|
||||||
|
snapshot: QueueSnapshot,
|
||||||
|
from: Long,
|
||||||
|
to: Long,
|
||||||
|
): Boolean = withMutationLock { move(snapshot, from, to) }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,22 +2,32 @@ 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.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.TEST_TIMEOUT_MS
|
import com.subtracks.TEST_TIMEOUT_MS
|
||||||
|
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.model.DownloadStatus
|
||||||
|
import com.subtracks.data.model.SongDownload
|
||||||
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.NetworkServerActionSink
|
import com.subtracks.data.repo.NetworkServerActionSink
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
|
import com.subtracks.data.repo.QueueResetPreparation
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.data.repo.replace
|
||||||
|
import com.subtracks.data.repo.setShuffle
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
@@ -29,6 +39,7 @@ import okhttp3.mockwebserver.RecordedRequest
|
|||||||
import okhttp3.mockwebserver.SocketPolicy
|
import okhttp3.mockwebserver.SocketPolicy
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
@@ -55,11 +66,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)
|
||||||
@@ -106,7 +113,40 @@ class SyncManagerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aFailedSyncStillInvalidatesTheLibraryCache() =
|
fun aNoOpSyncPreservesTheQueueResolutionCache() =
|
||||||
|
runBlocking {
|
||||||
|
val healthy = healthyDispatcher()
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
when {
|
||||||
|
request.path?.startsWith("/rest/search3.view") == true -> MockResponse().setBody(TWO_SONGS)
|
||||||
|
request.path?.startsWith("/rest/getPlaylists.view") == true -> MockResponse().setBody(PLAYLISTS)
|
||||||
|
request.path?.startsWith("/rest/getPlaylist.view") == true -> MockResponse().setBody(PLAYLIST)
|
||||||
|
else -> healthy.dispatch(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
|
||||||
|
queueRepository.replace(listOf(queueRepository.playlistEntry(1, "p1")))
|
||||||
|
val snapshot = queueRepository.snapshot()
|
||||||
|
assertEquals(2L, snapshot.size)
|
||||||
|
assertEquals(listOf("s1"), queueRepository.range(snapshot, 0, 0).map { it.item.song.id })
|
||||||
|
assertEquals(listOf("s2"), queueRepository.range(snapshot, 1, 1).map { it.item.song.id })
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
|
||||||
|
val offsetSeeks = queueRepository.offsetSeeks
|
||||||
|
val keysetSeeks = queueRepository.keysetSeeks
|
||||||
|
assertEquals(listOf("s2"), queueRepository.range(snapshot, 1, 1).map { it.item.song.id })
|
||||||
|
|
||||||
|
assertEquals(offsetSeeks, queueRepository.offsetSeeks)
|
||||||
|
assertEquals(keysetSeeks + 1, queueRepository.keysetSeeks)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailedSyncDoesNotAdvanceTheQueueContentVersion() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val before = queueRepository.snapshot().version
|
val before = queueRepository.snapshot().version
|
||||||
|
|
||||||
@@ -114,7 +154,20 @@ class SyncManagerTest {
|
|||||||
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
val status = withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
assertTrue(status is SyncStatus.Failed)
|
assertTrue(status is SyncStatus.Failed)
|
||||||
assertEquals(before + 1, queueRepository.snapshot().version)
|
assertEquals(before, queueRepository.snapshot().version)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailedSyncDoesNotResetTheQueue() =
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(1, "Local", "http://localhost", true, 0))
|
||||||
|
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
val before = queueRepository.snapshot().entries.map { it.entry }
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
|
assertEquals(before, queueRepository.snapshot().entries.map { it.entry })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -174,6 +227,358 @@ class SyncManagerTest {
|
|||||||
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
|
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailedSyncCarriesCommittedOriginsIntoTheNextSuccessfulReset() =
|
||||||
|
runBlocking {
|
||||||
|
val healthy = healthyDispatcher()
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
if (request.path?.startsWith("/rest/getPlaylists.view") == true) {
|
||||||
|
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
|
||||||
|
} else {
|
||||||
|
healthy.dispatch(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
|
||||||
|
val edited = queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)
|
||||||
|
queueRepository.replace(listOf(edited))
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
assertEquals(
|
||||||
|
0L,
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
|
||||||
|
manager =
|
||||||
|
SyncManager(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
queueRepository,
|
||||||
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
preferences = preferences,
|
||||||
|
showMessage = { messages += it.resolve(resources) },
|
||||||
|
scope = scope,
|
||||||
|
)
|
||||||
|
server.dispatcher = healthy
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
|
||||||
|
|
||||||
|
assertNull(
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPendingClearFailureRollsBackTheQueueResetAndCanBeRetried() =
|
||||||
|
runBlocking {
|
||||||
|
server.dispatcher = healthyDispatcher()
|
||||||
|
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
|
||||||
|
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
val before = queueRepository.snapshot()
|
||||||
|
val cacheVersion = queueRepository.libraryCacheVersion()
|
||||||
|
val resetAttempts = AtomicInteger()
|
||||||
|
queueRepository.setResetListener { reset ->
|
||||||
|
resetAttempts.incrementAndGet()
|
||||||
|
QueueResetPreparation(reset.snapshot)
|
||||||
|
}
|
||||||
|
failPendingOriginClear()
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
0L,
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
|
||||||
|
assertEquals(
|
||||||
|
listOf(0L, before.version, before.resetVersion, cacheVersion),
|
||||||
|
listOf(
|
||||||
|
resetAttempts.get().toLong(),
|
||||||
|
queueRepository.contentVersion(),
|
||||||
|
queueRepository.snapshot().resetVersion,
|
||||||
|
queueRepository.libraryCacheVersion(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
allowPendingOriginClear()
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
|
||||||
|
|
||||||
|
assertNull(
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||||
|
assertEquals(1, resetAttempts.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aResetListenerFailureIsReportedAndCanBeRetried() =
|
||||||
|
runBlocking {
|
||||||
|
server.dispatcher = healthyDispatcher()
|
||||||
|
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
|
||||||
|
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
val before = queueRepository.snapshot()
|
||||||
|
val cacheVersion = queueRepository.libraryCacheVersion()
|
||||||
|
val resetAttempted = CountDownLatch(1)
|
||||||
|
val resetAttempts = AtomicInteger()
|
||||||
|
queueRepository.setResetListener { reset ->
|
||||||
|
queueRepository.setPosition(42_000L)
|
||||||
|
resetAttempted.countDown()
|
||||||
|
if (resetAttempts.incrementAndGet() == 1) throw IllegalStateException("reconciliation failed")
|
||||||
|
QueueResetPreparation(reset.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
assertTrue(resetAttempted.await(10, TimeUnit.SECONDS))
|
||||||
|
awaitUntil("waiting for the queue reset listener failure") { manager.status.value !is SyncStatus.Running }
|
||||||
|
|
||||||
|
assertTrue(manager.status.value is SyncStatus.Failed)
|
||||||
|
assertEquals(
|
||||||
|
0L,
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
|
||||||
|
assertEquals(
|
||||||
|
listOf(before.version, before.resetVersion, cacheVersion, 0L),
|
||||||
|
listOf(
|
||||||
|
queueRepository.contentVersion(),
|
||||||
|
queueRepository.snapshot().resetVersion,
|
||||||
|
queueRepository.libraryCacheVersion(),
|
||||||
|
queueRepository.cursorPositionMs(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
|
||||||
|
|
||||||
|
assertEquals(2, resetAttempts.get())
|
||||||
|
assertNull(
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aPublicationFailureRecoversFromCommittedReset() =
|
||||||
|
runBlocking {
|
||||||
|
server.dispatcher = healthyDispatcher()
|
||||||
|
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
|
||||||
|
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
val before = queueRepository.snapshot()
|
||||||
|
val recoveries = AtomicInteger()
|
||||||
|
queueRepository.setResetListener { reset ->
|
||||||
|
QueueResetPreparation(
|
||||||
|
reset.snapshot,
|
||||||
|
publish = { throw IllegalStateException("publish failed") },
|
||||||
|
recover = { published ->
|
||||||
|
assertEquals(before.version + 1, published.version)
|
||||||
|
assertEquals(before.resetVersion + 1, published.resetVersion)
|
||||||
|
recoveries.incrementAndGet()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
|
||||||
|
|
||||||
|
assertEquals(1, recoveries.get())
|
||||||
|
assertNull(
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||||
|
assertEquals(before.version + 1, queueRepository.contentVersion())
|
||||||
|
assertEquals(before.resetVersion + 1, queueRepository.snapshot().resetVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aQueueWriteFailureAfterPendingClearRollsBackWithoutPublishing() =
|
||||||
|
runBlocking {
|
||||||
|
server.dispatcher = healthyDispatcher()
|
||||||
|
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
|
||||||
|
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1").copy(rangeStart = 0, rangeEnd = 0)))
|
||||||
|
val before = queueRepository.snapshot()
|
||||||
|
val cacheVersion = queueRepository.libraryCacheVersion()
|
||||||
|
val resetAttempts = AtomicInteger()
|
||||||
|
queueRepository.setResetListener { reset ->
|
||||||
|
resetAttempts.incrementAndGet()
|
||||||
|
QueueResetPreparation(reset.snapshot)
|
||||||
|
}
|
||||||
|
failQueueResetInsert()
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
0L,
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isNotEmpty())
|
||||||
|
assertEquals(
|
||||||
|
listOf(0L, before.version, before.resetVersion, cacheVersion),
|
||||||
|
listOf(
|
||||||
|
resetAttempts.get().toLong(),
|
||||||
|
queueRepository.contentVersion(),
|
||||||
|
queueRepository.snapshot().resetVersion,
|
||||||
|
queueRepository.libraryCacheVersion(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
allowQueueResetInsert()
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.drop(1).first { it is SyncStatus.Success } }
|
||||||
|
|
||||||
|
assertEquals(1, resetAttempts.get())
|
||||||
|
assertNull(
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
assertTrue(db.queueDao().pendingSyncOrigins(1).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailedSyncPreservesResolvedQueueShuffle() =
|
||||||
|
runBlocking {
|
||||||
|
server.dispatcher = healthyDispatcher()
|
||||||
|
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
|
||||||
|
queueRepository.replace(listOf(queueRepository.albumEntry(1, "al1")))
|
||||||
|
queueRepository.setShuffle(42L)
|
||||||
|
assertTrue(queueRepository.snapshot().shuffled)
|
||||||
|
val healthy = healthyDispatcher()
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
when {
|
||||||
|
request.path?.startsWith("/rest/search3.view") == true -> {
|
||||||
|
MockResponse().setBody(TWO_SONGS)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.path?.startsWith("/rest/getPlaylists.view") == true -> {
|
||||||
|
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
healthy.dispatch(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
|
val snapshot = queueRepository.snapshot()
|
||||||
|
assertTrue(snapshot.shuffled)
|
||||||
|
assertEquals(1L, snapshot.contextSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aFailedSongPruneDoesNotResetAnEditedDownloadedQueue() =
|
||||||
|
runBlocking {
|
||||||
|
val healthy = healthyDispatcher()
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
if (request.path?.startsWith("/rest/search3.view") == true) {
|
||||||
|
MockResponse().setBody(TWO_SONGS)
|
||||||
|
} else {
|
||||||
|
healthy.dispatch(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sourceRepository.addSource("Local", server.url("/").toString(), "u", "p", useTokenAuth = false)
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Success } }
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
val downloadsDir = File(context.cacheDir, "sync-downloads-${System.nanoTime()}")
|
||||||
|
val downloads =
|
||||||
|
DownloadRepository(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
FakeDownloadEngine(),
|
||||||
|
downloadsDir,
|
||||||
|
artworkStore = ArtworkStore(File(context.cacheDir, "art")),
|
||||||
|
artworkFetcher = { ByteArray(0) },
|
||||||
|
queueRepository = queueRepository,
|
||||||
|
scope = scope,
|
||||||
|
)
|
||||||
|
File(downloadsDir, "1").mkdirs()
|
||||||
|
File(downloadsDir, "1/s2").writeText("audio")
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed, downloadedAt = 1))
|
||||||
|
downloads.start()
|
||||||
|
downloads.awaitLoaded()
|
||||||
|
awaitUntil("waiting for download baseline") { downloads.localUri("s2") != null }
|
||||||
|
queueRepository.replace(listOf(queueRepository.downloadedEntry(1).copy(rangeStart = 1)))
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
|
when {
|
||||||
|
request.path?.startsWith("/rest/search3.view") == true -> {
|
||||||
|
MockResponse().setBody(SONG)
|
||||||
|
}
|
||||||
|
|
||||||
|
request.path?.startsWith("/rest/getPlaylists.view") == true -> {
|
||||||
|
MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
healthy.dispatch(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.requestSync()
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
1L,
|
||||||
|
queueRepository
|
||||||
|
.snapshot()
|
||||||
|
.entries
|
||||||
|
.single()
|
||||||
|
.entry.rangeStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun requestsWhileRunningCoalesceIntoOneSync() =
|
fun requestsWhileRunningCoalesceIntoOneSync() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
@@ -212,6 +617,36 @@ class SyncManagerTest {
|
|||||||
assertEquals(2, artistRequests.get())
|
assertEquals(2, artistRequests.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun failPendingOriginClear() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared(
|
||||||
|
"CREATE TRIGGER fail_pending_origin_clear BEFORE DELETE ON pending_sync_origins " +
|
||||||
|
"BEGIN SELECT RAISE(FAIL, 'pending-origin clear failed'); END",
|
||||||
|
) { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun allowPendingOriginClear() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared("DROP TRIGGER fail_pending_origin_clear") { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun failQueueResetInsert() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared(
|
||||||
|
"CREATE TRIGGER fail_queue_reset_insert BEFORE INSERT ON queue_entries " +
|
||||||
|
"BEGIN SELECT RAISE(FAIL, 'queue reset insert failed'); END",
|
||||||
|
) { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun allowQueueResetInsert() {
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
connection.usePrepared("DROP TRIGGER fail_queue_reset_insert") { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun healthyDispatcher() =
|
private fun healthyDispatcher() =
|
||||||
object : Dispatcher() {
|
object : Dispatcher() {
|
||||||
override fun dispatch(request: RecordedRequest): MockResponse =
|
override fun dispatch(request: RecordedRequest): MockResponse =
|
||||||
@@ -258,6 +693,24 @@ class SyncManagerTest {
|
|||||||
"<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" +
|
"<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" +
|
||||||
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
|
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
|
||||||
"</searchResult3></subsonic-response>"
|
"</searchResult3></subsonic-response>"
|
||||||
|
|
||||||
|
const val TWO_SONGS =
|
||||||
|
"<subsonic-response status=\"ok\" version=\"1.16.1\"><searchResult3>" +
|
||||||
|
"<song id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
|
||||||
|
"<song id=\"s2\" title=\"Song Two\" albumId=\"al1\" artistId=\"ar1\" track=\"2\"/>" +
|
||||||
|
"</searchResult3></subsonic-response>"
|
||||||
|
|
||||||
|
const val PLAYLISTS =
|
||||||
|
"<subsonic-response status=\"ok\" version=\"1.16.1\"><playlists>" +
|
||||||
|
"<playlist id=\"p1\" name=\"Playlist\" songCount=\"2\"/>" +
|
||||||
|
"</playlists></subsonic-response>"
|
||||||
|
|
||||||
|
const val PLAYLIST =
|
||||||
|
"<subsonic-response status=\"ok\" version=\"1.16.1\">" +
|
||||||
|
"<playlist id=\"p1\" name=\"Playlist\" songCount=\"2\">" +
|
||||||
|
"<entry id=\"s1\" title=\"Song One\" albumId=\"al1\" artistId=\"ar1\" track=\"1\"/>" +
|
||||||
|
"<entry id=\"s2\" title=\"Song Two\" albumId=\"al1\" artistId=\"ar1\" track=\"2\"/>" +
|
||||||
|
"</playlist></subsonic-response>"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
|
||||||
import androidx.room3.useWriterConnection
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.db.LoggingSQLiteDriver
|
import com.subtracks.data.db.LoggingSQLiteDriver
|
||||||
import com.subtracks.data.db.SqlExecution
|
import com.subtracks.data.db.SqlExecution
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.db.artistsDefaultQuery
|
import com.subtracks.data.db.artistPages
|
||||||
|
import com.subtracks.data.db.artistsPageQuery
|
||||||
|
import com.subtracks.data.db.createTestDatabase
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
@@ -19,6 +18,8 @@ import com.subtracks.data.model.DownloadStatus
|
|||||||
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.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
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.model.Source
|
import com.subtracks.data.model.Source
|
||||||
@@ -43,12 +44,7 @@ class SyncServiceTest {
|
|||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() {
|
fun setUp() {
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
db = createTestDatabase()
|
||||||
db =
|
|
||||||
Room
|
|
||||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
|
||||||
.setDriver(BundledSQLiteDriver())
|
|
||||||
.build()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -75,7 +71,7 @@ class SyncServiceTest {
|
|||||||
2,
|
2,
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.size,
|
.size,
|
||||||
)
|
)
|
||||||
@@ -98,7 +94,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -172,7 +168,7 @@ class SyncServiceTest {
|
|||||||
"Old",
|
"Old",
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.single()
|
.single()
|
||||||
.name,
|
.name,
|
||||||
@@ -185,7 +181,7 @@ class SyncServiceTest {
|
|||||||
"New",
|
"New",
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.single()
|
.single()
|
||||||
.name,
|
.name,
|
||||||
@@ -1107,16 +1103,16 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
|
val query = artistsPageQuery(1, 0, "alpha", ArtistSort.Name, false, false)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "alpha")
|
.artistPages(1, 0, "alpha")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1203,7 +1199,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1", "combo"),
|
listOf("a1", "combo"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1216,7 +1212,7 @@ class SyncServiceTest {
|
|||||||
listOf("a1"),
|
listOf("a1"),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.map { it.id },
|
.map { it.id },
|
||||||
)
|
)
|
||||||
@@ -1434,7 +1430,7 @@ class SyncServiceTest {
|
|||||||
SyncService(db, source).sync()
|
SyncService(db, source).sync()
|
||||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||||
|
|
||||||
val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
val filtered = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
|
||||||
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
||||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||||
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
||||||
@@ -1538,7 +1534,7 @@ class SyncServiceTest {
|
|||||||
albums.size.toLong(),
|
albums.size.toLong(),
|
||||||
db
|
db
|
||||||
.libraryDao()
|
.libraryDao()
|
||||||
.artistsByName(1, 0, "")
|
.artistPages(1, 0, "")
|
||||||
.allRows()
|
.allRows()
|
||||||
.first { it.id == "a2" }
|
.first { it.id == "a2" }
|
||||||
.albumCount,
|
.albumCount,
|
||||||
@@ -1780,7 +1776,7 @@ class SyncServiceTest {
|
|||||||
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
|
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
val downloadedArtists = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
val downloadedArtists = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
|
||||||
for (artist in downloadedArtists) {
|
for (artist in downloadedArtists) {
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"${artist.id} downloaded albums",
|
"${artist.id} downloaded albums",
|
||||||
@@ -1845,13 +1841,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),
|
||||||
@@ -1879,6 +1870,327 @@ class SyncServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playlistMembershipChangesReportOnlyTheirOwnOrigins() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
songs = listOf(song("s1"), song("s2")),
|
||||||
|
playlists = listOf(playlist("p1"), playlist("p2")),
|
||||||
|
playlistSongs =
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "p1", "s1", 0),
|
||||||
|
PlaylistSong(1, "p2", "s2", 0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.playlistSongs =
|
||||||
|
listOf(
|
||||||
|
PlaylistSong(1, "p1", "s2", 0),
|
||||||
|
PlaylistSong(1, "p2", "s2", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertEquals(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), summary.changedOrigins)
|
||||||
|
|
||||||
|
source.playlistSongs = listOf(PlaylistSong(1, "p2", "s2", 0))
|
||||||
|
|
||||||
|
val deletionSummary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertEquals(setOf(QueueOrigin(QueueKind.Playlist, 1, "p1")), deletionSummary.changedOrigins)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumAndSongChangesReportOnlyAffectedOrigins() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
artists = listOf(artist("a1"), artist("a2"), artist("a3")),
|
||||||
|
albums =
|
||||||
|
listOf(
|
||||||
|
album("al1"),
|
||||||
|
album("al2").copy(artistId = "a2"),
|
||||||
|
album("al3").copy(artistId = "a3"),
|
||||||
|
),
|
||||||
|
songs =
|
||||||
|
listOf(
|
||||||
|
song("s1").copy(genres = listOf("Rock")),
|
||||||
|
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.albums =
|
||||||
|
listOf(
|
||||||
|
album("al1").copy(name = "Renamed"),
|
||||||
|
album("al2").copy(artistId = "a2"),
|
||||||
|
album("al3").copy(artistId = "a3"),
|
||||||
|
)
|
||||||
|
source.songs =
|
||||||
|
listOf(
|
||||||
|
song("s1").copy(
|
||||||
|
albumId = "al3",
|
||||||
|
artistId = "a3",
|
||||||
|
genres = listOf("Electronic"),
|
||||||
|
starred = 1234,
|
||||||
|
),
|
||||||
|
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
setOf(
|
||||||
|
QueueOrigin(QueueKind.Album, 1, "al1"),
|
||||||
|
QueueOrigin(QueueKind.Album, 1, "al3"),
|
||||||
|
QueueOrigin(QueueKind.Artist, 1, "a1"),
|
||||||
|
QueueOrigin(QueueKind.Artist, 1, "a3"),
|
||||||
|
QueueOrigin(QueueKind.Genre, 1, "Rock"),
|
||||||
|
QueueOrigin(QueueKind.Genre, 1, "Electronic"),
|
||||||
|
QueueOrigin(QueueKind.Starred, 1, ""),
|
||||||
|
QueueOrigin(QueueKind.Song, 1, "s1"),
|
||||||
|
),
|
||||||
|
summary.changedOrigins,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletedSongsReportOnlyTheirPreviousOrigins() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
artists = listOf(artist("a1"), artist("a2")),
|
||||||
|
albums = listOf(album("al1"), album("al2").copy(artistId = "a2")),
|
||||||
|
songs =
|
||||||
|
listOf(
|
||||||
|
song("s1").copy(genres = listOf("Rock"), starred = 1234),
|
||||||
|
song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.songs = listOf(song("s2").copy(albumId = "al2", artistId = "a2", genres = listOf("Jazz")))
|
||||||
|
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
setOf(
|
||||||
|
QueueOrigin(QueueKind.Album, 1, "al1"),
|
||||||
|
QueueOrigin(QueueKind.Artist, 1, "a1"),
|
||||||
|
QueueOrigin(QueueKind.Genre, 1, "Rock"),
|
||||||
|
QueueOrigin(QueueKind.Starred, 1, ""),
|
||||||
|
QueueOrigin(QueueKind.Song, 1, "s1"),
|
||||||
|
),
|
||||||
|
summary.changedOrigins,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumCreditChangesReportBothArtistOrigins() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val plural = listOf(ArtistRef("a2", "Artist a2"))
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
artists = listOf(artist("a1"), artist("a2"), artist("a3")),
|
||||||
|
albums = listOf(album("al1").copy(artists = plural)),
|
||||||
|
songs = listOf(song("s1")),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.albums = listOf(album("al1").copy(artistId = "a3", artists = plural, year = 2024))
|
||||||
|
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a1")))
|
||||||
|
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a3")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun songPresenceChangesReportReferencingPlaylistOrigins() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
artists = listOf(artist("a1")),
|
||||||
|
albums = listOf(album("al1")),
|
||||||
|
songs = listOf(song("s1"), song("s2")),
|
||||||
|
playlists = listOf(playlist("p1")),
|
||||||
|
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0), PlaylistSong(1, "p1", "s2", 1)),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.songs = listOf(song("s2"))
|
||||||
|
val deleted = SyncService(db, source).sync()
|
||||||
|
assertTrue(deleted.changedOrigins.contains(QueueOrigin(QueueKind.Playlist, 1, "p1")))
|
||||||
|
|
||||||
|
source.songs = listOf(song("s1"), song("s2"))
|
||||||
|
val restored = SyncService(db, source).sync()
|
||||||
|
assertTrue(restored.changedOrigins.contains(QueueOrigin(QueueKind.Playlist, 1, "p1")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun songPresenceChangesBatchPlaylistMembershipQueries() =
|
||||||
|
runTest {
|
||||||
|
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
||||||
|
val loggingDb = createTestDatabase(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||||
|
try {
|
||||||
|
loggingDb.sourcesDao().upsertSource(
|
||||||
|
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||||
|
)
|
||||||
|
val songs = listOf(song("s1"), song("s2"), song("s3"))
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
songs = songs,
|
||||||
|
playlists = listOf(playlist("p1")),
|
||||||
|
playlistSongs = songs.mapIndexed { index, song -> PlaylistSong(1, "p1", song.id, index.toLong()) },
|
||||||
|
)
|
||||||
|
|
||||||
|
fun membershipQueryCount() =
|
||||||
|
synchronized(executions) {
|
||||||
|
executions.count {
|
||||||
|
it.sql.startsWith("SELECT playlistId FROM playlist_songs WHERE sourceId = ? AND songId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncService(loggingDb, source).sync()
|
||||||
|
val initialQueries = membershipQueryCount()
|
||||||
|
loggingDb.queueDao().clearPendingSyncOrigins(1)
|
||||||
|
synchronized(executions) { executions.clear() }
|
||||||
|
|
||||||
|
source.songs = emptyList()
|
||||||
|
val deleted = SyncService(loggingDb, source).sync()
|
||||||
|
val deletionQueries = membershipQueryCount()
|
||||||
|
assertTrue(deleted.changedOrigins.contains(QueueOrigin(QueueKind.Playlist, 1, "p1")))
|
||||||
|
assertTrue(loggingDb.queueDao().pendingSyncOrigins(1).any { it.kind == QueueKind.Playlist && it.refId == "p1" })
|
||||||
|
loggingDb.queueDao().clearPendingSyncOrigins(1)
|
||||||
|
synchronized(executions) { executions.clear() }
|
||||||
|
|
||||||
|
source.songs = songs
|
||||||
|
val restored = SyncService(loggingDb, source).sync()
|
||||||
|
val restorationQueries = membershipQueryCount()
|
||||||
|
assertTrue(restored.changedOrigins.contains(QueueOrigin(QueueKind.Playlist, 1, "p1")))
|
||||||
|
assertTrue(loggingDb.queueDao().pendingSyncOrigins(1).any { it.kind == QueueKind.Playlist && it.refId == "p1" })
|
||||||
|
assertEquals(listOf(1, 1, 1), listOf(initialQueries, deletionQueries, restorationQueries))
|
||||||
|
} finally {
|
||||||
|
loggingDb.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloadedSongSortChangesReportDownloadedOrigins() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(artists = listOf(artist("a1")), albums = listOf(album("al1")), songs = listOf(song("s1"), song("s2")))
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Completed))
|
||||||
|
|
||||||
|
source.songs = listOf(song("s1").copy(title = "Zzz", sortName = "Zzz"), song("s2").copy(title = "Zulu"))
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Downloaded, 1, "")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumChangesReportArtistsCreditedOnlyOnTracks() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
artists = listOf(artist("a1"), artist("a2")),
|
||||||
|
albums = listOf(album("al1")),
|
||||||
|
songs = listOf(song("s1").copy(artistId = "a2")),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.albums = listOf(album("al1").copy(year = 2024))
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a2")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumPruningReportsArtistsCreditedOnlyOnRemainingTracks() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
artists = listOf(artist("a1"), artist("a2")),
|
||||||
|
albums = listOf(album("al1")),
|
||||||
|
songs = listOf(song("s1").copy(artistId = "a2")),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.albums = emptyList()
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a2")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumChangesIgnoreSongsWithoutTrackArtists() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
artists = listOf(artist("a1")),
|
||||||
|
albums = listOf(album("al1")),
|
||||||
|
songs = listOf(song("s1").copy(artistId = null)),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.albums = listOf(album("al1").copy(year = 2024))
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Album, 1, "al1")))
|
||||||
|
assertTrue(summary.changedOrigins.none { it.kind == QueueKind.Artist && it.refId.isBlank() })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun committedOriginsArePersistedWithoutManagerRetention() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source = FakeMusicSource(artists = listOf(artist("a1")), albums = listOf(album("al1")), songs = listOf(song("s1")))
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
db.queueDao().clearPendingSyncOrigins(1)
|
||||||
|
|
||||||
|
source.albums = listOf(album("al1").copy(name = "Changed"))
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
val pending = db.queueDao().pendingSyncOrigins(1)
|
||||||
|
assertTrue(pending.any { it.kind == QueueKind.Album && it.refId == "al1" })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumPruningReportsArtistsCreditedOnlyByTheDeletedAlbum() =
|
||||||
|
runTest {
|
||||||
|
insertSource()
|
||||||
|
val source =
|
||||||
|
FakeMusicSource(
|
||||||
|
artists = listOf(artist("a1"), artist("a2")),
|
||||||
|
albums = listOf(album("al1"), album("al2")),
|
||||||
|
songs =
|
||||||
|
listOf(
|
||||||
|
song("s1").copy(artistId = "a2"),
|
||||||
|
song("s2").copy(albumId = "al2", artistId = "a2"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
|
||||||
|
source.albums = listOf(album("al2"))
|
||||||
|
source.songs = listOf(song("s2").copy(albumId = "al2", artistId = "a2"))
|
||||||
|
val summary = SyncService(db, source).sync()
|
||||||
|
|
||||||
|
assertTrue(summary.changedOrigins.contains(QueueOrigin(QueueKind.Artist, 1, "a1")))
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun totalChanges(): Long =
|
private suspend fun totalChanges(): Long =
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||||
|
|||||||
@@ -42,7 +42,34 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var buffering = false
|
private var buffering = false
|
||||||
|
|
||||||
override val itemCount: Int get() = items.size
|
@Volatile
|
||||||
|
var expectedThread: Thread? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var beforeRemoveFirst: (() -> Unit)? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var beforeCurrentItem: (() -> Unit)? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var roundTripItemsThroughMedia3 = false
|
||||||
|
|
||||||
|
private fun storedItem(item: QueueItem): QueueItem {
|
||||||
|
if (!roundTripItemsThroughMedia3) return item
|
||||||
|
val mediaItem = queueMediaItem(item, local = null, stream = "http://stream/${item.id}", transcode = false)
|
||||||
|
return queueItemFromMediaItem(mediaItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkThread() {
|
||||||
|
val expected = expectedThread ?: return
|
||||||
|
check(Thread.currentThread() == expected) { "Expected $expected, was ${Thread.currentThread()}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
override val itemCount: Int
|
||||||
|
get() {
|
||||||
|
checkThread()
|
||||||
|
return items.size
|
||||||
|
}
|
||||||
|
|
||||||
override val currentIndex: Int get() = index
|
override val currentIndex: Int get() = index
|
||||||
|
|
||||||
@@ -60,7 +87,12 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
|
|
||||||
override val currentPositionMs: Long get() = positionMs
|
override val currentPositionMs: Long get() = positionMs
|
||||||
|
|
||||||
override val currentItem: QueueItem? get() = items.getOrNull(index)
|
override val currentItem: QueueItem?
|
||||||
|
get() {
|
||||||
|
beforeCurrentItem?.invoke()
|
||||||
|
checkThread()
|
||||||
|
return items.getOrNull(index)
|
||||||
|
}
|
||||||
|
|
||||||
/** Overrides the URIs the window reports; defaults to a stream URI per item. */
|
/** Overrides the URIs the window reports; defaults to a stream URI per item. */
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -73,7 +105,7 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
item: QueueItem,
|
item: QueueItem,
|
||||||
) {
|
) {
|
||||||
operations += "replaceItem($index, ${item.id})"
|
operations += "replaceItem($index, ${item.id})"
|
||||||
if (index in items.indices) items[index] = item
|
if (index in items.indices) items[index] = storedItem(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setWindow(
|
override fun setWindow(
|
||||||
@@ -83,7 +115,7 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
) {
|
) {
|
||||||
operations += "setWindow(size=${items.size}, start=$startIndex, position=$startPositionMs)"
|
operations += "setWindow(size=${items.size}, start=$startIndex, position=$startPositionMs)"
|
||||||
this.items.clear()
|
this.items.clear()
|
||||||
this.items += items
|
this.items += items.map(::storedItem)
|
||||||
index = startIndex
|
index = startIndex
|
||||||
positionMs = startPositionMs
|
positionMs = startPositionMs
|
||||||
idle = true
|
idle = true
|
||||||
@@ -96,13 +128,13 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
|
|
||||||
override fun addFirst(item: QueueItem) {
|
override fun addFirst(item: QueueItem) {
|
||||||
operations += "addFirst(${item.id})"
|
operations += "addFirst(${item.id})"
|
||||||
items.add(0, item)
|
items.add(0, storedItem(item))
|
||||||
index++
|
index++
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun addLast(item: QueueItem) {
|
override fun addLast(item: QueueItem) {
|
||||||
operations += "addLast(${item.id})"
|
operations += "addLast(${item.id})"
|
||||||
items += item
|
items += storedItem(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun insertAt(
|
override fun insertAt(
|
||||||
@@ -110,11 +142,12 @@ class FakePlayerHandle : PlayerHandle {
|
|||||||
item: QueueItem,
|
item: QueueItem,
|
||||||
) {
|
) {
|
||||||
operations += "insertAt($index, ${item.id})"
|
operations += "insertAt($index, ${item.id})"
|
||||||
items.add(index, item)
|
items.add(index, storedItem(item))
|
||||||
if (index <= this.index) this.index++
|
if (index <= this.index) this.index++
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun removeFirst() {
|
override fun removeFirst() {
|
||||||
|
beforeRemoveFirst?.invoke()
|
||||||
operations += "removeFirst"
|
operations += "removeFirst"
|
||||||
items.removeAt(0)
|
items.removeAt(0)
|
||||||
index--
|
index--
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.subtracks.playback
|
|||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.ArtistRef
|
import com.subtracks.data.model.ArtistRef
|
||||||
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
|
import com.subtracks.data.repo.QueueResetItem
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -100,6 +103,24 @@ class MediaItemTest {
|
|||||||
assertEquals(false, queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false).albumContext())
|
assertEquals(false, queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false).albumContext())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theItemKeepsItsResetIdentity() {
|
||||||
|
val resetItem =
|
||||||
|
QueueResetItem(
|
||||||
|
QueueOrigin(QueueKind.Playlist, 1, "p1"),
|
||||||
|
"s1",
|
||||||
|
2,
|
||||||
|
upNext = false,
|
||||||
|
entryOccurrence = 1,
|
||||||
|
occurrence = 1,
|
||||||
|
originEntryId = 7,
|
||||||
|
)
|
||||||
|
|
||||||
|
val item = queueMediaItem(song.copy(resetItem = resetItem), local = null, stream = "http://server/stream?s=s1", transcode = false)
|
||||||
|
|
||||||
|
assertEquals(resetItem, queueItemFromMediaItem(item).resetItem)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun anItemWithoutGainReadsBackAsNoGain() {
|
fun anItemWithoutGainReadsBackAsNoGain() {
|
||||||
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
|
val item = queueMediaItem(song, local = null, stream = "http://server/stream?s=s1", transcode = false)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -571,9 +571,10 @@ class AppScreenshotTest {
|
|||||||
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
coverArt = { id, _ -> id?.let { CoverArtRef(it, "test:$it") } },
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onPlay = {},
|
onPlay = {},
|
||||||
onRemove = {},
|
onRemove = { _, _ -> },
|
||||||
onReorder = { _, _ -> },
|
onReorder = { _, _ -> },
|
||||||
onMove = { _, _ -> true },
|
onMove = { _, _ -> },
|
||||||
|
onCancelMove = {},
|
||||||
onLoadOlder = {},
|
onLoadOlder = {},
|
||||||
onLoadNewer = {},
|
onLoadNewer = {},
|
||||||
onUndo = {},
|
onUndo = {},
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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 queueRepository = QueueRepository(db)
|
||||||
|
val downloadRepository =
|
||||||
|
DownloadRepository(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
downloadEngine,
|
||||||
|
downloadsDir,
|
||||||
|
artworkStore = artworkStore,
|
||||||
|
artworkFetcher = { ByteArray(0) },
|
||||||
|
queueRepository = queueRepository,
|
||||||
|
scope = repoScope,
|
||||||
|
)
|
||||||
|
val controller =
|
||||||
|
PlaybackController(
|
||||||
|
sourceRepository,
|
||||||
|
queueRepository,
|
||||||
|
FakePlayerConnection(FakePlayerHandle()),
|
||||||
|
downloadRepository,
|
||||||
|
fakeUserPreferences(),
|
||||||
|
scope = controllerScope,
|
||||||
|
)
|
||||||
|
val libraryRepository =
|
||||||
|
LibraryRepository(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
fakeUserPreferences(),
|
||||||
|
queueRepository = queueRepository,
|
||||||
|
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,41 +1,20 @@
|
|||||||
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.data.repo.QueueResetPreparation
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.replace
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.ui.ViewModelRepositoryFixture
|
||||||
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.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
import org.junit.Assert.assertNotNull
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -43,60 +22,25 @@ 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.io.File
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@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 +51,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
|
||||||
@@ -146,6 +87,58 @@ class DownloadsViewModelTest {
|
|||||||
assertNotNull("an unnamed song should survive", row("s2"))
|
assertNotNull("an unnamed song should survive", row("s2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deletingSeveralSongsUsesOneResetPointForTheWholeBatch() {
|
||||||
|
sourceId =
|
||||||
|
runBlocking {
|
||||||
|
val id = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||||
|
db.libraryDao().upsertSongs((1..5).map { song(id, "s$it", "al1") })
|
||||||
|
(1..5).forEach { track ->
|
||||||
|
db.downloadDao().upsert(SongDownload(id, "s$track", DownloadStatus.Completed, downloadedAt = (6 - track).toLong()))
|
||||||
|
val file = File(fixture.downloadsDir, "$id/s$track")
|
||||||
|
file.parentFile?.mkdirs()
|
||||||
|
file.writeBytes(byteArrayOf(1))
|
||||||
|
}
|
||||||
|
id
|
||||||
|
}
|
||||||
|
downloadRepository.start()
|
||||||
|
await { (1..5).all { downloadRepository.localUri("s$it") != null } }
|
||||||
|
runBlocking {
|
||||||
|
fixture.queueRepository.replace(listOf(fixture.queueRepository.downloadedEntry(sourceId)))
|
||||||
|
fixture.queueRepository.setCursor(2)
|
||||||
|
}
|
||||||
|
val beforeReset = runBlocking { fixture.queueRepository.snapshot().resetVersion }
|
||||||
|
val firstResetStarted = CountDownLatch(1)
|
||||||
|
val allowFirstReset = CountDownLatch(1)
|
||||||
|
var resets = 0
|
||||||
|
fixture.queueRepository.setResetListener {
|
||||||
|
resets++
|
||||||
|
if (resets == 1) {
|
||||||
|
firstResetStarted.countDown()
|
||||||
|
allowFirstReset.await(5, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
QueueResetPreparation(it.snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.deleteSongs(listOf("s2", "s3"))
|
||||||
|
assertTrue(firstResetStarted.await(5, TimeUnit.SECONDS))
|
||||||
|
allowFirstReset.countDown()
|
||||||
|
await { runBlocking { fixture.queueRepository.snapshot().resetVersion > beforeReset } }
|
||||||
|
|
||||||
|
val snapshot = runBlocking { fixture.queueRepository.snapshot() }
|
||||||
|
assertEquals(2L, runBlocking { fixture.queueRepository.cursor() })
|
||||||
|
assertEquals(
|
||||||
|
"s5",
|
||||||
|
runBlocking {
|
||||||
|
fixture.queueRepository
|
||||||
|
.itemAt(snapshot, 2)
|
||||||
|
?.song
|
||||||
|
?.id
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assertEquals(1, resets)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun cancelSongsRemovesOnlyTheActiveDownload() {
|
fun cancelSongsRemovesOnlyTheActiveDownload() {
|
||||||
seedDownloads()
|
seedDownloads()
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -96,9 +91,10 @@ class NowPlayingRouteTest {
|
|||||||
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||||
artworkStore = artworkStore,
|
artworkStore = artworkStore,
|
||||||
artworkFetcher = { ByteArray(0) },
|
artworkFetcher = { ByteArray(0) },
|
||||||
|
queueRepository = queues,
|
||||||
scope = repoScope,
|
scope = repoScope,
|
||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
library = LibraryRepository(db, sources, serverActions, prefs, scope = repoScope)
|
library = LibraryRepository(db, sources, serverActions, prefs, queueRepository = queues, scope = repoScope)
|
||||||
handle = FakePlayerHandle()
|
handle = FakePlayerHandle()
|
||||||
controller =
|
controller =
|
||||||
PlaybackController(
|
PlaybackController(
|
||||||
@@ -106,6 +102,7 @@ class NowPlayingRouteTest {
|
|||||||
queues,
|
queues,
|
||||||
FakePlayerConnection(handle),
|
FakePlayerConnection(handle),
|
||||||
downloads,
|
downloads,
|
||||||
|
prefs,
|
||||||
showMessage = {},
|
showMessage = {},
|
||||||
scope = controllerScope,
|
scope = controllerScope,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package com.subtracks.ui.playback
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.subtracks.R
|
||||||
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
|
import com.subtracks.data.model.Song
|
||||||
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.annotation.GraphicsMode
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
class QueueScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffledContextRowsDoNotExposeRemoval() {
|
||||||
|
render(visible = listOf(row(upNext = false)), shuffle = true)
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("Remove from queue").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithText("Removed from queue").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffledUpNextRowsStillRemoveAndShowUndo() {
|
||||||
|
var removed: Long? = null
|
||||||
|
render(
|
||||||
|
visible = listOf(row(upNext = true)),
|
||||||
|
shuffle = true,
|
||||||
|
onRemove = { position, onComplete ->
|
||||||
|
removed = position
|
||||||
|
onComplete(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("Remove from queue").performClick()
|
||||||
|
|
||||||
|
assertEquals(0L, removed)
|
||||||
|
composeRule.onNodeWithText("Removed from queue").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Undo").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedRemovalDoesNotShowSuccessOrUndo() {
|
||||||
|
render(onRemove = { _, onComplete -> onComplete(false) })
|
||||||
|
|
||||||
|
removeFirstRow()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
assertNoSnackbar(R.string.queue_removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun appliedRemovalShowsUndoAndRunsItOnlyFromTheAction() {
|
||||||
|
var undos = 0
|
||||||
|
render(onRemove = { _, onComplete -> onComplete(true) }, onUndo = { undos++ })
|
||||||
|
|
||||||
|
removeFirstRow()
|
||||||
|
|
||||||
|
composeRule.onNodeWithText(composeRule.activity.getString(R.string.queue_removed)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(composeRule.activity.getString(R.string.undo)).performClick()
|
||||||
|
composeRule.runOnIdle { assertEquals(1, undos) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(
|
||||||
|
visible: List<QueueRow> = rows,
|
||||||
|
shuffle: Boolean = false,
|
||||||
|
onRemove: (Long, (Boolean) -> Unit) -> Unit = { _, onComplete -> onComplete(false) },
|
||||||
|
onMove: (QueueMove, (Boolean) -> Unit) -> Unit = { _, onComplete -> onComplete(false) },
|
||||||
|
onUndo: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
val visibleRows = mutableStateListOf<QueueRow>().also { it.addAll(visible) }
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
QueueScreen(
|
||||||
|
rows = visibleRows,
|
||||||
|
ready = true,
|
||||||
|
currentPosition = null,
|
||||||
|
currentSongId = null,
|
||||||
|
shuffle = shuffle,
|
||||||
|
coverArt = { _, _ -> null },
|
||||||
|
onBack = {},
|
||||||
|
onPlay = {},
|
||||||
|
onRemove = onRemove,
|
||||||
|
onReorder = { from, to -> visibleRows.add(to, visibleRows.removeAt(from)) },
|
||||||
|
onMove = onMove,
|
||||||
|
onCancelMove = {},
|
||||||
|
onLoadOlder = {},
|
||||||
|
onLoadNewer = {},
|
||||||
|
onUndo = onUndo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeFirstRow() {
|
||||||
|
composeRule.onAllNodesWithContentDescription(composeRule.activity.getString(R.string.queue_remove))[0].performClick()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertNoSnackbar(message: Int) {
|
||||||
|
composeRule.onNodeWithText(composeRule.activity.getString(message)).assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithText(composeRule.activity.getString(R.string.undo)).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun row(upNext: Boolean) =
|
||||||
|
QueueRow(
|
||||||
|
id = 0,
|
||||||
|
position = 0,
|
||||||
|
song = song("s1", 1),
|
||||||
|
upNext = upNext,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val rows =
|
||||||
|
(0..2).map { index ->
|
||||||
|
QueueRow(
|
||||||
|
id = index.toLong(),
|
||||||
|
position = index.toLong(),
|
||||||
|
song = song("s$index", index.toLong()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun song(
|
||||||
|
id: String,
|
||||||
|
track: Long,
|
||||||
|
) = AlbumSongItem(
|
||||||
|
Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = id,
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = "Song $id",
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = track,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
),
|
||||||
|
coverArt = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,19 +2,19 @@ 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
|
||||||
import com.subtracks.data.model.DownloadStatus
|
import com.subtracks.data.model.DownloadStatus
|
||||||
import com.subtracks.data.model.QueueKind
|
import com.subtracks.data.model.QueueKind
|
||||||
|
import com.subtracks.data.model.QueueOrigin
|
||||||
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.model.Source
|
import com.subtracks.data.model.Source
|
||||||
@@ -22,10 +22,14 @@ import com.subtracks.data.prefs.fakeUserPreferences
|
|||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.data.repo.replace
|
||||||
|
import com.subtracks.data.repo.setShuffle
|
||||||
import com.subtracks.playback.FakePlayerConnection
|
import com.subtracks.playback.FakePlayerConnection
|
||||||
import com.subtracks.playback.FakePlayerHandle
|
import com.subtracks.playback.FakePlayerHandle
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.CoroutineStart
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -33,11 +37,13 @@ import kotlinx.coroutines.SupervisorJob
|
|||||||
import kotlinx.coroutines.asCoroutineDispatcher
|
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.launch
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
@@ -47,7 +53,9 @@ 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.io.File
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
@@ -68,11 +76,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)
|
||||||
@@ -86,9 +90,18 @@ class QueueViewModelTest {
|
|||||||
downloadsDir,
|
downloadsDir,
|
||||||
artworkStore = artwork,
|
artworkStore = artwork,
|
||||||
artworkFetcher = { ByteArray(0) },
|
artworkFetcher = { ByteArray(0) },
|
||||||
|
queueRepository = queues,
|
||||||
scope = repoScope,
|
scope = repoScope,
|
||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
controller = PlaybackController(sources, queues, FakePlayerConnection(FakePlayerHandle()), downloads, scope = controllerScope)
|
controller =
|
||||||
|
PlaybackController(
|
||||||
|
sources,
|
||||||
|
queues,
|
||||||
|
FakePlayerConnection(FakePlayerHandle()),
|
||||||
|
downloads,
|
||||||
|
fakeUserPreferences(),
|
||||||
|
scope = controllerScope,
|
||||||
|
)
|
||||||
viewModel = QueueViewModel(queues, controller, sources, downloads)
|
viewModel = QueueViewModel(queues, controller, sources, downloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +171,7 @@ class QueueViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingWhileShuffledReconcilesTheRows() {
|
fun removingWhileShuffledIsRejectedWithoutChangingTheRows() {
|
||||||
runBlocking { seedSongs(20) }
|
runBlocking { seedSongs(20) }
|
||||||
runBlocking {
|
runBlocking {
|
||||||
queues.replace(listOf(queues.albumEntry(1, "al1")))
|
queues.replace(listOf(queues.albumEntry(1, "al1")))
|
||||||
@@ -173,15 +186,14 @@ class QueueViewModelTest {
|
|||||||
val before = runBlocking { queues.snapshot() }
|
val before = runBlocking { queues.snapshot() }
|
||||||
val currentPosition = controller.state.value.position!!
|
val currentPosition = controller.state.value.position!!
|
||||||
val removePosition = (0 until before.size).first { it != currentPosition }
|
val removePosition = (0 until before.size).first { it != currentPosition }
|
||||||
|
val beforeIds = rowsSnapshot().map { it.song.song.id }
|
||||||
|
|
||||||
viewModel.remove(removePosition)
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
viewModel.remove(removePosition, completion::complete)
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
|
||||||
await {
|
assertEquals(before.contextSize, runBlocking { queues.snapshot() }.contextSize)
|
||||||
val snapshot = runBlocking { queues.snapshot() }
|
assertEquals(beforeIds, rowsSnapshot().map { it.song.song.id })
|
||||||
val rows = rowsSnapshot()
|
|
||||||
rows.isNotEmpty() &&
|
|
||||||
runBlocking { rows.all { queues.itemAt(snapshot, it.position)?.song?.id == it.song.song.id } }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -288,7 +300,9 @@ class QueueViewModelTest {
|
|||||||
val from = rowsSnapshot()[10].position
|
val from = rowsSnapshot()[10].position
|
||||||
val to = rowsSnapshot()[13].position
|
val to = rowsSnapshot()[13].position
|
||||||
runOnMain { viewModel.reorder(10, 13) }
|
runOnMain { viewModel.reorder(10, 13) }
|
||||||
viewModel.move(from, to)
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
viewModel.move(from, to, onComplete = completion::complete)
|
||||||
|
assertTrue(awaitCompletion(completion))
|
||||||
await {
|
await {
|
||||||
rowsSnapshot().size == QUEUE_WINDOW_ROWS &&
|
rowsSnapshot().size == QUEUE_WINDOW_ROWS &&
|
||||||
rowsSnapshot().getOrNull(13)?.position == 233L
|
rowsSnapshot().getOrNull(13)?.position == 233L
|
||||||
@@ -377,7 +391,9 @@ class QueueViewModelTest {
|
|||||||
viewModel.open()
|
viewModel.open()
|
||||||
await { viewModel.ready }
|
await { viewModel.ready }
|
||||||
|
|
||||||
viewModel.remove(2)
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
viewModel.remove(2, completion::complete)
|
||||||
|
assertTrue(awaitCompletion(completion))
|
||||||
await {
|
await {
|
||||||
val positions = rowsSnapshot().map { it.position }
|
val positions = rowsSnapshot().map { it.position }
|
||||||
positions.isNotEmpty() && positions == (positions.first()..positions.last()).toList()
|
positions.isNotEmpty() && positions == (positions.first()..positions.last()).toList()
|
||||||
@@ -404,7 +420,9 @@ class QueueViewModelTest {
|
|||||||
rowsSnapshot()[30]
|
rowsSnapshot()[30]
|
||||||
.song.song.id
|
.song.song.id
|
||||||
|
|
||||||
viewModel.remove(rowsSnapshot()[30].position)
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
viewModel.remove(rowsSnapshot()[30].position, completion::complete)
|
||||||
|
assertTrue(awaitCompletion(completion))
|
||||||
await {
|
await {
|
||||||
rowsSnapshot().size == 60 &&
|
rowsSnapshot().size == 60 &&
|
||||||
rowsSnapshot().firstOrNull()?.position == 70L &&
|
rowsSnapshot().firstOrNull()?.position == 70L &&
|
||||||
@@ -431,6 +449,504 @@ class QueueViewModelTest {
|
|||||||
|
|
||||||
private fun assertContiguous() = assertTrue(isContiguous())
|
private fun assertContiguous() = assertTrue(isContiguous())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleRowRemovalIsRejectedAfterControllerReconciliation() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
resetAlbumWith("s0", 0)
|
||||||
|
assertEquals("s2", rowsSnapshot()[1].song.song.id)
|
||||||
|
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
viewModel.remove(1, completion::complete)
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
|
||||||
|
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
||||||
|
assertEquals(listOf("s0", "s1", "s2"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removalWaitingForReconciliationKeepsTheCapturedRow() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
resetAlbumWith("s0", 0)
|
||||||
|
val selected = rowsSnapshot()[1]
|
||||||
|
assertEquals("s2", selected.song.song.id)
|
||||||
|
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
runOnMain {
|
||||||
|
viewModel.reconcile()
|
||||||
|
viewModel.remove(selected.position, completion::complete)
|
||||||
|
}
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
|
||||||
|
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
||||||
|
assertEquals(listOf("s0", "s1", "s2"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedSparseTailMoveRestoresTheVisibleOrder() {
|
||||||
|
openReconciledSparseOfflineQueue()
|
||||||
|
val canonical = resolvedQueueIds()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val dragged = before[1]
|
||||||
|
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
runOnMain {
|
||||||
|
viewModel.reorder(1, 2)
|
||||||
|
val move = viewModel.rows.dropMoveFor(dragged.id)!!
|
||||||
|
assertTrue(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
|
||||||
|
}
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
|
||||||
|
assertEquals(canonical, resolvedQueueIds())
|
||||||
|
assertEquals(before.map { it.id }, rowsSnapshot().map { it.id })
|
||||||
|
assertEquals(before.map { it.position }, rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun windowLoadingWaitsForRejectedMoveRollback() {
|
||||||
|
openReconciledSparseOfflineQueue()
|
||||||
|
markDownloaded(1, "s0")
|
||||||
|
val canonical = resolvedQueueIds()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val dragged = before[1]
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.reorder(1, 2)
|
||||||
|
val move = viewModel.rows.dropMoveFor(dragged.id)!!
|
||||||
|
assertTrue(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
|
||||||
|
yield()
|
||||||
|
viewModel.loadOlder()
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
assertEquals(canonical, resolvedQueueIds())
|
||||||
|
assertEquals(listOf("s0", "s1", "s801", "s901"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
assertEquals(listOf(0L, 1L, 800L, 900L), rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedSparseTailRemovalRestoresTheVisibleOrder() {
|
||||||
|
openReconciledSparseOfflineQueue()
|
||||||
|
val canonical = resolvedQueueIds()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
viewModel.remove(before[1].position, completion::complete)
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
|
||||||
|
assertEquals(canonical, resolvedQueueIds())
|
||||||
|
assertEquals(before.map { it.id }, rowsSnapshot().map { it.id })
|
||||||
|
assertEquals(before.map { it.position }, rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconciliationRemovesADeletedHeadRowFromSparseOfflineRows() {
|
||||||
|
openSparseOfflineQueue()
|
||||||
|
val point = runBlocking { queues.captureResetPoint() }
|
||||||
|
runBlocking {
|
||||||
|
db.libraryDao().deleteSongs(1, listOf("s1"))
|
||||||
|
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.reconcile()
|
||||||
|
|
||||||
|
await { rowsSnapshot().map { it.song.song.id } == listOf("s801", "s901") }
|
||||||
|
assertEquals(listOf(800L, 900L), rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleRowMoveIsRejectedAfterControllerReconciliation() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
resetAlbumWith("s0", 0)
|
||||||
|
assertEquals("s2", rowsSnapshot()[1].song.song.id)
|
||||||
|
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
assertTrue(viewModel.move(1, 2, onComplete = completion::complete))
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
|
||||||
|
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveWaitingForQueuedResetDoesNotReportTheResetAsApplied() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
val point = prepareAlbumReset("s0", 0)
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val reset =
|
||||||
|
repoScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point)
|
||||||
|
}
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.reorder(1, 2)
|
||||||
|
assertTrue(viewModel.move(1, 2, onComplete = completion::complete))
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
|
||||||
|
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removalWaitingForQueuedResetDoesNotReportTheResetAsApplied() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
val point = prepareAlbumReset("s0", 0)
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val reset =
|
||||||
|
repoScope.launch(start = CoroutineStart.UNDISPATCHED) {
|
||||||
|
queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point)
|
||||||
|
}
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.remove(2, completion::complete)
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
runBlocking { withTimeout(TEST_TIMEOUT_MS) { reset.join() } }
|
||||||
|
assertEquals(listOf("s0", "s1", "s2", "s3"), resolvedQueueIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedTwoCallbackSparseTailDragRestoresTheDragBaseline() {
|
||||||
|
openReconciledSparseOfflineQueue(includeFarTail = true)
|
||||||
|
val canonical = resolvedQueueIds()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val dragged = before[1]
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
runOnMain {
|
||||||
|
viewModel.reorder(1, 2)
|
||||||
|
viewModel.reorder(2, 3)
|
||||||
|
val move = viewModel.rows.dropMoveFor(dragged.id)!!
|
||||||
|
assertTrue(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
assertEquals(canonical, resolvedQueueIds())
|
||||||
|
assertEquals(before.map { it.id }, rowsSnapshot().map { it.id })
|
||||||
|
assertEquals(before.map { it.position }, rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rejectedMixedGenerationCrossingRestoresTheWholeDrag() {
|
||||||
|
runOnMain {
|
||||||
|
viewModel.rows += row(0)
|
||||||
|
viewModel.rows += row(1)
|
||||||
|
viewModel.rows += row(2)
|
||||||
|
}
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val dragged = before[0]
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
runOnMain {
|
||||||
|
viewModel.reorder(0, 1)
|
||||||
|
viewModel.rows[2] = viewModel.rows[2].copy(resetVersion = 1)
|
||||||
|
viewModel.reorder(1, 2)
|
||||||
|
val move = viewModel.rows.dropMoveFor(dragged.id)!!
|
||||||
|
assertFalse(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
assertEquals(before.map { it.id }, rowsSnapshot().map { it.id })
|
||||||
|
assertEquals(before.map { it.position }, rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun secondOptimisticDragRejectedWhileMovePendingRollsBack() {
|
||||||
|
openSparseOfflineQueue()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val visibleIds = before.map { it.song.song.id }.toSet()
|
||||||
|
val expectedFirst = before.toMutableList().also { it.add(1, it.removeAt(0)) }.map { it.song.song.id }
|
||||||
|
val expectedSecond =
|
||||||
|
before
|
||||||
|
.toMutableList()
|
||||||
|
.also { it.add(1, it.removeAt(0)) }
|
||||||
|
.also { it.add(1, it.removeAt(2)) }
|
||||||
|
.map { it.song.song.id }
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val firstCompletion = CompletableDeferred<Boolean>()
|
||||||
|
val secondCompletion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.reorder(0, 1)
|
||||||
|
val firstMove = viewModel.rows.dropMoveFor(before[0].id)!!
|
||||||
|
assertTrue(viewModel.move(firstMove.from, firstMove.to, firstMove.resetVersion, firstCompletion::complete))
|
||||||
|
yield()
|
||||||
|
viewModel.reorder(2, 1)
|
||||||
|
assertEquals(expectedSecond, viewModel.rows.map { it.song.song.id })
|
||||||
|
val secondMove = viewModel.rows.dropMoveFor(before[2].id)!!
|
||||||
|
assertFalse(viewModel.move(secondMove.from, secondMove.to, secondMove.resetVersion, secondCompletion::complete))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(awaitCompletion(firstCompletion))
|
||||||
|
assertFalse(awaitCompletion(secondCompletion))
|
||||||
|
assertEquals(expectedFirst, rowsSnapshot().map { it.song.song.id })
|
||||||
|
assertEquals(expectedFirst, resolvedQueueIds().filter { it in visibleIds })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removalAfterInterleavedReorderRemovesTheCapturedRow() {
|
||||||
|
openSparseOfflineQueue()
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.remove(before[2].position, completion::complete)
|
||||||
|
yield()
|
||||||
|
viewModel.reorder(0, 2)
|
||||||
|
}
|
||||||
|
assertEquals(listOf("s801", "s901", "s1"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(awaitCompletion(completion))
|
||||||
|
assertEquals(listOf("s1", "s801"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
assertEquals(listOf("s1", "s801"), resolvedQueueIds().filter { it in setOf("s1", "s801", "s901") })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removalQueuedBehindMoveDoesNotUseItsOldPosition() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val moveCompletion = CompletableDeferred<Boolean>()
|
||||||
|
val removalCompletion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
assertTrue(
|
||||||
|
viewModel.move(
|
||||||
|
before[0].position,
|
||||||
|
before[2].position,
|
||||||
|
before[0].resetVersion,
|
||||||
|
moveCompletion::complete,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield()
|
||||||
|
assertEquals(
|
||||||
|
"s2",
|
||||||
|
viewModel.rows[1]
|
||||||
|
.song.song.id,
|
||||||
|
)
|
||||||
|
viewModel.remove(before[1].position, removalCompletion::complete)
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(awaitCompletion(moveCompletion))
|
||||||
|
assertFalse(awaitCompletion(removalCompletion))
|
||||||
|
assertEquals(listOf("s2", "s3", "s1"), resolvedQueueIds())
|
||||||
|
assertEquals(listOf("s2", "s3", "s1"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
assertEquals(listOf(0L, 1L, 2L), rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveQueuedBehindRemovalRejectsItsInvalidatedPositions() {
|
||||||
|
runBlocking { seedSongs(4) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
val before = rowsSnapshot()
|
||||||
|
val paused = pauseQueueMutation()
|
||||||
|
val removalCompletion = CompletableDeferred<Boolean>()
|
||||||
|
val moveCompletion = CompletableDeferred<Boolean>()
|
||||||
|
try {
|
||||||
|
runOnMainSuspending {
|
||||||
|
viewModel.remove(before[0].position, removalCompletion::complete)
|
||||||
|
yield()
|
||||||
|
assertTrue(
|
||||||
|
viewModel.move(
|
||||||
|
before[1].position,
|
||||||
|
before[2].position,
|
||||||
|
before[1].resetVersion,
|
||||||
|
moveCompletion::complete,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resumeQueueMutation(paused)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(awaitCompletion(removalCompletion))
|
||||||
|
assertFalse(awaitCompletion(moveCompletion))
|
||||||
|
assertEquals(listOf("s2", "s3", "s4"), resolvedQueueIds())
|
||||||
|
assertEquals(listOf("s2", "s3", "s4"), rowsSnapshot().map { it.song.song.id })
|
||||||
|
assertEquals(listOf(0L, 1L, 2L), rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun moveRejectsRowsFromDifferentResetGenerations() {
|
||||||
|
runOnMain {
|
||||||
|
viewModel.rows += row(0).copy(resetVersion = 0)
|
||||||
|
viewModel.rows += row(1).copy(resetVersion = 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.reorder(0, 1)
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
assertFalse(viewModel.move(0, 1, onComplete = completion::complete))
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
assertEquals(listOf(0L, 1L), rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sparseOfflineDropUsesTheAdjacentRowsGeneration() {
|
||||||
|
runOnMain {
|
||||||
|
viewModel.rows += row(1)
|
||||||
|
viewModel.rows += row(3)
|
||||||
|
viewModel.rows += row(5)
|
||||||
|
}
|
||||||
|
val draggedId = rowsSnapshot()[0].id
|
||||||
|
runOnMain {
|
||||||
|
viewModel.reorder(0, 1)
|
||||||
|
viewModel.rows[2] = viewModel.rows[2].copy(resetVersion = 1)
|
||||||
|
}
|
||||||
|
val move = rowsSnapshot().dropMoveFor(draggedId)!!
|
||||||
|
|
||||||
|
assertEquals(4L, move.to)
|
||||||
|
assertEquals(1L, move.resetVersion)
|
||||||
|
val completion = CompletableDeferred<Boolean>()
|
||||||
|
assertFalse(viewModel.move(move.from, move.to, move.resetVersion, completion::complete))
|
||||||
|
assertFalse(awaitCompletion(completion))
|
||||||
|
assertEquals(listOf(1L, 3L, 5L), rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun staleRowPlayIsRejectedAfterControllerReconciliation() {
|
||||||
|
runBlocking { seedSongs(3) }
|
||||||
|
controller.playAlbum(1, "al1", 2)
|
||||||
|
await { controller.state.value.position == 2L }
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
resetAlbumWith("s0", 0)
|
||||||
|
assertEquals("s2", rowsSnapshot()[1].song.song.id)
|
||||||
|
assertEquals(3L, controller.state.value.position)
|
||||||
|
|
||||||
|
viewModel.play(1)
|
||||||
|
Thread.sleep(200)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"s3",
|
||||||
|
controller.state.value.item
|
||||||
|
?.id,
|
||||||
|
)
|
||||||
|
assertEquals(3L, controller.state.value.position)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openSparseOfflineQueue(includeFarTail: Boolean = false) {
|
||||||
|
runBlocking { seedSongs(if (includeFarTail) 1001 else 901) }
|
||||||
|
markDownloaded(1, "s1")
|
||||||
|
markDownloaded(1, "s801")
|
||||||
|
markDownloaded(1, "s901")
|
||||||
|
if (includeFarTail) markDownloaded(1, "s1001")
|
||||||
|
setOffline()
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await {
|
||||||
|
controller.state.value.item
|
||||||
|
?.id == "s1"
|
||||||
|
}
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
viewModel.loadNewer()
|
||||||
|
val expected = if (includeFarTail) listOf(0L, 800L, 900L, 1000L) else listOf(0L, 800L, 900L)
|
||||||
|
await { rowsSnapshot().map { it.position } == expected }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReconciledSparseOfflineQueue(includeFarTail: Boolean = false) {
|
||||||
|
openSparseOfflineQueue(includeFarTail)
|
||||||
|
resetAlbumWith("s0", 0)
|
||||||
|
viewModel.reconcile()
|
||||||
|
await { rowsSnapshot().firstOrNull()?.resetVersion == 1L }
|
||||||
|
val expected = if (includeFarTail) listOf(1L, 800L, 900L, 1000L) else listOf(1L, 800L, 900L)
|
||||||
|
assertEquals(expected, rowsSnapshot().map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetAlbumWith(
|
||||||
|
songId: String,
|
||||||
|
track: Long,
|
||||||
|
) {
|
||||||
|
val layout = controller.state.value.layout
|
||||||
|
val point = prepareAlbumReset(songId, track)
|
||||||
|
runBlocking { queues.resetChangedOrigins(setOf(QueueOrigin(QueueKind.Album, 1, "al1")), point) }
|
||||||
|
await { controller.state.value.layout > layout }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareAlbumReset(
|
||||||
|
songId: String,
|
||||||
|
track: Long,
|
||||||
|
) = runBlocking {
|
||||||
|
val point = queues.captureResetPoint()
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(
|
||||||
|
sourceId = 1,
|
||||||
|
id = songId,
|
||||||
|
albumId = "al1",
|
||||||
|
artistId = "ar1",
|
||||||
|
title = "Song $songId",
|
||||||
|
album = "Album",
|
||||||
|
artist = "Artist",
|
||||||
|
duration = 100,
|
||||||
|
track = track,
|
||||||
|
disc = 1,
|
||||||
|
starred = null,
|
||||||
|
genre = null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
point
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolvedQueueIds(): List<String> =
|
||||||
|
runBlocking {
|
||||||
|
val snapshot = queues.snapshot()
|
||||||
|
queues.range(snapshot, 0, snapshot.size - 1).map { it.item.song.id }
|
||||||
|
}
|
||||||
|
|
||||||
private fun isContiguous(): Boolean {
|
private fun isContiguous(): Boolean {
|
||||||
val positions = rowsSnapshot().map { it.position }
|
val positions = rowsSnapshot().map { it.position }
|
||||||
return positions.isNotEmpty() && positions == (positions.first()..positions.last()).toList()
|
return positions.isNotEmpty() && positions == (positions.first()..positions.last()).toList()
|
||||||
@@ -549,11 +1065,35 @@ class QueueViewModelTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun pauseQueueMutation(): Pair<CountDownLatch, Job> {
|
||||||
|
val locked = CountDownLatch(1)
|
||||||
|
val release = CountDownLatch(1)
|
||||||
|
val locker =
|
||||||
|
repoScope.launch {
|
||||||
|
queues.withMutationLock {
|
||||||
|
locked.countDown()
|
||||||
|
release.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(locked.await(TEST_TIMEOUT_MS, TimeUnit.MILLISECONDS))
|
||||||
|
return release to locker
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resumeQueueMutation(paused: Pair<CountDownLatch, Job>) {
|
||||||
|
paused.first.countDown()
|
||||||
|
runBlocking { withTimeout(TEST_TIMEOUT_MS) { paused.second.join() } }
|
||||||
|
}
|
||||||
|
|
||||||
// `rows` is a snapshot list mutated on the main dispatcher, so read it there rather than from
|
// `rows` is a snapshot list mutated on the main dispatcher, so read it there rather than from
|
||||||
// the test thread (otherwise iterating it races the view model and throws or reads torn state).
|
// the test thread (otherwise iterating it races the view model and throws or reads torn state).
|
||||||
private fun rowsSnapshot(): List<QueueRow> = runBlocking { withContext(dispatcher) { viewModel.rows.toList() } }
|
private fun rowsSnapshot(): List<QueueRow> = runBlocking { withContext(dispatcher) { viewModel.rows.toList() } }
|
||||||
|
|
||||||
private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
|
private fun runOnMain(block: () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
|
||||||
|
|
||||||
|
private fun runOnMainSuspending(block: suspend () -> Unit) = runBlocking { withContext(dispatcher) { block() } }
|
||||||
|
|
||||||
|
private fun awaitCompletion(completion: CompletableDeferred<Boolean>): Boolean =
|
||||||
|
runBlocking { withTimeout(TEST_TIMEOUT_MS) { completion.await() } }
|
||||||
|
|
||||||
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected state", predicate)
|
private fun await(predicate: () -> Boolean) = awaitUntil("waiting for the expected state", predicate)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
@@ -72,6 +67,7 @@ class AddSourceViewModelTest {
|
|||||||
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||||
artworkStore = artwork,
|
artworkStore = artwork,
|
||||||
artworkFetcher = { ByteArray(0) },
|
artworkFetcher = { ByteArray(0) },
|
||||||
|
queueRepository = QueueRepository(db),
|
||||||
scope = repoScope,
|
scope = repoScope,
|
||||||
)
|
)
|
||||||
server = MockWebServer()
|
server = MockWebServer()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick
|
|||||||
import androidx.compose.ui.test.performScrollToNode
|
import androidx.compose.ui.test.performScrollToNode
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
|
||||||
|
import com.subtracks.data.prefs.SongTapBehavior
|
||||||
import com.subtracks.data.prefs.SortNameMode
|
import com.subtracks.data.prefs.SortNameMode
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
@@ -82,10 +83,29 @@ class SettingsScreenTest {
|
|||||||
assertEquals(SortNameMode.SortName, requested)
|
assertEquals(SortNameMode.SortName, requested)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theSongTapBehaviorRowChangesTheSetting() {
|
||||||
|
var requested: SongTapBehavior? = null
|
||||||
|
renderSettings(
|
||||||
|
keepScreenOn = false,
|
||||||
|
onKeepScreenOnChange = {},
|
||||||
|
onSongTapBehaviorChange = { requested = it },
|
||||||
|
scrollTo = "Song tap behavior",
|
||||||
|
)
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Song tap behavior").performClick()
|
||||||
|
composeRule.onNodeWithText("Play and shuffle").performClick()
|
||||||
|
composeRule.onNodeWithText("Done").performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
assertEquals(SongTapBehavior.Shuffle, requested)
|
||||||
|
}
|
||||||
|
|
||||||
private fun renderSettings(
|
private fun renderSettings(
|
||||||
keepScreenOn: Boolean,
|
keepScreenOn: Boolean,
|
||||||
onKeepScreenOnChange: (Boolean) -> Unit,
|
onKeepScreenOnChange: (Boolean) -> Unit,
|
||||||
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
onSortNameModeChange: (SortNameMode) -> Unit = {},
|
||||||
|
onSongTapBehaviorChange: (SongTapBehavior) -> Unit = {},
|
||||||
scrollTo: String = "Keep screen on",
|
scrollTo: String = "Keep screen on",
|
||||||
) {
|
) {
|
||||||
composeRule.setContent {
|
composeRule.setContent {
|
||||||
@@ -110,6 +130,7 @@ class SettingsScreenTest {
|
|||||||
onScrobblingChange = {},
|
onScrobblingChange = {},
|
||||||
onKeepScreenOnChange = onKeepScreenOnChange,
|
onKeepScreenOnChange = onKeepScreenOnChange,
|
||||||
onSortNameModeChange = onSortNameModeChange,
|
onSortNameModeChange = onSortNameModeChange,
|
||||||
|
onSongTapBehaviorChange = onSongTapBehaviorChange,
|
||||||
onAddServer = {},
|
onAddServer = {},
|
||||||
onOpenDownloads = {},
|
onOpenDownloads = {},
|
||||||
onOpenLicenses = {},
|
onOpenLicenses = {},
|
||||||
|
|||||||
@@ -84,13 +84,15 @@ The queue lives in SQL, not memory. `queue_entries` is an ordered list of *refer
|
|||||||
|
|
||||||
`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
|
`QueueRepository` resolves a queue position to a song lazily: it walks the entries summing their lengths (a `COUNT(*)` per entry) and then fetches one row with `LIMIT 1 OFFSET`. `PlaybackController` keeps only the resolved entry lengths and the cursor in memory — never the songs — and hands the player a bounded window (currently `2 * 25 + 1` items) centred on the cursor. A player index maps back to a queue position as `windowStart + index`. The window is loaded once with `setMediaItems` and then shifted incrementally with `addMediaItems`/`removeMediaItem` as playback advances; a shift never replaces the playlist, so the current item and its prepared next source survive and transitions stay gapless. `hasNext`/`hasPrevious` are intentionally always true rather than derived from the SQL queue size: the previous/next buttons stay usable at both ends of the queue so repeat mode and restart-the-track keep working, and `next()`/`previous()` decide the actual end-of-queue action (advance, wrap when repeating, restart, or no-op). The snapshot is re-read whenever the window shifts and on skip, so a sync that shrinks the referenced album or playlist cannot strand the window on stale lengths; if the active source changes under a loaded queue, playback stops rather than resolving the old queue's stream URLs against the new server.
|
||||||
|
|
||||||
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Reordering the context while shuffled would need that order back, so the context is playback-only while shuffled and manual ordering goes through the up-next block.
|
All queue and player-window mutations run through one `QueueMutationScope`. Snapshot reads, edits, sync/download resets, Undo and playback reconciliation share that lock, and reset reconciliation is invoked synchronously before the reset operation returns; there is no asynchronous reset collector. Content revisions reject stale writes, a separate reset generation rejects row operations created before a canonical reset, and a library revision only controls resolution caches. Navigation computes its target inside the mutation operation, while position-based UI edits carry their reset generation from operation creation. External initialization waits, including download-state loading, stay outside the mutation lock.
|
||||||
|
|
||||||
|
Playback is a *context* plus a manual *up next* list. `queue_entries` is the context — the album, playlist or song you started, and the only thing shuffle permutes. `up_next_entries` is the manual queue: the same reference-plus-range rows, ordered, never shuffled. Every entry also retains its origin context, so explicit songs created by rearranges can later be merged back into the album, playlist, artist or genre they came from. A `playback_cursor.upNextAnchor` holds the context track the block sits behind; the controller re-anchors it whenever playback lands on a different context track, so the block always plays next. `QueueRepository` resolves a combined position by mapping it through the context order and splicing the block after the anchor, so the controller's window and next/previous logic keep working. Add to queue appends to the block and play next inserts at its front; both reuse the context edit machinery (range split, insert, compact). Advancing out of a block track removes it — consume as played, with no history — and a new context clears the block, so a manual edit never disturbs the shuffle permutation. Shuffle itself stores no per-track order: `playback_cursor.shuffleSeed`/`shuffleSize` are the whole state, and a position maps through a seekable Feistel permutation of `[0, size)`, so a very large album cannot materialise an order list. Context removal and reordering are disabled while shuffled; manual ordering and deletion still work in the up-next block and in non-shuffled contexts.
|
||||||
|
|
||||||
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
|
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
|
||||||
|
|
||||||
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
|
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Some streams stay length-unknown anyway: an Opus/Ogg/Vorbis transcode (a length estimate breaks the end-seek they rely on), a transcode of a track with no library duration, and a direct play the server does not give a `Content-Length`. Media3 marks an unknown-length, unknown-duration progressive stream live, and a live item makes the media session publish no playback position, so the notification would show no progress even though the item carries a real duration; `KnownDurationPlayer` wraps the `ExoPlayer` and reports an item with a library duration as finite, restoring the position and progress the session publishes without making the stream seekable. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
|
||||||
|
|
||||||
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
|
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index while retaining its origin context, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
|
||||||
|
|
||||||
Scrobbling is a `Scrobbler` that collects the controller's `state` and `positionMs` and drives the Subsonic `scrobble` endpoint: a now-playing notification (`submission=false`) when a track starts playing, and one submission (`submission=true`, with the playback start time) when the track passes half its duration or four minutes, whichever comes first. That split is the endpoint's own `submission` flag, and the threshold is the Last.fm rule the server forwards to; servers such as Navidrome trust the client and apply no threshold themselves. Tracks under 30 seconds are never submitted, and only time actually played counts: forward and backward seeks are ignored, and a rewind only starts a fresh listen when it lands in the track's first few seconds (a repeat or an explicit restart). The duration is the player's resolved value, falling back to the library row when the player has not reported one. Sending is decoupled from the collector so a slow or offline write cannot stall position tracking, while a lock keeps the writes in order. The policy is a pure state machine with no Android dependencies. A Settings toggle (`UserPreferences.scrobbling`, on by default) gates both calls. A submission also bumps the played song's local `playCount`/`played` and its album and artist ranks, so the play sorts move before the next sync; the sync then overwrites them with the server's own numbers, so a play the server never accepted is rolled back at that point.
|
Scrobbling is a `Scrobbler` that collects the controller's `state` and `positionMs` and drives the Subsonic `scrobble` endpoint: a now-playing notification (`submission=false`) when a track starts playing, and one submission (`submission=true`, with the playback start time) when the track passes half its duration or four minutes, whichever comes first. That split is the endpoint's own `submission` flag, and the threshold is the Last.fm rule the server forwards to; servers such as Navidrome trust the client and apply no threshold themselves. Tracks under 30 seconds are never submitted, and only time actually played counts: forward and backward seeks are ignored, and a rewind only starts a fresh listen when it lands in the track's first few seconds (a repeat or an explicit restart). The duration is the player's resolved value, falling back to the library row when the player has not reported one. Sending is decoupled from the collector so a slow or offline write cannot stall position tracking, while a lock keeps the writes in order. The policy is a pure state machine with no Android dependencies. A Settings toggle (`UserPreferences.scrobbling`, on by default) gates both calls. A submission also bumps the played song's local `playCount`/`played` and its album and artist ranks, so the play sorts move before the next sync; the sync then overwrites them with the server's own numbers, so a play the server never accepted is rolled back at that point.
|
||||||
|
|
||||||
@@ -126,7 +128,7 @@ Songs have a primary and a fallback fetch. The primary probes `search3` with an
|
|||||||
|
|
||||||
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
|
Cleanup is the "everything I had, minus everything the server just sent" step described above: artists, albums, songs and playlists delete the leftover IDs in 500-row chunks, disc titles are deleted per `(album, disc)` for albums that still exist, and playlist entries beyond the new count are removed along with entries for playlists that disappeared.
|
||||||
|
|
||||||
Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. The one hazard is a scan that stops early — a server returning a short page by mistake, or a library larger than the page cap — because the prune trusts that the stream actually reached the end. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on `SyncStatus.Running` if that is ever noticeable.
|
Sync is not atomic. Each batch commits on its own, so a failure leaves the batches already written in place and skips that entity's prune; stale rows can linger until the next successful sync, which also invalidates the queue's cached library. Changed origins are merged only after their batch transaction commits and are persisted per source after failures, so the next successful sync includes them even after a process restart. A successful sync reports the origins it actually changed and collapses only those context and up-next blocks back to their canonical references; unrelated origins and failed syncs leave local edits alone. The current item and up-next anchor are remapped by origin, song ID and position as a best effort, without promising exact duplicate-occurrence identity. If the current song disappeared it finishes in a detached slot before the refreshed queue continues at a deterministic clamped position. Each batch commit also makes Room notify paged observers once per batch (a few per second, bounded by the network round-trips), which is cheap but can make an on-screen list churn during a sync; gate UI observation on `SyncStatus.Running` if that is ever noticeable.
|
||||||
|
|
||||||
## Search
|
## Search
|
||||||
|
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ android.useAndroidX=true
|
|||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
mavenCentralMirrorUrl=https://maven-central.storage-download.googleapis.com/maven2/
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pluginManagement {
|
pluginManagement {
|
||||||
|
val mavenCentralMirrorUrl = providers.gradleProperty("mavenCentralMirrorUrl").get()
|
||||||
repositories {
|
repositories {
|
||||||
google {
|
google {
|
||||||
content {
|
content {
|
||||||
@@ -7,6 +8,9 @@ pluginManagement {
|
|||||||
includeGroupByRegex("androidx.*")
|
includeGroupByRegex("androidx.*")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maven(mavenCentralMirrorUrl) {
|
||||||
|
name = "MavenCentralMirror"
|
||||||
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
}
|
}
|
||||||
@@ -16,6 +20,9 @@ dependencyResolutionManagement {
|
|||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
maven(providers.gradleProperty("mavenCentralMirrorUrl").get()) {
|
||||||
|
name = "MavenCentralMirror"
|
||||||
|
}
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ export const LIB = ".integration/music"
|
|||||||
export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT]
|
export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT]
|
||||||
|
|
||||||
const MUSIC = [
|
const MUSIC = [
|
||||||
{ id: 197, name: "Ugress - Retroconnaissance EP", songs: 4 }
|
{ id: "7GZYpfn8wypaTR00LWeNI4", name: "Ugress - Retroconnaissance", songs: 4 }
|
||||||
{ id: 199, name: "Ugress - Kosmonaut", songs: 4 }
|
{ id: "5gMe1SlZZzuOMkRVWJaodV", name: "Ugress - Kosmonaut", songs: 4 }
|
||||||
{ id: 321, name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
|
{ id: "0JnRLI5lo4eNU1pOePqfcG", name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const MUSIC_DOWNLOAD = "https://demo.navidrome.org/rest/download.view?u=demo&p=demo&v=1.16.1&c=subtracks-test&id="
|
||||||
|
|
||||||
|
const MUSIC_ART = "https://demo.navidrome.org/rest/getCoverArt.view?u=demo&p=demo&v=1.16.1&c=subtracks-test&id="
|
||||||
|
|
||||||
# Keep in step with TestServers.kt.
|
# Keep in step with TestServers.kt.
|
||||||
export def test-servers [] {
|
export def test-servers [] {
|
||||||
[
|
[
|
||||||
@@ -33,17 +37,18 @@ def song-count [dir: string] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export def ensure-music [] {
|
export def ensure-music [] {
|
||||||
if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs }) {
|
if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs and ($"($LIB)/($m.id)/folder.jpg" | path exists) }) {
|
||||||
print $"using test music in ($LIB)"
|
print $"using test music in ($LIB)"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
print "downloading test music from demo.subsonic.org (one-time)"
|
print "downloading test music from demo.navidrome.org (one-time)"
|
||||||
mkdir $LIB
|
mkdir $LIB
|
||||||
let tmp = (^mktemp -d | str trim)
|
let tmp = (^mktemp -d | str trim)
|
||||||
for m in $MUSIC {
|
for m in $MUSIC {
|
||||||
print $"($m.name)..."
|
print $"($m.name)..."
|
||||||
^curl -fsS -o $"($tmp)/($m.id).zip" $"http://demo.subsonic.org/rest/download.view?u=guest1&p=guest&v=1.13.0&c=subtracks-test&id=($m.id)"
|
^curl -fsS -o $"($tmp)/($m.id).zip" $"($MUSIC_DOWNLOAD)($m.id)"
|
||||||
^unzip -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)"
|
^unzip -j -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)"
|
||||||
|
^curl -fsS -o $"($LIB)/($m.id)/folder.jpg" $"($MUSIC_ART)($m.id)"
|
||||||
}
|
}
|
||||||
rm -rf $tmp
|
rm -rf $tmp
|
||||||
}
|
}
|
||||||
@@ -163,8 +168,9 @@ export def start-prune [work: string] {
|
|||||||
let music = ($dir | path join "music")
|
let music = ($dir | path join "music")
|
||||||
mkdir ($dir | path join "data")
|
mkdir ($dir | path join "data")
|
||||||
mkdir $music
|
mkdir $music
|
||||||
^cp -r $"((pwd) | path join $LIB)/197" $music
|
for m in ($MUSIC | first 2) {
|
||||||
^cp -r $"((pwd) | path join $LIB)/199" $music
|
^cp -r $"((pwd) | path join $LIB)/($m.id)" $music
|
||||||
|
}
|
||||||
|
|
||||||
let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $PRUNE_PORT --musicfolder $music --datafolder ($dir | path join "data") --loglevel warn out+err> ($dir | path join "navidrome.log") })
|
let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $PRUNE_PORT --musicfolder $music --datafolder ($dir | path join "data") --loglevel warn out+err> ($dir | path join "navidrome.log") })
|
||||||
let ready = (try {
|
let ready = (try {
|
||||||
@@ -198,12 +204,12 @@ export def prepare-fixtures [server: record] {
|
|||||||
|
|
||||||
let albums = (poll "albums" {||
|
let albums = (poll "albums" {||
|
||||||
let items = (sub-get $base $user $pass "getAlbumList2" [["type" "newest"]] | get albumList2.album)
|
let items = (sub-get $base $user $pass "getAlbumList2" [["type" "newest"]] | get albumList2.album)
|
||||||
if (["Kosmonaut" "Retroconnaissance EP" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null }
|
if (["Kosmonaut" "Retroconnaissance" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null }
|
||||||
})
|
})
|
||||||
|
|
||||||
let ugress = (named-id $artists "Ugress")
|
let ugress = (named-id $artists "Ugress")
|
||||||
let kosmo = (named-id $albums "Kosmonaut")
|
let kosmo = (named-id $albums "Kosmonaut")
|
||||||
let retro = (named-id $albums "Retroconnaissance EP")
|
let retro = (named-id $albums "Retroconnaissance")
|
||||||
let dunno = (named-id $albums "I Don't Know What I'm Doing")
|
let dunno = (named-id $albums "I Don't Know What I'm Doing")
|
||||||
|
|
||||||
poll "album songs" {||
|
poll "album songs" {||
|
||||||
|
|||||||
Reference in New Issue
Block a user