mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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 " +
|
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId " +
|
||||||
"ORDER BY playlist_songs.position"
|
"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
|
@Dao
|
||||||
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
@DaoReturnTypeConverters(PagingSourceDaoReturnTypeConverter::class)
|
||||||
interface LibraryDao {
|
interface LibraryDao {
|
||||||
@@ -155,253 +197,149 @@ interface LibraryDao {
|
|||||||
)
|
)
|
||||||
suspend fun deleteOrphanPlaylistSongs(sourceId: Long)
|
suspend fun deleteOrphanPlaylistSongs(sourceId: Long)
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByName(
|
fun albumsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_NAME_REVERSED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByNameReversed(
|
fun albumsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByArtist(
|
fun albumsByArtist(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ARTIST_REVERSED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByArtistReversed(
|
fun albumsByArtistReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByYear(
|
fun albumsByYear(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_YEAR_REVERSED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByYearReversed(
|
fun albumsByYearReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByRecentlyAdded(
|
fun albumsByRecentlyAdded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_ADDED_REVERSED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByRecentlyAddedReversed(
|
fun albumsByRecentlyAddedReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByStarred(
|
fun albumsByStarred(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ALBUMS_FILTER ORDER BY $ALBUM_ORDER_BY_STARRED_REVERSED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun albumsByStarredReversed(
|
fun albumsByStarredReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Album>
|
): PagingSource<Int, Album>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun artistsByName(
|
fun artistsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_NAME_REVERSED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun artistsByNameReversed(
|
fun artistsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun artistsByAlbumCount(
|
fun artistsByAlbumCount(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_ALBUM_COUNT_REVERSED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun artistsByAlbumCountReversed(
|
fun artistsByAlbumCountReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun artistsByStarred(
|
fun artistsByStarred(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $ARTISTS_FILTER ORDER BY $ARTIST_ORDER_BY_STARRED_REVERSED")
|
||||||
"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",
|
|
||||||
)
|
|
||||||
fun artistsByStarredReversed(
|
fun artistsByStarredReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
starredFilter: Int,
|
starredFilter: Int,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Artist>
|
): PagingSource<Int, Artist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME")
|
||||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY name COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
fun playlistsByName(
|
fun playlistsByName(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Playlist>
|
): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_NAME_REVERSED")
|
||||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY name COLLATE NOCASE DESC, id",
|
|
||||||
)
|
|
||||||
fun playlistsByNameReversed(
|
fun playlistsByNameReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Playlist>
|
): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED")
|
||||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY created DESC, name COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
fun playlistsByAdded(
|
fun playlistsByAdded(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Playlist>
|
): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_ADDED_REVERSED")
|
||||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY created ASC, name COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
fun playlistsByAddedReversed(
|
fun playlistsByAddedReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Playlist>
|
): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED")
|
||||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY changed DESC, name COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
fun playlistsByUpdated(
|
fun playlistsByUpdated(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
search: String,
|
||||||
): PagingSource<Int, Playlist>
|
): PagingSource<Int, Playlist>
|
||||||
|
|
||||||
@Query(
|
@Query("SELECT * $PLAYLISTS_FILTER ORDER BY $PLAYLIST_ORDER_BY_UPDATED_REVERSED")
|
||||||
"SELECT * FROM playlists WHERE sourceId = :sourceId " +
|
|
||||||
"AND (:search = '' OR instr(lower(name), lower(:search)) > 0) " +
|
|
||||||
"ORDER BY changed ASC, name COLLATE NOCASE, id",
|
|
||||||
)
|
|
||||||
fun playlistsByUpdatedReversed(
|
fun playlistsByUpdatedReversed(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
search: String,
|
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> =
|
val MIGRATIONS: Array<Migration> =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MIGRATION_1_2,
|
MIGRATION_1_2,
|
||||||
@@ -150,4 +231,5 @@ val MIGRATIONS: Array<Migration> =
|
|||||||
MIGRATION_11_12,
|
MIGRATION_11_12,
|
||||||
MIGRATION_12_13,
|
MIGRATION_12_13,
|
||||||
MIGRATION_13_14,
|
MIGRATION_13_14,
|
||||||
|
MIGRATION_14_15,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import com.subtracks.data.model.SubsonicSource
|
|||||||
ShuffleOrder::class,
|
ShuffleOrder::class,
|
||||||
ArtworkSeed::class,
|
ArtworkSeed::class,
|
||||||
],
|
],
|
||||||
version = 14,
|
version = 15,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@ColumnTypeConverters(QueueKindConverter::class)
|
@ColumnTypeConverters(QueueKindConverter::class)
|
||||||
|
|||||||
@@ -48,12 +48,25 @@ data class SubsonicSource(
|
|||||||
onDelete = ForeignKey.CASCADE,
|
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(
|
data class Artist(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
@ColumnInfo(collate = ColumnInfo.NOCASE) val name: String,
|
||||||
val albumCount: Long,
|
val albumCount: Long,
|
||||||
val starred: Long?,
|
val starred: Long?,
|
||||||
val coverArt: String? = null,
|
val coverArt: String? = null,
|
||||||
@@ -70,14 +83,34 @@ data class Artist(
|
|||||||
onDelete = ForeignKey.CASCADE,
|
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(
|
data class Album(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
val id: String,
|
val id: String,
|
||||||
val artistId: String?,
|
val artistId: String?,
|
||||||
val name: String,
|
@ColumnInfo(collate = ColumnInfo.NOCASE) val name: String,
|
||||||
val albumArtist: String?,
|
@ColumnInfo(collate = ColumnInfo.NOCASE) val albumArtist: String?,
|
||||||
val created: Long,
|
val created: Long,
|
||||||
val coverArt: String?,
|
val coverArt: String?,
|
||||||
val genre: String?,
|
val genre: String?,
|
||||||
@@ -118,12 +151,25 @@ data class Disc(
|
|||||||
onDelete = ForeignKey.CASCADE,
|
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(
|
data class Playlist(
|
||||||
val sourceId: Long,
|
val sourceId: Long,
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
@ColumnInfo(collate = ColumnInfo.NOCASE) val name: String,
|
||||||
val comment: String?,
|
val comment: String?,
|
||||||
val coverArt: String?,
|
val coverArt: String?,
|
||||||
val songCount: Long,
|
val songCount: Long,
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ private suspend fun <T> PooledConnection.upsertChanged(
|
|||||||
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
"ON CONFLICT(${key.joinToString()}) DO UPDATE SET " +
|
||||||
updates.joinToString { "$it = excluded.$it" } +
|
updates.joinToString { "$it = excluded.$it" } +
|
||||||
" WHERE " +
|
" WHERE " +
|
||||||
updates.joinToString(" OR ") { "$table.$it IS NOT excluded.$it" }
|
updates.joinToString(" OR ") { "$table.$it COLLATE BINARY IS NOT excluded.$it" }
|
||||||
usePrepared(sql) { statement ->
|
usePrepared(sql) { statement ->
|
||||||
rows.forEach { row ->
|
rows.forEach { row ->
|
||||||
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
values(row).forEachIndexed { index, value -> statement.bind(index + 1, value) }
|
||||||
|
|||||||
@@ -3,16 +3,21 @@ package com.subtracks.data.db
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.paging.PagingSource
|
import androidx.paging.PagingSource
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
|
import androidx.room3.useReaderConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.model.Album
|
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.Song
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -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 =
|
private suspend fun source(): Long =
|
||||||
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
|
db.sourcesDao().insertSource(Source(name = "Navidrome", address = "http://localhost", isActive = true, createdAt = 0))
|
||||||
|
|
||||||
@@ -139,6 +330,38 @@ class LibraryDaoTest {
|
|||||||
songCount = 1,
|
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(
|
private fun song(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
id: String,
|
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(
|
private fun applySchema(
|
||||||
connection: SQLiteConnection,
|
connection: SQLiteConnection,
|
||||||
version: Int,
|
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
|
@Test
|
||||||
fun syncStoresStarredAndAddedTimestamps() =
|
fun syncStoresStarredAndAddedTimestamps() =
|
||||||
runTest {
|
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.
|
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.
|
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