add library sort ordering

This commit is contained in:
Boof2015
2026-08-11 21:09:00 -04:00
parent 23b8aa3d94
commit 7941cc2ae6
17 changed files with 1356 additions and 149 deletions
@@ -645,6 +645,212 @@ class RoomLibraryRepositoryTest {
)
}
@Test
fun descendingTitlePagesAndAnchorsRemainGapFree() = runBlocking {
publish("g1", seedAlphabet())
val dao = catalog.catalogDao()
val expected = dao.getTitlePage(null, "", ALPHABET_SEED_SIZE)
.sortedWith(compareByDescending<ActiveTrackView> { it.titleSortKey }.thenBy { it.path })
val paged = mutableListOf<ActiveTrackView>()
var afterKey: String? = null
var afterPath = ""
while (true) {
val page = dao.getTitlePageDescending(afterKey, afterPath, 37)
if (page.isEmpty()) break
paged += page
afterKey = page.last().titleSortKey
afterPath = page.last().path
}
assertEquals(expected.map { it.path }, paged.map { it.path })
val anchor = dao.getTitleSectionAnchorsDescending().first { it.sectionLabel == "F" }
val at = dao.getTitlePageDescending(anchor.sortKey, "", 40)
val above = dao.getTitlePageBeforeDescending(anchor.sortKey, "", 40)
assertEquals("F", SortKeys.sectionLabel(at.first().title))
assertTrue(above.all { SortKeys.sectionLabel(it.title) != "F" })
val anchorIndex = expected.indexOfFirst { it.path == at.first().path }
assertEquals(
expected.subList(anchorIndex - above.size, anchorIndex).map { it.path },
above.reversed().map { it.path },
)
assertTrue(
dao.getTitlePageBeforeDescending(expected.first().titleSortKey, expected.first().path, 40)
.isEmpty(),
)
}
@Test
fun trackDirectionsReverseOnlyThePrimaryField() = runBlocking {
val rows = listOf(
track("g1", 1, "Zulu Beta").copy(
artist = "Zulu",
artistSortKey = SortKeys.forText("Zulu"),
album = "Beta",
albumSortKey = SortKeys.forText("Beta"),
addedAt = 30,
duration = 300.0,
),
track("g1", 2, "Zulu Alpha").copy(
artist = "Zulu",
artistSortKey = SortKeys.forText("Zulu"),
album = "Alpha",
albumSortKey = SortKeys.forText("Alpha"),
addedAt = 10,
duration = 100.0,
),
track("g1", 3, "Alpha Gamma").copy(
artist = "Alpha",
artistSortKey = SortKeys.forText("Alpha"),
album = "Gamma",
albumSortKey = SortKeys.forText("Gamma"),
addedAt = 20,
duration = 200.0,
),
)
publish("g1", rows)
val dao = catalog.catalogDao()
assertEquals(
listOf(rows[1].path, rows[0].path, rows[2].path),
dao.getArtistOrderPageDescending(null, "", 0, 0, "", "", 10).map { it.path },
)
assertEquals(
listOf(rows[1].path, rows[2].path, rows[0].path),
dao.getRecentlyAddedPageAscending(null, "", 10).map { it.path },
)
assertEquals(
listOf(rows[1].path, rows[2].path, rows[0].path),
dao.getDurationPageAscending(null, "", 10).map { it.path },
)
assertEquals(
dao.getArtistOrderPageDescending(null, "", 0, 0, "", "", 10).map { it.path },
dao.getAllPathsByArtistDescending(),
)
assertEquals(
dao.getTitlePageDescending(null, "", 10).map { it.path },
dao.getAllPathsByTitleDescending(),
)
assertEquals(
dao.getRecentlyAddedPageAscending(null, "", 10).map { it.path },
dao.getAllPathsByRecentlyAddedAscending(),
)
assertEquals(
dao.getDurationPageAscending(null, "", 10).map { it.path },
dao.getAllPathsByDurationAscending(),
)
}
@Test
fun albumAndArtistSummaryQueriesSupportBothDirections() = runBlocking {
val dao = catalog.catalogDao()
val revision = 7L
dao.putAlbumSummaries(
listOf(
AlbumSummaryEntity(
revision = revision,
identityKey = "z-beta",
album = "Beta",
artist = "Zulu",
year = 2020,
trackCount = 1,
totalDuration = 1.0,
latestAddedAt = 30,
nameSortKey = SortKeys.forText("Beta"),
artistSortKey = SortKeys.forText("Zulu"),
sectionLabel = "B",
isSingle = false,
),
AlbumSummaryEntity(
revision = revision,
identityKey = "z-alpha",
album = "Alpha",
artist = "Zulu",
year = null,
trackCount = 1,
totalDuration = 1.0,
latestAddedAt = 10,
nameSortKey = SortKeys.forText("Alpha"),
artistSortKey = SortKeys.forText("Zulu"),
sectionLabel = "A",
isSingle = false,
),
AlbumSummaryEntity(
revision = revision,
identityKey = "a-gamma",
album = "Gamma",
artist = "Alpha",
year = 1990,
trackCount = 1,
totalDuration = 1.0,
latestAddedAt = 20,
nameSortKey = SortKeys.forText("Gamma"),
artistSortKey = SortKeys.forText("Alpha"),
sectionLabel = "G",
isSingle = false,
),
),
)
assertEquals(
listOf("z-alpha", "z-beta", "a-gamma"),
dao.getAlbumArtistPageDescending(revision, true, null, "", "", 10)
.map { it.identityKey },
)
assertEquals(
listOf("a-gamma", "z-beta", "z-alpha"),
dao.getAlbumNamePageDescending(revision, true, null, "", 10)
.map { it.identityKey },
)
assertEquals(
listOf("a-gamma"),
dao.getAlbumNamePageBeforeDescending(
revision,
true,
SortKeys.forText("Beta"),
"z-beta",
10,
).map { it.identityKey },
)
assertEquals(
listOf("a-gamma", "z-beta", "z-alpha"),
dao.getAlbumYearPageAscending(revision, true, 0, null, "", "", 10)
.map { it.identityKey },
)
assertEquals(
listOf("z-alpha", "a-gamma", "z-beta"),
dao.getAlbumRecentPageAscending(revision, true, null, "", 10)
.map { it.identityKey },
)
dao.putArtistSummaries(
listOf(
artistSummary(revision, "zulu", "Zulu", 2),
artistSummary(revision, "alpha", "Alpha", 2),
artistSummary(revision, "beta", "Beta", 1),
),
)
assertEquals(
listOf("Zulu", "Beta", "Alpha"),
dao.getArtistNamePageDescending(revision, "astra", true, null, "", 10)
.map { it.artist },
)
assertEquals(
listOf("Zulu"),
dao.getArtistNamePageBeforeDescending(
revision,
"astra",
true,
SortKeys.forText("Beta"),
"beta",
10,
).map { it.artist },
)
assertEquals(
listOf("Beta", "Alpha", "Zulu"),
dao.getArtistCountPageAscending(revision, "astra", true, null, "", "", 10)
.map { it.artist },
)
}
@Test
fun structuredArtistCreditsPreserveNamesContainingPunctuation() = runBlocking {
val artistNames = listOf("Earth, Wind & Fire", "The Emotions")
@@ -965,6 +1171,25 @@ class RoomLibraryRepositoryTest {
sectionLabel = SortKeys.sectionLabel(title),
)
private fun artistSummary(
revision: Long,
key: String,
name: String,
count: Long,
): ArtistSummaryEntity = ArtistSummaryEntity(
revision = revision,
artistKey = key,
artist = name,
groupingMode = "astra",
trackCount = count,
primaryTrackCount = count,
albumCount = 1,
nameSortKey = SortKeys.forText(name),
sectionLabel = SortKeys.sectionLabel(name),
isCollaboration = false,
artworkHashesJson = "[]",
)
private companion object {
/** 26 letters x 10 tracks. */
const val ALPHABET_SEED_SIZE = 260
@@ -81,11 +81,12 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getTrackPage") Coroutine {
sort: String,
direction: String,
cursor: String?,
limit: Int,
->
try {
repository().getTrackPage(sort, cursor, limit)
repository().getTrackPage(sort, direction, cursor, limit)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
@@ -93,11 +94,12 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getTrackPageBefore") Coroutine {
sort: String,
direction: String,
cursor: String?,
limit: Int,
->
try {
repository().getTrackPageBefore(sort, cursor, limit)
repository().getTrackPageBefore(sort, direction, cursor, limit)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
@@ -385,12 +387,13 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getAlbumPage") Coroutine {
sort: String,
direction: String,
includeSingles: Boolean,
cursor: String?,
limit: Int,
->
try {
repository().getAlbumPage(sort, includeSingles, cursor, limit)
repository().getAlbumPage(sort, direction, includeSingles, cursor, limit)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
@@ -398,12 +401,13 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getAlbumPageBefore") Coroutine {
sort: String,
direction: String,
includeSingles: Boolean,
cursor: String?,
limit: Int,
->
try {
repository().getAlbumPageBefore(sort, includeSingles, cursor, limit)
repository().getAlbumPageBefore(sort, direction, includeSingles, cursor, limit)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
@@ -411,13 +415,14 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getArtistPage") Coroutine {
sort: String,
direction: String,
groupingMode: String,
includeCollaborations: Boolean,
cursor: String?,
limit: Int,
->
try {
repository().getArtistPage(sort, groupingMode, includeCollaborations, cursor, limit)
repository().getArtistPage(sort, direction, groupingMode, includeCollaborations, cursor, limit)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
@@ -425,13 +430,21 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getArtistPageBefore") Coroutine {
sort: String,
direction: String,
groupingMode: String,
includeCollaborations: Boolean,
cursor: String?,
limit: Int,
->
try {
repository().getArtistPageBefore(sort, groupingMode, includeCollaborations, cursor, limit)
repository().getArtistPageBefore(
sort,
direction,
groupingMode,
includeCollaborations,
cursor,
limit,
)
} catch (_: StaleRevisionException) {
mapOf("error" to "STALE_REVISION")
}
@@ -560,6 +573,7 @@ class AstraLibraryDataModule : Module() {
AsyncFunction("getSectionAnchors") Coroutine {
kind: String,
sort: String,
direction: String,
includeSingles: Boolean,
groupingMode: String,
includeCollaborations: Boolean,
@@ -567,6 +581,7 @@ class AstraLibraryDataModule : Module() {
repository().getSectionAnchors(
kind,
sort,
direction,
includeSingles,
groupingMode,
includeCollaborations,
@@ -635,41 +635,43 @@ class AstraLibraryRepository private constructor(
suspend fun getTrackPage(
sort: String,
directionRaw: String,
cursorRaw: String?,
requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database ->
initialize()
val dao = database.catalogDao()
val revision = dao.getRevision()
val cursor = validateCursor(cursorRaw, revision, "tracks:$sort")
val direction = normalizeSortDirection(directionRaw)
val cursor = validateCursor(cursorRaw, revision, "tracks:$sort:$direction")
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val rows = when (sort) {
"artist" -> dao.getArtistOrderPage(
afterArtistKey = cursor?.text1,
afterAlbumKey = cursor?.text2.orEmpty(),
afterDisc = cursor?.number1?.toInt() ?: 0,
afterTrack = cursor?.number2?.toInt() ?: 0,
afterTitleKey = cursor?.let(::cursorTitleKey).orEmpty(),
afterPath = cursor?.let { cursorPath(it) }.orEmpty(),
limit = limit,
"artist" -> (if (direction == "desc") dao::getArtistOrderPageDescending else dao::getArtistOrderPage)(
cursor?.text1,
cursor?.text2.orEmpty(),
cursor?.number1?.toInt() ?: 0,
cursor?.number2?.toInt() ?: 0,
cursor?.let(::cursorTitleKey).orEmpty(),
cursor?.let { cursorPath(it) }.orEmpty(),
limit,
)
"recently_added" -> dao.getRecentlyAddedPage(
afterAddedAt = cursor?.number1,
afterPath = cursor?.text1.orEmpty(),
limit = limit,
"recently_added" -> (if (direction == "asc") dao::getRecentlyAddedPageAscending else dao::getRecentlyAddedPage)(
cursor?.number1,
cursor?.text1.orEmpty(),
limit,
)
"duration" -> dao.getDurationPage(
afterDuration = cursor?.decimal1,
afterPath = cursor?.text1.orEmpty(),
limit = limit,
"duration" -> (if (direction == "asc") dao::getDurationPageAscending else dao::getDurationPage)(
cursor?.decimal1,
cursor?.text1.orEmpty(),
limit,
)
else -> dao.getTitlePage(
afterTitleKey = cursor?.text1,
afterPath = cursor?.text2.orEmpty(),
limit = limit,
else -> (if (direction == "desc") dao::getTitlePageDescending else dao::getTitlePage)(
cursor?.text1,
cursor?.text2.orEmpty(),
limit,
)
}
val next = rows.lastOrNull()?.let { row -> trackCursor(revision, sort, row).encode() }
val next = rows.lastOrNull()?.let { row -> trackCursor(revision, sort, direction, row).encode() }
mapOf(
"items" to rows.map(ActiveTrackView::toBridgeMap),
"nextCursor" to next,
@@ -693,36 +695,46 @@ class AstraLibraryRepository private constructor(
*/
suspend fun getTrackPageBefore(
sort: String,
directionRaw: String,
cursorRaw: String?,
requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database ->
initialize()
val dao = database.catalogDao()
val revision = dao.getRevision()
val cursor = validateCursor(cursorRaw, revision, "tracks:$sort")
val direction = normalizeSortDirection(directionRaw)
val cursor = validateCursor(cursorRaw, revision, "tracks:$sort:$direction")
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = when {
cursor == null -> emptyList()
sort == "artist" -> dao.getArtistOrderPageBefore(
beforeArtistKey = cursor.text1.orEmpty(),
beforeAlbumKey = cursor.text2.orEmpty(),
beforeDisc = cursor.number1?.toInt() ?: 0,
beforeTrack = cursor.number2?.toInt() ?: 0,
beforeTitleKey = cursorTitleKey(cursor),
beforePath = cursorPath(cursor),
limit = limit,
sort == "artist" -> (if (direction == "desc") {
dao::getArtistOrderPageBeforeDescending
} else {
dao::getArtistOrderPageBefore
})(
cursor.text1.orEmpty(),
cursor.text2.orEmpty(),
cursor.number1?.toInt() ?: 0,
cursor.number2?.toInt() ?: 0,
cursorTitleKey(cursor),
cursorPath(cursor),
limit,
)
sort == "title" -> dao.getTitlePageBefore(
beforeTitleKey = cursor.text1.orEmpty(),
beforePath = cursor.text2.orEmpty(),
limit = limit,
sort == "title" -> (if (direction == "desc") {
dao::getTitlePageBeforeDescending
} else {
dao::getTitlePageBefore
})(
cursor.text1.orEmpty(),
cursor.text2.orEmpty(),
limit,
)
else -> emptyList()
}
// The DESC result's last row is the topmost one — the cursor for the page above this one.
val previous = descending.takeIf { it.size == limit }
?.lastOrNull()
?.let { row -> trackCursor(revision, sort, row).encode() }
?.let { row -> trackCursor(revision, sort, direction, row).encode() }
mapOf(
"items" to descending.reversed().map(ActiveTrackView::toBridgeMap),
"nextCursor" to null,
@@ -733,11 +745,16 @@ class AstraLibraryRepository private constructor(
}
/** Shared by the forward and backward track pages so the two can never disagree. */
private fun trackCursor(revision: Long, sort: String, row: ActiveTrackView): TrackPageCursor =
private fun trackCursor(
revision: Long,
sort: String,
direction: String,
row: ActiveTrackView,
): TrackPageCursor =
when (sort) {
"artist" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
kind = "tracks:$sort:$direction",
text1 = row.artistSortKey,
text2 = row.albumSortKey,
text3 = "${row.titleSortKey}\u0000${row.path}",
@@ -748,19 +765,19 @@ class AstraLibraryRepository private constructor(
)
"recently_added" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
kind = "tracks:$sort:$direction",
text1 = row.path,
number1 = row.addedAt,
)
"duration" -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
kind = "tracks:$sort:$direction",
text1 = row.path,
decimal1 = row.duration,
)
else -> TrackPageCursor(
revision = revision,
kind = "tracks:$sort",
kind = "tracks:$sort:$direction",
text1 = row.titleSortKey,
text2 = row.path,
)
@@ -1882,17 +1899,19 @@ class AstraLibraryRepository private constructor(
suspend fun getAlbumPage(
sort: String,
directionRaw: String,
includeSingles: Boolean,
cursorRaw: String?,
requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database ->
val dao = database.catalogDao()
val revision = dao.getRevision()
val kind = "albums:$sort:${if (includeSingles) 1 else 0}"
val direction = normalizeSortDirection(directionRaw)
val kind = "albums:$sort:$direction:${if (includeSingles) 1 else 0}"
val cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val rows = when (sort) {
"artist" -> dao.getAlbumArtistPage(
"artist" -> (if (direction == "desc") dao::getAlbumArtistPageDescending else dao::getAlbumArtistPage)(
revision,
includeSingles,
cursor?.text1,
@@ -1900,22 +1919,23 @@ class AstraLibraryRepository private constructor(
cursor?.text3.orEmpty(),
limit,
)
"recently_added" -> dao.getAlbumRecentPage(
"recently_added" -> (if (direction == "asc") dao::getAlbumRecentPageAscending else dao::getAlbumRecentPage)(
revision,
includeSingles,
cursor?.number1,
cursor?.text1.orEmpty(),
limit,
)
"year" -> dao.getAlbumYearPage(
"year" -> (if (direction == "asc") dao::getAlbumYearPageAscending else dao::getAlbumYearPage)(
revision,
includeSingles,
cursor?.number1?.toInt(),
cursor?.number1?.toInt() ?: 0,
cursor?.number2?.toInt(),
cursor?.text1.orEmpty(),
cursor?.text2.orEmpty(),
limit,
)
else -> dao.getAlbumNamePage(
else -> (if (direction == "desc") dao::getAlbumNamePageDescending else dao::getAlbumNamePage)(
revision,
includeSingles,
cursor?.text1,
@@ -1936,18 +1956,24 @@ class AstraLibraryRepository private constructor(
/** Backward twin of [getAlbumPage]; see [getTrackPageBefore] for the contract. */
suspend fun getAlbumPageBefore(
sort: String,
directionRaw: String,
includeSingles: Boolean,
cursorRaw: String?,
requestedLimit: Int,
): Map<String, Any?> = withCatalogRecovery { database ->
val dao = database.catalogDao()
val revision = dao.getRevision()
val kind = "albums:$sort:${if (includeSingles) 1 else 0}"
val direction = normalizeSortDirection(directionRaw)
val kind = "albums:$sort:$direction:${if (includeSingles) 1 else 0}"
val cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = when {
cursor == null -> emptyList()
sort == "artist" -> dao.getAlbumArtistPageBefore(
sort == "artist" -> (if (direction == "desc") {
dao::getAlbumArtistPageBeforeDescending
} else {
dao::getAlbumArtistPageBefore
})(
revision,
includeSingles,
cursor.text1.orEmpty(),
@@ -1955,7 +1981,11 @@ class AstraLibraryRepository private constructor(
cursor.text3.orEmpty(),
limit,
)
sort == "name" -> dao.getAlbumNamePageBefore(
sort == "name" -> (if (direction == "desc") {
dao::getAlbumNamePageBeforeDescending
} else {
dao::getAlbumNamePageBefore
})(
revision,
includeSingles,
cursor.text1.orEmpty(),
@@ -2003,6 +2033,7 @@ class AstraLibraryRepository private constructor(
text1 = row.nameSortKey,
text2 = row.identityKey,
number1 = (row.year ?: 0).toLong(),
number2 = if (row.year == null) 1 else 0,
)
else -> TrackPageCursor(
revision,
@@ -2014,6 +2045,7 @@ class AstraLibraryRepository private constructor(
suspend fun getArtistPage(
sort: String,
directionRaw: String,
groupingMode: String,
includeCollaborations: Boolean,
cursorRaw: String?,
@@ -2022,11 +2054,12 @@ class AstraLibraryRepository private constructor(
val dao = database.catalogDao()
val revision = dao.getRevision()
val mode = if (groupingMode == "fileTags") "fileTags" else "astra"
val kind = "artists:$sort:$mode:${if (includeCollaborations) 1 else 0}"
val direction = normalizeSortDirection(directionRaw)
val kind = "artists:$sort:$direction:$mode:${if (includeCollaborations) 1 else 0}"
val cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val rows = if (sort == "track_count") {
dao.getArtistCountPage(
(if (direction == "asc") dao::getArtistCountPageAscending else dao::getArtistCountPage)(
revision,
mode,
includeCollaborations,
@@ -2036,7 +2069,7 @@ class AstraLibraryRepository private constructor(
limit,
)
} else {
dao.getArtistNamePage(
(if (direction == "desc") dao::getArtistNamePageDescending else dao::getArtistNamePage)(
revision,
mode,
includeCollaborations,
@@ -2058,6 +2091,7 @@ class AstraLibraryRepository private constructor(
/** Backward twin of [getArtistPage]; see [getTrackPageBefore] for the contract. */
suspend fun getArtistPageBefore(
sort: String,
directionRaw: String,
groupingMode: String,
includeCollaborations: Boolean,
cursorRaw: String?,
@@ -2066,13 +2100,18 @@ class AstraLibraryRepository private constructor(
val dao = database.catalogDao()
val revision = dao.getRevision()
val mode = if (groupingMode == "fileTags") "fileTags" else "astra"
val kind = "artists:$sort:$mode:${if (includeCollaborations) 1 else 0}"
val direction = normalizeSortDirection(directionRaw)
val kind = "artists:$sort:$direction:$mode:${if (includeCollaborations) 1 else 0}"
val cursor = validateCursor(cursorRaw, revision, kind)
val limit = requestedLimit.coerceIn(1, MAX_PAGE_SIZE)
val descending = if (cursor == null || sort == "track_count") {
emptyList()
} else {
dao.getArtistNamePageBefore(
(if (direction == "desc") {
dao::getArtistNamePageBeforeDescending
} else {
dao::getArtistNamePageBefore
})(
revision,
mode,
includeCollaborations,
@@ -2689,6 +2728,7 @@ class AstraLibraryRepository private constructor(
suspend fun getSectionAnchors(
kind: String,
sort: String,
directionRaw: String,
includeSingles: Boolean,
groupingMode: String,
includeCollaborations: Boolean,
@@ -2696,6 +2736,7 @@ class AstraLibraryRepository private constructor(
withCatalogRecovery { database ->
val dao = database.catalogDao()
val revision = dao.getRevision()
val direction = normalizeSortDirection(directionRaw)
val anchors: List<Pair<String, TrackPageCursor>> = when (kind) {
"albums" -> {
val rows = dao.getAllAlbumSummaries(revision).filter { includeSingles || !it.isSingle }
@@ -2703,17 +2744,32 @@ class AstraLibraryRepository private constructor(
if (sort == "artist") SortKeys.sectionLabel(row.artist) else SortKeys.sectionLabel(row.album)
}.map { (label, section) ->
if (sort == "artist") {
val first = section.minWith(compareBy<AlbumSummaryEntity>({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey }))
val first = section.sortedWith(
if (direction == "desc") {
compareByDescending<AlbumSummaryEntity> { it.artistSortKey }
.thenBy { it.nameSortKey }
.thenBy { it.identityKey }
} else {
compareBy<AlbumSummaryEntity>({ it.artistSortKey }, { it.nameSortKey }, { it.identityKey })
},
).first()
label to TrackPageCursor(
revision,
"albums:artist:${if (includeSingles) 1 else 0}",
"albums:artist:$direction:${if (includeSingles) 1 else 0}",
text1 = first.artistSortKey,
)
} else {
val first = section.minWith(compareBy<AlbumSummaryEntity>({ it.nameSortKey }, { it.identityKey }))
val first = section.sortedWith(
if (direction == "desc") {
compareByDescending<AlbumSummaryEntity> { it.nameSortKey }
.thenBy { it.identityKey }
} else {
compareBy<AlbumSummaryEntity>({ it.nameSortKey }, { it.identityKey })
},
).first()
label to TrackPageCursor(
revision,
"albums:name:${if (includeSingles) 1 else 0}",
"albums:name:$direction:${if (includeSingles) 1 else 0}",
text1 = first.nameSortKey,
)
}
@@ -2725,10 +2781,17 @@ class AstraLibraryRepository private constructor(
.filter { includeCollaborations || !it.isCollaboration }
.groupBy { row -> SortKeys.sectionLabel(row.artist) }
.map { (label, section) ->
val first = section.minWith(compareBy<ArtistSummaryEntity>({ it.nameSortKey }, { it.artistKey }))
val first = section.sortedWith(
if (direction == "desc") {
compareByDescending<ArtistSummaryEntity> { it.nameSortKey }
.thenBy { it.artistKey }
} else {
compareBy<ArtistSummaryEntity>({ it.nameSortKey }, { it.artistKey })
},
).first()
label to TrackPageCursor(
revision,
"artists:name:$mode:${if (includeCollaborations) 1 else 0}",
"artists:name:$direction:$mode:${if (includeCollaborations) 1 else 0}",
text1 = first.nameSortKey,
)
}
@@ -2740,22 +2803,38 @@ class AstraLibraryRepository private constructor(
.map { (label, section) ->
label to TrackPageCursor(
revision,
"tracks:artist",
text1 = section.minOf(ArtistSectionAnchorCandidate::sortKey),
"tracks:artist:$direction",
text1 = if (direction == "desc") {
section.maxOf(ArtistSectionAnchorCandidate::sortKey)
} else {
section.minOf(ArtistSectionAnchorCandidate::sortKey)
},
)
}
} else {
dao.getTitleSectionAnchors().map { row ->
val rows = if (direction == "desc") {
dao.getTitleSectionAnchorsDescending()
} else {
dao.getTitleSectionAnchors()
}
rows.map { row ->
row.sectionLabel to TrackPageCursor(
revision,
"tracks:title",
"tracks:title:$direction",
text1 = row.sortKey,
)
}
}
}
}
anchors.sortedWith(compareBy<Pair<String, TrackPageCursor>> { it.second.text1 }.thenBy { it.first })
val anchorComparator = compareBy<Pair<String, TrackPageCursor>> { it.second.text1 }
.thenBy { it.first }
val orderedAnchors = if (direction == "desc") {
anchors.sortedWith(anchorComparator.reversed())
} else {
anchors.sortedWith(anchorComparator)
}
orderedAnchors
.map { (label, cursor) ->
mapOf(
"label" to label,
@@ -2842,11 +2921,33 @@ class AstraLibraryRepository private constructor(
"manual" -> (context["paths"] as? List<*>)
?.mapNotNull { it as? String }
.orEmpty()
else -> when (context["sort"] as? String) {
"artist" -> catalogDao.getAllPathsByArtist()
"recently_added" -> catalogDao.getAllPathsByRecentlyAdded()
"duration" -> catalogDao.getAllPathsByDuration()
else -> catalogDao.getAllPathsByTitle()
else -> {
val sort = context["sort"] as? String ?: "title"
val direction = (context["direction"] as? String)
?.takeIf { it == "asc" || it == "desc" }
?: legacyTrackSortDirection(sort)
when (sort) {
"artist" -> if (direction == "desc") {
catalogDao.getAllPathsByArtistDescending()
} else {
catalogDao.getAllPathsByArtist()
}
"recently_added" -> if (direction == "asc") {
catalogDao.getAllPathsByRecentlyAddedAscending()
} else {
catalogDao.getAllPathsByRecentlyAdded()
}
"duration" -> if (direction == "asc") {
catalogDao.getAllPathsByDurationAscending()
} else {
catalogDao.getAllPathsByDuration()
}
else -> if (direction == "desc") {
catalogDao.getAllPathsByTitleDescending()
} else {
catalogDao.getAllPathsByTitle()
}
}
}
}
@@ -2954,6 +3055,12 @@ class AstraLibraryRepository private constructor(
return cursor
}
private fun normalizeSortDirection(value: String): String =
if (value == "desc") "desc" else "asc"
private fun legacyTrackSortDirection(sort: String): String =
if (sort == "recently_added" || sort == "duration") "desc" else "asc"
private fun cursorPath(cursor: TrackPageCursor): String =
cursor.text3?.substringAfter('\u0000', "") ?: ""
@@ -213,6 +213,9 @@ interface CatalogDao {
@Query("SELECT path FROM active_tracks ORDER BY title_sort_key, path")
suspend fun getAllPathsByTitle(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY title_sort_key DESC, path")
suspend fun getAllPathsByTitleDescending(): List<String>
@Query(
"""
SELECT path FROM active_tracks
@@ -221,12 +224,26 @@ interface CatalogDao {
)
suspend fun getAllPathsByArtist(): List<String>
@Query(
"""
SELECT path FROM active_tracks
ORDER BY artist_sort_key DESC, album_sort_key, disc_sort, track_sort, title_sort_key, path
""",
)
suspend fun getAllPathsByArtistDescending(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY added_at DESC, path")
suspend fun getAllPathsByRecentlyAdded(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY added_at, path")
suspend fun getAllPathsByRecentlyAddedAscending(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY duration DESC, path")
suspend fun getAllPathsByDuration(): List<String>
@Query("SELECT path FROM active_tracks ORDER BY duration, path")
suspend fun getAllPathsByDurationAscending(): List<String>
@Query(
"""
SELECT path FROM active_tracks
@@ -364,6 +381,22 @@ interface CatalogDao {
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
WHERE (:afterTitleKey IS NULL
OR title_sort_key < :afterTitleKey
OR (title_sort_key = :afterTitleKey AND path > :afterPath))
ORDER BY title_sort_key DESC, path
LIMIT :limit
""",
)
suspend fun getTitlePageDescending(
afterTitleKey: String?,
afterPath: String,
limit: Int,
): List<ActiveTrackView>
/**
* Mirror of [getTitlePage] walking backwards. Rows come out DESC — the caller
* reverses them so `items` is ascending like every other page. Backward paging
@@ -384,6 +417,21 @@ interface CatalogDao {
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
WHERE title_sort_key > :beforeTitleKey
OR (title_sort_key = :beforeTitleKey AND path < :beforePath)
ORDER BY title_sort_key, path DESC
LIMIT :limit
""",
)
suspend fun getTitlePageBeforeDescending(
beforeTitleKey: String,
beforePath: String,
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
@@ -411,6 +459,33 @@ interface CatalogDao {
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
WHERE (:afterArtistKey IS NULL
OR artist_sort_key < :afterArtistKey
OR (artist_sort_key = :afterArtistKey AND album_sort_key > :afterAlbumKey)
OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort > :afterDisc)
OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc
AND track_sort > :afterTrack)
OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc
AND track_sort = :afterTrack AND title_sort_key > :afterTitleKey)
OR (artist_sort_key = :afterArtistKey AND album_sort_key = :afterAlbumKey AND disc_sort = :afterDisc
AND track_sort = :afterTrack AND title_sort_key = :afterTitleKey AND path > :afterPath))
ORDER BY artist_sort_key DESC, album_sort_key, disc_sort, track_sort, title_sort_key, path
LIMIT :limit
""",
)
suspend fun getArtistOrderPageDescending(
afterArtistKey: String?,
afterAlbumKey: String,
afterDisc: Int,
afterTrack: Int,
afterTitleKey: String,
afterPath: String,
limit: Int,
): List<ActiveTrackView>
/** Mirror of [getArtistOrderPage] walking backwards; rows come out DESC. */
@Query(
"""
@@ -439,6 +514,33 @@ interface CatalogDao {
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
WHERE artist_sort_key > :beforeArtistKey
OR (artist_sort_key = :beforeArtistKey AND album_sort_key < :beforeAlbumKey)
OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort < :beforeDisc)
OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc
AND track_sort < :beforeTrack)
OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc
AND track_sort = :beforeTrack AND title_sort_key < :beforeTitleKey)
OR (artist_sort_key = :beforeArtistKey AND album_sort_key = :beforeAlbumKey AND disc_sort = :beforeDisc
AND track_sort = :beforeTrack AND title_sort_key = :beforeTitleKey AND path < :beforePath)
ORDER BY artist_sort_key, album_sort_key DESC, disc_sort DESC, track_sort DESC,
title_sort_key DESC, path DESC
LIMIT :limit
""",
)
suspend fun getArtistOrderPageBeforeDescending(
beforeArtistKey: String,
beforeAlbumKey: String,
beforeDisc: Int,
beforeTrack: Int,
beforeTitleKey: String,
beforePath: String,
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
@@ -455,6 +557,22 @@ interface CatalogDao {
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
WHERE (:afterAddedAt IS NULL
OR added_at > :afterAddedAt
OR (added_at = :afterAddedAt AND path > :afterPath))
ORDER BY added_at, path
LIMIT :limit
""",
)
suspend fun getRecentlyAddedPageAscending(
afterAddedAt: Long?,
afterPath: String,
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
@@ -471,6 +589,22 @@ interface CatalogDao {
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
WHERE (:afterDuration IS NULL
OR duration > :afterDuration
OR (duration = :afterDuration AND path > :afterPath))
ORDER BY duration, path
LIMIT :limit
""",
)
suspend fun getDurationPageAscending(
afterDuration: Double?,
afterPath: String,
limit: Int,
): List<ActiveTrackView>
@Query(
"""
SELECT * FROM active_tracks
@@ -542,6 +676,16 @@ interface CatalogDao {
)
suspend fun getTitleSectionAnchors(): List<SectionAnchorRow>
@Query(
"""
SELECT section_label, MAX(title_sort_key) AS sort_key
FROM active_tracks
GROUP BY section_label
ORDER BY sort_key DESC
""",
)
suspend fun getTitleSectionAnchorsDescending(): List<SectionAnchorRow>
@Query(
"""
SELECT artist, MIN(artist_sort_key) AS sort_key
@@ -595,6 +739,26 @@ interface CatalogDao {
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (:afterKey IS NULL
OR name_sort_key < :afterKey
OR (name_sort_key = :afterKey AND identity_key > :afterId))
ORDER BY name_sort_key DESC, identity_key
LIMIT :limit
""",
)
suspend fun getAlbumNamePageDescending(
revision: Long,
includeSingles: Boolean,
afterKey: String?,
afterId: String,
limit: Int,
): List<AlbumSummaryEntity>
/** Mirror of [getAlbumNamePage] walking backwards; rows come out DESC. */
@Query(
"""
@@ -615,6 +779,25 @@ interface CatalogDao {
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (name_sort_key > :beforeKey
OR (name_sort_key = :beforeKey AND identity_key < :beforeId))
ORDER BY name_sort_key, identity_key DESC
LIMIT :limit
""",
)
suspend fun getAlbumNamePageBeforeDescending(
revision: Long,
includeSingles: Boolean,
beforeKey: String,
beforeId: String,
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
@@ -638,6 +821,29 @@ interface CatalogDao {
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (:afterArtistKey IS NULL
OR artist_sort_key < :afterArtistKey
OR (artist_sort_key = :afterArtistKey AND name_sort_key > :afterNameKey)
OR (artist_sort_key = :afterArtistKey AND name_sort_key = :afterNameKey
AND identity_key > :afterId))
ORDER BY artist_sort_key DESC, name_sort_key, identity_key
LIMIT :limit
""",
)
suspend fun getAlbumArtistPageDescending(
revision: Long,
includeSingles: Boolean,
afterArtistKey: String?,
afterNameKey: String,
afterId: String,
limit: Int,
): List<AlbumSummaryEntity>
/** Mirror of [getAlbumArtistPage] walking backwards; rows come out DESC. */
@Query(
"""
@@ -661,6 +867,28 @@ interface CatalogDao {
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (artist_sort_key > :beforeArtistKey
OR (artist_sort_key = :beforeArtistKey AND name_sort_key < :beforeNameKey)
OR (artist_sort_key = :beforeArtistKey AND name_sort_key = :beforeNameKey
AND identity_key < :beforeId))
ORDER BY artist_sort_key, name_sort_key DESC, identity_key DESC
LIMIT :limit
""",
)
suspend fun getAlbumArtistPageBeforeDescending(
revision: Long,
includeSingles: Boolean,
beforeArtistKey: String,
beforeNameKey: String,
beforeId: String,
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
@@ -686,12 +914,36 @@ interface CatalogDao {
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (:afterYear IS NULL
OR COALESCE(year, 0) < :afterYear
OR (COALESCE(year, 0) = :afterYear AND name_sort_key > :afterNameKey)
OR (COALESCE(year, 0) = :afterYear AND name_sort_key = :afterNameKey
AND (:afterAddedAt IS NULL
OR latest_added_at > :afterAddedAt
OR (latest_added_at = :afterAddedAt AND identity_key > :afterId))
ORDER BY latest_added_at, identity_key
LIMIT :limit
""",
)
suspend fun getAlbumRecentPageAscending(
revision: Long,
includeSingles: Boolean,
afterAddedAt: Long?,
afterId: String,
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (:afterUnknown IS NULL
OR CASE WHEN year IS NULL THEN 1 ELSE 0 END > :afterUnknown
OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown
AND COALESCE(year, 0) < :afterYear)
OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown
AND COALESCE(year, 0) = :afterYear AND name_sort_key > :afterNameKey)
OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown
AND COALESCE(year, 0) = :afterYear AND name_sort_key = :afterNameKey
AND identity_key > :afterId))
ORDER BY COALESCE(year, 0) DESC, name_sort_key, identity_key
ORDER BY CASE WHEN year IS NULL THEN 1 ELSE 0 END, year DESC, name_sort_key, identity_key
LIMIT :limit
""",
)
@@ -699,6 +951,35 @@ interface CatalogDao {
revision: Long,
includeSingles: Boolean,
afterYear: Int?,
afterUnknown: Int?,
afterNameKey: String,
afterId: String,
limit: Int,
): List<AlbumSummaryEntity>
@Query(
"""
SELECT * FROM album_summaries
WHERE revision = :revision
AND (:includeSingles OR is_single = 0)
AND (:afterUnknown IS NULL
OR CASE WHEN year IS NULL THEN 1 ELSE 0 END > :afterUnknown
OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown
AND COALESCE(year, 0) > :afterYear)
OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown
AND COALESCE(year, 0) = :afterYear AND name_sort_key > :afterNameKey)
OR (CASE WHEN year IS NULL THEN 1 ELSE 0 END = :afterUnknown
AND COALESCE(year, 0) = :afterYear AND name_sort_key = :afterNameKey
AND identity_key > :afterId))
ORDER BY CASE WHEN year IS NULL THEN 1 ELSE 0 END, year, name_sort_key, identity_key
LIMIT :limit
""",
)
suspend fun getAlbumYearPageAscending(
revision: Long,
includeSingles: Boolean,
afterYear: Int?,
afterUnknown: Int?,
afterNameKey: String,
afterId: String,
limit: Int,
@@ -747,6 +1028,28 @@ interface CatalogDao {
limit: Int,
): List<ArtistSummaryEntity>
@Query(
"""
SELECT * FROM artist_summaries
WHERE revision = :revision
AND grouping_mode = :groupingMode
AND (:includeCollaborations OR is_collaboration = 0)
AND (:afterKey IS NULL
OR name_sort_key < :afterKey
OR (name_sort_key = :afterKey AND artist_key > :afterId))
ORDER BY name_sort_key DESC, artist_key
LIMIT :limit
""",
)
suspend fun getArtistNamePageDescending(
revision: Long,
groupingMode: String,
includeCollaborations: Boolean,
afterKey: String?,
afterId: String,
limit: Int,
): List<ArtistSummaryEntity>
/** Mirror of [getArtistNamePage] walking backwards; rows come out DESC. */
@Query(
"""
@@ -769,6 +1072,27 @@ interface CatalogDao {
limit: Int,
): List<ArtistSummaryEntity>
@Query(
"""
SELECT * FROM artist_summaries
WHERE revision = :revision
AND grouping_mode = :groupingMode
AND (:includeCollaborations OR is_collaboration = 0)
AND (name_sort_key > :beforeKey
OR (name_sort_key = :beforeKey AND artist_key < :beforeId))
ORDER BY name_sort_key, artist_key DESC
LIMIT :limit
""",
)
suspend fun getArtistNamePageBeforeDescending(
revision: Long,
groupingMode: String,
includeCollaborations: Boolean,
beforeKey: String,
beforeId: String,
limit: Int,
): List<ArtistSummaryEntity>
@Query(
"""
SELECT * FROM artist_summaries
@@ -793,6 +1117,30 @@ interface CatalogDao {
limit: Int,
): List<ArtistSummaryEntity>
@Query(
"""
SELECT * FROM artist_summaries
WHERE revision = :revision
AND grouping_mode = :groupingMode
AND (:includeCollaborations OR is_collaboration = 0)
AND (:afterCount IS NULL
OR track_count > :afterCount
OR (track_count = :afterCount AND name_sort_key > :afterNameKey)
OR (track_count = :afterCount AND name_sort_key = :afterNameKey AND artist_key > :afterId))
ORDER BY track_count, name_sort_key, artist_key
LIMIT :limit
""",
)
suspend fun getArtistCountPageAscending(
revision: Long,
groupingMode: String,
includeCollaborations: Boolean,
afterCount: Long?,
afterNameKey: String,
afterId: String,
limit: Int,
): List<ArtistSummaryEntity>
@Query(
"""
SELECT * FROM artist_summaries