Author SHA1 Message Date
BonexP 9b92c5313b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 18.2% (57 of 313 strings)

Co-authored-by: BonexP <bonexp@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/subtracks/subtracks/zh_Hans/
Translation: Subtracks/subtracks
2026-11-14 12:08:02 +00:00
austinried d76f236f59 downloads: recover reset observation and skip progress work 2026-11-14 12:05:29 +00:00
austinried 50c4dfce03 sync: batch playlist origin lookups 2026-11-14 12:05:29 +00:00
austinried 746446be86 queue: make reset remapping origin-safe and bounded 2026-11-14 12:05:29 +00:00
austinried df8cf73385 ui: preserve pending older queue loads 2026-11-14 12:05:29 +00:00
austinried 97e165e8c2 ui: preserve queue windows during reconciliation 2026-11-14 12:05:29 +00:00
austinried eb4d537437 test: drop reverse queue debug output 2026-11-14 12:05:29 +00:00
austinried 31e4596860 ui: reconcile queue edits against current rows 2026-11-14 12:05:29 +00:00
austinried b95b8c3569 ui: validate queue moves against content revisions 2026-11-14 12:05:29 +00:00
austinried e4549f5fa1 test: combine queue removal coverage 2026-11-14 12:05:29 +00:00
austinried e2c3f31c26 ui: serialize queue moves with row updates 2026-11-14 12:05:29 +00:00
austinried 2766affa65 ui: roll back rejected multi-step queue edits 2026-11-14 12:05:29 +00:00
austinried 4d5328d8a0 playback: report whether queue edits were applied 2026-11-14 12:05:29 +00:00
austinried 97e700a1ab test: cover rejected queue edit snackbars 2026-11-14 12:05:29 +00:00
austinried 5fadc21455 ui: roll back rejected queue edits 2026-11-14 12:05:29 +00:00
austinried 64ec3265b7 ui: reject stale and mixed-generation queue edits 2026-11-14 12:05:29 +00:00
austinried 9514bc501a downloads: reconcile bulk deletion as one batch 2026-11-14 12:05:29 +00:00
austinried 35f4f9178b downloads: adapt reset tests to prepared publication 2026-11-14 12:05:29 +00:00
austinried e4d97f7aba downloads: harden queue reset reconciliation
Refresh per-source rows under the reconciliation mutex before reset decisions and chunk queue-visibility checks to respect SQLite bind limits.

Cover a blocked pre-completion emission and source deletion at the bundled SQLite variable limit.
2026-11-14 12:05:29 +00:00
austinried cc94dfc458 downloads: reconcile visible queue reset baselines
Consume captured reset points on the first collector emission and leave pending sources uninitialized during reconciliation.

Build ordered baselines from queue-visible completed downloads and ignore pruned-row transitions while retaining all-row state and recovery.
2026-11-14 12:05:28 +00:00
austinried 79915d6c7a library: skip queue resets for missing songs 2026-11-14 12:05:28 +00:00
austinried 4e72b1f38d downloads: preserve completed reset baselines 2026-11-14 12:05:28 +00:00
austinried ed19ca2ece library: reconcile queues after starred and download changes 2026-11-14 12:05:28 +00:00
austinried 481f12c892 sync: preserve legacy identity during queue resets 2026-11-14 12:05:28 +00:00
austinried 544b442f4e sync: make queue reset publication recoverable 2026-11-14 12:05:28 +00:00
austinried 705c93053f sync: publish queue resets after commit
Prepare queue and playback reconciliation inside the reset transaction without publishing listener, cache, or revision state. Publish through a non-suspending, non-cancellable post-commit step so committed resets are never retried over later edits.

Keep the pending-origin clear under the cursor lock without a redundant single-statement transaction, and cover listener and post-clear write failures.
2026-11-14 12:05:28 +00:00
austinried ffc61bdbd2 sync: use playlist deletion count 2026-11-14 12:05:28 +00:00
austinried 3de80dacdd sync: reuse playlist song deletion helper 2026-11-14 12:05:28 +00:00
austinried 95c8a2a915 sync: preserve queue lock order during resets
Move pending-origin clearing into the queue mutation lock, acquire the cursor lock before Room, and clear pending rows before cache, revision, or listener side effects. Keep sync listener reconciliation transactional for retries while preserving direct reset commit visibility.
2026-11-14 12:05:28 +00:00
austinried 92e5bb45c9 sync: simplify library deletion 2026-11-14 12:05:28 +00:00
austinried 2afad4fa0d sync: verify updates recreate pending origins 2026-11-14 12:05:28 +00:00
austinried 4e51f906fe sync: consume pending origins with queue resets 2026-11-14 12:05:28 +00:00
austinried 548bf39136 sync: reset queues only for successfully changed origins 2026-11-14 12:05:28 +00:00
austinried acc820e263 playback: reconcile detached items during offline transitions 2026-11-14 12:05:28 +00:00
austinried 4b5d02f17b playback: keep detached fallback out of published position 2026-11-14 12:05:28 +00:00
austinried fd0eb0a9a0 playback: retain durable reset identity through Media3 2026-11-14 12:05:28 +00:00
austinried 01ccc6475d playback: resolve song tap behavior inside queue mutation 2026-11-14 12:05:28 +00:00
austinried a9738c6311 playback: preserve detached fallback queue state 2026-11-14 12:05:28 +00:00
austinried 767c3db1ae playback: serialize transitions and preserve detached playback
Run transition bookkeeping under the queue mutation lock, retain detached playback when its final survivor is removed, and restore offline repeat wrapping without a forward survivor. Centralize detached survivor rebuilding and add regressions for each defect.
2026-11-14 12:05:28 +00:00
austinried 018f30a3d8 playback: preserve playback across canonical queue resets 2026-11-14 12:05:28 +00:00
austinried 762639cab5 queue: invalidate keyset cursors on edits 2026-11-14 12:03:42 +00:00
austinried 94c7fba536 queue: preserve ambiguous legacy reset identity 2026-11-14 12:03:42 +00:00
austinried ddc6811c34 queue: suppress shuffled context removal 2026-11-14 12:03:42 +00:00
austinried 04dba5ef76 queue: leave ambiguous migrated fragments unreset 2026-11-14 12:03:42 +00:00
austinried 0448842f60 queue: retain reset identity across duplicate edits 2026-11-14 12:03:42 +00:00
austinried b9085af44c queue: preserve fragmented origin entry identity 2026-11-14 12:03:42 +00:00
austinried 44e2eb7ae8 queue: add serialized, origin-aware mutations 2026-11-14 12:03:42 +00:00
austinried f43bc877f0 Revert "brand: lift the icon background off black"
This reverts commit d9af47323a.
2026-11-14 09:22:54 +00:00
austinried d9af47323a brand: lift the icon background off black
The near-black #120C12 behind the icon reads as black on dark home
screens, and the same fill backs the store icon, feature graphic and
README logo. Raise it to a plum #2D1C2D (L* 4.6 -> 13.1) so the
background is visible without costing the #F862F8 foreground its
contrast. The amber nightly background moves from #1A1200 to #2E2000 by
the same lightness step, so it stays amber instead of drifting to
brown-black.

Raster assets were recoloured from the committed originals; the vector
sources in images/ have no background and are unchanged.
2026-11-12 13:07:46 +00:00
austinried 02404ef562 ui: rename the song tap preference 2026-11-12 13:03:41 +00:00
austinried d05fb6fc07 playback: resolve the pending tap preference inside the start lock 2026-11-12 07:53:06 +00:00
austinried 2bbff7e477 playback: take the start lock before reading the tap preference 2026-11-12 07:39:14 +00:00
austinried e94b365495 playback: make the song tap shuffle behaviour a preference 2026-11-12 07:05:20 +00:00
austinried d62bff5ace playback: simplify the context start paths 2026-11-12 05:02:49 +00:00
austinried 29335f7ddf playback: turn off shuffle when a song is tapped 2026-11-12 04:31:58 +00:00
austinried 11276c2e1f playback: revert the restored session prepare
Preparing the restored window on launch was meant to make the paused
session reachable from Bluetooth and Automate. The follow-up on the issue
shows the notification appears, but the car's play command still does not
reach the app until the phone is unlocked, and the speculative prepare
buffered up to 50 seconds of the restored track on every launch while
claiming the media controls whenever the app opened.

Removing the prepare also removes the error gate it needed, which could
hide genuine playback errors while paused.

Reverts #230.
2026-11-05 13:13:22 +00:00
austinried eb55858cc4 playback: preserve null keys and ranged duplicate ranks 2026-11-04 13:15:54 +00:00
austinried 95ad993996 playback: resolve shuffled windows without flat ID lists 2026-11-04 13:06:45 +00:00
austinried 11abd4afbd ui: share bulk download deletion confirmation 2026-11-04 12:16:06 +00:00
austinried 69f41b3459 playback: collapse the restore guard into one null chain 2026-11-04 11:12:07 +00:00
austinried 09baaeb77d playback: keep speculative prepare failures quiet
A restore now prepares the saved window before any play gesture, so a
network failure at launch reached the error listener and showed a toast
even though the user had not asked to play. Only report playback errors
when playback was requested.

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

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

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

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

The generated statements are identical to the previous ones modulo
whitespace: the downloaded variants keep the same CASE/download conditions,
and the observed entities match what Room inferred for the old queries. A
small helper rewrites the named parameters to RoomRawQuery's positional
form, including the repeated :sourceId bindings.
2026-11-03 01:03:45 +00:00
austinried 29935b7bee build: mirror Maven Central for dependency resolution
The CI runner's egress IP is rate-limited by repo.maven.apache.org (HTTP 429 on a Robolectric native runtime jar), and a missing Gradle cache then breaks the run. Try Google's Maven Central mirror first and fall back to the canonical repository.
2026-11-01 05:45:25 +00:00
austinried 4bfe67ec19 build: add the nightly res through the directories set
AGP 9 deprecates AndroidSourceDirectorySet.srcDir() in favour of the directories mutable set, which was the last source-set deprecation warning in the build.
2026-11-01 05:35:04 +00:00
austinried 060a5f036f ci: key the test music cache by the demo album ids
The album id is the server id, so use it for the download, the local folder and the prune fixture instead of maintaining a separate remoteId mapping. The cache key moves to v3 because v2 may already hold folders named after the old local ids.
2026-11-01 03:19:17 +00:00
austinried c169549ef4 ci: take integration test music from the Navidrome demo
demo.subsonic.org no longer accepts the guest1 account, so the harness downloaded an XML error page and unzip failed before Gradle ran. Download the same three albums from demo.navidrome.org with the demo account, fetch each album's cover art into folder.jpg, and match the album title the files actually carry (Retroconnaissance, without the EP suffix). Bump the music cache key so stale copies from the old source are not reused.
2026-11-01 03:12:01 +00:00
76 changed files with 11412 additions and 2652 deletions
+5
View File
@@ -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
+1 -1
View File
@@ -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
+3 -1
View File
@@ -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) }
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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 {
@@ -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"
} }
} }
@@ -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,
),
)
private fun SQLiteStatement.bindAlbumFilters( // RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
sourceId: Long, // index, so the SQL can keep its names and only the first-appearance order needs binding.
starredFilter: Int, internal fun rawQuery(
search: String, sql: String,
bindings: Map<String, Any?>,
): RoomRawQuery {
val values =
Regex(":(\\w+)")
.findAll(sql)
.map { it.groupValues[1] }
.distinct()
.map(bindings::getValue)
.toList()
return RoomRawQuery(sql) { statement ->
values.forEachIndexed { index, value -> statement.bind(index + 1, value) }
}
}
private fun SQLiteStatement.bind(
index: Int,
value: Any?,
) { ) {
bindLong(1, sourceId) when (value) {
bindInt(2, starredFilter) null -> bindNull(index)
bindInt(3, starredFilter) is Int -> bindLong(index, value.toLong())
bindInt(4, starredFilter) is Long -> bindLong(index, value)
bindText(5, search) is String -> bindText(index, value)
bindText(6, search) else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
bindText(7, search)
bindText(8, search)
bindText(9, search)
bindText(10, search)
bindText(11, search)
bindText(12, search)
}
internal fun artistsDefaultQuery(
sourceId: Long,
starredFilter: Int,
search: String,
sort: ArtistSort,
descending: Boolean,
sortNameMode: SortNameMode = SortNameMode.SortName,
): RoomRawQuery {
val order = artistOrder(sort, descending, sortNameMode)
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindInt(2, starredFilter)
stmt.bindInt(3, starredFilter)
stmt.bindInt(4, starredFilter)
stmt.bindText(5, search)
stmt.bindText(6, search)
stmt.bindText(7, search)
stmt.bindText(8, search)
stmt.bindText(9, search)
stmt.bindText(10, search)
}
}
internal fun playlistsDefaultQuery(
sourceId: Long,
search: String,
sort: PlaylistSort,
descending: Boolean,
): RoomRawQuery {
val order = playlistOrder(sort, descending)
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
stmt.bindLong(1, sourceId)
stmt.bindText(2, search)
stmt.bindText(3, search)
stmt.bindText(4, search)
stmt.bindText(5, search)
stmt.bindText(6, search)
} }
} }
@@ -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, " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " + "ROW_NUMBER() OVER (ORDER BY songs.sortName COLLATE NOCASE, songs.id) - 1 AS songRank " +
"ORDER BY songs.sortName COLLATE NOCASE, songs.id", "FROM song_genres JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre" +
") 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>
+5
View File
@@ -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 = {},
+5 -3
View File
@@ -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
+2
View File
@@ -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/
+7
View File
@@ -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()
} }
} }
+17 -11
View File
@@ -12,11 +12,15 @@ export const LIB = ".integration/music"
export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT] 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" {||