mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
index the album, artist and playlist list sorts
This commit is contained in:
+1037
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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user