Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11276c2e1f | ||
|
|
eb55858cc4 | ||
|
|
95ad993996 | ||
|
|
11abd4afbd | ||
|
|
69f41b3459 | ||
|
|
09baaeb77d | ||
|
|
484cc55385 | ||
|
|
599924bdd0 | ||
|
|
d6ff3ee086 | ||
|
|
b2a1716be2 | ||
|
|
51792f836b | ||
|
|
446a35d26e | ||
|
|
b18e0891f6 | ||
|
|
c53f1aef06 | ||
|
|
87e0609c05 | ||
|
|
c3a5382c27 | ||
|
|
e8cbb93714 | ||
|
|
6f1e05d15a | ||
|
|
c165a6cc88 | ||
|
|
18c24ca566 | ||
|
|
8d6f7e2341 | ||
|
|
29935b7bee | ||
|
|
4bfe67ec19 | ||
|
|
060a5f036f | ||
|
|
c169549ef4 | ||
|
|
3bd29f75e6 | ||
|
|
d7a3a41ab2 | ||
|
|
05e318f9ab | ||
|
|
e8fb0ac564 | ||
|
|
7379f3d93e |
@@ -40,6 +40,11 @@ runs:
|
||||
path: ~/.gradle/caches
|
||||
key: gradle-v2-${{ hashFiles('**/*.gradle*', '**/gradle.properties', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-v2-
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.m2/repository/org/robolectric
|
||||
key: robolectric-m2-v1-${{ hashFiles('gradle/libs.versions.toml', 'app/src/test/resources/robolectric.properties') }}
|
||||
restore-keys: robolectric-m2-v1-
|
||||
- id: signing
|
||||
if: inputs.sign == 'true'
|
||||
shell: bash
|
||||
|
||||
@@ -50,5 +50,5 @@ jobs:
|
||||
- uses: actions/cache@v6
|
||||
with:
|
||||
path: .integration/music
|
||||
key: integration-music-v1
|
||||
key: integration-music-v3
|
||||
- run: nix develop .#integration --command nu tools/integration-test.nu
|
||||
|
||||
@@ -85,7 +85,9 @@ jobs:
|
||||
echo "value=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Check release notes exist
|
||||
run: test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||
run: |
|
||||
test -f "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt"
|
||||
test -f "docs/releases/$GITHUB_REF_NAME.md"
|
||||
- name: Write Play service account
|
||||
if: steps.stable.outputs.value == 'true'
|
||||
env:
|
||||
@@ -123,7 +125,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "metadata/en-US/changelogs/${{ needs.build.outputs.version-code }}.txt")
|
||||
args=(--verify-tag --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md")
|
||||
if [ "${{ steps.stable.outputs.value }}" != "true" ]; then
|
||||
args+=(--prerelease)
|
||||
fi
|
||||
|
||||
@@ -37,6 +37,7 @@ See [signing/README.md](signing/README.md) for the fingerprints and the commands
|
||||
- **Adaptive Theming:** Colours drawn from the current song's artwork.
|
||||
- **Search:** Fast search across the whole library, with per-list sort and filter controls.
|
||||
- **Playback:** Gapless playback, an editable queue you can reorder and jump through, plus loop and shuffle modes.
|
||||
- **ReplayGain:** Loudness normalization from the tags your server reports, with Auto, track and album modes and a preamp.
|
||||
- **Up Next:** Add items into the currently playing queue without rewriting it.
|
||||
- **Multiple Servers:** Connect several servers and switch between them quickly.
|
||||
- **Stream Quality:** Bitrate and format settings for Wi-Fi and mobile, with automatic switching.
|
||||
|
||||
@@ -36,8 +36,8 @@ android {
|
||||
applicationId = "com.subtracks"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 13
|
||||
versionName = "3.0.0"
|
||||
versionCode = 14
|
||||
versionName = "3.1.0"
|
||||
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ android {
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("debug").res.srcDir("src/nightly/res")
|
||||
getByName("debug").res.directories.add("src/nightly/res")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -181,6 +181,8 @@ val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
||||
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||
|
||||
tasks.withType<Test>().configureEach {
|
||||
// Robolectric fetches its Android SDK jar itself, outside Gradle's repositories.
|
||||
systemProperty("robolectric.dependency.repo.url", providers.gradleProperty("mavenCentralMirrorUrl").get())
|
||||
if (name.endsWith("UnitTest")) {
|
||||
filter {
|
||||
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||
|
||||
@@ -56,7 +56,7 @@ class SubsonicSourceIntegrationTest(
|
||||
assertNotNull(kosmo.starred)
|
||||
assertTrue(kosmo.created <= System.currentTimeMillis() / 1000)
|
||||
|
||||
assertNull(albums.first { it.name == "Retroconnaissance EP" }.starred)
|
||||
assertNull(albums.first { it.name == "Retroconnaissance" }.starred)
|
||||
assertNull(albums.first { it.name == "I Don't Know What I'm Doing" }.starred)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ class SubsonicSourceIntegrationTest(
|
||||
.filter { it.artistId == artists.first { a -> a.name == "Ugress" }.id }
|
||||
.map { it.name }
|
||||
.sorted()
|
||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance EP"), ugressAlbums)
|
||||
assertEquals(listOf("Kosmonaut", "Retroconnaissance"), ugressAlbums)
|
||||
|
||||
val bradAlbums =
|
||||
albums
|
||||
|
||||
@@ -22,7 +22,7 @@ class SubsonicWriteIntegrationTest(
|
||||
.albums()
|
||||
.toList()
|
||||
.flatten()
|
||||
.first { it.name == "Retroconnaissance EP" }
|
||||
.first { it.name == "Retroconnaissance" }
|
||||
|
||||
source.setStar(StarType.Album, album.id, true)
|
||||
try {
|
||||
@@ -94,7 +94,7 @@ class SubsonicWriteIntegrationTest(
|
||||
.songs()
|
||||
.toList()
|
||||
.flatten()
|
||||
.first { it.album == "Retroconnaissance EP" }
|
||||
.first { it.album == "Retroconnaissance" }
|
||||
|
||||
source.setStar(StarType.Song, song.id, true)
|
||||
try {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -18,12 +14,7 @@ class MultiSourceSyncIntegrationTest {
|
||||
@Test
|
||||
fun syncsEveryServerIntoOneLibraryWithoutInterference() =
|
||||
runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
TestServers.all.forEachIndexed { index, server ->
|
||||
val id = index + 1L
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||
@@ -28,12 +24,7 @@ class PruneSyncIntegrationTest {
|
||||
val removed = File(musicDir, REMOVED_ALBUM_DIR)
|
||||
check(removed.isDirectory) { "$removed is missing; the harness must prepare the prune library" }
|
||||
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = "prune", address = baseUrl, isActive = true, createdAt = 0),
|
||||
@@ -93,7 +84,7 @@ class PruneSyncIntegrationTest {
|
||||
?: error("$name is not set; run the integration harness (tools/integration-test.nu)")
|
||||
|
||||
private companion object {
|
||||
const val REMOVED_ALBUM_DIR = "197"
|
||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance EP"
|
||||
const val REMOVED_ALBUM_DIR = "7GZYpfn8wypaTR00LWeNI4"
|
||||
const val REMOVED_ALBUM_NAME = "Retroconnaissance"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
@@ -19,12 +15,7 @@ class SyncServiceIntegrationTest {
|
||||
fun syncMirrorsTheLibraryIntoRoomForEveryServer() =
|
||||
runBlocking {
|
||||
servers.forEach { server ->
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val db = createTestDatabase()
|
||||
try {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = server.name, address = server.baseUrl, isActive = true, createdAt = 0),
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.RoomRawQuery
|
||||
|
||||
internal val CREDITED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery())
|
||||
internal val DOWNLOADED_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true))
|
||||
internal val DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST =
|
||||
albumCreditRows(AlbumCreditQuery(downloaded = true, artistScoped = true, selectAlbums = true))
|
||||
internal val DOWNLOADED_COVER_ALBUM_ROWS = albumCreditRows(AlbumCreditQuery(downloaded = true, coverOnly = true))
|
||||
internal val ARTIST_COVER_CREDITS = albumCreditRows(AlbumCreditQuery(coverOnly = true))
|
||||
internal val ARTIST_COVER_CREDITS_FOR_ARTIST = albumCreditRows(AlbumCreditQuery(coverOnly = true, artistScoped = true))
|
||||
|
||||
private data class AlbumCreditQuery(
|
||||
val artistScoped: Boolean = false,
|
||||
val coverOnly: Boolean = false,
|
||||
val downloaded: Boolean = false,
|
||||
val selectAlbums: Boolean = false,
|
||||
)
|
||||
|
||||
private fun albumCreditRows(query: AlbumCreditQuery): String =
|
||||
if (query.selectAlbums) {
|
||||
listOf(
|
||||
primaryAlbumCredits(query),
|
||||
albumRelationCredits(query),
|
||||
trackRelationCredits(query),
|
||||
trackCredits(query),
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
primaryAlbumCredits(query),
|
||||
albumRelationCredits(query),
|
||||
trackCredits(query),
|
||||
trackRelationCredits(query),
|
||||
)
|
||||
}.joinToString(if (query.downloaded) " UNION " else " UNION ALL ")
|
||||
|
||||
private fun primaryAlbumCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM albums "
|
||||
query.downloaded -> "SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums "
|
||||
else -> "SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType FROM albums "
|
||||
},
|
||||
)
|
||||
append("WHERE albums.sourceId = :sourceId AND ${query.directArtist("albums.artistId")} ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||
}.trim()
|
||||
|
||||
private fun albumRelationCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM album_artists "
|
||||
query.downloaded -> "SELECT albums.id, album_artists.artistId FROM album_artists "
|
||||
else -> "SELECT album_artists.artistId, albums.id, 'album' FROM album_artists "
|
||||
},
|
||||
)
|
||||
append("JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId ")
|
||||
append("WHERE album_artists.sourceId = :sourceId ")
|
||||
if (query.artistScoped) append("AND album_artists.artistId = :artistId ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND $ALBUM_DOWNLOADED ")
|
||||
}.trim()
|
||||
|
||||
private fun trackCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM songs "
|
||||
query.downloaded -> "SELECT songs.albumId, songs.artistId FROM songs "
|
||||
else -> "SELECT songs.artistId, songs.albumId, 'track' FROM songs "
|
||||
},
|
||||
)
|
||||
append(SONG_ALBUM_JOIN)
|
||||
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||
append("WHERE songs.sourceId = :sourceId AND ${query.directArtist("songs.artistId")} ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||
}.trim()
|
||||
|
||||
private fun trackRelationCredits(query: AlbumCreditQuery): String =
|
||||
buildString {
|
||||
append(
|
||||
when {
|
||||
query.selectAlbums -> "SELECT albums.* FROM song_artists "
|
||||
query.downloaded -> "SELECT songs.albumId, song_artists.artistId FROM song_artists "
|
||||
else -> "SELECT song_artists.artistId, songs.albumId, 'track' FROM song_artists "
|
||||
},
|
||||
)
|
||||
append("JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId ")
|
||||
append(SONG_ALBUM_JOIN)
|
||||
if (query.downloaded) append(TRACK_DOWNLOAD_JOIN)
|
||||
append("WHERE song_artists.sourceId = :sourceId ")
|
||||
if (query.artistScoped) append("AND song_artists.artistId = :artistId ")
|
||||
append(query.coverFilter())
|
||||
if (query.downloaded) append("AND sd.status = 'Completed' ")
|
||||
}.trim()
|
||||
|
||||
private fun AlbumCreditQuery.directArtist(column: String): String = if (artistScoped) "$column = :artistId" else "$column IS NOT NULL"
|
||||
|
||||
private fun AlbumCreditQuery.coverFilter(): String = if (coverOnly) "AND albums.coverArt IS NOT NULL " else ""
|
||||
|
||||
private const val SONG_ALBUM_JOIN = "JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId "
|
||||
|
||||
private const val TRACK_DOWNLOAD_JOIN = "JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id "
|
||||
|
||||
private const val ALBUM_DOWNLOADED =
|
||||
"EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')"
|
||||
|
||||
internal fun downloadedArtistArtQuery(
|
||||
sourceId: Long,
|
||||
artistIds: List<String>,
|
||||
): RoomRawQuery {
|
||||
val bindings = mutableMapOf<String, Any?>("sourceId" to sourceId)
|
||||
val placeholders =
|
||||
artistIds
|
||||
.mapIndexed { index, artistId ->
|
||||
val name = "artistId$index"
|
||||
bindings[name] = artistId
|
||||
":$name"
|
||||
}.ifEmpty { listOf("NULL") }
|
||||
return rawQuery(DOWNLOADED_ARTIST_ART_UPDATE_SQL.replace(":artistIds", placeholders.joinToString(",")), bindings)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.RoomDatabase
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import com.subtracks.log.Log
|
||||
|
||||
@@ -11,7 +12,9 @@ fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||
.setDriver(driver)
|
||||
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||
.addCallback(ArtistSearchTriggerOptimization)
|
||||
.addCallback(SqliteLifecycleLogging)
|
||||
.addSubtracksDatabaseCallbacks()
|
||||
.build()
|
||||
}
|
||||
|
||||
internal fun RoomDatabase.Builder<SubtracksDatabase>.addSubtracksDatabaseCallbacks(): RoomDatabase.Builder<SubtracksDatabase> =
|
||||
addCallback(ArtistSearchTriggerOptimization).addCallback(SqliteLifecycleLogging)
|
||||
|
||||
@@ -10,15 +10,21 @@ import androidx.room3.Transaction
|
||||
import androidx.room3.Upsert
|
||||
import androidx.room3.paging.PagingSourceDaoReturnTypeConverter
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumArtistRef
|
||||
import com.subtracks.data.model.AlbumSearch
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
import com.subtracks.data.model.ArtistSearch
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DiscKey
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSearch
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongArtistRef
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -36,20 +42,6 @@ internal const val DOWNLOADED_SONG =
|
||||
"EXISTS (SELECT 1 FROM song_downloads sd WHERE sd.sourceId = playlist_songs.sourceId " +
|
||||
"AND sd.songId = playlist_songs.songId AND sd.status = 'Completed')"
|
||||
|
||||
internal const val ALBUMS_FILTER =
|
||||
"FROM albums WHERE sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')) " +
|
||||
"AND (:search = '' " +
|
||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||
"WHERE album_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||
"OR instr(lower(albumArtist), lower(:search)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(:search)) > 0 " +
|
||||
"OR instr(lower(albumArtistSort), lower(:search)) > 0))) "
|
||||
|
||||
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, sortName COLLATE NOCASE, id"
|
||||
@@ -67,13 +59,7 @@ internal const val ALBUM_ORDER_BY_RECENT_REVERSED = "played ASC NULLS LAST, sort
|
||||
|
||||
private const val ALBUM_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||
private const val ALBUM_ARTIST_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN albumArtist WHEN 'NonLatin' THEN (CASE WHEN albumArtist GLOB '*[A-Za-z]*' THEN albumArtist ELSE albumArtistSort END) ELSE albumArtistSort END) COLLATE NOCASE"
|
||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE = "$ALBUM_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE = "$ALBUM_ARTIST_SORT_KEY_MODE, year, $ALBUM_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED =
|
||||
"$ALBUM_ARTIST_SORT_KEY_MODE DESC, year DESC, $ALBUM_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
|
||||
internal const val ARTIST_CREDITED =
|
||||
"(artists.credited = 1 OR COALESCE((SELECT creditsStale FROM sources WHERE id = artists.sourceId), 1) = 1)"
|
||||
@@ -98,134 +84,18 @@ internal const val CREDITED_SONG_ROWS =
|
||||
"ON songs.sourceId = album_artists.sourceId AND songs.albumId = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId"
|
||||
|
||||
// May repeat an (artistId, albumId) pair when several credit paths reach it; consumers dedupe.
|
||||
internal const val CREDITED_ALBUM_ROWS =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId"
|
||||
|
||||
internal const val ALBUMS_FOR_ARTIST =
|
||||
internal val ALBUMS_FOR_ARTIST =
|
||||
"SELECT albums.* FROM (SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) WHERE artistId = :artistId) credits " +
|
||||
"JOIN albums ON albums.sourceId = :sourceId AND albums.id = credits.albumId " +
|
||||
"ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||
|
||||
internal const val ALBUM_CREDIT_ALBUM_IDS =
|
||||
internal val ALBUM_CREDIT_ALBUM_IDS =
|
||||
"SELECT DISTINCT albumId FROM ($CREDITED_ALBUM_ROWS) " +
|
||||
"WHERE artistId = :artistId AND creditType = 'album'"
|
||||
|
||||
// Download visibility follows the credit path: an album credit includes the album when any song is
|
||||
// downloaded, while a track credit includes it only when the credited track is downloaded.
|
||||
internal const val DOWNLOADED_ALBUM_ROWS =
|
||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND sd.status = 'Completed'"
|
||||
|
||||
private const val DOWNLOADED_ARTIST_ALBUMS =
|
||||
internal val DOWNLOADED_ARTIST_ALBUMS =
|
||||
"SELECT albumId AS id FROM ($DOWNLOADED_ALBUM_ROWS) WHERE artistId = artists.id"
|
||||
|
||||
private const val DOWNLOADED_COVER_ALBUM_ROWS =
|
||||
"SELECT albums.id AS albumId, albums.artistId AS artistId FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.id, album_artists.artistId FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, songs.artistId FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"AND sd.status = 'Completed' " +
|
||||
"UNION " +
|
||||
"SELECT songs.albumId, song_artists.artistId FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL AND sd.status = 'Completed'"
|
||||
|
||||
// Cover art only needs credited albums that actually have a cover, so filter before the window sort.
|
||||
private const val ARTIST_COVER_CREDITS =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId IS NOT NULL " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId IS NOT NULL AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND albums.coverArt IS NOT NULL"
|
||||
|
||||
private const val ARTIST_COVER_CREDITS_FOR_ARTIST =
|
||||
"SELECT albums.artistId AS artistId, albums.id AS albumId, 'album' AS creditType " +
|
||||
"FROM albums WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT album_artists.artistId, albums.id, 'album' " +
|
||||
"FROM album_artists JOIN albums " +
|
||||
"ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT songs.artistId, songs.albumId, 'track' " +
|
||||
"FROM songs JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId AND albums.coverArt IS NOT NULL " +
|
||||
"UNION ALL " +
|
||||
"SELECT song_artists.artistId, songs.albumId, 'track' " +
|
||||
"FROM song_artists JOIN songs " +
|
||||
"ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||
"AND albums.coverArt IS NOT NULL"
|
||||
|
||||
private const val ARTIST_COVER_RANKS_TAIL =
|
||||
"ranked AS (SELECT credits.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY credits.artistId " +
|
||||
@@ -236,19 +106,19 @@ private const val ARTIST_COVER_RANKS_TAIL =
|
||||
"deduped AS (SELECT artistId, coverArt, " +
|
||||
"ROW_NUMBER() OVER (PARTITION BY artistId ORDER BY coverRank) AS coverRank FROM unique_ranked)"
|
||||
|
||||
private const val ARTIST_COVER_RANKS =
|
||||
private val ARTIST_COVER_RANKS =
|
||||
"credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS) GROUP BY artistId, albumId), " +
|
||||
ARTIST_COVER_RANKS_TAIL
|
||||
|
||||
private const val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||
private val ARTIST_COVER_RANKS_FOR_ARTIST =
|
||||
"credits AS (SELECT artistId, albumId, " +
|
||||
"MIN(CASE creditType WHEN 'album' THEN 0 ELSE 1 END) AS creditRank " +
|
||||
"FROM ($ARTIST_COVER_CREDITS_FOR_ARTIST) GROUP BY artistId, albumId), " +
|
||||
ARTIST_COVER_RANKS_TAIL
|
||||
|
||||
internal const val ARTIST_ART_UPDATE_SQL =
|
||||
internal val ARTIST_ART_UPDATE_SQL =
|
||||
"WITH $ARTIST_COVER_RANKS, " +
|
||||
"covers AS (SELECT artistId, group_concat(coverArt, char(31)) AS coverArts FROM (" +
|
||||
"SELECT artistId, coverArt, coverRank FROM deduped WHERE coverRank <= 4 ORDER BY artistId, coverRank" +
|
||||
@@ -274,11 +144,11 @@ internal const val ARTIST_ART_UPDATE_SQL =
|
||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||
"(artists.coverArts != desired.coverArts OR artists.downloadedCoverArts != desired.downloadedCoverArts)"
|
||||
|
||||
internal const val ARTIST_COVER_ARTS_SQL =
|
||||
internal val ARTIST_COVER_ARTS_SQL =
|
||||
"WITH $ARTIST_COVER_RANKS_FOR_ARTIST " +
|
||||
"SELECT coverArt FROM deduped WHERE coverRank <= 4 ORDER BY coverRank"
|
||||
|
||||
internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
internal val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
"WITH downloaded_albums AS (SELECT DISTINCT artistId, albumId FROM ($DOWNLOADED_COVER_ALBUM_ROWS) " +
|
||||
"WHERE artistId IN (:artistIds)), " +
|
||||
"downloaded AS (SELECT dl.artistId AS artistId, albums.coverArt AS coverArt, " +
|
||||
@@ -300,33 +170,13 @@ internal const val DOWNLOADED_ARTIST_ART_UPDATE_SQL =
|
||||
"FROM desired WHERE artists.rowid = desired.rowid AND " +
|
||||
"artists.downloadedCoverArts != desired.downloadedCoverArts"
|
||||
|
||||
internal const val ARTISTS_FILTER =
|
||||
"FROM artists WHERE sourceId = :sourceId " +
|
||||
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
|
||||
"AND $ARTIST_CREDITED " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (" +
|
||||
"SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl " +
|
||||
"WHERE dl.id = artists.id AND dl.status = 'Completed')) " +
|
||||
"AND (:search = '' " +
|
||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||
"WHERE artist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(:search) < 3 AND (instr(lower(name), lower(:search)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(:search)) > 0))) "
|
||||
internal const val ARTIST_COLUMNS_TAIL =
|
||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, " +
|
||||
"artists.downloadedCoverArts, artists.playCount, artists.played, artists.songCount, " +
|
||||
"artists.sortName, artists.credited"
|
||||
|
||||
internal const val ARTIST_COLUMNS =
|
||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, artists.starred, " +
|
||||
"artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited"
|
||||
|
||||
// With the Downloaded filter on, count the albums that actually have a download so the number under
|
||||
// an artist matches the albums its detail screen lists.
|
||||
internal const val ARTISTS_SELECT =
|
||||
"SELECT artists.sourceId, artists.id, artists.name, " +
|
||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||
"SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)" +
|
||||
") ELSE artists.albumCount END AS albumCount, " +
|
||||
"artists.starred, artists.coverArt, artists.artistImageUrl, artists.coverArts, artists.downloadedCoverArts, " +
|
||||
"artists.playCount, artists.played, artists.songCount, artists.sortName, artists.credited "
|
||||
"artists.sourceId, artists.id, artists.name, artists.albumCount, $ARTIST_COLUMNS_TAIL"
|
||||
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, sortName COLLATE NOCASE, id"
|
||||
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, sortName COLLATE NOCASE DESC, id DESC"
|
||||
@@ -340,33 +190,11 @@ internal const val ARTIST_ORDER_BY_RECENT_REVERSED = "artists.played ASC NULLS L
|
||||
private const val ARTIST_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN name WHEN 'NonLatin' THEN (CASE WHEN name GLOB '*[A-Za-z]*' THEN name ELSE sortName END) ELSE sortName END) COLLATE NOCASE"
|
||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE = "$ARTIST_NAME_SORT_KEY_MODE, id"
|
||||
internal const val ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED = "$ARTIST_NAME_SORT_KEY_MODE DESC, id DESC"
|
||||
|
||||
private const val SONG_NAME_SORT_KEY_MODE =
|
||||
"(CASE :sortMode WHEN 'Name' THEN songs.title WHEN 'NonLatin' THEN (CASE WHEN songs.title GLOB '*[A-Za-z]*' THEN songs.title ELSE songs.sortName END) ELSE songs.sortName END) COLLATE NOCASE"
|
||||
internal const val SONG_SEARCH_ORDER_SORT_MODE = "$SONG_NAME_SORT_KEY_MODE, songs.id"
|
||||
|
||||
internal const val PLAYLISTS_FILTER =
|
||||
"FROM playlists WHERE sourceId = :sourceId " +
|
||||
"AND (:downloadedFilter = 0 OR EXISTS (SELECT 1 FROM playlist_songs ps " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId " +
|
||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')) " +
|
||||
"AND (:search = '' " +
|
||||
"OR (length(:search) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
||||
"WHERE playlist_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(:search) < 3 AND instr(lower(name), lower(:search)) > 0)) "
|
||||
|
||||
// With the Downloaded filter on, count the songs that actually have a download so the number under
|
||||
// a playlist matches the songs its detail screen lists.
|
||||
internal const val PLAYLISTS_SELECT =
|
||||
"SELECT playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt, " +
|
||||
"CASE WHEN :downloadedFilter = 1 THEN (" +
|
||||
"SELECT COUNT(DISTINCT ps.songId) FROM playlist_songs ps " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
|
||||
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
|
||||
") ELSE playlists.songCount END AS songCount, " +
|
||||
"playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
|
||||
|
||||
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
|
||||
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
|
||||
internal const val PLAYLIST_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
|
||||
@@ -394,6 +222,39 @@ internal const val SONG_SEARCH_FILTER =
|
||||
"(length(:search) >= $SEARCH_MIN_LENGTH AND songs.rowid IN (SELECT rowid FROM song_search " +
|
||||
"WHERE song_search MATCH '\"' || replace(:search, '\"', '\"\"') || '\"'))"
|
||||
|
||||
internal val ARTIST_AGGREGATES_SQL =
|
||||
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||
"totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"FROM credited GROUP BY artistId" +
|
||||
"), album_totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||
"FROM credited_albums GROUP BY artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId) c " +
|
||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||
"OR artists.credited != c.credited)"
|
||||
|
||||
internal val ALBUMS_FOR_ARTIST_DOWNLOADED =
|
||||
"SELECT * FROM ($DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST) ORDER BY year DESC, sortName COLLATE NOCASE, id"
|
||||
|
||||
@Dao
|
||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||
interface LibraryDao {
|
||||
@@ -426,54 +287,37 @@ interface LibraryDao {
|
||||
)
|
||||
suspend fun recomputeAlbumPlayData(sourceId: Long)
|
||||
|
||||
// Counts, plays and the credited flag come from the shared credit unions. The download-aware
|
||||
// queries are separate because their visibility follows the credit path.
|
||||
@Query(
|
||||
"WITH credited AS (SELECT DISTINCT * FROM ($CREDITED_SONG_ROWS)), " +
|
||||
"credited_albums AS ($CREDITED_ALBUM_ROWS), " +
|
||||
"totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT songId) AS songCount, " +
|
||||
"SUM(playCount) AS playCount, MAX(played) AS played " +
|
||||
"FROM credited GROUP BY artistId" +
|
||||
"), album_totals AS (" +
|
||||
"SELECT artistId AS id, COUNT(DISTINCT albumId) AS albumCount " +
|
||||
"FROM credited_albums GROUP BY artistId" +
|
||||
") " +
|
||||
"UPDATE artists SET songCount = c.songCount, albumCount = c.albumCount, " +
|
||||
"playCount = c.playCount, played = c.played, credited = c.credited " +
|
||||
"FROM (SELECT artists.id AS id, COALESCE(totals.songCount, 0) AS songCount, " +
|
||||
"COALESCE(album_totals.albumCount, 0) AS albumCount, " +
|
||||
"COALESCE(totals.playCount, 0) AS playCount, totals.played AS played, " +
|
||||
"((NOT EXISTS (SELECT 1 FROM album_artists WHERE sourceId = artists.sourceId) " +
|
||||
"AND NOT EXISTS (SELECT 1 FROM song_artists WHERE sourceId = artists.sourceId)) " +
|
||||
"OR EXISTS (SELECT 1 FROM album_artists aa " +
|
||||
"WHERE aa.sourceId = artists.sourceId AND aa.artistId = artists.id) " +
|
||||
"OR EXISTS (SELECT 1 FROM song_artists sa " +
|
||||
"WHERE sa.sourceId = artists.sourceId AND sa.artistId = artists.id)) AS credited " +
|
||||
"FROM artists LEFT JOIN totals ON totals.id = artists.id " +
|
||||
"LEFT JOIN album_totals ON album_totals.id = artists.id " +
|
||||
"WHERE artists.sourceId = :sourceId) c " +
|
||||
"WHERE artists.sourceId = :sourceId AND artists.id = c.id " +
|
||||
"AND (artists.songCount != c.songCount OR artists.albumCount != c.albumCount " +
|
||||
"OR artists.playCount != c.playCount OR artists.played IS NOT c.played " +
|
||||
"OR artists.credited != c.credited)",
|
||||
)
|
||||
suspend fun recomputeArtistAggregates(sourceId: Long)
|
||||
@RawQuery
|
||||
suspend fun recomputeArtistAggregates(query: RoomRawQuery): Int
|
||||
|
||||
@Query(ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeArtistArt(sourceId: Long)
|
||||
suspend fun recomputeArtistAggregates(sourceId: Long) {
|
||||
recomputeArtistAggregates(rawQuery(ARTIST_AGGREGATES_SQL, mapOf("sourceId" to sourceId)))
|
||||
}
|
||||
|
||||
@RawQuery
|
||||
suspend fun recomputeArtistArt(query: RoomRawQuery): Int
|
||||
|
||||
suspend fun recomputeArtistArt(sourceId: Long) {
|
||||
recomputeArtistArt(rawQuery(ARTIST_ART_UPDATE_SQL, mapOf("sourceId" to sourceId)))
|
||||
}
|
||||
|
||||
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||
fun artistCoverArts(query: RoomRawQuery): Flow<List<String>>
|
||||
|
||||
@Query(ARTIST_COVER_ARTS_SQL)
|
||||
fun artistCoverArts(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>>
|
||||
): Flow<List<String>> = artistCoverArts(rawQuery(ARTIST_COVER_ARTS_SQL, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||
|
||||
@RawQuery
|
||||
suspend fun recomputeDownloadedArtistArt(query: RoomRawQuery): Int
|
||||
|
||||
@Query(DOWNLOADED_ARTIST_ART_UPDATE_SQL)
|
||||
suspend fun recomputeDownloadedArtistArt(
|
||||
sourceId: Long,
|
||||
artistIds: List<String>,
|
||||
)
|
||||
) {
|
||||
recomputeDownloadedArtistArt(downloadedArtistArtQuery(sourceId, artistIds))
|
||||
}
|
||||
|
||||
@Query("UPDATE artists SET downloadedCoverArts = '' WHERE sourceId = :sourceId AND downloadedCoverArts != ''")
|
||||
suspend fun clearDownloadedArtistArt(sourceId: Long)
|
||||
@@ -804,245 +648,27 @@ interface LibraryDao {
|
||||
@RawQuery(observedEntities = [Playlist::class])
|
||||
fun playlistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE")
|
||||
fun albumsByName(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
// These observe every table the statements read; the sets match what Room inferred for the
|
||||
// old downloaded @Query methods, so download and library writes invalidate as before.
|
||||
@RawQuery(observedEntities = [Album::class, Song::class, SongDownload::class, AlbumSearch::class])
|
||||
fun downloadedAlbumsPaging(query: RoomRawQuery): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||
fun albumsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
@RawQuery(
|
||||
observedEntities = [
|
||||
Album::class,
|
||||
Song::class,
|
||||
SongDownload::class,
|
||||
AlbumArtistRef::class,
|
||||
SongArtistRef::class,
|
||||
Artist::class,
|
||||
Source::class,
|
||||
ArtistSearch::class,
|
||||
],
|
||||
)
|
||||
fun downloadedArtistsPaging(query: RoomRawQuery): PagingSource<Int, Artist>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE")
|
||||
fun albumsByArtist(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_SORT_MODE_REVERSED")
|
||||
fun albumsByArtistReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
||||
fun albumsByYear(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
|
||||
fun albumsByYearReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
|
||||
fun albumsByRecentlyAdded(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
|
||||
fun albumsByRecentlyAddedReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
|
||||
fun albumsByStarred(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
|
||||
fun albumsByStarredReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT")
|
||||
fun albumsByFrequent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_FREQUENT_REVERSED")
|
||||
fun albumsByFrequentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT")
|
||||
fun albumsByRecent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_RECENT_REVERSED")
|
||||
fun albumsByRecentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Album>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE")
|
||||
fun artistsByName(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_SORT_MODE_REVERSED")
|
||||
fun artistsByNameReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
sortMode: String = "",
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
||||
fun artistsByAlbumCount(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
|
||||
fun artistsByAlbumCountReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
|
||||
fun artistsByStarred(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
|
||||
fun artistsByStarredReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT")
|
||||
fun artistsByFrequent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_FREQUENT_REVERSED")
|
||||
fun artistsByFrequentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT")
|
||||
fun artistsByRecent(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$ARTISTS_SELECT $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_RECENT_REVERSED")
|
||||
fun artistsByRecentReversed(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Artist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
|
||||
fun playlistsByName(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
|
||||
fun playlistsByNameReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
|
||||
fun playlistsByAdded(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
|
||||
fun playlistsByAddedReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
|
||||
fun playlistsByUpdated(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("$PLAYLISTS_SELECT $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
|
||||
fun playlistsByUpdatedReversed(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
downloadedFilter: Int = 0,
|
||||
): PagingSource<Int, Playlist>
|
||||
@RawQuery(observedEntities = [PlaylistSong::class, SongDownload::class, Playlist::class, PlaylistSearch::class])
|
||||
fun downloadedPlaylistsPaging(query: RoomRawQuery): PagingSource<Int, Playlist>
|
||||
|
||||
@Query("SELECT * FROM albums WHERE sourceId = :sourceId AND played IS NOT NULL ORDER BY played DESC, sortName COLLATE NOCASE, id")
|
||||
fun homeRecentlyPlayedAlbums(sourceId: Long): PagingSource<Int, Album>
|
||||
@@ -1276,17 +902,21 @@ interface LibraryDao {
|
||||
songId: String,
|
||||
): Flow<List<String>>
|
||||
|
||||
@Query(ALBUMS_FOR_ARTIST)
|
||||
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||
fun albumsForArtist(query: RoomRawQuery): Flow<List<Album>>
|
||||
|
||||
fun albumsForArtist(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<Album>>
|
||||
): Flow<List<Album>> = albumsForArtist(rawQuery(ALBUMS_FOR_ARTIST, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||
|
||||
@RawQuery(observedEntities = [Album::class, AlbumArtistRef::class, Song::class, SongArtistRef::class])
|
||||
fun albumCreditAlbumIds(query: RoomRawQuery): Flow<List<String>>
|
||||
|
||||
@Query(ALBUM_CREDIT_ALBUM_IDS)
|
||||
fun albumCreditAlbumIds(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<String>>
|
||||
): Flow<List<String>> = albumCreditAlbumIds(rawQuery(ALBUM_CREDIT_ALBUM_IDS, mapOf("sourceId" to sourceId, "artistId" to artistId)))
|
||||
|
||||
@Query("SELECT * FROM discs WHERE sourceId = :sourceId AND albumId = :albumId ORDER BY disc")
|
||||
fun discs(
|
||||
@@ -1323,36 +953,16 @@ interface LibraryDao {
|
||||
playlistId: String,
|
||||
): PagingSource<Int, PlaylistSongItem>
|
||||
|
||||
// Download visibility follows the credit path: see DOWNLOADED_ARTIST_ALBUMS.
|
||||
@Query(
|
||||
"SELECT * FROM (" +
|
||||
"SELECT albums.* FROM albums " +
|
||||
"WHERE albums.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM album_artists " +
|
||||
"JOIN albums ON albums.sourceId = album_artists.sourceId AND albums.id = album_artists.albumId " +
|
||||
"WHERE album_artists.sourceId = :sourceId AND album_artists.artistId = :artistId " +
|
||||
"AND EXISTS (SELECT 1 FROM songs " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id " +
|
||||
"WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed') " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM song_artists " +
|
||||
"JOIN songs ON songs.sourceId = song_artists.sourceId AND songs.id = song_artists.songId " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||
"WHERE song_artists.sourceId = :sourceId AND song_artists.artistId = :artistId " +
|
||||
"UNION " +
|
||||
"SELECT albums.* FROM songs " +
|
||||
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id AND sd.status = 'Completed' " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId" +
|
||||
") ORDER BY year DESC, sortName COLLATE NOCASE, id",
|
||||
@RawQuery(
|
||||
observedEntities = [Album::class, Song::class, SongDownload::class, AlbumArtistRef::class, SongArtistRef::class],
|
||||
)
|
||||
fun albumsForArtistDownloaded(query: RoomRawQuery): Flow<List<Album>>
|
||||
|
||||
fun albumsForArtistDownloaded(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<List<Album>>
|
||||
): Flow<List<Album>> =
|
||||
albumsForArtistDownloaded(
|
||||
rawQuery(ALBUMS_FOR_ARTIST_DOWNLOADED, mapOf("sourceId" to sourceId, "artistId" to artistId)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,49 +7,161 @@ import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
|
||||
// These filters mirror the download-aware ones in LibraryDao but never mention song_downloads, so
|
||||
// the resulting PagingSource is not invalidated by download writes (including progress updates).
|
||||
internal const val ALBUMS_FILTER_BASE =
|
||||
"FROM albums WHERE sourceId = ? " +
|
||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM album_search " +
|
||||
"WHERE album_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||
"OR instr(lower(albumArtist), lower(?)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(?)) > 0 " +
|
||||
"OR instr(lower(albumArtistSort), lower(?)) > 0))) "
|
||||
// The default filters intentionally omit song_downloads: the unfiltered lists do not need the
|
||||
// join, and their paging sources do not observe download writes (including progress updates).
|
||||
internal const val ALBUMS_FILTER = """
|
||||
FROM albums WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(albumArtist), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0
|
||||
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
internal const val ARTISTS_FILTER_BASE =
|
||||
"FROM artists WHERE sourceId = ? " +
|
||||
"AND (? = 0 OR (? = 1 AND starred IS NOT NULL) OR (? = 2 AND starred IS NULL)) " +
|
||||
"AND $ARTIST_CREDITED " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM artist_search " +
|
||||
"WHERE artist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND (instr(lower(name), lower(?)) > 0 " +
|
||||
"OR instr(lower(sortName), lower(?)) > 0))) "
|
||||
internal const val ALBUMS_FILTER_DOWNLOADED = """
|
||||
FROM albums WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND EXISTS (SELECT 1 FROM songs
|
||||
JOIN song_downloads sd ON sd.sourceId = songs.sourceId AND sd.songId = songs.id
|
||||
WHERE songs.sourceId = albums.sourceId AND songs.albumId = albums.id AND sd.status = 'Completed')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM album_search
|
||||
WHERE album_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(albumArtist), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0
|
||||
OR instr(lower(albumArtistSort), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
internal const val PLAYLISTS_FILTER_BASE =
|
||||
"FROM playlists WHERE sourceId = ? " +
|
||||
"AND (? = '' " +
|
||||
"OR (length(?) >= 3 AND rowid IN (SELECT rowid FROM playlist_search " +
|
||||
"WHERE playlist_search MATCH '\"' || replace(?, '\"', '\"\"') || '\"')) " +
|
||||
"OR (length(?) < 3 AND instr(lower(name), lower(?)) > 0)) "
|
||||
internal const val ARTISTS_FILTER = """
|
||||
FROM artists WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND $ARTIST_CREDITED
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
|
||||
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
internal fun albumsDefaultQuery(
|
||||
internal const val ARTISTS_FILTER_DOWNLOADED = """
|
||||
FROM artists WHERE sourceId = :sourceId
|
||||
AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL))
|
||||
AND $ARTIST_CREDITED
|
||||
AND EXISTS (SELECT 1 FROM ($ARTIST_DOWNLOAD_ROWS) dl
|
||||
WHERE dl.id = artists.id AND dl.status = 'Completed')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM artist_search
|
||||
WHERE artist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND (instr(lower(name), lower(:search)) > 0
|
||||
OR instr(lower(sortName), lower(:search)) > 0)))
|
||||
"""
|
||||
|
||||
// The downloaded list counts the albums that actually have a download so the number under an artist
|
||||
// matches the albums its detail screen lists.
|
||||
internal val ARTIST_COLUMNS_DOWNLOADED = """
|
||||
artists.sourceId, artists.id, artists.name,
|
||||
(SELECT COUNT(*) FROM ($DOWNLOADED_ARTIST_ALBUMS)) AS albumCount,
|
||||
$ARTIST_COLUMNS_TAIL
|
||||
"""
|
||||
|
||||
internal const val PLAYLISTS_FILTER = """
|
||||
FROM playlists WHERE sourceId = :sourceId
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
|
||||
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
|
||||
"""
|
||||
|
||||
internal const val PLAYLISTS_FILTER_DOWNLOADED = """
|
||||
FROM playlists WHERE sourceId = :sourceId
|
||||
AND EXISTS (SELECT 1 FROM playlist_songs ps
|
||||
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId
|
||||
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id AND sd.status = 'Completed')
|
||||
AND (:search = ''
|
||||
OR (length(:search) >= $SEARCH_MIN_LENGTH AND rowid IN (SELECT rowid FROM playlist_search
|
||||
WHERE playlist_search MATCH '"' || replace(:search, '"', '""') || '"'))
|
||||
OR (length(:search) < $SEARCH_MIN_LENGTH AND instr(lower(name), lower(:search)) > 0))
|
||||
"""
|
||||
|
||||
// The downloaded list counts the songs that actually have a download so the number under a
|
||||
// playlist matches the songs its detail screen lists.
|
||||
internal const val PLAYLISTS_COLUMNS_DOWNLOADED = """
|
||||
playlists.sourceId, playlists.id, playlists.name, playlists.comment, playlists.coverArt,
|
||||
(SELECT COUNT(DISTINCT ps.songId)
|
||||
FROM playlist_songs ps
|
||||
JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed'
|
||||
WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id
|
||||
) AS songCount,
|
||||
playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared
|
||||
"""
|
||||
|
||||
internal fun albumsPageQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: AlbumSort,
|
||||
descending: Boolean,
|
||||
downloaded: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery {
|
||||
val order = albumOrder(sort, descending, sortNameMode)
|
||||
return RoomRawQuery("SELECT * $ALBUMS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
}
|
||||
}
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql = "SELECT * ${if (downloaded) ALBUMS_FILTER_DOWNLOADED else ALBUMS_FILTER} ORDER BY ${albumOrder(
|
||||
sort,
|
||||
descending,
|
||||
sortNameMode,
|
||||
)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun artistsPageQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
downloaded: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT ${if (downloaded) ARTIST_COLUMNS_DOWNLOADED else ARTIST_COLUMNS} " +
|
||||
"${if (downloaded) ARTISTS_FILTER_DOWNLOADED else ARTISTS_FILTER} " +
|
||||
"ORDER BY ${artistOrder(sort, descending, sortNameMode)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun playlistsPageQuery(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
sort: PlaylistSort,
|
||||
descending: Boolean,
|
||||
downloaded: Boolean,
|
||||
): RoomRawQuery =
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT ${if (downloaded) PLAYLISTS_COLUMNS_DOWNLOADED else "*"} " +
|
||||
"${if (downloaded) PLAYLISTS_FILTER_DOWNLOADED else PLAYLISTS_FILTER} " +
|
||||
"ORDER BY ${playlistOrder(sort, descending)}",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"search" to search,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun rediscoverAlbumsQuery(
|
||||
sourceId: Long,
|
||||
@@ -57,70 +169,47 @@ internal fun rediscoverAlbumsQuery(
|
||||
search: String,
|
||||
cutoff: Long,
|
||||
): RoomRawQuery =
|
||||
RoomRawQuery(
|
||||
"SELECT * $ALBUMS_FILTER_BASE AND played IS NOT NULL AND played < ? " +
|
||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||
) { stmt ->
|
||||
stmt.bindAlbumFilters(sourceId, starredFilter, search)
|
||||
stmt.bindLong(13, cutoff)
|
||||
}
|
||||
rawQuery(
|
||||
sql =
|
||||
"SELECT * $ALBUMS_FILTER AND played IS NOT NULL AND played < :cutoff " +
|
||||
"ORDER BY played ASC, sortName COLLATE NOCASE, id",
|
||||
bindings =
|
||||
mapOf(
|
||||
"sourceId" to sourceId,
|
||||
"starredFilter" to starredFilter,
|
||||
"search" to search,
|
||||
"cutoff" to cutoff,
|
||||
),
|
||||
)
|
||||
|
||||
private fun SQLiteStatement.bindAlbumFilters(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
// RoomRawQuery takes a positional bind function, but SQLite gives a repeated named parameter one
|
||||
// index, so the SQL can keep its names and only the first-appearance order needs binding.
|
||||
internal fun rawQuery(
|
||||
sql: String,
|
||||
bindings: Map<String, Any?>,
|
||||
): RoomRawQuery {
|
||||
val values =
|
||||
Regex(":(\\w+)")
|
||||
.findAll(sql)
|
||||
.map { it.groupValues[1] }
|
||||
.distinct()
|
||||
.map(bindings::getValue)
|
||||
.toList()
|
||||
return RoomRawQuery(sql) { statement ->
|
||||
values.forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun SQLiteStatement.bind(
|
||||
index: Int,
|
||||
value: Any?,
|
||||
) {
|
||||
bindLong(1, sourceId)
|
||||
bindInt(2, starredFilter)
|
||||
bindInt(3, starredFilter)
|
||||
bindInt(4, starredFilter)
|
||||
bindText(5, search)
|
||||
bindText(6, search)
|
||||
bindText(7, search)
|
||||
bindText(8, search)
|
||||
bindText(9, search)
|
||||
bindText(10, search)
|
||||
bindText(11, search)
|
||||
bindText(12, search)
|
||||
}
|
||||
|
||||
internal fun artistsDefaultQuery(
|
||||
sourceId: Long,
|
||||
starredFilter: Int,
|
||||
search: String,
|
||||
sort: ArtistSort,
|
||||
descending: Boolean,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): RoomRawQuery {
|
||||
val order = artistOrder(sort, descending, sortNameMode)
|
||||
return RoomRawQuery("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindInt(2, starredFilter)
|
||||
stmt.bindInt(3, starredFilter)
|
||||
stmt.bindInt(4, starredFilter)
|
||||
stmt.bindText(5, search)
|
||||
stmt.bindText(6, search)
|
||||
stmt.bindText(7, search)
|
||||
stmt.bindText(8, search)
|
||||
stmt.bindText(9, search)
|
||||
stmt.bindText(10, search)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun playlistsDefaultQuery(
|
||||
sourceId: Long,
|
||||
search: String,
|
||||
sort: PlaylistSort,
|
||||
descending: Boolean,
|
||||
): RoomRawQuery {
|
||||
val order = playlistOrder(sort, descending)
|
||||
return RoomRawQuery("SELECT * $PLAYLISTS_FILTER_BASE ORDER BY $order") { stmt ->
|
||||
stmt.bindLong(1, sourceId)
|
||||
stmt.bindText(2, search)
|
||||
stmt.bindText(3, search)
|
||||
stmt.bindText(4, search)
|
||||
stmt.bindText(5, search)
|
||||
stmt.bindText(6, search)
|
||||
when (value) {
|
||||
null -> bindNull(index)
|
||||
is Int -> bindLong(index, value.toLong())
|
||||
is Long -> bindLong(index, value)
|
||||
is String -> bindText(index, value)
|
||||
else -> error("Unsupported SQL parameter: ${value::class.qualifiedName}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -214,14 +214,34 @@ interface QueueDao {
|
||||
): List<String>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM songs " +
|
||||
"LEFT JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.id IN (:ids)",
|
||||
"SELECT CASE WHEN EXISTS (SELECT 1 FROM songs WHERE sourceId = :sourceId AND albumId = :albumId AND id = :songId) " +
|
||||
"THEN (SELECT COUNT(*) FROM songs ranked CROSS JOIN songs target " +
|
||||
"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,
|
||||
ids: List<String>,
|
||||
): List<AlbumSongItem>
|
||||
albumId: String,
|
||||
songId: String,
|
||||
): Long?
|
||||
|
||||
@Query(
|
||||
"SELECT CASE WHEN firstPosition IS NULL THEN NULL ELSE " +
|
||||
"(SELECT COUNT(*) - 1 FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND position <= firstPosition) END " +
|
||||
"FROM (SELECT MIN(position) AS firstPosition FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId AND songId = :songId)",
|
||||
)
|
||||
suspend fun playlistRank(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
songId: String,
|
||||
): Long?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM song_genres WHERE sourceId = :sourceId AND genre = :genre")
|
||||
suspend fun genreLength(
|
||||
@@ -243,17 +263,6 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM song_genres " +
|
||||
"JOIN songs ON songs.sourceId = song_genres.sourceId AND songs.id = song_genres.songId " +
|
||||
"WHERE song_genres.sourceId = :sourceId AND song_genres.genre = :genre " +
|
||||
"ORDER BY songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun genreSongIds(
|
||||
sourceId: Long,
|
||||
genre: String,
|
||||
): List<String>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM song_downloads WHERE sourceId = :sourceId AND status = 'Completed'")
|
||||
suspend fun downloadedLength(sourceId: Long): Long
|
||||
|
||||
@@ -272,12 +281,6 @@ interface QueueDao {
|
||||
limit: Int,
|
||||
): 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(
|
||||
"SELECT songs.*, albums.coverArt AS coverArt FROM song_downloads d " +
|
||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||
@@ -290,12 +293,4 @@ interface QueueDao {
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
): List<AlbumSongItem>
|
||||
|
||||
@Query(
|
||||
"SELECT songs.id FROM song_downloads d " +
|
||||
"JOIN songs ON songs.sourceId = d.sourceId AND songs.id = d.songId " +
|
||||
"WHERE d.sourceId = :sourceId AND d.status = 'Completed' " +
|
||||
"ORDER BY d.downloadedAt DESC, songs.sortName COLLATE NOCASE, songs.id",
|
||||
)
|
||||
suspend fun downloadedSongIds(sourceId: Long): List<String>
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import androidx.paging.PagingSource
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.albumsDefaultQuery
|
||||
import com.subtracks.data.db.artistsDefaultQuery
|
||||
import com.subtracks.data.db.playlistsDefaultQuery
|
||||
import com.subtracks.data.db.albumsPageQuery
|
||||
import com.subtracks.data.db.artistsPageQuery
|
||||
import com.subtracks.data.db.playlistsPageQuery
|
||||
import com.subtracks.data.db.rediscoverAlbumsQuery
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
@@ -195,68 +195,17 @@ class LibraryRepository(
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(40, enablePlaceholders = true) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
AlbumSort.Name -> {
|
||||
if (descending) {
|
||||
dao.albumsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.albumsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Artist -> {
|
||||
if (descending) {
|
||||
dao.albumsByArtistReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.albumsByArtist(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Year -> {
|
||||
if (descending) {
|
||||
dao.albumsByYearReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByYear(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Added -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentlyAddedReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecentlyAdded(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.albumsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.albumsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
AlbumSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.albumsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.albumsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val query =
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starred.ordinal,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
sortNameMode = mode,
|
||||
)
|
||||
if (downloaded) dao.downloadedAlbumsPaging(query) else dao.albumsPaging(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,52 +220,17 @@ class LibraryRepository(
|
||||
userPreferences.sortNameMode().flatMapLatest { mode ->
|
||||
pager(60) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, starred.ordinal, search, sort, descending, mode))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
ArtistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.artistsByNameReversed(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
} else {
|
||||
dao.artistsByName(sourceId, starred.ordinal, search, downloadedFilter, mode.name)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.AlbumCount -> {
|
||||
if (descending) {
|
||||
dao.artistsByAlbumCountReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByAlbumCount(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Starred -> {
|
||||
if (descending) {
|
||||
dao.artistsByStarredReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByStarred(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Frequent -> {
|
||||
if (descending) {
|
||||
dao.artistsByFrequentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByFrequent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
ArtistSort.Recent -> {
|
||||
if (descending) {
|
||||
dao.artistsByRecentReversed(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
} else {
|
||||
dao.artistsByRecent(sourceId, starred.ordinal, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val query =
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starred.ordinal,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
sortNameMode = mode,
|
||||
)
|
||||
if (downloaded) dao.downloadedArtistsPaging(query) else dao.artistsPaging(query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,36 +243,15 @@ class LibraryRepository(
|
||||
): Flow<PagingData<Playlist>> =
|
||||
pager(40) {
|
||||
val dao = db.libraryDao()
|
||||
if (!downloaded) {
|
||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, search, sort, descending))
|
||||
} else {
|
||||
val downloadedFilter = 1
|
||||
when (sort) {
|
||||
PlaylistSort.Name -> {
|
||||
if (descending) {
|
||||
dao.playlistsByNameReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByName(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Added -> {
|
||||
if (descending) {
|
||||
dao.playlistsByAddedReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByAdded(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
|
||||
PlaylistSort.Updated -> {
|
||||
if (descending) {
|
||||
dao.playlistsByUpdatedReversed(sourceId, search, downloadedFilter)
|
||||
} else {
|
||||
dao.playlistsByUpdated(sourceId, search, downloadedFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val query =
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = downloaded,
|
||||
)
|
||||
if (downloaded) dao.downloadedPlaylistsPaging(query) else dao.playlistsPaging(query)
|
||||
}
|
||||
|
||||
fun album(
|
||||
@@ -467,10 +360,7 @@ class LibraryRepository(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
songId: String,
|
||||
): Long {
|
||||
val index = db.queueDao().albumSongIds(sourceId, albumId).indexOf(songId)
|
||||
return if (index < 0) 0L else index.toLong()
|
||||
}
|
||||
): Long = db.queueDao().albumRank(sourceId, albumId, songId) ?: 0L
|
||||
|
||||
fun playlist(
|
||||
sourceId: Long,
|
||||
|
||||
@@ -209,6 +209,202 @@ data class Reanchored(
|
||||
val changed: Boolean,
|
||||
)
|
||||
|
||||
private interface QueueKindResolver {
|
||||
fun supportsKeyset(key: OrderKey): Boolean = false
|
||||
|
||||
suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
): Long
|
||||
|
||||
suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>
|
||||
|
||||
suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>? = null
|
||||
|
||||
suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem>? = null
|
||||
|
||||
suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
): Long? = null
|
||||
}
|
||||
|
||||
private val queueKindResolvers =
|
||||
mapOf<QueueKind, QueueKindResolver>(
|
||||
QueueKind.Song to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.songLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = if (entry.refId == songId && length(dao, entry) > 0) 0L else null
|
||||
},
|
||||
QueueKind.Playlist to
|
||||
object : QueueKindResolver {
|
||||
override fun supportsKeyset(key: OrderKey) = key.position != null
|
||||
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.playlistLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
|
||||
override suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = dao.playlistRank(entry.sourceId, entry.refId, songId)
|
||||
},
|
||||
QueueKind.Album to
|
||||
object : QueueKindResolver {
|
||||
override fun supportsKeyset(key: OrderKey) = key.disc != null && key.track != null
|
||||
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.albumLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
|
||||
override suspend fun rowsFrom(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
|
||||
override suspend fun rowsBefore(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
) = dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
|
||||
override suspend fun rank(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
songId: String,
|
||||
) = dao.albumRank(entry.sourceId, entry.refId, songId)
|
||||
},
|
||||
QueueKind.Artist to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.artistLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
},
|
||||
QueueKind.Genre to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.genreLength(entry.sourceId, entry.refId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
},
|
||||
QueueKind.Downloaded to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.downloadedLength(entry.sourceId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||
},
|
||||
QueueKind.Starred to
|
||||
object : QueueKindResolver {
|
||||
override suspend fun length(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
) = dao.starredLength(entry.sourceId)
|
||||
|
||||
override suspend fun rows(
|
||||
dao: com.subtracks.data.db.QueueDao,
|
||||
entry: QueueEntry,
|
||||
offset: Long,
|
||||
limit: Int,
|
||||
) = dao.starredSongs(entry.sourceId, offset, limit)
|
||||
},
|
||||
)
|
||||
|
||||
private fun resolverFor(kind: QueueKind): QueueKindResolver = queueKindResolvers.getValue(kind)
|
||||
|
||||
class QueueRepository(
|
||||
private val db: SubtracksDatabase,
|
||||
) {
|
||||
@@ -216,12 +412,6 @@ class QueueRepository(
|
||||
|
||||
private val queueVersion = AtomicLong()
|
||||
|
||||
@Volatile
|
||||
private var cachedFlatIds: List<String>? = null
|
||||
|
||||
@Volatile
|
||||
private var cachedFlatVersion = -1L
|
||||
|
||||
@Volatile
|
||||
private var shuffleRemoved: ShuffleState? = null
|
||||
|
||||
@@ -235,6 +425,9 @@ class QueueRepository(
|
||||
internal var offsetSeeks = 0L
|
||||
private set
|
||||
|
||||
internal var resolvedRowCount = 0L
|
||||
private set
|
||||
|
||||
private val cursorMutex = Mutex()
|
||||
|
||||
fun playlistEntry(
|
||||
@@ -388,7 +581,6 @@ class QueueRepository(
|
||||
}
|
||||
|
||||
fun invalidateLibraryCache() {
|
||||
cachedFlatIds = null
|
||||
lengthCache.clear()
|
||||
keyCache.clear()
|
||||
shuffleRemoved = null
|
||||
@@ -409,8 +601,8 @@ class QueueRepository(
|
||||
snapshot: QueueSnapshot,
|
||||
songId: String,
|
||||
): Long? {
|
||||
val upIndex = idsFor(snapshot.upNext).indexOf(songId)
|
||||
if (upIndex >= 0) return snapshot.anchorPlay + 1 + upIndex
|
||||
val upIndex = flatIndexOf(snapshot.upNext, songId)
|
||||
if (upIndex != null) return snapshot.anchorPlay + 1 + upIndex
|
||||
return combinedContextIndexOf(snapshot, songId)
|
||||
}
|
||||
|
||||
@@ -418,9 +610,8 @@ class QueueRepository(
|
||||
snapshot: QueueSnapshot,
|
||||
songId: String,
|
||||
): Long? {
|
||||
val flat = flatIds(snapshot).indexOf(songId)
|
||||
if (flat < 0) return null
|
||||
return snapshot.playContext(flat.toLong())?.let { snapshot.combined(it) }
|
||||
val flat = flatIndexOf(snapshot.entries, songId) ?: return null
|
||||
return snapshot.playContext(flat)?.let { snapshot.combined(it) }
|
||||
}
|
||||
|
||||
suspend fun range(
|
||||
@@ -633,7 +824,6 @@ class QueueRepository(
|
||||
}
|
||||
}
|
||||
queueVersion.incrementAndGet()
|
||||
cachedFlatIds = null
|
||||
}
|
||||
|
||||
private suspend fun writeUpNext(entries: List<QueueEntry>) {
|
||||
@@ -650,34 +840,40 @@ class QueueRepository(
|
||||
queueVersion.incrementAndGet()
|
||||
}
|
||||
|
||||
private suspend fun flatIds(snapshot: QueueSnapshot): List<String> {
|
||||
val cached = cachedFlatIds
|
||||
if (cached != null && cachedFlatVersion == snapshot.version && cached.size.toLong() == snapshot.contextSize) return cached
|
||||
val ids = idsFor(snapshot.entries)
|
||||
cachedFlatIds = ids
|
||||
cachedFlatVersion = snapshot.version
|
||||
return ids
|
||||
private suspend fun flatIndexOf(
|
||||
entries: List<ResolvedQueueEntry>,
|
||||
songId: String,
|
||||
): Long? {
|
||||
var start = 0L
|
||||
for (resolved in entries) {
|
||||
val entry = resolved.entry
|
||||
val rank = resolverFor(entry.kind).rank(dao, entry, songId)
|
||||
if (rank != null && rank >= entry.offset && rank < entry.offset + resolved.length) {
|
||||
return start + rank - entry.offset
|
||||
}
|
||||
val fallback = fallbackRank(entry, resolved.length, songId)
|
||||
if (fallback != null) return start + fallback - entry.offset
|
||||
start += resolved.length
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun idsFor(resolved: List<ResolvedQueueEntry>): List<String> {
|
||||
val ids = ArrayList<String>()
|
||||
for (entry in resolved) {
|
||||
val ref = entry.entry
|
||||
val entryIds =
|
||||
when (ref.kind) {
|
||||
QueueKind.Song -> listOf(ref.refId)
|
||||
QueueKind.Album -> dao.albumSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Playlist -> dao.playlistSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Artist -> dao.artistSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Genre -> dao.genreSongIds(ref.sourceId, ref.refId)
|
||||
QueueKind.Downloaded -> dao.downloadedSongIds(ref.sourceId)
|
||||
QueueKind.Starred -> dao.starredSongIds(ref.sourceId)
|
||||
}
|
||||
val from = ref.offset.toInt().coerceIn(0, entryIds.size)
|
||||
val to = (ref.offset + entry.length).toInt().coerceIn(from, entryIds.size)
|
||||
ids.addAll(entryIds.subList(from, to))
|
||||
private suspend fun fallbackRank(
|
||||
entry: QueueEntry,
|
||||
length: Long,
|
||||
songId: String,
|
||||
): Long? {
|
||||
var offset = entry.offset
|
||||
val end = entry.offset + length
|
||||
while (offset < end) {
|
||||
val limit = minOf(QUEUE_CHUNK.toLong(), end - offset).toInt()
|
||||
val batch = rows(entry, offset, limit)
|
||||
val index = batch.indexOfFirst { it.song.id == songId }
|
||||
if (index >= 0) return offset + index
|
||||
if (batch.size < limit) return null
|
||||
offset += batch.size
|
||||
}
|
||||
return ids
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun contextPlayRange(
|
||||
@@ -688,21 +884,11 @@ class QueueRepository(
|
||||
if (first > last) return emptyList()
|
||||
val items = mutableListOf<QueueWindowItem>()
|
||||
if (snapshot.shuffled) {
|
||||
val sourceId =
|
||||
snapshot.entries
|
||||
.firstOrNull()
|
||||
?.entry
|
||||
?.sourceId ?: return emptyList()
|
||||
val ids = flatIds(snapshot)
|
||||
val requested = mutableListOf<Pair<Long, String>>()
|
||||
for (play in first..last) {
|
||||
val flat = snapshot.flatContext(play) ?: continue
|
||||
val id = ids.getOrNull(flat.toInt()) ?: continue
|
||||
requested += play to id
|
||||
val located = snapshot.locateContext(play) ?: continue
|
||||
val item = rows(located.first, located.first.offset + located.second, 1).firstOrNull() ?: continue
|
||||
items += QueueWindowItem(snapshot.combined(play), item, playlistPosition = item.playlistPosition)
|
||||
}
|
||||
if (requested.isEmpty()) return emptyList()
|
||||
val byId = dao.songsByIds(sourceId, requested.map { it.second }.distinct()).associateBy { it.song.id }
|
||||
requested.forEach { (play, id) -> byId[id]?.let { items += QueueWindowItem(snapshot.combined(play), it) } }
|
||||
return items
|
||||
}
|
||||
var start = 0L
|
||||
@@ -757,27 +943,20 @@ class QueueRepository(
|
||||
): List<SongItem> {
|
||||
if (entry.kind == QueueKind.Song) {
|
||||
offsetSeeks++
|
||||
return dao.song(entry.sourceId, entry.refId, offset, limit)
|
||||
return resolverFor(entry.kind).rows(dao, entry, offset, limit).also { resolvedRowCount += it.size }
|
||||
}
|
||||
val ref = QueueRef(entry.kind, entry.sourceId, entry.refId)
|
||||
val anchored = keyCache[ref]?.let { keyedRows(entry, offset, limit, it) }
|
||||
if (anchored != null) {
|
||||
keysetSeeks++
|
||||
if (anchored.isNotEmpty()) remember(ref, offset, anchored)
|
||||
resolvedRowCount += anchored.size
|
||||
return anchored
|
||||
}
|
||||
offsetSeeks++
|
||||
val fresh =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Album -> dao.albumSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Artist -> dao.artistSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Genre -> dao.genreSongs(entry.sourceId, entry.refId, offset, limit)
|
||||
QueueKind.Downloaded -> dao.downloadedSongs(entry.sourceId, offset, limit)
|
||||
QueueKind.Starred -> dao.starredSongs(entry.sourceId, offset, limit)
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
val fresh = resolverFor(entry.kind).rows(dao, entry, offset, limit)
|
||||
if (fresh.isNotEmpty()) remember(ref, offset, fresh)
|
||||
resolvedRowCount += fresh.size
|
||||
return fresh
|
||||
}
|
||||
|
||||
@@ -810,7 +989,7 @@ class QueueRepository(
|
||||
limit: Int,
|
||||
cursor: KeyCursor,
|
||||
): List<SongItem>? {
|
||||
if (!cursor.key.seekable(entry.kind)) return null
|
||||
if (!resolverFor(entry.kind).supportsKeyset(cursor.key)) return null
|
||||
val from = cursor.ordinal
|
||||
// A backward seek from a far cursor is not cheaper than the offset query: the album
|
||||
// predicate is not sargable (it has to include NULL disc/track), so it scans the partition
|
||||
@@ -836,43 +1015,14 @@ class QueueRepository(
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem> =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongsFrom(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
QueueKind.Album -> dao.albumSongsFrom(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
QueueKind.Artist -> emptyList()
|
||||
QueueKind.Genre -> emptyList()
|
||||
QueueKind.Downloaded -> emptyList()
|
||||
QueueKind.Starred -> emptyList()
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
): List<SongItem> = resolverFor(entry.kind).rowsFrom(dao, entry, key, skip, limit) ?: emptyList()
|
||||
|
||||
private suspend fun rowsBefore(
|
||||
entry: QueueEntry,
|
||||
key: OrderKey,
|
||||
skip: Long,
|
||||
limit: Int,
|
||||
): List<SongItem> =
|
||||
when (entry.kind) {
|
||||
QueueKind.Playlist -> dao.playlistSongsBefore(entry.sourceId, entry.refId, key.position!!, skip, limit)
|
||||
QueueKind.Album -> dao.albumSongsBefore(entry.sourceId, entry.refId, key.disc!!, key.track!!, key.id, skip, limit)
|
||||
QueueKind.Artist -> emptyList()
|
||||
QueueKind.Genre -> emptyList()
|
||||
QueueKind.Downloaded -> emptyList()
|
||||
QueueKind.Starred -> emptyList()
|
||||
QueueKind.Song -> emptyList()
|
||||
}
|
||||
|
||||
private fun OrderKey.seekable(kind: QueueKind): Boolean =
|
||||
when (kind) {
|
||||
QueueKind.Playlist -> position != null
|
||||
QueueKind.Album -> disc != null && track != null
|
||||
QueueKind.Artist -> false
|
||||
QueueKind.Genre -> false
|
||||
QueueKind.Downloaded -> false
|
||||
QueueKind.Starred -> false
|
||||
QueueKind.Song -> false
|
||||
}
|
||||
): List<SongItem> = resolverFor(entry.kind).rowsBefore(dao, entry, key, skip, limit) ?: emptyList()
|
||||
|
||||
private suspend fun lengthOf(
|
||||
entry: QueueEntry,
|
||||
@@ -897,36 +1047,7 @@ class QueueRepository(
|
||||
)
|
||||
|
||||
private suspend fun QueueEntry.resolvedLength(): Long {
|
||||
val total =
|
||||
when (kind) {
|
||||
QueueKind.Song -> {
|
||||
dao.songLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Playlist -> {
|
||||
dao.playlistLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Album -> {
|
||||
dao.albumLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Artist -> {
|
||||
dao.artistLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Genre -> {
|
||||
dao.genreLength(sourceId, refId)
|
||||
}
|
||||
|
||||
QueueKind.Downloaded -> {
|
||||
dao.downloadedLength(sourceId)
|
||||
}
|
||||
|
||||
QueueKind.Starred -> {
|
||||
dao.starredLength(sourceId)
|
||||
}
|
||||
}
|
||||
val total = resolverFor(kind).length(dao, this)
|
||||
val available = (total - offset).coerceAtLeast(0)
|
||||
return count?.coerceAtMost(available) ?: available
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class SyncService(
|
||||
source.artists().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
changed =
|
||||
write("artists", artistColumns, primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.values() } ||
|
||||
write("artists", primaryKey, batch, preserved = setOf("albumCount"), markStale = true) { it.columns() } ||
|
||||
changed
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
artistsSynced += batch.size
|
||||
@@ -137,7 +137,7 @@ class SyncService(
|
||||
connection.immediateTransaction {
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveSongSortNames(batch)
|
||||
connection.upsertChanged("songs", songColumns, primaryKey, resolved) { it.values() }
|
||||
connection.upsertChanged("songs", primaryKey, resolved) { it.columns() }
|
||||
connection.usePrepared("DELETE FROM song_genres WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
batch.forEach { song ->
|
||||
statement.bind(1, song.sourceId)
|
||||
@@ -148,7 +148,7 @@ class SyncService(
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_genres", songGenreColumns, songGenreKey, genres) { it.values() }
|
||||
connection.upsertChanged("song_genres", songGenreKey, genres) { it.columns() }
|
||||
connection.usePrepared("DELETE FROM song_artists WHERE sourceId = ? AND songId = ? AND position >= ?") { statement ->
|
||||
batch.forEach { song ->
|
||||
statement.bind(1, song.sourceId)
|
||||
@@ -159,7 +159,7 @@ class SyncService(
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("song_artists", songArtistColumns, songArtistKey, artists) { it.values() }
|
||||
connection.upsertChanged("song_artists", songArtistKey, artists) { it.columns() }
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(batch.first().sourceId)
|
||||
}
|
||||
@@ -256,7 +256,7 @@ class SyncService(
|
||||
val seen = HashedIds()
|
||||
source.playlists().collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||
write("playlists", primaryKey, batch) { it.columns() }
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
playlistsSynced += batch.size
|
||||
}
|
||||
@@ -269,7 +269,7 @@ class SyncService(
|
||||
val counts = HashMap<String, Long>()
|
||||
source.playlistSongs(playlistIds).collect { batch ->
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||
write("playlist_songs", playlistSongKey, batch) { it.columns() }
|
||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||
playlistSongsSynced += batch.size
|
||||
}
|
||||
@@ -280,17 +280,16 @@ class SyncService(
|
||||
// One commit per batch, so Room notifies paged observers once per batch rather than per row.
|
||||
private suspend fun <T> write(
|
||||
table: String,
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
preserved: Set<String> = emptySet(),
|
||||
markStale: Boolean = false,
|
||||
values: (T) -> List<Any?>,
|
||||
columns: (T) -> List<Pair<String, Any?>>,
|
||||
): Boolean =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
val before = connection.totalChanges()
|
||||
connection.upsertChanged(table, columns, key, rows, preserved, values)
|
||||
connection.upsertChanged(table, key, rows, preserved, columns)
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed && markStale) connection.markCreditsStale(source.id)
|
||||
}
|
||||
@@ -309,8 +308,8 @@ class SyncService(
|
||||
connection.immediateTransaction {
|
||||
val before = connection.totalChanges()
|
||||
val resolved = connection.resolveAlbumSortNames(albums)
|
||||
connection.upsertChanged("albums", albumColumns, primaryKey, resolved) { it.values() }
|
||||
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
|
||||
connection.upsertChanged("albums", primaryKey, resolved) { it.columns() }
|
||||
connection.upsertChanged("discs", discKey, discs) { it.columns() }
|
||||
connection.usePrepared("DELETE FROM album_artists WHERE sourceId = ? AND albumId = ? AND position >= ?") { statement ->
|
||||
albums.forEach { album ->
|
||||
statement.bind(1, album.sourceId)
|
||||
@@ -321,7 +320,7 @@ class SyncService(
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
connection.upsertChanged("album_artists", albumArtistColumns, albumArtistKey, artists) { it.values() }
|
||||
connection.upsertChanged("album_artists", albumArtistKey, artists) { it.columns() }
|
||||
(connection.totalChanges() != before).also { changed ->
|
||||
if (changed) connection.markCreditsStale(albums.first().sourceId)
|
||||
}
|
||||
@@ -438,58 +437,8 @@ private fun discHash(
|
||||
|
||||
private val primaryKey = listOf("sourceId", "id")
|
||||
|
||||
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt", "artistImageUrl", "sortName")
|
||||
|
||||
private val albumColumns =
|
||||
listOf(
|
||||
"sourceId",
|
||||
"id",
|
||||
"artistId",
|
||||
"name",
|
||||
"albumArtist",
|
||||
"created",
|
||||
"coverArt",
|
||||
"genre",
|
||||
"year",
|
||||
"starred",
|
||||
"songCount",
|
||||
"artists",
|
||||
"sortName",
|
||||
"albumArtistSort",
|
||||
)
|
||||
|
||||
private val discKey = listOf("sourceId", "albumId", "disc")
|
||||
|
||||
private val discColumns = listOf("sourceId", "albumId", "disc", "title")
|
||||
|
||||
private val songColumns =
|
||||
listOf(
|
||||
"sourceId",
|
||||
"id",
|
||||
"albumId",
|
||||
"artistId",
|
||||
"title",
|
||||
"album",
|
||||
"artist",
|
||||
"duration",
|
||||
"track",
|
||||
"disc",
|
||||
"starred",
|
||||
"created",
|
||||
"playCount",
|
||||
"played",
|
||||
"trackGain",
|
||||
"albumGain",
|
||||
"trackPeak",
|
||||
"albumPeak",
|
||||
"artists",
|
||||
"sortName",
|
||||
"artistSort",
|
||||
)
|
||||
|
||||
private val playlistColumns =
|
||||
listOf("sourceId", "id", "name", "comment", "coverArt", "songCount", "created", "changed", "duration", "owner", "shared")
|
||||
|
||||
private fun Song.genreNames(): List<String> =
|
||||
genres
|
||||
.flatMap { it.genreParts() }
|
||||
@@ -500,71 +449,93 @@ private fun Song.genreNames(): List<String> =
|
||||
// Navidrome's default genre tag split (resources/mappings.yaml): semicolon, slash, comma.
|
||||
private fun String.genreParts(): List<String> = split(';', '/', ',').map { it.trim() }.filter { it.isNotEmpty() }
|
||||
|
||||
private val songGenreColumns = listOf("sourceId", "songId", "position", "genre")
|
||||
|
||||
private val songGenreKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val albumArtistColumns = listOf("sourceId", "albumId", "position", "artistId", "name")
|
||||
|
||||
private val albumArtistKey = listOf("sourceId", "albumId", "position")
|
||||
|
||||
private val songArtistColumns = listOf("sourceId", "songId", "position", "artistId", "name")
|
||||
|
||||
private val songArtistKey = listOf("sourceId", "songId", "position")
|
||||
|
||||
private val playlistSongColumns = listOf("sourceId", "playlistId", "songId", "position")
|
||||
|
||||
private val playlistSongKey = listOf("sourceId", "playlistId", "position")
|
||||
|
||||
private fun Artist.values() = listOf<Any?>(sourceId, id, name, albumCount, starred, coverArt, artistImageUrl, sortName)
|
||||
|
||||
private fun Album.values() =
|
||||
listOf<Any?>(
|
||||
sourceId,
|
||||
id,
|
||||
artistId,
|
||||
name,
|
||||
albumArtist,
|
||||
created,
|
||||
coverArt,
|
||||
genre,
|
||||
year,
|
||||
starred,
|
||||
songCount,
|
||||
ArtistRefsCodec.encode(artistRefs),
|
||||
sortName,
|
||||
albumArtistSort,
|
||||
private fun Artist.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"name" to name,
|
||||
"albumCount" to albumCount,
|
||||
"starred" to starred,
|
||||
"coverArt" to coverArt,
|
||||
"artistImageUrl" to artistImageUrl,
|
||||
"sortName" to sortName,
|
||||
)
|
||||
|
||||
private fun AlbumArtistRef.values() = listOf<Any?>(sourceId, albumId, position, artistId, name)
|
||||
private fun Album.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
"albumArtist" to albumArtist,
|
||||
"created" to created,
|
||||
"coverArt" to coverArt,
|
||||
"genre" to genre,
|
||||
"year" to year,
|
||||
"starred" to starred,
|
||||
"songCount" to songCount,
|
||||
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||
"sortName" to sortName,
|
||||
"albumArtistSort" to albumArtistSort,
|
||||
)
|
||||
|
||||
private fun SongArtistRef.values() = listOf<Any?>(sourceId, songId, position, artistId, name)
|
||||
private fun AlbumArtistRef.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"albumId" to albumId,
|
||||
"position" to position,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
)
|
||||
|
||||
private fun Disc.values() = listOf<Any?>(sourceId, albumId, disc, title)
|
||||
private fun SongArtistRef.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
"artistId" to artistId,
|
||||
"name" to name,
|
||||
)
|
||||
|
||||
private fun Song.values() =
|
||||
listOf<Any?>(
|
||||
sourceId,
|
||||
id,
|
||||
albumId,
|
||||
artistId,
|
||||
title,
|
||||
album,
|
||||
artist,
|
||||
duration,
|
||||
track,
|
||||
disc,
|
||||
starred,
|
||||
created,
|
||||
playCount,
|
||||
played,
|
||||
trackGain,
|
||||
albumGain,
|
||||
trackPeak,
|
||||
albumPeak,
|
||||
ArtistRefsCodec.encode(artistRefs),
|
||||
sortName,
|
||||
artistSort,
|
||||
private fun Disc.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"albumId" to albumId,
|
||||
"disc" to disc,
|
||||
"title" to title,
|
||||
)
|
||||
|
||||
private fun Song.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"albumId" to albumId,
|
||||
"artistId" to artistId,
|
||||
"title" to title,
|
||||
"album" to album,
|
||||
"artist" to artist,
|
||||
"duration" to duration,
|
||||
"track" to track,
|
||||
"disc" to disc,
|
||||
"starred" to starred,
|
||||
"created" to created,
|
||||
"playCount" to playCount,
|
||||
"played" to played,
|
||||
"trackGain" to trackGain,
|
||||
"albumGain" to albumGain,
|
||||
"trackPeak" to trackPeak,
|
||||
"albumPeak" to albumPeak,
|
||||
"artists" to ArtistRefsCodec.encode(artistRefs),
|
||||
"sortName" to sortName,
|
||||
"artistSort" to artistSort,
|
||||
)
|
||||
|
||||
private fun List<ArtistRef>.positions(): List<Pair<Long, ArtistRef>> =
|
||||
@@ -576,32 +547,56 @@ private fun Album.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.po
|
||||
|
||||
private fun Song.artistPositions(): List<Pair<Long, ArtistRef>> = artistRefs.positions()
|
||||
|
||||
private fun SongGenre.values() = listOf<Any?>(sourceId, songId, position, genre)
|
||||
private fun SongGenre.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
"genre" to genre,
|
||||
)
|
||||
|
||||
private fun Playlist.values() = listOf<Any?>(sourceId, id, name, comment, coverArt, songCount, created, changed, duration, owner, shared)
|
||||
private fun Playlist.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"id" to id,
|
||||
"name" to name,
|
||||
"comment" to comment,
|
||||
"coverArt" to coverArt,
|
||||
"songCount" to songCount,
|
||||
"created" to created,
|
||||
"changed" to changed,
|
||||
"duration" to duration,
|
||||
"owner" to owner,
|
||||
"shared" to shared,
|
||||
)
|
||||
|
||||
private fun PlaylistSong.values() = listOf<Any?>(sourceId, playlistId, songId, position)
|
||||
private fun PlaylistSong.columns(): List<Pair<String, Any?>> =
|
||||
listOf(
|
||||
"sourceId" to sourceId,
|
||||
"playlistId" to playlistId,
|
||||
"songId" to songId,
|
||||
"position" to position,
|
||||
)
|
||||
|
||||
private suspend fun <T> PooledConnection.upsertChanged(
|
||||
table: String,
|
||||
columns: List<String>,
|
||||
key: List<String>,
|
||||
rows: List<T>,
|
||||
preserved: Set<String> = emptySet(),
|
||||
values: (T) -> List<Any?>,
|
||||
columns: (T) -> List<Pair<String, Any?>>,
|
||||
) {
|
||||
if (rows.isEmpty()) return
|
||||
check(values(rows.first()).size == columns.size) { "column/value mismatch for $table" }
|
||||
val updates = columns.filterNot { it in key || it in preserved }
|
||||
val columnNames = columns(rows.first()).map { it.first }
|
||||
val updates = columnNames.filterNot { it in key || it in preserved }
|
||||
val sql =
|
||||
"INSERT INTO $table (${columns.joinToString()}) VALUES (${columns.joinToString { "?" }}) " +
|
||||
"INSERT INTO $table (${columnNames.joinToString()}) VALUES (${columnNames.joinToString { "?" }}) " +
|
||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||
updates.joinToString { "$it = excluded.$it" } +
|
||||
" WHERE " +
|
||||
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
||||
usePrepared(sql) { statement ->
|
||||
rows.forEach { row ->
|
||||
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||
columns(row).forEachIndexed { index, (_, value) -> statement.bind(index + 1, value) }
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
|
||||
@@ -6,10 +6,21 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class PendingDownloadDelete(
|
||||
val name: String,
|
||||
@@ -18,6 +29,70 @@ data class PendingDownloadDelete(
|
||||
val bytes: Long,
|
||||
)
|
||||
|
||||
class PendingDownloadDeleteController internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val downloadedBytes: State<suspend (DownloadList, String) -> Long>,
|
||||
) {
|
||||
var pending by mutableStateOf<PendingDownloadDelete?>(null)
|
||||
private set
|
||||
|
||||
fun onAction(
|
||||
target: MenuTarget,
|
||||
action: BulkDownloadAction,
|
||||
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
|
||||
) {
|
||||
val (list, refId) = target.bulkRef() ?: return
|
||||
onAction(target.title, list, refId, action, onAction)
|
||||
}
|
||||
|
||||
fun onAction(
|
||||
name: String,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
action: BulkDownloadAction,
|
||||
onAction: (DownloadList, String, BulkDownloadAction) -> Unit,
|
||||
) {
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pending = PendingDownloadDelete(name, list, refId, downloadedBytes.value(list, refId))
|
||||
}
|
||||
} else {
|
||||
onAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberPendingDownloadDelete(
|
||||
dismissals: Flow<Unit>,
|
||||
downloadedBytes: suspend (DownloadList, String) -> Long,
|
||||
): PendingDownloadDeleteController {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentDownloadedBytes = rememberUpdatedState(downloadedBytes)
|
||||
val controller = remember(scope) { PendingDownloadDeleteController(scope, currentDownloadedBytes) }
|
||||
DismissOnRequest(dismissals, controller::dismiss)
|
||||
return controller
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PendingDownloadDeleteConfirmation(
|
||||
controller: PendingDownloadDeleteController,
|
||||
onConfirm: (PendingDownloadDelete) -> Unit,
|
||||
) {
|
||||
controller.pending?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { onConfirm(pending) },
|
||||
onDismiss = controller::dismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteDownloadsDialog(
|
||||
name: String,
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -30,6 +29,7 @@ import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -39,16 +39,16 @@ import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistChooserSheet
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -74,21 +74,16 @@ fun AlbumDetailRoute(
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
val dismissals = contextMenuHost?.dismissals ?: emptyFlow()
|
||||
val pendingDelete = rememberPendingDownloadDelete(dismissals) { _, _ -> viewModel.downloadedBytes() }
|
||||
var showArtistChooser by remember { mutableStateOf(false) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) {
|
||||
pendingDelete = null
|
||||
showArtistChooser = false
|
||||
}
|
||||
DismissOnRequest(dismissals) { showArtistChooser = false }
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||
} else {
|
||||
viewModel.onDownloadAction(action)
|
||||
pendingDelete.onAction(album?.name.orEmpty(), DownloadList.Album, albumId, action) { _, _, requested ->
|
||||
viewModel.onDownloadAction(requested)
|
||||
}
|
||||
}
|
||||
val actions =
|
||||
@@ -141,13 +136,8 @@ fun AlbumDetailRoute(
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||
)
|
||||
|
||||
pendingDelete?.let { bytes ->
|
||||
DeleteDownloadsDialog(
|
||||
name = album?.name.orEmpty(),
|
||||
bytes = bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||
}
|
||||
|
||||
if (showArtistChooser) {
|
||||
|
||||
@@ -59,7 +59,6 @@ import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
@@ -111,13 +110,12 @@ import com.subtracks.ui.components.ArtistHeroArt
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.CoverArt
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDelete
|
||||
import com.subtracks.ui.components.bulkRef
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
@@ -130,7 +128,6 @@ import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import com.subtracks.ui.theme.rememberOverlaidNameBusy
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -217,26 +214,13 @@ fun ArtistDetailRoute(
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
val actions =
|
||||
ItemActions(
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
bulkDownload = { target, action ->
|
||||
target.bulkRef()?.let { (list, refId) ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pendingDelete =
|
||||
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
},
|
||||
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
)
|
||||
@@ -267,13 +251,8 @@ fun ArtistDetailRoute(
|
||||
onShuffle = viewModel::shuffle,
|
||||
)
|
||||
|
||||
pendingDelete?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
@@ -126,13 +125,12 @@ import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDelete
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.SearchField
|
||||
import com.subtracks.ui.components.bulkRef
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.components.statusBarScrim
|
||||
import com.subtracks.ui.home.HomeRoute
|
||||
import com.subtracks.ui.home.HomeSection
|
||||
@@ -143,7 +141,6 @@ import com.subtracks.ui.theme.playerSurfaceColor
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import kotlin.math.abs
|
||||
@@ -278,26 +275,13 @@ fun LibraryRoute(
|
||||
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true), fallbackName = playback.item?.title)
|
||||
val downloadsState = listTab?.let { viewModel.downloads(it).collectAsStateWithLifecycle() }
|
||||
val downloads = downloadsState?.value ?: emptyMap()
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow(), viewModel::downloadedBytes)
|
||||
val itemActions =
|
||||
ItemActions(
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
bulkDownload = { target, action ->
|
||||
target.bulkRef()?.let { (list, refId) ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch {
|
||||
pendingDelete =
|
||||
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
|
||||
}
|
||||
} else {
|
||||
viewModel.onDownloadAction(list, refId, action)
|
||||
}
|
||||
}
|
||||
},
|
||||
bulkDownload = { target, action -> pendingDelete.onAction(target, action, viewModel::onDownloadAction) },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
viewArtist = onViewArtist,
|
||||
@@ -377,13 +361,8 @@ fun LibraryRoute(
|
||||
},
|
||||
)
|
||||
|
||||
pendingDelete?.let { pending ->
|
||||
DeleteDownloadsDialog(
|
||||
name = pending.name,
|
||||
bytes = pending.bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) { pending ->
|
||||
viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -18,6 +17,7 @@ import androidx.paging.compose.itemKey
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
@@ -27,16 +27,16 @@ import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.CloseWhenDownloadsGone
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
import com.subtracks.ui.components.ItemActions
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.PendingDownloadDeleteConfirmation
|
||||
import com.subtracks.ui.components.rememberPendingDownloadDelete
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.rememberArtworkColors
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -60,16 +60,12 @@ fun PlaylistDetailRoute(
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
CloseWhenDownloadsGone(downloadStatus.downloaded, offline, onBack)
|
||||
val scope = rememberCoroutineScope()
|
||||
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||
DismissOnRequest(contextMenuHost?.dismissals ?: emptyFlow()) { pendingDelete = null }
|
||||
val pendingDelete = rememberPendingDownloadDelete(contextMenuHost?.dismissals ?: emptyFlow()) { _, _ -> viewModel.downloadedBytes() }
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val requestDownloadAction: (BulkDownloadAction) -> Unit = { action ->
|
||||
if (action == BulkDownloadAction.Delete) {
|
||||
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||
} else {
|
||||
viewModel.onDownloadAction(action)
|
||||
pendingDelete.onAction(playlist?.name.orEmpty(), DownloadList.Playlist, playlistId, action) { _, _, requested ->
|
||||
viewModel.onDownloadAction(requested)
|
||||
}
|
||||
}
|
||||
val actions =
|
||||
@@ -104,13 +100,8 @@ fun PlaylistDetailRoute(
|
||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||
)
|
||||
|
||||
pendingDelete?.let { bytes ->
|
||||
DeleteDownloadsDialog(
|
||||
name = playlist?.name.orEmpty(),
|
||||
bytes = bytes,
|
||||
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
||||
onDismiss = { pendingDelete = null },
|
||||
)
|
||||
PendingDownloadDeleteConfirmation(pendingDelete) {
|
||||
viewModel.onDownloadAction(BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AlbumCreditsTest {
|
||||
@Test
|
||||
fun everyVariantUsesAllFourCreditPaths() {
|
||||
val variants =
|
||||
listOf(
|
||||
CREDITED_ALBUM_ROWS,
|
||||
DOWNLOADED_ALBUM_ROWS,
|
||||
DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST,
|
||||
DOWNLOADED_COVER_ALBUM_ROWS,
|
||||
ARTIST_COVER_CREDITS,
|
||||
ARTIST_COVER_CREDITS_FOR_ARTIST,
|
||||
ALBUMS_FOR_ARTIST_DOWNLOADED,
|
||||
)
|
||||
variants.forEach { sql ->
|
||||
assertEquals(sql, 1, sql.occurrences("FROM albums"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM album_artists"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM songs JOIN albums"))
|
||||
assertEquals(sql, 1, sql.occurrences("FROM song_artists"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun variantsKeepTheirOwnFiltersAndDuplicateSemantics() {
|
||||
assertFalse(CREDITED_ALBUM_ROWS.contains("coverArt IS NOT NULL"))
|
||||
assertFalse(CREDITED_ALBUM_ROWS.contains("song_downloads"))
|
||||
assertTrue(CREDITED_ALBUM_ROWS.contains(" UNION ALL "))
|
||||
assertEquals(4, ARTIST_COVER_CREDITS.occurrences("coverArt IS NOT NULL"))
|
||||
assertEquals(4, ARTIST_COVER_CREDITS_FOR_ARTIST.occurrences("= :artistId"))
|
||||
assertEquals(4, DOWNLOADED_ALBUM_ROWS.occurrences("sd.status = 'Completed'"))
|
||||
assertEquals(4, DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST.occurrences("= :artistId"))
|
||||
assertEquals(4, DOWNLOADED_COVER_ALBUM_ROWS.occurrences("coverArt IS NOT NULL"))
|
||||
assertTrue(DOWNLOADED_ALBUM_ROWS.contains(" UNION "))
|
||||
assertFalse(DOWNLOADED_ALBUM_ROWS.contains(" UNION ALL "))
|
||||
assertFalse(DOWNLOADED_ALBUM_ROWS.contains("creditType"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun consumersEmbedTheSharedVariants() {
|
||||
assertTrue(ALBUMS_FOR_ARTIST.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(ALBUM_CREDIT_ALBUM_IDS.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(ARTIST_AGGREGATES_SQL.contains(CREDITED_ALBUM_ROWS))
|
||||
assertTrue(DOWNLOADED_ARTIST_ALBUMS.contains(DOWNLOADED_ALBUM_ROWS))
|
||||
assertTrue(ALBUMS_FOR_ARTIST_DOWNLOADED.contains(DOWNLOADED_ALBUM_DETAILS_FOR_ARTIST))
|
||||
assertTrue(ARTIST_COVER_ARTS_SQL.contains(ARTIST_COVER_CREDITS_FOR_ARTIST))
|
||||
assertTrue(ARTIST_ART_UPDATE_SQL.contains(ARTIST_COVER_CREDITS))
|
||||
assertTrue(ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
|
||||
assertTrue(DOWNLOADED_ARTIST_ART_UPDATE_SQL.contains(DOWNLOADED_COVER_ALBUM_ROWS))
|
||||
}
|
||||
|
||||
private fun String.occurrences(value: String): Int = split(value).size - 1
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ArtworkSeed
|
||||
import com.subtracks.data.model.Source
|
||||
@@ -22,12 +18,7 @@ class ArtworkSeedDaoTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.data.model.Album
|
||||
@@ -39,12 +35,7 @@ class LibraryDaoTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -67,17 +58,17 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma"),
|
||||
dao.albumsByName(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
||||
dao.albumPages(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Gamma", "Beta", "Alpha"),
|
||||
dao.albumsByNameReversed(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
||||
dao.albumPages(sourceId, starredFilter = 0, search = "", descending = true).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma"),
|
||||
dao.albumsByYear(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
||||
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Year).page().map { it.name },
|
||||
)
|
||||
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, starredFilter = 1, search = "").page().map { it.name })
|
||||
assertEquals(listOf("Alpha"), dao.albumPages(sourceId, starredFilter = 1, search = "").page().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -95,14 +86,14 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("Beta", "Gamma", "Alpha"),
|
||||
dao.albumsByRecentlyAdded(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
||||
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Added).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Gamma", "Beta"),
|
||||
dao.albumsByStarred(sourceId, starredFilter = 0, search = "").page().map { it.name },
|
||||
dao.albumPages(sourceId, starredFilter = 0, search = "", sort = AlbumSort.Starred).page().map { it.name },
|
||||
)
|
||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, starredFilter = 0, search = "gam").page().map { it.name })
|
||||
assertEquals(listOf("Beta"), dao.albumsByName(sourceId, starredFilter = 2, search = "").page().map { it.name })
|
||||
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, starredFilter = 0, search = "gam").page().map { it.name })
|
||||
assertEquals(listOf("Beta"), dao.albumPages(sourceId, starredFilter = 2, search = "").page().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -148,19 +139,19 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
||||
dao.albumsByStarred(sourceId, 0, "").page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Starred).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
||||
dao.albumsByStarredReversed(sourceId, 0, "").page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Starred, descending = true).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha", "Delta", "Gamma"),
|
||||
dao.artistsByStarred(sourceId, 0, "").page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Starred).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma", "Delta"),
|
||||
dao.artistsByStarredReversed(sourceId, 0, "").page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Starred, descending = true).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -206,19 +197,19 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("Two", "One", "Three"),
|
||||
dao.albumsByFrequent(sourceId, 0, "").page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Two", "One", "Three"),
|
||||
dao.albumsByRecent(sourceId, 0, "").page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Many", "Few"),
|
||||
dao.artistsByFrequent(sourceId, 0, "").page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Many", "Few"),
|
||||
dao.artistsByRecent(sourceId, 0, "").page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -268,12 +259,17 @@ class LibraryDaoTest {
|
||||
|
||||
val pairs =
|
||||
listOf(
|
||||
dao.albumsByName(sourceId, 0, "") to dao.albumsByNameReversed(sourceId, 0, ""),
|
||||
dao.albumsByArtist(sourceId, 0, "") to dao.albumsByArtistReversed(sourceId, 0, ""),
|
||||
dao.albumsByYear(sourceId, 0, "") to dao.albumsByYearReversed(sourceId, 0, ""),
|
||||
dao.albumsByRecentlyAdded(sourceId, 0, "") to dao.albumsByRecentlyAddedReversed(sourceId, 0, ""),
|
||||
dao.albumsByFrequent(sourceId, 0, "") to dao.albumsByFrequentReversed(sourceId, 0, ""),
|
||||
dao.albumsByRecent(sourceId, 0, "") to dao.albumsByRecentReversed(sourceId, 0, ""),
|
||||
dao.albumPages(sourceId, 0, "") to dao.albumPages(sourceId, 0, "", descending = true),
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist) to
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, descending = true),
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Year) to
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Year, descending = true),
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Added) to
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Added, descending = true),
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent) to
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Frequent, descending = true),
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent) to
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Recent, descending = true),
|
||||
)
|
||||
for ((base, reversed) in pairs) {
|
||||
val ids = base.page().map { it.id }
|
||||
@@ -303,23 +299,23 @@ class LibraryDaoTest {
|
||||
|
||||
val artistPairs =
|
||||
listOf(
|
||||
dao.artistsByName(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.id },
|
||||
dao.artistsByAlbumCount(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByAlbumCountReversed(sourceId, 0, "").page().map { it.id },
|
||||
dao.artistsByFrequent(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByFrequentReversed(sourceId, 0, "").page().map { it.id },
|
||||
dao.artistsByRecent(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistsByRecentReversed(sourceId, 0, "").page().map { it.id },
|
||||
dao.artistPages(sourceId, 0, "").page().map { it.id } to
|
||||
dao.artistPages(sourceId, 0, "", descending = true).page().map { it.id },
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.AlbumCount).page().map { it.id } to
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.AlbumCount, descending = true).page().map { it.id },
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent).page().map { it.id } to
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Frequent, descending = true).page().map { it.id },
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent).page().map { it.id } to
|
||||
dao.artistPages(sourceId, 0, "", sort = ArtistSort.Recent, descending = true).page().map { it.id },
|
||||
)
|
||||
val playlistPairs =
|
||||
listOf(
|
||||
dao.playlistsByName(sourceId, "").page().map { it.id } to
|
||||
dao.playlistsByNameReversed(sourceId, "").page().map { it.id },
|
||||
dao.playlistsByAdded(sourceId, "").page().map { it.id } to
|
||||
dao.playlistsByAddedReversed(sourceId, "").page().map { it.id },
|
||||
dao.playlistsByUpdated(sourceId, "").page().map { it.id } to
|
||||
dao.playlistsByUpdatedReversed(sourceId, "").page().map { it.id },
|
||||
dao.playlistPages(sourceId, "").page().map { it.id } to
|
||||
dao.playlistPages(sourceId, "", descending = true).page().map { it.id },
|
||||
dao.playlistPages(sourceId, "", sort = PlaylistSort.Added).page().map { it.id } to
|
||||
dao.playlistPages(sourceId, "", sort = PlaylistSort.Added, descending = true).page().map { it.id },
|
||||
dao.playlistPages(sourceId, "", sort = PlaylistSort.Updated).page().map { it.id } to
|
||||
dao.playlistPages(sourceId, "", sort = PlaylistSort.Updated, descending = true).page().map { it.id },
|
||||
)
|
||||
for ((base, reversed) in artistPairs + playlistPairs) {
|
||||
assertEquals(base.reversed(), reversed)
|
||||
@@ -357,9 +353,11 @@ class LibraryDaoTest {
|
||||
ALBUM_ORDER_BY_RECENT_REVERSED to "index_albums_recent",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||
for (filter in listOf(ALBUMS_FILTER, ALBUMS_FILTER_DOWNLOADED)) {
|
||||
val plan = plan("SELECT * $filter ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,12 +380,49 @@ class LibraryDaoTest {
|
||||
ARTIST_ORDER_BY_RECENT_REVERSED to "index_artists_recent",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan = plan("SELECT * $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
val plan = plan("SELECT $ARTIST_COLUMNS $ARTISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedArtistOrdersUseTheIndexesTheirSortColumnsHave() =
|
||||
runTest {
|
||||
val orders =
|
||||
listOf(
|
||||
"${artistNameSortKey(SortNameMode.SortName)}, id" to "index_artists_name",
|
||||
"${artistNameSortKey(SortNameMode.SortName)} DESC, id DESC" to "index_artists_name",
|
||||
"${artistNameSortKey(SortNameMode.Name)}, id" to "index_artists_name_plain",
|
||||
"${artistNameSortKey(SortNameMode.Name)} DESC, id DESC" to "index_artists_name_plain",
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED to "index_artists_albumCount",
|
||||
ARTIST_ORDER_BY_STARRED to "index_artists_starred",
|
||||
ARTIST_ORDER_BY_STARRED_REVERSED to "index_artists_starred",
|
||||
ARTIST_ORDER_BY_FREQUENT to "index_artists_frequent",
|
||||
ARTIST_ORDER_BY_FREQUENT_REVERSED to "index_artists_frequent",
|
||||
ARTIST_ORDER_BY_RECENT to "index_artists_recent",
|
||||
ARTIST_ORDER_BY_RECENT_REVERSED to "index_artists_recent",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan =
|
||||
plan(
|
||||
"SELECT $ARTIST_COLUMNS_DOWNLOADED $ARTISTS_FILTER_DOWNLOADED " +
|
||||
"ORDER BY $order LIMIT 20 OFFSET 0",
|
||||
topLevelOnly = true,
|
||||
)
|
||||
if (order == ARTIST_ORDER_BY_ALBUM_COUNT || order == ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED) {
|
||||
// The displayed count comes from song_downloads, so this order cannot use
|
||||
// index_artists_albumCount; the hand-written statement sorted in memory too.
|
||||
assertTrue("$order\n$plan", plan.contains("USING INDEX index_artists_sourceId"))
|
||||
assertTrue("$order\n$plan", plan.contains("USE TEMP B-TREE FOR ORDER BY"))
|
||||
} else {
|
||||
assertTrue("$order\n$plan", plan.contains("USING INDEX $index"))
|
||||
assertFalse("$order\n$plan", plan.contains("USE TEMP B-TREE FOR ORDER BY"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistOrdersAreIndexBacked() =
|
||||
runTest {
|
||||
@@ -401,9 +436,18 @@ class LibraryDaoTest {
|
||||
PLAYLIST_ORDER_BY_UPDATED_REVERSED to "index_playlists_updated",
|
||||
)
|
||||
for ((order, index) in orders) {
|
||||
val plan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
assertTrue(plan, plan.contains("USING INDEX $index"))
|
||||
assertFalse(plan, plan.contains("TEMP B-TREE"))
|
||||
val defaultPlan = plan("SELECT * $PLAYLISTS_FILTER ORDER BY $order LIMIT 20 OFFSET 0")
|
||||
assertTrue(defaultPlan, defaultPlan.contains("USING INDEX $index"))
|
||||
assertFalse(defaultPlan, defaultPlan.contains("TEMP B-TREE"))
|
||||
|
||||
val downloadedPlan =
|
||||
plan(
|
||||
"SELECT $PLAYLISTS_COLUMNS_DOWNLOADED $PLAYLISTS_FILTER_DOWNLOADED " +
|
||||
"ORDER BY $order LIMIT 20 OFFSET 0",
|
||||
topLevelOnly = true,
|
||||
)
|
||||
assertTrue(downloadedPlan, downloadedPlan.contains("USING INDEX $index"))
|
||||
assertFalse(downloadedPlan, downloadedPlan.contains("USE TEMP B-TREE FOR ORDER BY"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,17 +500,85 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("Downloaded"),
|
||||
dao.albumsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", downloaded = true).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("ar-1"),
|
||||
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.id },
|
||||
dao.artistPages(sourceId, 0, "", downloaded = true).page().map { it.id },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("pl-1"),
|
||||
dao.playlistsByName(sourceId, "", downloadedFilter = 1).page().map { it.id },
|
||||
dao.playlistPages(sourceId, "", downloaded = true).page().map { it.id },
|
||||
)
|
||||
assertEquals(3, dao.albumsByName(sourceId, 0, "").page().size)
|
||||
assertEquals(3, dao.albumPages(sourceId, 0, "").page().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedFiltersHonourSearchAndStarred() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-1", "Artist", albumCount = 2).copy(starred = 5),
|
||||
artist(sourceId, "ar-2", "Other", albumCount = 1),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Downloaded One", year = 2000, starred = 5),
|
||||
album(sourceId, "al-2", "Downloaded Two", year = 2001, starred = null),
|
||||
album(sourceId, "al-3", "Missing", year = 2002, starred = 5),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null),
|
||||
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2"),
|
||||
song(sourceId, "s3", "Three", starred = null).copy(albumId = "al-3", artistId = "ar-2"),
|
||||
),
|
||||
)
|
||||
dao.upsertPlaylists(
|
||||
listOf(
|
||||
playlist(sourceId, "pl-1", "Has download", created = 0, changed = 0),
|
||||
playlist(sourceId, "pl-2", "No download", created = 1, changed = 1),
|
||||
),
|
||||
)
|
||||
dao.upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(sourceId, "pl-1", "s1", 0),
|
||||
PlaylistSong(sourceId, "pl-2", "s2", 0),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
|
||||
assertEquals(
|
||||
listOf("Downloaded One"),
|
||||
dao.albumPages(sourceId, 0, "one", downloaded = true).page().map { it.name },
|
||||
)
|
||||
assertTrue(dao.albumPages(sourceId, 0, "missing", downloaded = true).page().isEmpty())
|
||||
assertEquals(
|
||||
listOf("Downloaded One"),
|
||||
dao.albumPages(sourceId, 1, "", downloaded = true).page().map { it.name },
|
||||
)
|
||||
assertTrue(dao.albumPages(sourceId, 2, "", downloaded = true).page().isEmpty())
|
||||
|
||||
assertEquals(
|
||||
listOf("ar-1"),
|
||||
dao.artistPages(sourceId, 0, "art", downloaded = true).page().map { it.id },
|
||||
)
|
||||
assertTrue(dao.artistPages(sourceId, 0, "other", downloaded = true).page().isEmpty())
|
||||
assertEquals(
|
||||
listOf("ar-1"),
|
||||
dao.artistPages(sourceId, 1, "", downloaded = true).page().map { it.id },
|
||||
)
|
||||
assertTrue(dao.artistPages(sourceId, 2, "", downloaded = true).page().isEmpty())
|
||||
|
||||
assertEquals(
|
||||
listOf("pl-1"),
|
||||
dao.playlistPages(sourceId, "has", downloaded = true).page().map { it.id },
|
||||
)
|
||||
assertTrue(dao.playlistPages(sourceId, "no", downloaded = true).page().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -628,7 +740,7 @@ class LibraryDaoTest {
|
||||
|
||||
dao.recomputeArtistArt(sourceId)
|
||||
|
||||
val artists = dao.artistsByName(sourceId, 0, "").page().associateBy { it.id }
|
||||
val artists = dao.artistPages(sourceId, 0, "").page().associateBy { it.id }
|
||||
|
||||
assertEquals("own-art", artists.getValue("ar-own").coverArt)
|
||||
assertNull(artists.getValue("ar-album").coverArt)
|
||||
@@ -648,7 +760,7 @@ class LibraryDaoTest {
|
||||
assertEquals(
|
||||
listOf("album-ref-art"),
|
||||
dao
|
||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false))
|
||||
.artistPages(sourceId, 0, "", ArtistSort.Name, false)
|
||||
.page()
|
||||
.first { it.id == "ar-album-ref" }
|
||||
.coverArts,
|
||||
@@ -774,6 +886,26 @@ class LibraryDaoTest {
|
||||
assertEquals(listOf("art-2", "art-1"), dao.artistOnce(sourceId, "ar-1")!!.downloadedCoverArts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun freshDatabaseOptimizesArtistSearchTriggers() =
|
||||
runTest {
|
||||
val triggers =
|
||||
db.useReaderConnection { connection ->
|
||||
connection.usePrepared(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'trigger' " +
|
||||
"AND name IN ('room_fts_content_sync_artist_search_BEFORE_UPDATE', " +
|
||||
"'room_fts_content_sync_artist_search_AFTER_UPDATE')",
|
||||
) { statement ->
|
||||
buildList {
|
||||
while (statement.step()) add(statement.getText(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(2, triggers.size)
|
||||
assertTrue(triggers.all { it.contains("UPDATE OF `name`, `sortName`") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistNameUpdatesKeepTheSearchIndexValid() =
|
||||
runTest {
|
||||
@@ -869,18 +1001,7 @@ class LibraryDaoTest {
|
||||
runTest {
|
||||
val plan = plan(ALBUM_CREDIT_ALBUM_IDS)
|
||||
|
||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertFalse(plan, plan.contains("SCAN albums"))
|
||||
assertFalse(plan, plan.contains("SCAN songs"))
|
||||
assertArtistCreditIndexes(plan)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -904,7 +1025,7 @@ class LibraryDaoTest {
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
assertEquals(
|
||||
listOf("Album Artist"),
|
||||
dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", downloaded = true).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Album Artist"),
|
||||
@@ -913,7 +1034,7 @@ class LibraryDaoTest {
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s2", DownloadStatus.Completed))
|
||||
|
||||
val filtered = dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page()
|
||||
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true).page()
|
||||
assertEquals(listOf("Album Artist", "Guest"), filtered.map { it.name })
|
||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||
|
||||
@@ -961,14 +1082,14 @@ class LibraryDaoTest {
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
|
||||
val filtered = dao.artistsByName(sourceId, 0, "", downloadedFilter = 1).page()
|
||||
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true).page()
|
||||
assertEquals(1, filtered.size)
|
||||
assertEquals(1L, filtered.first().albumCount)
|
||||
|
||||
assertEquals(
|
||||
5L,
|
||||
dao
|
||||
.artistsByName(sourceId, 0, "")
|
||||
.artistPages(sourceId, 0, "")
|
||||
.page()
|
||||
.first { it.id == "ar-1" }
|
||||
.albumCount,
|
||||
@@ -1006,7 +1127,7 @@ class LibraryDaoTest {
|
||||
|
||||
// ar-1 has two downloaded albums, ar-2 one, so the downloaded order is ar-1 first even
|
||||
// though the synced counts would put ar-2 first.
|
||||
val filtered = dao.artistsByAlbumCount(sourceId, 0, "", downloadedFilter = 1).page()
|
||||
val filtered = dao.artistPages(sourceId, 0, "", downloaded = true, sort = ArtistSort.AlbumCount).page()
|
||||
assertEquals(listOf("ar-1", "ar-2"), filtered.map { it.id })
|
||||
assertEquals(listOf(2L, 1L), filtered.map { it.albumCount })
|
||||
}
|
||||
@@ -1036,7 +1157,7 @@ class LibraryDaoTest {
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
|
||||
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
|
||||
val filtered = dao.playlistPages(sourceId, "", downloaded = true).page()
|
||||
assertEquals(1, filtered.size)
|
||||
assertEquals(1L, filtered.first().songCount)
|
||||
assertEquals("austin", filtered.first().owner)
|
||||
@@ -1045,7 +1166,7 @@ class LibraryDaoTest {
|
||||
assertEquals(
|
||||
5L,
|
||||
dao
|
||||
.playlistsByName(sourceId, "")
|
||||
.playlistPages(sourceId, "")
|
||||
.page()
|
||||
.first { it.id == "pl-1" }
|
||||
.songCount,
|
||||
@@ -1064,11 +1185,11 @@ class LibraryDaoTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
|
||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "GAM").page().map { it.name })
|
||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "ga").page().map { it.name })
|
||||
assertEquals(listOf("Alpha"), dao.albumsByName(sourceId, 0, "Else").page().map { it.name })
|
||||
assertTrue(dao.albumsByName(sourceId, 0, "zzz").page().isEmpty())
|
||||
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "amm").page().map { it.name })
|
||||
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "GAM").page().map { it.name })
|
||||
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "ga").page().map { it.name })
|
||||
assertEquals(listOf("Alpha"), dao.albumPages(sourceId, 0, "Else").page().map { it.name })
|
||||
assertTrue(dao.albumPages(sourceId, 0, "zzz").page().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1077,17 +1198,17 @@ class LibraryDaoTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null)))
|
||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "gam").page().map { it.name })
|
||||
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "gam").page().map { it.name })
|
||||
|
||||
dao.setAlbumStar(sourceId, "a", 7)
|
||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "gam").page().map { it.name })
|
||||
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "gam").page().map { it.name })
|
||||
|
||||
dao.upsertAlbums(listOf(album(sourceId, "a", "Delta", year = null, starred = null)))
|
||||
assertTrue(dao.albumsByName(sourceId, 0, "gam").page().isEmpty())
|
||||
assertEquals(listOf("Delta"), dao.albumsByName(sourceId, 0, "elt").page().map { it.name })
|
||||
assertTrue(dao.albumPages(sourceId, 0, "gam").page().isEmpty())
|
||||
assertEquals(listOf("Delta"), dao.albumPages(sourceId, 0, "elt").page().map { it.name })
|
||||
|
||||
dao.deleteAlbums(sourceId, listOf("a"))
|
||||
assertTrue(dao.albumsByName(sourceId, 0, "elt").page().isEmpty())
|
||||
assertTrue(dao.albumPages(sourceId, 0, "elt").page().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1101,8 +1222,8 @@ class LibraryDaoTest {
|
||||
val second = source()
|
||||
dao.upsertAlbums(listOf(album(second, "a", "Beta", year = null, starred = null)))
|
||||
|
||||
assertEquals(listOf("Beta"), dao.albumsByName(second, 0, "eta").page().map { it.name })
|
||||
assertTrue(dao.albumsByName(second, 0, "gam").page().isEmpty())
|
||||
assertEquals(listOf("Beta"), dao.albumPages(second, 0, "eta").page().map { it.name })
|
||||
assertTrue(dao.albumPages(second, 0, "gam").page().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1113,10 +1234,10 @@ class LibraryDaoTest {
|
||||
dao.upsertArtists(listOf(artist(sourceId, "r1", "The Beatles", albumCount = 1)))
|
||||
dao.upsertPlaylists(listOf(playlist(sourceId, "l1", "Road Trip", created = 0, changed = 0)))
|
||||
|
||||
assertEquals(listOf("r1"), dao.artistsByName(sourceId, 0, "eat").page().map { it.id })
|
||||
assertEquals(listOf("l1"), dao.playlistsByName(sourceId, "rip").page().map { it.id })
|
||||
assertTrue(dao.artistsByName(sourceId, 0, "rip").page().isEmpty())
|
||||
assertTrue(dao.playlistsByName(sourceId, "eat").page().isEmpty())
|
||||
assertEquals(listOf("r1"), dao.artistPages(sourceId, 0, "eat").page().map { it.id })
|
||||
assertEquals(listOf("l1"), dao.playlistPages(sourceId, "rip").page().map { it.id })
|
||||
assertTrue(dao.artistPages(sourceId, 0, "rip").page().isEmpty())
|
||||
assertTrue(dao.playlistPages(sourceId, "eat").page().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1131,10 +1252,10 @@ class LibraryDaoTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Say \"Hello\""), dao.albumsByName(sourceId, 0, "\"Hel").page().map { it.name })
|
||||
assertEquals(listOf("Say \"Hello\""), dao.albumPages(sourceId, 0, "\"Hel").page().map { it.name })
|
||||
assertEquals(
|
||||
listOf("Say \"Hello\""),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "\"Hel", AlbumSort.Name, false)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "\"Hel", AlbumSort.Name, false).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1145,7 +1266,7 @@ class LibraryDaoTest {
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertAlbums(listOf(album(sourceId, "a", "Gamma", year = null, starred = null).copy(albumArtist = null)))
|
||||
|
||||
assertEquals(listOf("Gamma"), dao.albumsByName(sourceId, 0, "amm").page().map { it.name })
|
||||
assertEquals(listOf("Gamma"), dao.albumPages(sourceId, 0, "amm").page().map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1236,25 +1357,48 @@ class LibraryDaoTest {
|
||||
runTest {
|
||||
val plan = plan(ALBUMS_FOR_ARTIST)
|
||||
|
||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertFalse(plan, plan.contains("SCAN albums"))
|
||||
assertFalse(plan, plan.contains("SCAN songs"))
|
||||
assertArtistCreditIndexes(plan)
|
||||
}
|
||||
|
||||
private suspend fun plan(sql: String): String =
|
||||
@Test
|
||||
fun downloadedArtistAlbumsFindCreditsThroughArtistIndexes() =
|
||||
runTest {
|
||||
val plan = plan(ALBUMS_FOR_ARTIST_DOWNLOADED)
|
||||
assertTrue(plan, plan.contains("SEARCH albums USING INDEX sqlite_autoindex_albums_1"))
|
||||
assertTrue(plan, plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(plan, plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertFalse(plan, plan.contains("SCAN albums"))
|
||||
assertFalse(plan, plan.contains("SCAN songs"))
|
||||
assertFalse(plan, plan.contains("SCAN album_artists"))
|
||||
assertFalse(plan, plan.contains("SCAN song_artists"))
|
||||
}
|
||||
|
||||
private fun assertArtistCreditIndexes(plan: String) {
|
||||
assertTrue(plan, plan.contains("USING INDEX index_albums_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_album_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertTrue(plan, plan.contains("USING INDEX index_songs_sourceId_artistId (sourceId=? AND artistId=?)"))
|
||||
assertTrue(
|
||||
plan,
|
||||
plan.contains("USING INDEX index_song_artists_sourceId_artistId (sourceId=? AND artistId=?)"),
|
||||
)
|
||||
assertFalse(plan, plan.contains("SCAN albums"))
|
||||
assertFalse(plan, plan.contains("SCAN songs"))
|
||||
}
|
||||
|
||||
private suspend fun plan(
|
||||
sql: String,
|
||||
topLevelOnly: Boolean = false,
|
||||
): String =
|
||||
db.useReaderConnection { connection ->
|
||||
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
|
||||
val details = mutableListOf<String>()
|
||||
while (statement.step()) details += statement.getText(3)
|
||||
while (statement.step()) {
|
||||
if (!topLevelOnly || statement.getLong(1) == 0L) details += statement.getText(3)
|
||||
}
|
||||
details.joinToString("\n")
|
||||
}
|
||||
}
|
||||
@@ -1274,35 +1418,35 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma"),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", AlbumSort.Name, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Gamma", "Beta", "Alpha"),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, true)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", AlbumSort.Name, true).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta", "Gamma"),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Year, false)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", AlbumSort.Year, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Gamma", "Beta", "Alpha"),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Artist, false)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", AlbumSort.Artist, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha"),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "alp", AlbumSort.Name, false)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "alp", AlbumSort.Name, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Gamma"),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "am", AlbumSort.Name, false)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "am", AlbumSort.Name, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha"),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 1, "", AlbumSort.Name, false)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 1, "", AlbumSort.Name, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Gamma"),
|
||||
dao.albumsPaging(albumsDefaultQuery(sourceId, 2, "", AlbumSort.Name, false)).page().map { it.name },
|
||||
dao.albumPages(sourceId, 2, "", AlbumSort.Name, false).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1326,23 +1470,23 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta"),
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "a", ArtistSort.Name, false)).page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "a", ArtistSort.Name, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.AlbumCount, false)).page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", ArtistSort.AlbumCount, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta"),
|
||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "a", PlaylistSort.Name, false)).page().map { it.name },
|
||||
dao.playlistPages(sourceId, "a", PlaylistSort.Name, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Alpha", "Beta"),
|
||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Added, false)).page().map { it.name },
|
||||
dao.playlistPages(sourceId, "", PlaylistSort.Added, false).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beta", "Alpha"),
|
||||
dao.playlistsPaging(playlistsDefaultQuery(sourceId, "", PlaylistSort.Updated, false)).page().map { it.name },
|
||||
dao.playlistPages(sourceId, "", PlaylistSort.Updated, false).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1355,12 +1499,12 @@ class LibraryDaoTest {
|
||||
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null).copy(albumId = "a")))
|
||||
|
||||
val defaultInvalidated = AtomicBoolean(false)
|
||||
val default = dao.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false))
|
||||
val default = dao.albumPages(sourceId, 0, "", AlbumSort.Name, false)
|
||||
default.registerInvalidatedCallback { defaultInvalidated.set(true) }
|
||||
default.page()
|
||||
|
||||
val downloadedInvalidated = AtomicBoolean(false)
|
||||
val downloaded = dao.albumsByName(sourceId, 0, "", downloadedFilter = 1)
|
||||
val downloaded = dao.albumPages(sourceId, 0, "", downloaded = true)
|
||||
downloaded.registerInvalidatedCallback { downloadedInvalidated.set(true) }
|
||||
downloaded.page()
|
||||
|
||||
@@ -1373,6 +1517,52 @@ class LibraryDaoTest {
|
||||
awaitUntil("the default list observes album writes") { defaultInvalidated.get() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadWritesInvalidateTheDownloadedArtistAndPlaylistPagingSources() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 1)))
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Album", year = null, starred = null)))
|
||||
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null)))
|
||||
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "Playlist", created = 0, changed = 0)))
|
||||
dao.upsertPlaylistSongs(listOf(PlaylistSong(sourceId, "pl-1", "s1", 0)))
|
||||
|
||||
val artistInvalidated = AtomicBoolean(false)
|
||||
val artists = dao.artistPages(sourceId, downloaded = true)
|
||||
artists.registerInvalidatedCallback { artistInvalidated.set(true) }
|
||||
artists.page()
|
||||
|
||||
val playlistInvalidated = AtomicBoolean(false)
|
||||
val playlists = dao.playlistPages(sourceId, downloaded = true)
|
||||
playlists.registerInvalidatedCallback { playlistInvalidated.set(true) }
|
||||
playlists.page()
|
||||
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
|
||||
awaitUntil("the downloaded artists observe download writes") { artistInvalidated.get() }
|
||||
awaitUntil("the downloaded playlists observe download writes") { playlistInvalidated.get() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songWritesInvalidateTheDownloadedArtistPagingSource() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(listOf(artist(sourceId, "ar-1", "Artist", albumCount = 1)))
|
||||
dao.upsertAlbums(listOf(album(sourceId, "al-1", "Album", year = null, starred = null)))
|
||||
dao.upsertSongs(listOf(song(sourceId, "s1", "One", starred = null)))
|
||||
|
||||
val invalidated = AtomicBoolean(false)
|
||||
val artists = dao.artistPages(sourceId, downloaded = true)
|
||||
artists.registerInvalidatedCallback { invalidated.set(true) }
|
||||
artists.page()
|
||||
|
||||
dao.upsertSongs(listOf(song(sourceId, "s2", "Two", starred = null)))
|
||||
|
||||
awaitUntil("the downloaded artists observe song writes") { invalidated.get() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sortNamesDriveArtistAndAlbumOrder() =
|
||||
runTest {
|
||||
@@ -1395,27 +1585,27 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "あいみょん"),
|
||||
dao.artistsByName(sourceId, 0, "").page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("あいみょん", "宇多田ヒカル"),
|
||||
dao.artistsByNameReversed(sourceId, 0, "").page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", descending = true).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Zzz", "Aaa"),
|
||||
dao.albumsByName(sourceId, 0, "").page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "").page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Zzz"),
|
||||
dao.albumsByNameReversed(sourceId, 0, "").page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", descending = true).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Zzz"),
|
||||
dao.albumsByArtist(sourceId, 0, "").page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Zzz", "Aaa"),
|
||||
dao.albumsByArtistReversed(sourceId, 0, "").page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, descending = true).page().map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1445,39 +1635,111 @@ class LibraryDaoTest {
|
||||
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "Zebra", "Beatles", "あいみょん"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.SortName.name).page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.SortName).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.Name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "あいみょん", "Zebra", "Beatles"),
|
||||
dao.artistsByNameReversed(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
dao
|
||||
.artistPages(sourceId, 0, "", descending = true, sortNameMode = SortNameMode.Name)
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("宇多田ヒカル", "Beatles", "Zebra", "あいみょん"),
|
||||
dao.artistsByName(sourceId, 0, "", sortMode = SortNameMode.NonLatin.name).page().map { it.name },
|
||||
dao.artistPages(sourceId, 0, "", sortNameMode = SortNameMode.NonLatin).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Beatles", "Zzz"),
|
||||
dao.albumsByName(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
dao.albumPages(sourceId, 0, "", sortNameMode = SortNameMode.Name).page().map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Zzz", "Beatles"),
|
||||
dao.albumsByArtist(sourceId, 0, "", sortMode = SortNameMode.Name.name).page().map { it.name },
|
||||
dao
|
||||
.albumPages(sourceId, 0, "", sort = AlbumSort.Artist, sortNameMode = SortNameMode.Name)
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beatles", "Zebra", "あいみょん", "宇多田ヒカル"),
|
||||
dao
|
||||
.artistsPaging(artistsDefaultQuery(sourceId, 0, "", ArtistSort.Name, false, SortNameMode.Name))
|
||||
.page()
|
||||
.artistsPaging(
|
||||
artistsPageQuery(sourceId, 0, "", ArtistSort.Name, false, false, SortNameMode.Name),
|
||||
).page()
|
||||
.map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Aaa", "Beatles", "Zzz"),
|
||||
dao
|
||||
.albumsPaging(albumsDefaultQuery(sourceId, 0, "", AlbumSort.Name, false, SortNameMode.Name))
|
||||
.albumsPaging(
|
||||
albumsPageQuery(sourceId, 0, "", AlbumSort.Name, false, false, SortNameMode.Name),
|
||||
).page()
|
||||
.map { it.name },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadedSortsHonourTheSortNamePreference() =
|
||||
runTest {
|
||||
val sourceId = source()
|
||||
val dao = db.libraryDao()
|
||||
dao.upsertArtists(
|
||||
listOf(
|
||||
artist(sourceId, "ar-1", "Beatles", albumCount = 1).copy(sortName = "Beatles, The"),
|
||||
artist(sourceId, "ar-2", "Zebra", albumCount = 1).copy(sortName = "Aaa"),
|
||||
),
|
||||
)
|
||||
dao.upsertAlbums(
|
||||
listOf(
|
||||
album(sourceId, "al-1", "Beatles", year = null, starred = null)
|
||||
.copy(sortName = "Beatles, The", albumArtistSort = "Beatles, The", albumArtist = "Beatles"),
|
||||
album(sourceId, "al-2", "Zebra", year = null, starred = null)
|
||||
.copy(
|
||||
sortName = "Aaa",
|
||||
albumArtistSort = "Aaa",
|
||||
albumArtist = "Zebra",
|
||||
artistId = "ar-2",
|
||||
),
|
||||
),
|
||||
)
|
||||
dao.upsertSongs(
|
||||
listOf(
|
||||
song(sourceId, "s1", "One", starred = null).copy(albumId = "al-1"),
|
||||
song(sourceId, "s2", "Two", starred = null).copy(albumId = "al-2", artistId = "ar-2"),
|
||||
),
|
||||
)
|
||||
listOf("s1", "s2").forEach {
|
||||
db.downloadDao().upsert(SongDownload(sourceId, it, DownloadStatus.Completed))
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
listOf("Zebra", "Beatles"),
|
||||
dao
|
||||
.artistPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.SortName)
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beatles", "Zebra"),
|
||||
dao
|
||||
.artistPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.Name)
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Zebra", "Beatles"),
|
||||
dao
|
||||
.albumPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.SortName)
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Beatles", "Zebra"),
|
||||
dao
|
||||
.albumPages(sourceId, 0, "", downloaded = true, sortNameMode = SortNameMode.Name)
|
||||
.page()
|
||||
.map { it.name },
|
||||
)
|
||||
@@ -1507,11 +1769,11 @@ class LibraryDaoTest {
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "utada").page().map { it.name })
|
||||
assertEquals(listOf("宇多田ヒカル"), dao.artistsByName(sourceId, 0, "ut").page().map { it.name })
|
||||
assertEquals(listOf("宇多田ヒカル"), dao.artistPages(sourceId, 0, "utada").page().map { it.name })
|
||||
assertEquals(listOf("宇多田ヒカル"), dao.artistPages(sourceId, 0, "ut").page().map { it.name })
|
||||
assertEquals(listOf("宇多田ヒカル"), dao.searchArtists(sourceId, "utada", 10).first().map { it.name })
|
||||
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "first").page().map { it.name })
|
||||
assertEquals(listOf("First Love"), dao.albumsByName(sourceId, 0, "fi").page().map { it.name })
|
||||
assertEquals(listOf("First Love"), dao.albumPages(sourceId, 0, "first").page().map { it.name })
|
||||
assertEquals(listOf("First Love"), dao.albumPages(sourceId, 0, "fi").page().map { it.name })
|
||||
assertEquals(listOf("First Love"), dao.searchAlbums(sourceId, "first", 10).first().map { it.name })
|
||||
assertEquals(listOf("Automatic"), dao.searchSongs(sourceId, "utada", 10).first().map { it.song.title })
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
@@ -27,12 +23,7 @@ class LibraryHomeDaoTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.paging.PagingSource
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.SortNameMode
|
||||
|
||||
internal fun LibraryDao.albumPages(
|
||||
sourceId: Long,
|
||||
starredFilter: Int = 0,
|
||||
search: String = "",
|
||||
sort: AlbumSort = AlbumSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): PagingSource<Int, Album> =
|
||||
if (downloaded) {
|
||||
downloadedAlbumsPaging(
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
albumsPaging(
|
||||
albumsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun LibraryDao.artistPages(
|
||||
sourceId: Long,
|
||||
starredFilter: Int = 0,
|
||||
search: String = "",
|
||||
sort: ArtistSort = ArtistSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
sortNameMode: SortNameMode = SortNameMode.SortName,
|
||||
): PagingSource<Int, Artist> =
|
||||
if (downloaded) {
|
||||
downloadedArtistsPaging(
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
artistsPaging(
|
||||
artistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
starredFilter = starredFilter,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
sortNameMode = sortNameMode,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun LibraryDao.playlistPages(
|
||||
sourceId: Long,
|
||||
search: String = "",
|
||||
sort: PlaylistSort = PlaylistSort.Name,
|
||||
descending: Boolean = false,
|
||||
downloaded: Boolean = false,
|
||||
): PagingSource<Int, Playlist> =
|
||||
if (downloaded) {
|
||||
downloadedPlaylistsPaging(
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = true,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
playlistsPaging(
|
||||
playlistsPageQuery(
|
||||
sourceId = sourceId,
|
||||
search = search,
|
||||
sort = sort,
|
||||
descending = descending,
|
||||
downloaded = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.immediateTransaction
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
@@ -14,6 +11,9 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.AlbumSort
|
||||
import com.subtracks.data.prefs.ArtistSort
|
||||
import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.data.sync.SyncService
|
||||
@@ -37,14 +37,8 @@ class QueryTimingMeasurementTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(driver)
|
||||
.addCallback(ArtistSearchTriggerOptimization)
|
||||
.build()
|
||||
db = createTestDatabase(driver)
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -110,28 +104,37 @@ class QueryTimingMeasurementTest {
|
||||
|
||||
private suspend fun measureReads() {
|
||||
val dao = db.libraryDao()
|
||||
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
|
||||
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
|
||||
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
|
||||
timed("albums first page (added)") { dao.albumsByRecentlyAdded(1, 0, "").firstPage() }
|
||||
timed("albums first page (starred)") { dao.albumsByStarred(1, 0, "").firstPage() }
|
||||
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
|
||||
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
|
||||
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
|
||||
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
|
||||
timed("albums first page (name)") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).firstPage() }
|
||||
timed("albums first page (name, downloaded)") {
|
||||
dao.albumPages(1, 0, "", sort = AlbumSort.Name, downloaded = true).firstPage()
|
||||
}
|
||||
timed("albums first page (artist)") { dao.albumPages(1, 0, "", sort = AlbumSort.Artist).firstPage() }
|
||||
timed("albums first page (year)") { dao.albumPages(1, 0, "", sort = AlbumSort.Year).firstPage() }
|
||||
timed("albums first page (added)") { dao.albumPages(1, 0, "", sort = AlbumSort.Added).firstPage() }
|
||||
timed("albums first page (starred)") { dao.albumPages(1, 0, "", sort = AlbumSort.Starred).firstPage() }
|
||||
timed("albums first page (frequent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Frequent).firstPage() }
|
||||
timed("albums first page (recent)") { dao.albumPages(1, 0, "", sort = AlbumSort.Recent).firstPage() }
|
||||
timed("artists first page (name)") { dao.artistPages(1, 0, "", sort = ArtistSort.Name).firstPage() }
|
||||
timed("artists first page (name, downloaded)") {
|
||||
dao.artistPages(1, 0, "", sort = ArtistSort.Name, downloaded = true).firstPage()
|
||||
}
|
||||
timed("artists first page (frequent)") { dao.artistPages(1, 0, "", sort = ArtistSort.Frequent).firstPage() }
|
||||
timed("artist albums") { dao.albumsForArtist(1, "ar0").first() }
|
||||
timed("artist name") { dao.artistName(1, "ar0").first() }
|
||||
timed("artist songs") { db.queueDao().artistSongs(1, "ar0", 0, 100) }
|
||||
timed("artist length") { db.queueDao().artistLength(1, "ar0") }
|
||||
timed("artist song ids") { db.queueDao().artistSongIds(1, "ar0") }
|
||||
timed("downloaded artists") { dao.downloadedArtists(1).firstPage() }
|
||||
timed("artists downloaded filter") { dao.artistsByName(1, 0, "", downloadedFilter = 1).firstPage() }
|
||||
timed("artists downloaded filter") { dao.artistPages(1, 0, "", downloaded = true).firstPage() }
|
||||
timed("artist statuses") { db.downloadDao().artistStatuses(1).first() }
|
||||
timed("artist status") { db.downloadDao().artistStatus(1, "ar0").first() }
|
||||
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
|
||||
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
|
||||
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
|
||||
timed("albums search short") { dao.albumsByName(1, 0, "al").firstPage() }
|
||||
timed("playlists first page (name)") { dao.playlistPages(1, "", sort = PlaylistSort.Name).firstPage() }
|
||||
timed("playlists first page (name, downloaded)") {
|
||||
dao.playlistPages(1, "", sort = PlaylistSort.Name, downloaded = true).firstPage()
|
||||
}
|
||||
timed("albums deep page") { dao.albumPages(1, 0, "", sort = AlbumSort.Name).deepPage(1000) }
|
||||
timed("albums search FTS") { dao.albumPages(1, 0, "Album 1", sort = AlbumSort.Name).firstPage() }
|
||||
timed("albums search short") { dao.albumPages(1, 0, "al", sort = AlbumSort.Name).firstPage() }
|
||||
timed("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
|
||||
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
|
||||
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.SQLiteDriver
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
|
||||
fun createTestDatabase(driver: SQLiteDriver = BundledSQLiteDriver()): SubtracksDatabase {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
return Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(driver)
|
||||
.addSubtracksDatabaseCallbacks()
|
||||
.build()
|
||||
}
|
||||
@@ -3,13 +3,12 @@ package com.subtracks.data.repo
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkFetcher
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
@@ -84,11 +83,7 @@ class DownloadRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sources = SourceRepository(db, OkHttpClient(), prefs, artwork, scope = scope)
|
||||
|
||||
@@ -4,12 +4,11 @@ import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.paging.PagingDataEvent
|
||||
import androidx.paging.PagingDataPresenter
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.ArtistBio
|
||||
@@ -63,11 +62,7 @@ class LibraryRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sourceRepository =
|
||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||
@@ -208,6 +203,42 @@ class LibraryRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theDownloadedAlbumGridOnlyPresentsDownloadedRows() =
|
||||
runBlocking {
|
||||
val sourceId = sourceRepository.addSource("nav", "http://localhost/", "u", "p", false)
|
||||
db.libraryDao().upsertAlbums(listOf(seedAlbum(sourceId, 1), seedAlbum(sourceId, 2)))
|
||||
db.libraryDao().upsertSongs(
|
||||
listOf(
|
||||
seedSong(sourceId).copy(id = "s1", albumId = "al-0001"),
|
||||
seedSong(sourceId).copy(id = "s2", albumId = "al-0002"),
|
||||
),
|
||||
)
|
||||
db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Completed))
|
||||
|
||||
val presenterDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
val presenter = RecordingPresenter(presenterDispatcher)
|
||||
val job =
|
||||
launch {
|
||||
repository
|
||||
.albums(
|
||||
sourceId = sourceId,
|
||||
sort = AlbumSort.Name,
|
||||
descending = false,
|
||||
starred = StarredFilter.Any,
|
||||
search = "",
|
||||
downloaded = true,
|
||||
).collect { presenter.collectFrom(it) }
|
||||
}
|
||||
try {
|
||||
awaitRefreshes(presenter, 1)
|
||||
assertEquals(1, presented(presenter, presenterDispatcher))
|
||||
} finally {
|
||||
job.cancelAndJoin()
|
||||
presenterDispatcher.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun artistAlbumsSeparatesAlbumCreditsFromTrackAppearances() =
|
||||
runBlocking {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.PendingActionKind
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
@@ -82,12 +79,7 @@ class PendingServerActionSinkTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sourceId = runBlocking { db.sourcesDao().insertSource(Source(name = "server", address = "http://x/", createdAt = 0)) }
|
||||
delegate = RecordingSink()
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -26,12 +23,7 @@ class QueueKeysetTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
repository = QueueRepository(db)
|
||||
}
|
||||
|
||||
@@ -158,6 +150,18 @@ class QueueKeysetTest {
|
||||
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
|
||||
fun scanningAnAlbumWithNullDiscAndTrackBackwardsResolvesEveryRow() =
|
||||
runTest {
|
||||
@@ -268,6 +272,22 @@ class QueueKeysetTest {
|
||||
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
|
||||
fun theAlbumKeysetQuerySeeksTheOrderIndex() =
|
||||
runTest {
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useReaderConnection
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
@@ -33,12 +30,7 @@ class QueueRepositoryTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
repository = QueueRepository(db)
|
||||
}
|
||||
|
||||
@@ -735,6 +727,32 @@ class QueueRepositoryTest {
|
||||
assertEquals(listOf("s1", "s2", "s3"), resolveAll(restored))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffledLargeContextWindowOnlyResolvesRequestedRows() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("huge", "Huge")))
|
||||
db.useWriterConnection { transactor ->
|
||||
transactor.usePrepared(
|
||||
"WITH RECURSIVE n(x) AS (VALUES(0) UNION ALL SELECT x + 1 FROM n WHERE x < 199999) " +
|
||||
"INSERT INTO playlist_songs SELECT 1, 'huge', 's1', x FROM n",
|
||||
) { statement ->
|
||||
statement.step()
|
||||
}
|
||||
}
|
||||
repository.snapshotAfter(listOf(repository.playlistEntry(1, "huge")))
|
||||
repository.setShuffle(42L)
|
||||
val snapshot = repository.snapshot()
|
||||
|
||||
val window = repository.window(snapshot, center = 100_000, radius = 25)
|
||||
|
||||
assertEquals(200_000L, snapshot.contextSize)
|
||||
assertEquals(51, window.size)
|
||||
assertEquals(51L, repository.resolvedRowCount)
|
||||
assertEquals((99_975L..100_025L).toList(), window.map { it.position })
|
||||
assertTrue(window.all { it.item.song.id == "s1" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shuffledRangeResolvesEntriesInPositionOrder() =
|
||||
runTest {
|
||||
@@ -859,6 +877,55 @@ class QueueRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexUsesTheFirstDuplicateOrdinalAcrossSparsePositions() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "duplicates", "s2", 10),
|
||||
PlaylistSong(1, "duplicates", "s1", 20),
|
||||
PlaylistSong(1, "duplicates", "s2", 30),
|
||||
),
|
||||
)
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.playlistEntry(1, "duplicates")))
|
||||
|
||||
assertEquals(0L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexFindsADuplicateInsideTheEntryRange() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
db.libraryDao().upsertPlaylists(listOf(playlist("duplicates", "Duplicates")))
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "duplicates", "s2", 10),
|
||||
PlaylistSong(1, "duplicates", "s1", 20),
|
||||
PlaylistSong(1, "duplicates", "s2", 30),
|
||||
),
|
||||
)
|
||||
val entry = repository.playlistEntry(1, "duplicates").copy(rangeStart = 1, rangeEnd = 2)
|
||||
val snapshot = repository.snapshotAfter(listOf(entry))
|
||||
|
||||
assertEquals(1L, repository.combinedContextIndexOf(snapshot, "s2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contextIndexFallsBackToBoundedRowsForGenre() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
insertGenre("s1", "Rock")
|
||||
insertGenre("s3", "Rock")
|
||||
insertGenre("s4", "Rock")
|
||||
val snapshot = repository.snapshotAfter(listOf(repository.genreEntry(1, "Rock")))
|
||||
|
||||
assertEquals(2L, repository.combinedContextIndexOf(snapshot, "s4"))
|
||||
assertTrue(repository.resolvedRowCount <= QUEUE_CHUNK)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun upNextCombinedIndexOfCountsFromTheAnchorIntoTheBlock() =
|
||||
runTest {
|
||||
|
||||
@@ -2,13 +2,12 @@ package com.subtracks.data.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.artistImageKey
|
||||
@@ -58,11 +57,7 @@ class SourceRepositoryTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
prefs = fakeUserPreferences()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
@@ -314,7 +309,7 @@ class SourceRepositoryTest {
|
||||
assertNull(repository.artistImage("ar2", "https://img.example/ar2.jpg"))
|
||||
}
|
||||
|
||||
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSourceId().first { it != null }!! }
|
||||
private suspend fun awaitActiveSourceId(): Long = withTimeout(TEST_TIMEOUT_MS) { repository.activeSource.first { it != null }!! }
|
||||
|
||||
private suspend fun awaitCoverArt(coverArt: String): CoverArtRef =
|
||||
withTimeout(TEST_TIMEOUT_MS) {
|
||||
|
||||
@@ -2,13 +2,12 @@ package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
@@ -55,11 +54,7 @@ class SyncManagerTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
sourceRepository =
|
||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.LoggingSQLiteDriver
|
||||
import com.subtracks.data.db.SqlExecution
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.artistsDefaultQuery
|
||||
import com.subtracks.data.db.artistPages
|
||||
import com.subtracks.data.db.artistsPageQuery
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.ArtistRef
|
||||
@@ -43,12 +42,7 @@ class SyncServiceTest {
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -75,7 +69,7 @@ class SyncServiceTest {
|
||||
2,
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
@@ -98,7 +92,7 @@ class SyncServiceTest {
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -172,7 +166,7 @@ class SyncServiceTest {
|
||||
"Old",
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.single()
|
||||
.name,
|
||||
@@ -185,7 +179,7 @@ class SyncServiceTest {
|
||||
"New",
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.single()
|
||||
.name,
|
||||
@@ -1107,16 +1101,16 @@ class SyncServiceTest {
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
val query = artistsDefaultQuery(1, 0, "alpha", ArtistSort.Name, false)
|
||||
val query = artistsPageQuery(1, 0, "alpha", ArtistSort.Name, false, false)
|
||||
assertEquals(
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "alpha")
|
||||
.artistPages(1, 0, "alpha")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -1203,7 +1197,7 @@ class SyncServiceTest {
|
||||
listOf("a1", "combo"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -1216,7 +1210,7 @@ class SyncServiceTest {
|
||||
listOf("a1"),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.map { it.id },
|
||||
)
|
||||
@@ -1434,7 +1428,7 @@ class SyncServiceTest {
|
||||
SyncService(db, source).sync()
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
|
||||
|
||||
val filtered = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
||||
val filtered = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
|
||||
assertEquals(listOf("A", "B"), filtered.map { it.name })
|
||||
assertEquals(listOf(1L, 1L), filtered.map { it.albumCount })
|
||||
assertEquals(ListDownloadStatus(total = 1, downloaded = 1), db.downloadDao().artistStatus(1, "a2").first())
|
||||
@@ -1538,7 +1532,7 @@ class SyncServiceTest {
|
||||
albums.size.toLong(),
|
||||
db
|
||||
.libraryDao()
|
||||
.artistsByName(1, 0, "")
|
||||
.artistPages(1, 0, "")
|
||||
.allRows()
|
||||
.first { it.id == "a2" }
|
||||
.albumCount,
|
||||
@@ -1780,7 +1774,7 @@ class SyncServiceTest {
|
||||
assertEquals("$artistId downloaded", downloaded.toLong(), status.downloaded)
|
||||
}
|
||||
|
||||
val downloadedArtists = db.libraryDao().artistsByName(1, 0, "", downloadedFilter = 1).allRows()
|
||||
val downloadedArtists = db.libraryDao().artistPages(1, 0, "", downloaded = true).allRows()
|
||||
for (artist in downloadedArtists) {
|
||||
assertEquals(
|
||||
"${artist.id} downloaded albums",
|
||||
@@ -1845,13 +1839,8 @@ class SyncServiceTest {
|
||||
@Test
|
||||
fun anIdenticalSyncSkipsTheAggregateQueries() =
|
||||
runTest {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val executions = Collections.synchronizedList(mutableListOf<SqlExecution>())
|
||||
val loggingDb =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||
.build()
|
||||
val loggingDb = createTestDatabase(LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it })
|
||||
try {
|
||||
loggingDb.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
|
||||
@@ -3,9 +3,7 @@ package com.subtracks.playback
|
||||
import android.content.Context
|
||||
import android.content.res.Resources
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.R
|
||||
@@ -14,6 +12,7 @@ import com.subtracks.UiMessage
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Album
|
||||
@@ -83,11 +82,7 @@ class PlaybackControllerTest {
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
resources = context.resources
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
prefs = fakeUserPreferences()
|
||||
networkMode = MutableStateFlow(NetworkMode.Wifi)
|
||||
sources =
|
||||
|
||||
@@ -40,12 +40,14 @@ import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.ListQuery
|
||||
import com.subtracks.data.repo.SEARCH_RESULT_LIMIT
|
||||
import com.subtracks.data.repo.normalizeBiography
|
||||
import com.subtracks.data.source.subsonic.SubsonicSource
|
||||
import com.subtracks.data.source.subsonic.TestServer
|
||||
import com.subtracks.data.source.subsonic.TestServers
|
||||
import com.subtracks.data.sync.SyncService
|
||||
import com.subtracks.playback.PlaybackState
|
||||
import com.subtracks.playback.QueueItem
|
||||
import com.subtracks.ui.components.ArtistArt
|
||||
import com.subtracks.ui.home.HomeFeed
|
||||
import com.subtracks.ui.home.HomeScreen
|
||||
import com.subtracks.ui.library.AlbumDetailScreen
|
||||
@@ -82,11 +84,15 @@ import javax.imageio.ImageIO
|
||||
* by `:*UnitTest` or the integration suite; run it explicitly with `gradle :app:demoScreenshots`.
|
||||
* The synced database is rebuilt each run; downloaded cover art is cached under the system temp dir
|
||||
* so reruns do not re-fetch it.
|
||||
*
|
||||
* The device is a 393x786 dp phone at 440 dpi, which renders 1080x2161. A capture one pixel over
|
||||
* 1080x2160 breaks the stores' 2:1 limit, so the captures are cropped to 1080x2160 afterwards, with
|
||||
* the last row taken from the row above it.
|
||||
*/
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(qualifiers = RobolectricDeviceQualifiers.PixelXL)
|
||||
@Config(qualifiers = "w393dp-h786dp-normal-long-notround-any-440dpi-keyshidden-nonav")
|
||||
class DemoScreenshotTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
@@ -123,6 +129,7 @@ class DemoScreenshotTest {
|
||||
HomeScreen(
|
||||
feed = demo.feed,
|
||||
coverArt = demo.coverArt,
|
||||
artistArt = demo::artistArt,
|
||||
playingSongId = demo.homePlayingSong.id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -178,6 +185,7 @@ class DemoScreenshotTest {
|
||||
ArtistDetailScreen(
|
||||
artist = demo.artist,
|
||||
artistName = demo.artist.name,
|
||||
bio = demo.artistBio,
|
||||
albums =
|
||||
ArtistAlbums(
|
||||
albumCredits =
|
||||
@@ -216,6 +224,7 @@ class DemoScreenshotTest {
|
||||
onSync = {},
|
||||
onOpenSettings = {},
|
||||
artwork = demo.artwork(demo.nowPlayingAlbum.coverArt),
|
||||
artistArt = demo::artistArt,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
demo.MiniPlayerOverlay(demo.nowPlayingSong, demo.nowPlayingAlbum, progressInset = true)
|
||||
@@ -228,6 +237,7 @@ class DemoScreenshotTest {
|
||||
onQueryChange = {},
|
||||
results = demo.search,
|
||||
coverArt = demo.coverArt,
|
||||
artistArt = demo::artistArt,
|
||||
playingSongId = demo.nowPlayingSong.id,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
@@ -288,7 +298,7 @@ class DemoScreenshotTest {
|
||||
|
||||
private fun reencodeOpaqueRgb(file: File) {
|
||||
val decoded = ImageIO.read(file)
|
||||
val rgb = BufferedImage(decoded.width, decoded.height, BufferedImage.TYPE_INT_RGB)
|
||||
val rgb = BufferedImage(decoded.width, decoded.width * 2, BufferedImage.TYPE_INT_RGB)
|
||||
rgb.createGraphics().run {
|
||||
drawImage(decoded, 0, 0, null)
|
||||
dispose()
|
||||
@@ -329,6 +339,7 @@ class DemoScreenshotTest {
|
||||
val homePlayingAlbum: Album,
|
||||
val homePlayingSong: Song,
|
||||
val artist: Artist,
|
||||
val artistBio: String?,
|
||||
val artistAlbums: List<Album>,
|
||||
val search: SearchResults,
|
||||
val coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
@@ -371,6 +382,15 @@ class DemoScreenshotTest {
|
||||
return artworkColorsFromSeeds(primary?.rgb ?: FALLBACK_SEED, null)
|
||||
}
|
||||
|
||||
fun artistArt(artist: Artist): ArtistArt {
|
||||
val thumbnail = artist.coverArt?.let { coverArt(it, true) }
|
||||
return ArtistArt(
|
||||
image = thumbnail,
|
||||
thumbnail = thumbnail,
|
||||
covers = artist.coverArts.take(4).mapNotNull { coverArt(it, true) },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val FALLBACK_SEED = 0xFF3A7BD5.toInt()
|
||||
private const val DEMO_BASE = "https://demo.navidrome.org/"
|
||||
@@ -417,9 +437,11 @@ class DemoScreenshotTest {
|
||||
val homePlayingAlbum = allAlbums.firstOrNull { it.name == "Shaking The Habitual" } ?: allAlbums.last()
|
||||
val homePlayingSong = songsOf(homePlayingAlbum).first()
|
||||
val artist =
|
||||
allArtists.firstOrNull { it.name == "Ugress" }
|
||||
allArtists.firstOrNull { it.name == "Nine Inch Nails" }
|
||||
?: allArtists.firstOrNull { it.name == "pornophonique" }
|
||||
?: allArtists.maxByOrNull { it.albumCount }
|
||||
?: error("demo library has no artists")
|
||||
val artistBio = normalizeBiography(source.artistBiography(artist.id))
|
||||
val artistAlbums = dao.albumsForArtist(1, artist.id).first()
|
||||
|
||||
val coverArtIds =
|
||||
@@ -509,6 +531,7 @@ class DemoScreenshotTest {
|
||||
homePlayingAlbum = homePlayingAlbum,
|
||||
homePlayingSong = homePlayingSong,
|
||||
artist = artist,
|
||||
artistBio = artistBio,
|
||||
artistAlbums = artistAlbums.filter { it.coverArt != null },
|
||||
search = search,
|
||||
coverArt = coverArt,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.subtracks.ui
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ViewModelRepositoryFixture {
|
||||
val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
init {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
}
|
||||
|
||||
val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val db = createTestDatabase()
|
||||
val artworkStore = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
val downloadEngine = FakeDownloadEngine()
|
||||
val downloadsDir = File(context.cacheDir, "downloads-${System.nanoTime()}")
|
||||
val sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artworkStore, scope = repoScope)
|
||||
val downloadRepository =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
downloadEngine,
|
||||
downloadsDir,
|
||||
artworkStore = artworkStore,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
)
|
||||
val queueRepository = QueueRepository(db)
|
||||
val controller =
|
||||
PlaybackController(
|
||||
sourceRepository,
|
||||
queueRepository,
|
||||
FakePlayerConnection(FakePlayerHandle()),
|
||||
downloadRepository,
|
||||
scope = controllerScope,
|
||||
)
|
||||
val libraryRepository =
|
||||
LibraryRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
)
|
||||
|
||||
fun close() {
|
||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
dispatcher.close()
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,13 @@ import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.BulkDownloadAction
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -55,6 +60,115 @@ class DeleteDownloadsDialogTest {
|
||||
composeRule.onNodeWithText("512 B", substring = true).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedWorkflowLooksUpBytesAndConfirmsTheRequestedTargetOnce() {
|
||||
val actions = mutableListOf<Triple<DownloadList, String, BulkDownloadAction>>()
|
||||
lateinit var controller: PendingDownloadDeleteController
|
||||
renderWorkflow(
|
||||
downloadedBytes = { list, refId ->
|
||||
assertEquals(DownloadList.Album, list)
|
||||
assertEquals("al1", refId)
|
||||
1_500_000
|
||||
},
|
||||
onAction = { list, refId, action -> actions += Triple(list, refId, action) },
|
||||
onController = { controller = it },
|
||||
)
|
||||
|
||||
composeRule.runOnIdle {
|
||||
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||
}
|
||||
composeRule.waitUntil { controller.pending != null }
|
||||
composeRule.onNodeWithText("Kid A", substring = true).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("MB", substring = true).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Delete").performClick()
|
||||
|
||||
composeRule.runOnIdle {
|
||||
assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Delete)), actions)
|
||||
assertNull(controller.pending)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedWorkflowCancelDoesNotConfirm() {
|
||||
val actions = mutableListOf<BulkDownloadAction>()
|
||||
lateinit var controller: PendingDownloadDeleteController
|
||||
renderWorkflow(
|
||||
onAction = { _, _, action -> actions += action },
|
||||
onController = { controller = it },
|
||||
)
|
||||
|
||||
composeRule.runOnIdle {
|
||||
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||
}
|
||||
composeRule.waitUntil { controller.pending != null }
|
||||
composeRule.onNodeWithText("Cancel").performClick()
|
||||
|
||||
composeRule.runOnIdle {
|
||||
assertEquals(emptyList<BulkDownloadAction>(), actions)
|
||||
assertNull(controller.pending)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedWorkflowDismissesWithTheContextMenu() {
|
||||
val dismissals = MutableSharedFlow<Unit>(replay = 1)
|
||||
lateinit var controller: PendingDownloadDeleteController
|
||||
renderWorkflow(dismissals = dismissals, onController = { controller = it })
|
||||
|
||||
composeRule.runOnIdle {
|
||||
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Delete) { _, _, _ -> }
|
||||
}
|
||||
composeRule.waitUntil { controller.pending != null }
|
||||
composeRule.runOnIdle { dismissals.tryEmit(Unit) }
|
||||
|
||||
composeRule.waitUntil { controller.pending == null }
|
||||
composeRule.onNodeWithText("Delete").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sharedWorkflowDispatchesNonDeleteActionsImmediately() {
|
||||
val actions = mutableListOf<Triple<DownloadList, String, BulkDownloadAction>>()
|
||||
var lookups = 0
|
||||
lateinit var controller: PendingDownloadDeleteController
|
||||
renderWorkflow(
|
||||
downloadedBytes = { _, _ ->
|
||||
lookups++
|
||||
0
|
||||
},
|
||||
onAction = { list, refId, action -> actions += Triple(list, refId, action) },
|
||||
onController = { controller = it },
|
||||
)
|
||||
|
||||
composeRule.runOnIdle {
|
||||
controller.onAction("Kid A", DownloadList.Album, "al1", BulkDownloadAction.Download) { list, refId, action ->
|
||||
actions += Triple(list, refId, action)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.runOnIdle {
|
||||
assertEquals(listOf(Triple(DownloadList.Album, "al1", BulkDownloadAction.Download)), actions)
|
||||
assertEquals(0, lookups)
|
||||
assertNull(controller.pending)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderWorkflow(
|
||||
dismissals: kotlinx.coroutines.flow.Flow<Unit> = emptyFlow(),
|
||||
downloadedBytes: suspend (DownloadList, String) -> Long = { _, _ -> 0 },
|
||||
onAction: (DownloadList, String, BulkDownloadAction) -> Unit = { _, _, _ -> },
|
||||
onController: (PendingDownloadDeleteController) -> Unit,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
val controller = rememberPendingDownloadDelete(dismissals, downloadedBytes)
|
||||
onController(controller)
|
||||
PendingDownloadDeleteConfirmation(controller) { pending ->
|
||||
onAction(pending.list, pending.refId, BulkDownloadAction.Delete)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun render(
|
||||
bytes: Long = 0,
|
||||
onConfirm: () -> Unit = {},
|
||||
|
||||
@@ -1,40 +1,16 @@
|
||||
package com.subtracks.ui.downloads
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import com.subtracks.ui.ViewModelRepositoryFixture
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -42,61 +18,23 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DownloadsViewModelTest {
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var sourceRepository: SourceRepository
|
||||
private lateinit var downloadRepository: DownloadRepository
|
||||
private lateinit var controller: PlaybackController
|
||||
private lateinit var fixture: ViewModelRepositoryFixture
|
||||
private val db get() = fixture.db
|
||||
private val sourceRepository get() = fixture.sourceRepository
|
||||
private val downloadRepository get() = fixture.downloadRepository
|
||||
private val controller get() = fixture.controller
|
||||
private lateinit var viewModel: DownloadsViewModel
|
||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
private var sourceId: Long = 0
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||
downloadRepository =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
FakeDownloadEngine(),
|
||||
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||
artworkStore = artwork,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
)
|
||||
controller =
|
||||
PlaybackController(
|
||||
sourceRepository,
|
||||
QueueRepository(db),
|
||||
FakePlayerConnection(FakePlayerHandle()),
|
||||
downloadRepository,
|
||||
scope = controllerScope,
|
||||
)
|
||||
fixture = ViewModelRepositoryFixture()
|
||||
viewModel =
|
||||
DownloadsViewModel(
|
||||
LibraryRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
),
|
||||
fixture.libraryRepository,
|
||||
downloadRepository,
|
||||
controller,
|
||||
"Unknown artist",
|
||||
@@ -107,10 +45,7 @@ class DownloadsViewModelTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
|
||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
dispatcher.close()
|
||||
fixture.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -5,14 +5,13 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -81,11 +80,7 @@ class NowPlayingRouteTest {
|
||||
private fun setUpState() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val artworkStore = ArtworkStore(File(context.cacheDir, "art"))
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
val sources = SourceRepository(db, OkHttpClient(), prefs, artworkStore, scope = repoScope)
|
||||
val queues = QueueRepository(db)
|
||||
downloads =
|
||||
|
||||
@@ -2,14 +2,13 @@ package com.subtracks.ui.playback
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
@@ -68,11 +67,7 @@ class QueueViewModelTest {
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
sources =
|
||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = repoScope)
|
||||
queues = QueueRepository(db)
|
||||
|
||||
@@ -1,43 +1,19 @@
|
||||
package com.subtracks.ui.search
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.FakePlayerConnection
|
||||
import com.subtracks.playback.FakePlayerHandle
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import com.subtracks.ui.ViewModelRepositoryFixture
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -45,70 +21,24 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SearchViewModelTest {
|
||||
private val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var sourceRepository: SourceRepository
|
||||
private lateinit var fixture: ViewModelRepositoryFixture
|
||||
private val db get() = fixture.db
|
||||
private val sourceRepository get() = fixture.sourceRepository
|
||||
private lateinit var viewModel: SearchViewModel
|
||||
private val repoScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val controllerScope = CoroutineScope(SupervisorJob() + dispatcher)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||
val downloads =
|
||||
DownloadRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
FakeDownloadEngine(),
|
||||
File(context.cacheDir, "downloads-${System.nanoTime()}"),
|
||||
artworkStore = artwork,
|
||||
artworkFetcher = { ByteArray(0) },
|
||||
scope = repoScope,
|
||||
)
|
||||
val controller =
|
||||
PlaybackController(
|
||||
sourceRepository,
|
||||
QueueRepository(db),
|
||||
FakePlayerConnection(FakePlayerHandle()),
|
||||
downloads,
|
||||
scope = controllerScope,
|
||||
)
|
||||
viewModel =
|
||||
SearchViewModel(
|
||||
LibraryRepository(
|
||||
db,
|
||||
sourceRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
),
|
||||
sourceRepository,
|
||||
controller,
|
||||
)
|
||||
fixture = ViewModelRepositoryFixture()
|
||||
viewModel = SearchViewModel(fixture.libraryRepository, sourceRepository, fixture.controller)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
runBlocking { viewModel.viewModelScope.coroutineContext[Job]?.cancelAndJoin() }
|
||||
cancelAndJoinBlocking(controllerScope, repoScope)
|
||||
db.close()
|
||||
Dispatchers.resetMain()
|
||||
dispatcher.close()
|
||||
fixture.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,13 +2,12 @@ package com.subtracks.ui.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.db.createTestDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
@@ -57,11 +56,7 @@ class AddSourceViewModelTest {
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(dispatcher)
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
db = createTestDatabase()
|
||||
val artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, scope = repoScope)
|
||||
downloadRepository =
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
## What's new
|
||||
|
||||
- ReplayGain: normalizes loudness from the tags your server reports. Auto, the default, uses album gain for album queues and track gain for playlists, singles and shuffles. Playback settings also offer track gain, album gain, off and a preamp
|
||||
- Artist pages show an expandable biography, artwork from the server or built from the artist's albums, and separate sections for album credits and appearances
|
||||
- Sort lists can use regular names (the default), sort names, or sort names only for names without Latin letters
|
||||
- Improved handling for albums and tracks with multiple artists, including their names in song subtitles, the queue and info dialogs
|
||||
- Info dialogs in album, artist and playlist menus, listing credited artists, sort names, owner and sharing, and play and download details
|
||||
- Keep the screen on while now playing is open setting, off by default (thanks @chiangww!)
|
||||
|
||||
## Fixed
|
||||
|
||||
- Album and artist scroll positions survive navigating away and back
|
||||
- Search keeps its query and focus when you return
|
||||
- The library tab strip scrolls, so the settings gear is never squeezed out
|
||||
- Notification progress for streams of unknown length, such as Opus/Ogg transcodes
|
||||
- Album grid no longer shifts covers after a scrobble reloads the list
|
||||
- Now playing screen: artwork wraps with repeat, and cover shadows are no longer clipped
|
||||
|
||||
## Performance
|
||||
|
||||
- Faster library sync on servers without `search3`
|
||||
- Artist lists and downloads stay fast on large libraries
|
||||
|
||||
**Full changelog:** https://github.com/austinried/subtracks/compare/v3.0.0...v3.1.0
|
||||
@@ -9,3 +9,5 @@ android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
kotlin.code.style=official
|
||||
|
||||
mavenCentralMirrorUrl=https://maven-central.storage-download.googleapis.com/maven2/
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
- ReplayGain: loudness from server tags, with Auto, track, album, off, preamp
|
||||
- Artist pages: bio, art, credits and appearances
|
||||
- Sort lists: regular names or sort names
|
||||
- Improved handling for multi-artist albums/tracks
|
||||
- Keep the screen on while now playing is open setting, off by default (thanks @chiangww!)
|
||||
|
||||
Fixed
|
||||
|
||||
- Notification progress for unknown-length streams (Opus/Ogg)
|
||||
- Now playing artwork and shadow fixes
|
||||
|
||||
Release notes: https://github.com/austinried/subtracks/releases/tag/v3.1.0
|
||||
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 513 KiB After Width: | Height: | Size: 577 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 2.6 MiB |
|
Before Width: | Height: | Size: 469 KiB After Width: | Height: | Size: 421 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 371 KiB |
@@ -1,4 +1,5 @@
|
||||
pluginManagement {
|
||||
val mavenCentralMirrorUrl = providers.gradleProperty("mavenCentralMirrorUrl").get()
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
@@ -7,6 +8,9 @@ pluginManagement {
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
maven(mavenCentralMirrorUrl) {
|
||||
name = "MavenCentralMirror"
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
@@ -16,6 +20,9 @@ dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
maven(providers.gradleProperty("mavenCentralMirrorUrl").get()) {
|
||||
name = "MavenCentralMirror"
|
||||
}
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,11 +12,15 @@ export const LIB = ".integration/music"
|
||||
export const ports = [$NAV_PORT $GONIC_PORT $LMS_PORT $NC_PORT $PRUNE_PORT]
|
||||
|
||||
const MUSIC = [
|
||||
{ id: 197, name: "Ugress - Retroconnaissance EP", songs: 4 }
|
||||
{ id: 199, name: "Ugress - Kosmonaut", songs: 4 }
|
||||
{ id: 321, name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
|
||||
{ id: "7GZYpfn8wypaTR00LWeNI4", name: "Ugress - Retroconnaissance", songs: 4 }
|
||||
{ id: "5gMe1SlZZzuOMkRVWJaodV", name: "Ugress - Kosmonaut", songs: 4 }
|
||||
{ id: "0JnRLI5lo4eNU1pOePqfcG", name: "Brad Sucks - I Don't Know What I'm Doing", songs: 12 }
|
||||
]
|
||||
|
||||
const MUSIC_DOWNLOAD = "https://demo.navidrome.org/rest/download.view?u=demo&p=demo&v=1.16.1&c=subtracks-test&id="
|
||||
|
||||
const MUSIC_ART = "https://demo.navidrome.org/rest/getCoverArt.view?u=demo&p=demo&v=1.16.1&c=subtracks-test&id="
|
||||
|
||||
# Keep in step with TestServers.kt.
|
||||
export def test-servers [] {
|
||||
[
|
||||
@@ -33,17 +37,18 @@ def song-count [dir: string] {
|
||||
}
|
||||
|
||||
export def ensure-music [] {
|
||||
if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs }) {
|
||||
if ($MUSIC | all {|m| (song-count $"($LIB)/($m.id)") == $m.songs and ($"($LIB)/($m.id)/folder.jpg" | path exists) }) {
|
||||
print $"using test music in ($LIB)"
|
||||
return
|
||||
}
|
||||
print "downloading test music from demo.subsonic.org (one-time)"
|
||||
print "downloading test music from demo.navidrome.org (one-time)"
|
||||
mkdir $LIB
|
||||
let tmp = (^mktemp -d | str trim)
|
||||
for m in $MUSIC {
|
||||
print $"($m.name)..."
|
||||
^curl -fsS -o $"($tmp)/($m.id).zip" $"http://demo.subsonic.org/rest/download.view?u=guest1&p=guest&v=1.13.0&c=subtracks-test&id=($m.id)"
|
||||
^unzip -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)"
|
||||
^curl -fsS -o $"($tmp)/($m.id).zip" $"($MUSIC_DOWNLOAD)($m.id)"
|
||||
^unzip -j -q -o $"($tmp)/($m.id).zip" -d $"($LIB)/($m.id)"
|
||||
^curl -fsS -o $"($LIB)/($m.id)/folder.jpg" $"($MUSIC_ART)($m.id)"
|
||||
}
|
||||
rm -rf $tmp
|
||||
}
|
||||
@@ -163,8 +168,9 @@ export def start-prune [work: string] {
|
||||
let music = ($dir | path join "music")
|
||||
mkdir ($dir | path join "data")
|
||||
mkdir $music
|
||||
^cp -r $"((pwd) | path join $LIB)/197" $music
|
||||
^cp -r $"((pwd) | path join $LIB)/199" $music
|
||||
for m in ($MUSIC | first 2) {
|
||||
^cp -r $"((pwd) | path join $LIB)/($m.id)" $music
|
||||
}
|
||||
|
||||
let daemon = (job spawn { ^navidrome --address 127.0.0.1 --port $PRUNE_PORT --musicfolder $music --datafolder ($dir | path join "data") --loglevel warn out+err> ($dir | path join "navidrome.log") })
|
||||
let ready = (try {
|
||||
@@ -198,12 +204,12 @@ export def prepare-fixtures [server: record] {
|
||||
|
||||
let albums = (poll "albums" {||
|
||||
let items = (sub-get $base $user $pass "getAlbumList2" [["type" "newest"]] | get albumList2.album)
|
||||
if (["Kosmonaut" "Retroconnaissance EP" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null }
|
||||
if (["Kosmonaut" "Retroconnaissance" "I Don't Know What I'm Doing"] | all {|name| $items | any {|i| $i.name == $name } }) { $items } else { null }
|
||||
})
|
||||
|
||||
let ugress = (named-id $artists "Ugress")
|
||||
let kosmo = (named-id $albums "Kosmonaut")
|
||||
let retro = (named-id $albums "Retroconnaissance EP")
|
||||
let retro = (named-id $albums "Retroconnaissance")
|
||||
let dunno = (named-id $albums "I Don't Know What I'm Doing")
|
||||
|
||||
poll "album songs" {||
|
||||
|
||||