index the album, artist and playlist list sorts

This commit is contained in:
austinried
2026-10-04 00:29:59 +00:00
parent f05a1c79a8
commit d703b8ec5b
10 changed files with 1618 additions and 136 deletions
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,48 @@ internal const val PLAYLIST_SONGS_SQL =
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
"ORDER BY playlist_songs.position"
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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) "
internal const val ALBUM_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ARTIST = "albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ARTIST_REVERSED = "albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_YEAR = "year DESC, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_YEAR_REVERSED = "year ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ALBUM_ORDER_BY_ADDED = "created DESC, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC"
// Starred orders keep unstarred rows last in both directions, matching the list grouping, so the
// reversed order is not a literal mirror of the base order.
internal const val ALBUM_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ALBUM_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
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 (:search = '' OR instr(lower(name), lower(:search)) > 0) "
internal const val ARTIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT = "albumCount DESC, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED = "albumCount ASC, name COLLATE NOCASE DESC, id DESC"
internal const val ARTIST_ORDER_BY_STARRED = "starred DESC NULLS LAST, name COLLATE NOCASE, id"
internal const val ARTIST_ORDER_BY_STARRED_REVERSED = "starred ASC NULLS LAST, name COLLATE NOCASE DESC, id DESC"
internal const val PLAYLISTS_FILTER =
"FROM playlists WHERE sourceId = :sourceId " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) "
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"
internal const val PLAYLIST_ORDER_BY_ADDED_REVERSED = "created ASC, name COLLATE NOCASE DESC, id DESC"
internal const val PLAYLIST_ORDER_BY_UPDATED = "changed DESC, name COLLATE NOCASE, id"
internal const val PLAYLIST_ORDER_BY_UPDATED_REVERSED = "changed ASC, name COLLATE NOCASE DESC, id DESC"
@Dao
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
interface LibraryDao {
@@ -155,253 +197,149 @@ interface LibraryDao {
)
suspend fun deleteOrphanPlaylistSongs(sourceId: Long)
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY name COLLATE NOCASE, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
fun albumsByName(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY name COLLATE NOCASE DESC, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
fun albumsByNameReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY albumArtist COLLATE NOCASE, year, name COLLATE NOCASE, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
fun albumsByArtist(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY albumArtist COLLATE NOCASE DESC, year DESC, name COLLATE NOCASE DESC, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
fun albumsByArtistReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY year DESC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
fun albumsByYear(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY year ASC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
fun albumsByYearReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY created DESC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
fun albumsByRecentlyAdded(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY created ASC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
fun albumsByRecentlyAddedReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
fun albumsByStarred(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * 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 instr(lower(name), lower(:search)) > 0 OR instr(lower(albumArtist), lower(:search)) > 0) " +
"ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
fun albumsByStarredReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Album>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY name COLLATE NOCASE, id",
)
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
fun artistsByName(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY name COLLATE NOCASE DESC, id",
)
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
fun artistsByNameReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY albumCount DESC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
fun artistsByAlbumCount(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY albumCount ASC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
fun artistsByAlbumCountReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY starred IS NULL, starred DESC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
fun artistsByStarred(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM artists WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY starred IS NULL, starred ASC, name COLLATE NOCASE, id",
)
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
fun artistsByStarredReversed(
sourceId: Long,
starredFilter: Int,
search: String,
): PagingSource<Int, Artist>
@Query(
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY name COLLATE NOCASE, id",
)
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
fun playlistsByName(
sourceId: Long,
search: String,
): PagingSource<Int, Playlist>
@Query(
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY name COLLATE NOCASE DESC, id",
)
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
fun playlistsByNameReversed(
sourceId: Long,
search: String,
): PagingSource<Int, Playlist>
@Query(
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY created DESC, name COLLATE NOCASE, id",
)
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
fun playlistsByAdded(
sourceId: Long,
search: String,
): PagingSource<Int, Playlist>
@Query(
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY created ASC, name COLLATE NOCASE, id",
)
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
fun playlistsByAddedReversed(
sourceId: Long,
search: String,
): PagingSource<Int, Playlist>
@Query(
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY changed DESC, name COLLATE NOCASE, id",
)
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
fun playlistsByUpdated(
sourceId: Long,
search: String,
): PagingSource<Int, Playlist>
@Query(
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
"ORDER BY changed ASC, name COLLATE NOCASE, id",
)
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
fun playlistsByUpdatedReversed(
sourceId: Long,
search: String,
@@ -135,6 +135,87 @@ val MIGRATION_13_14 =
}
}
val MIGRATION_14_15 =
object : Migration(14, 15) {
override suspend fun migrate(connection: SQLiteConnection) {
rebuildTable(connection, "albums", ALBUMS_V14, ALBUMS_V14_COPY, ALBUMS_V14_INDICES)
rebuildTable(connection, "artists", ARTISTS_V14, ARTISTS_V14_COPY, ARTISTS_V14_INDICES)
rebuildTable(connection, "playlists", PLAYLISTS_V14, PLAYLISTS_V14_COPY, PLAYLISTS_V14_INDICES)
}
}
private suspend fun rebuildTable(
connection: SQLiteConnection,
table: String,
createSql: String,
copySql: String,
indices: List<String>,
) {
val temp = "${table}_new"
connection.execSQL(createSql.replace("\${TABLE_NAME}", temp))
connection.execSQL(copySql.replace("\${TABLE_NAME}", temp))
connection.execSQL("DROP TABLE `$table`")
connection.execSQL("ALTER TABLE `$temp` RENAME TO `$table`")
indices.forEach { connection.execSQL(it.replace("\${TABLE_NAME}", table)) }
}
private const val ALBUMS_V14 =
"CREATE TABLE IF NOT EXISTS `\${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, `artistId` TEXT, " +
"`name` TEXT NOT NULL COLLATE NOCASE, `albumArtist` TEXT COLLATE NOCASE, `created` INTEGER NOT NULL, " +
"`coverArt` TEXT, `genre` TEXT, `year` INTEGER, `starred` INTEGER, `songCount` INTEGER NOT NULL, " +
"PRIMARY KEY(`sourceId`, `id`), " +
"FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )"
private const val ALBUMS_V14_COPY =
"INSERT INTO `\${TABLE_NAME}` (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount) " +
"SELECT sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, starred, songCount FROM `albums`"
private val ALBUMS_V14_INDICES =
listOf(
"CREATE INDEX IF NOT EXISTS `index_albums_sourceId` ON `\${TABLE_NAME}` (`sourceId`)",
"CREATE INDEX IF NOT EXISTS `index_albums_sourceId_artistId` ON `\${TABLE_NAME}` (`sourceId`, `artistId`)",
"CREATE INDEX IF NOT EXISTS `index_albums_name` ON `\${TABLE_NAME}` (`sourceId`, `name`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_albums_artist` ON `\${TABLE_NAME}` (`sourceId`, `albumArtist`, `year`, `name`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_albums_year` ON `\${TABLE_NAME}` (`sourceId` ASC, `year` DESC, `name` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_added` ON `\${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_albums_starred` ON `\${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)",
)
private const val ARTISTS_V14 =
"CREATE TABLE IF NOT EXISTS `\${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, " +
"`name` TEXT NOT NULL COLLATE NOCASE, `albumCount` INTEGER NOT NULL, `starred` INTEGER, `coverArt` TEXT, " +
"PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )"
private const val ARTISTS_V14_COPY =
"INSERT INTO `\${TABLE_NAME}` (sourceId, id, name, albumCount, starred, coverArt) " +
"SELECT sourceId, id, name, albumCount, starred, coverArt FROM `artists`"
private val ARTISTS_V14_INDICES =
listOf(
"CREATE INDEX IF NOT EXISTS `index_artists_sourceId` ON `\${TABLE_NAME}` (`sourceId`)",
"CREATE INDEX IF NOT EXISTS `index_artists_name` ON `\${TABLE_NAME}` (`sourceId`, `name`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_artists_albumCount` ON `\${TABLE_NAME}` (`sourceId` ASC, `albumCount` DESC, `name` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_artists_starred` ON `\${TABLE_NAME}` (`sourceId` ASC, `starred` DESC, `name` ASC, `id` ASC)",
)
private const val PLAYLISTS_V14 =
"CREATE TABLE IF NOT EXISTS `\${TABLE_NAME}` (`sourceId` INTEGER NOT NULL, `id` TEXT NOT NULL, " +
"`name` TEXT NOT NULL COLLATE NOCASE, `comment` TEXT, `coverArt` TEXT, `songCount` INTEGER NOT NULL, " +
"`created` INTEGER NOT NULL, `changed` INTEGER NOT NULL DEFAULT 0, `duration` INTEGER NOT NULL DEFAULT 0, " +
"PRIMARY KEY(`sourceId`, `id`), FOREIGN KEY(`sourceId`) REFERENCES `sources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )"
private const val PLAYLISTS_V14_COPY =
"INSERT INTO `\${TABLE_NAME}` (sourceId, id, name, comment, coverArt, songCount, created, changed, duration) " +
"SELECT sourceId, id, name, comment, coverArt, songCount, created, changed, duration FROM `playlists`"
private val PLAYLISTS_V14_INDICES =
listOf(
"CREATE INDEX IF NOT EXISTS `index_playlists_sourceId` ON `\${TABLE_NAME}` (`sourceId`)",
"CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `\${TABLE_NAME}` (`sourceId`, `name`, `id`)",
"CREATE INDEX IF NOT EXISTS `index_playlists_added` ON `\${TABLE_NAME}` (`sourceId` ASC, `created` DESC, `name` ASC, `id` ASC)",
"CREATE INDEX IF NOT EXISTS `index_playlists_updated` ON `\${TABLE_NAME}` (`sourceId` ASC, `changed` DESC, `name` ASC, `id` ASC)",
)
val MIGRATIONS: Array<Migration> =
arrayOf(
MIGRATION_1_2,
@@ -150,4 +231,5 @@ val MIGRATIONS: Array<Migration> =
MIGRATION_11_12,
MIGRATION_12_13,
MIGRATION_13_14,
MIGRATION_14_15,
)
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
ShuffleOrder::class,
ArtworkSeed::class,
],
version = 14,
version = 15,
exportSchema = true,
)
@ColumnTypeConverters(QueueKindConverter::class)
@@ -48,12 +48,25 @@ data class SubsonicSource(
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId")],
indices = [
Index("sourceId"),
Index(name = "index_artists_name", value = ["sourceId", "name", "id"]),
Index(
name = "index_artists_albumCount",
value = ["sourceId", "albumCount", "name", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_artists_starred",
value = ["sourceId", "starred", "name", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
)
data class Artist(
val sourceId: Long,
val id: String,
val name: String,
@ColumnInfo(collate = ColumnInfo.NOCASE) val name: String,
val albumCount: Long,
val starred: Long?,
val coverArt: String? = null,
@@ -70,14 +83,34 @@ data class Artist(
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId"), Index("sourceId", "artistId")],
indices = [
Index("sourceId"),
Index("sourceId", "artistId"),
Index(name = "index_albums_name", value = ["sourceId", "name", "id"]),
Index(name = "index_albums_artist", value = ["sourceId", "albumArtist", "year", "name", "id"]),
Index(
name = "index_albums_year",
value = ["sourceId", "year", "name", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_added",
value = ["sourceId", "created", "name", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_albums_starred",
value = ["sourceId", "starred", "name", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
)
data class Album(
val sourceId: Long,
val id: String,
val artistId: String?,
val name: String,
val albumArtist: String?,
@ColumnInfo(collate = ColumnInfo.NOCASE) val name: String,
@ColumnInfo(collate = ColumnInfo.NOCASE) val albumArtist: String?,
val created: Long,
val coverArt: String?,
val genre: String?,
@@ -118,12 +151,25 @@ data class Disc(
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index("sourceId")],
indices = [
Index("sourceId"),
Index(name = "index_playlists_name", value = ["sourceId", "name", "id"]),
Index(
name = "index_playlists_added",
value = ["sourceId", "created", "name", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
Index(
name = "index_playlists_updated",
value = ["sourceId", "changed", "name", "id"],
orders = [Index.Order.ASC, Index.Order.DESC, Index.Order.ASC, Index.Order.ASC],
),
],
)
data class Playlist(
val sourceId: Long,
val id: String,
val name: String,
@ColumnInfo(collate = ColumnInfo.NOCASE) val name: String,
val comment: String?,
val coverArt: String?,
val songCount: Long,
@@ -285,7 +285,7 @@ private suspend fun <T> PooledConnection.upsertChanged(
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
updates.joinToString { "$it = excluded.$it" } +
" WHERE " +
updates.joinToString(" OR ") { "$table.$it IS NOT excluded.$it" }
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) }
@@ -3,16 +3,21 @@ package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
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.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -108,6 +113,192 @@ class LibraryDaoTest {
)
}
@Test
fun starredSortsKeepUnstarredLastInBothDirections() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "a1", "Alpha", year = null, starred = 100),
album(sourceId, "a2", "Beta", year = null, starred = 200),
album(sourceId, "a3", "Gamma", year = null, starred = null),
album(sourceId, "a4", "Delta", year = null, starred = null),
),
)
dao.upsertArtists(
listOf(
artist(sourceId, "r1", "Alpha", albumCount = 1).copy(starred = 100),
artist(sourceId, "r2", "Beta", albumCount = 1).copy(starred = 200),
artist(sourceId, "r3", "Gamma", albumCount = 1).copy(starred = null),
artist(sourceId, "r4", "Delta", albumCount = 1).copy(starred = null),
),
)
assertEquals(
listOf("Beta", "Alpha", "Delta", "Gamma"),
dao.albumsByStarred(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta", "Gamma", "Delta"),
dao.albumsByStarredReversed(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Beta", "Alpha", "Delta", "Gamma"),
dao.artistsByStarred(sourceId, 0, "").page().map { it.name },
)
assertEquals(
listOf("Alpha", "Beta", "Gamma", "Delta"),
dao.artistsByStarredReversed(sourceId, 0, "").page().map { it.name },
)
}
@Test
fun reversedAlbumSortsReverseEveryTiebreaker() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertAlbums(
listOf(
album(sourceId, "a1", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Zed"),
album(sourceId, "a2", "Same", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"),
album(sourceId, "a3", "Same", year = 1990, starred = null, created = 200).copy(albumArtist = "Amy"),
album(sourceId, "a4", "Other", year = 2000, starred = null, created = 100).copy(albumArtist = "Amy"),
),
)
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, ""),
)
for ((base, reversed) in pairs) {
val ids = base.page().map { it.id }
assertEquals(ids.reversed(), reversed.page().map { it.id })
}
}
@Test
fun reversedArtistAndPlaylistSortsReverseEveryTiebreaker() =
runTest {
val sourceId = source()
val dao = db.libraryDao()
dao.upsertArtists(
listOf(
artist(sourceId, "r1", "Same", albumCount = 2),
artist(sourceId, "r2", "Other", albumCount = 1),
artist(sourceId, "r3", "Same", albumCount = 1),
),
)
dao.upsertPlaylists(
listOf(
playlist(sourceId, "l1", "Same", created = 100, changed = 100),
playlist(sourceId, "l2", "Other", created = 100, changed = 200),
playlist(sourceId, "l3", "Same", created = 200, changed = 100),
),
)
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 },
)
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 },
)
for ((base, reversed) in artistPairs + playlistPairs) {
assertEquals(base.reversed(), reversed)
}
}
@Test
fun albumOrdersAreIndexBacked() =
runTest {
val orders =
listOf(
ALBUM_ORDER_BY_NAME to "index_albums_name",
ALBUM_ORDER_BY_NAME_REVERSED to "index_albums_name",
ALBUM_ORDER_BY_ARTIST to "index_albums_artist",
ALBUM_ORDER_BY_ARTIST_REVERSED to "index_albums_artist",
ALBUM_ORDER_BY_YEAR to "index_albums_year",
ALBUM_ORDER_BY_YEAR_REVERSED to "index_albums_year",
ALBUM_ORDER_BY_ADDED to "index_albums_added",
ALBUM_ORDER_BY_ADDED_REVERSED to "index_albums_added",
ALBUM_ORDER_BY_STARRED to "index_albums_starred",
ALBUM_ORDER_BY_STARRED_REVERSED to "index_albums_starred",
)
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"))
}
}
@Test
fun artistOrdersAreIndexBacked() =
runTest {
val orders =
listOf(
ARTIST_ORDER_BY_NAME to "index_artists_name",
ARTIST_ORDER_BY_NAME_REVERSED to "index_artists_name",
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",
)
for ((order, index) in orders) {
val plan = plan("SELECT * $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 playlistOrdersAreIndexBacked() =
runTest {
val orders =
listOf(
PLAYLIST_ORDER_BY_NAME to "index_playlists_name",
PLAYLIST_ORDER_BY_NAME_REVERSED to "index_playlists_name",
PLAYLIST_ORDER_BY_ADDED to "index_playlists_added",
PLAYLIST_ORDER_BY_ADDED_REVERSED to "index_playlists_added",
PLAYLIST_ORDER_BY_UPDATED to "index_playlists_updated",
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"))
}
}
@Test
fun anUnindexedAlbumOrderStillShowsATempSort() =
runTest {
val plan = plan("SELECT * $ALBUMS_FILTER ORDER BY songCount, id LIMIT 20 OFFSET 0")
assertTrue(plan, plan.contains("TEMP B-TREE"))
}
private suspend fun plan(sql: String): String =
db.useReaderConnection { connection ->
connection.usePrepared("EXPLAIN QUERY PLAN $sql") { statement ->
val details = mutableListOf<String>()
while (statement.step()) details += statement.getText(3)
details.joinToString("\n")
}
}
private suspend fun source(): Long =
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
@@ -139,6 +330,38 @@ class LibraryDaoTest {
songCount = 1,
)
private fun artist(
sourceId: Long,
id: String,
name: String,
albumCount: Long,
) = Artist(
sourceId = sourceId,
id = id,
name = name,
albumCount = albumCount,
starred = null,
coverArt = null,
)
private fun playlist(
sourceId: Long,
id: String,
name: String,
created: Long,
changed: Long,
) = Playlist(
sourceId = sourceId,
id = id,
name = name,
comment = null,
coverArt = null,
songCount = 1,
created = created,
changed = changed,
duration = 100,
)
private fun song(
sourceId: Long,
id: String,
@@ -47,6 +47,109 @@ class MigrationsTest {
}
}
@Test
fun theLibrarySortIndicesInheritCaseInsensitiveCollationAndDirection() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, versions.first())
MIGRATIONS.sortedBy { it.startVersion }.forEach { it.migrate(migrated) }
assertEquals("NOCASE", indexColumn(migrated, "index_albums_name", "name").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "albumArtist").collation)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_artist", "name").collation)
assertEquals(1, indexColumn(migrated, "index_albums_year", "year").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_albums_year", "name").collation)
assertEquals(0, indexColumn(migrated, "index_albums_year", "name").descending)
assertEquals(1, indexColumn(migrated, "index_albums_added", "created").descending)
assertEquals(1, indexColumn(migrated, "index_albums_starred", "starred").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_artists_name", "name").collation)
assertEquals(1, indexColumn(migrated, "index_artists_albumCount", "albumCount").descending)
assertEquals(1, indexColumn(migrated, "index_artists_starred", "starred").descending)
assertEquals("NOCASE", indexColumn(migrated, "index_playlists_name", "name").collation)
assertEquals(1, indexColumn(migrated, "index_playlists_added", "created").descending)
assertEquals(1, indexColumn(migrated, "index_playlists_updated", "changed").descending)
}
}
@Test
fun thePlaylistChangedAndDurationColumnsSurviveTheRebuild() =
runTest {
driver.open(":memory:").use { migrated ->
applySchema(migrated, versions.first())
migrated.execSQL(
"INSERT INTO sources (id, name, address, isActive, createdAt) " +
"VALUES (1, 'server', 'http://localhost', 1, 100)",
)
migrated.execSQL(
"INSERT INTO artists (sourceId, id, name, albumCount, starred) " +
"VALUES (1, 'ar1', 'The Artist', 1, NULL)",
)
migrated.execSQL(
"INSERT INTO albums (sourceId, id, artistId, name, albumArtist, created, coverArt, genre, year, " +
"starred, songCount, frequentRank, recentRank) " +
"VALUES (1, 'al1', 'ar1', 'The Album', 'The Artist', 101, NULL, NULL, 1999, NULL, 1, NULL, NULL)",
)
migrated.execSQL(
"INSERT INTO playlists (sourceId, id, name, comment, coverArt, songCount, created) " +
"VALUES (1, 'pl1', 'The Playlist', NULL, NULL, 3, 102)",
)
MIGRATIONS.sortedBy { it.startVersion }.forEach { migration ->
migration.migrate(migrated)
when (migration.endVersion) {
5 -> migrated.execSQL("UPDATE playlists SET duration = 987654 WHERE id = 'pl1'")
8 -> migrated.execSQL("UPDATE playlists SET changed = 123456 WHERE id = 'pl1'")
11 -> migrated.execSQL("INSERT INTO discs (sourceId, albumId, disc, title) VALUES (1, 'al1', 2, 'The Disc')")
}
}
assertEquals("The Playlist", text(migrated, "SELECT name FROM playlists WHERE id = 'pl1'"))
assertEquals(123456L, long(migrated, "SELECT changed FROM playlists WHERE id = 'pl1'"))
assertEquals(987654L, long(migrated, "SELECT duration FROM playlists WHERE id = 'pl1'"))
assertEquals("The Album", text(migrated, "SELECT name FROM albums WHERE id = 'al1'"))
assertEquals("The Artist", text(migrated, "SELECT name FROM artists WHERE id = 'ar1'"))
assertEquals("The Disc", text(migrated, "SELECT title FROM discs WHERE albumId = 'al1' AND disc = 2"))
}
}
private fun text(
connection: SQLiteConnection,
sql: String,
): String =
connection.prepare(sql).use { statement ->
check(statement.step()) { "No row for $sql" }
statement.getText(0)
}
private fun long(
connection: SQLiteConnection,
sql: String,
): Long =
connection.prepare(sql).use { statement ->
check(statement.step()) { "No row for $sql" }
statement.getLong(0)
}
private fun indexColumn(
connection: SQLiteConnection,
index: String,
column: String,
): IndexColumn {
connection.prepare("PRAGMA index_xinfo(`$index`)").use { statement ->
while (statement.step()) {
if (!statement.isNull(2) && statement.getText(2) == column) {
return IndexColumn(collation = statement.getText(4), descending = statement.getInt(3))
}
}
}
error("Column $column not found in index $index")
}
private data class IndexColumn(
val collation: String,
val descending: Int,
)
private fun applySchema(
connection: SQLiteConnection,
version: Int,
@@ -182,6 +182,59 @@ class SyncServiceTest {
)
}
@Test
fun aCaseOnlyNameChangeRewritesTheRow() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = listOf(artist("a1", name = "the beatles")),
albums = listOf(album("al1").copy(name = "abbey road", albumArtist = "the beatles")),
playlists = listOf(playlist("p1").copy(name = "driving")),
)
SyncService(db, source).sync()
source.artists = listOf(artist("a1", name = "The Beatles"))
source.albums = listOf(album("al1").copy(name = "Abbey Road", albumArtist = "The Beatles"))
source.playlists = listOf(playlist("p1").copy(name = "Driving"))
SyncService(db, source).sync()
assertEquals(
"The Beatles",
db
.libraryDao()
.artist(1, "a1")
.first()
?.name,
)
assertEquals(
"Abbey Road",
db
.libraryDao()
.album(1, "al1")
.first()
?.name,
)
assertEquals(
"The Beatles",
db
.libraryDao()
.album(1, "al1")
.first()
?.albumArtist,
)
assertEquals(
"Driving",
db
.libraryDao()
.playlist(1, "p1")
.first()
?.name,
)
}
@Test
fun syncStoresStarredAndAddedTimestamps() =
runTest {
+1 -1
View File
@@ -100,7 +100,7 @@ UI and playback preferences live in DataStore (`user_prefs`), not in SQLite: the
Every schema version step ships a hand-written `Migration` (`data/db/Migrations.kt`), and there is no destructive fallback, so an upgrade never silently wipes the local mirror. The exported schemas under `app/schemas/` are the source of truth for the migration SQL, and `MigrationsTest` runs each step against a real SQLite connection and asserts the resulting tables, columns and indices.
Library screens page over Room with `PagingSource<Int, T>` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this.
Library screens page over Room with `PagingSource<Int, T>` queries, ordering by a sort chosen in preferences (name, artist, year, recently added). Every album, artist and playlist order has an index on its own table, and the sortable text columns carry `COLLATE NOCASE`, so a page seeks the index in order instead of re-sorting the table for each `LIMIT`/`OFFSET`. Starred orders pin unstarred rows last in both directions, matching the list grouping, so a starred "reversed" order is not a literal mirror of the base order. Paging is local, but it keeps memory bounded on large libraries and lets a huge playlist render incrementally. Room 3 requires `@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)` on the DAO for this.
Song rows show the album's cover art, fetched by joining `songs` to `albums` into a `SongListItem` rather than storing cover art on the song: some servers return a distinct cover ID per track, and keeping those would fragment the image cache for no visual gain. Artists show their image cropped to a circle.