db: keep playlist owner and sharing in filtered lists

The hand-written playlist projection used by the Downloaded filter and
offline mode did not select the new owner and shared columns, so the
playlist Info dialog lost them there while unfiltered lists had them.
Select both columns and pin the filtered path in LibraryDaoTest.

Also share the artist-name extraction between the subtitle and the
plural label so duplicate or blank refs cannot make them disagree,
accept the XML boolean form "1" for the public flag, and strengthen the
date-adjacency and pluralisation tests.
This commit is contained in:
austinried
2026-10-21 14:04:46 +00:00
parent f46f43b6cd
commit d8ac7fefc7
7 changed files with 55 additions and 11 deletions
@@ -365,7 +365,7 @@ internal const val PLAYLISTS_SELECT =
"JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " + "JOIN song_downloads sd ON sd.sourceId = ps.sourceId AND sd.songId = ps.songId AND sd.status = 'Completed' " +
"WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " + "WHERE ps.sourceId = playlists.sourceId AND ps.playlistId = playlists.id " +
") ELSE playlists.songCount END AS songCount, " + ") ELSE playlists.songCount END AS songCount, " +
"playlists.created, playlists.changed, playlists.duration " "playlists.created, playlists.changed, playlists.duration, playlists.owner, playlists.shared "
internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id" internal const val PLAYLIST_ORDER_BY_NAME = "name COLLATE NOCASE, id"
internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC" internal const val PLAYLIST_ORDER_BY_NAME_REVERSED = "name COLLATE NOCASE DESC, id DESC"
@@ -367,7 +367,7 @@ internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull() internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
internal fun Attributes.boolAttr(name: String): Boolean = attr(name).equals("true", ignoreCase = true) internal fun Attributes.boolAttr(name: String): Boolean = attr(name).let { it == "1" || it.equals("true", ignoreCase = true) }
internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() } internal fun Attributes.floatAttr(name: String): Float? = attr(name).takeIf { it.isNotEmpty() }?.toFloatOrNull()?.takeIf { it.isFinite() }
@@ -7,12 +7,22 @@ private const val ARTIST_SEPARATOR = " \u00B7 "
internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists) internal fun Song.artistSubtitle(): String? = artistSubtitle(artist, artists)
internal fun Song.artistNames(): List<String> = artistNames(artist, artists)
internal fun artistNames(
artist: String?,
artists: List<ArtistRef>,
): List<String> {
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct()
return names.ifEmpty { listOfNotNull(artist?.takeIf { it.isNotBlank() }) }
}
internal fun artistSubtitle( internal fun artistSubtitle(
artist: String?, artist: String?,
artists: List<ArtistRef>, artists: List<ArtistRef>,
): String? { ): String? {
val names = artists.map { it.name }.filter { it.isNotBlank() }.distinct() val names = artistNames(artist, artists)
if (names.isEmpty()) return artist?.takeIf { it.isNotBlank() } if (names.isEmpty()) return null
val primary = artist?.takeIf { it in names } val primary = artist?.takeIf { it in names }
return buildList { return buildList {
primary?.let(::add) primary?.let(::add)
@@ -46,12 +46,7 @@ fun SongInfoDialog(
title = { Text(stringResource(R.string.info)) }, title = { Text(stringResource(R.string.info)) },
text = { text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
val multipleArtists = val multipleArtists = song.artistNames().size > 1
song.artistRefs
.map { it.name }
.filter { it.isNotBlank() }
.distinct()
.size > 1
val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name val regularArtistSort = song.artist?.takeIf { it.isNotBlank() } ?: song.artistRefs.firstOrNull()?.name
InfoRow(stringResource(R.string.resources_sort_by_title), song.title) InfoRow(stringResource(R.string.resources_sort_by_title), song.title)
InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title)) InfoRow(stringResource(R.string.info_sort_title), song.sortName.takeIfDifferentFrom(song.title))
@@ -1016,7 +1016,12 @@ class LibraryDaoTest {
runTest { runTest {
val sourceId = source() val sourceId = source()
val dao = db.libraryDao() val dao = db.libraryDao()
dao.upsertPlaylists(listOf(playlist(sourceId, "pl-1", "List", created = 0, changed = 0).copy(songCount = 5))) dao.upsertPlaylists(
listOf(
playlist(sourceId, "pl-1", "List", created = 0, changed = 0)
.copy(songCount = 5, owner = "austin", shared = true),
),
)
dao.upsertSongs( dao.upsertSongs(
listOf( listOf(
song(sourceId, "s1", "One", starred = null), song(sourceId, "s1", "One", starred = null),
@@ -1034,6 +1039,8 @@ class LibraryDaoTest {
val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page() val filtered = dao.playlistsByName(sourceId, "", downloadedFilter = 1).page()
assertEquals(1, filtered.size) assertEquals(1, filtered.size)
assertEquals(1L, filtered.first().songCount) assertEquals(1L, filtered.first().songCount)
assertEquals("austin", filtered.first().owner)
assertEquals(true, filtered.first().shared)
assertEquals( assertEquals(
5L, 5L,
@@ -754,6 +754,7 @@ class SubsonicXmlTest {
<playlists> <playlists>
<playlist id="pl1" name="Shared" owner="austin" public="true" songCount="2" duration="120"/> <playlist id="pl1" name="Shared" owner="austin" public="true" songCount="2" duration="120"/>
<playlist id="pl2" name="Private" owner="austin" public="false" songCount="1" duration="60"/> <playlist id="pl2" name="Private" owner="austin" public="false" songCount="1" duration="60"/>
<playlist id="pl3" name="Numeric" owner="austin" public="1" songCount="1" duration="60"/>
</playlists> </playlists>
</subsonic-response> </subsonic-response>
""".trimIndent(), """.trimIndent(),
@@ -764,6 +765,7 @@ class SubsonicXmlTest {
assertEquals("austin", playlists[0].owner) assertEquals("austin", playlists[0].owner)
assertTrue(playlists[0].shared) assertTrue(playlists[0].shared)
assertEquals(false, playlists[1].shared) assertEquals(false, playlists[1].shared)
assertTrue(playlists[2].shared)
} }
private class CountingInputStream( private class CountingInputStream(
@@ -11,6 +11,7 @@ import com.subtracks.data.model.ArtistRef
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.ui.theme.SubtracksTheme import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -171,6 +172,12 @@ class LibraryInfoDialogTest {
val plays = composeRule.onNodeWithText("Plays").fetchSemanticsNode().boundsInRoot val plays = composeRule.onNodeWithText("Plays").fetchSemanticsNode().boundsInRoot
assertTrue("last played must follow added", lastPlayed.top >= added.bottom) assertTrue("last played must follow added", lastPlayed.top >= added.bottom)
assertTrue("plays must not sit between the dates", plays.top >= lastPlayed.bottom) assertTrue("plays must not sit between the dates", plays.top >= lastPlayed.bottom)
assertEquals(
"no row may sit between added and last played",
lastPlayed.top - added.top,
plays.top - lastPlayed.top,
0.5f,
)
} }
@Test @Test
@@ -228,6 +235,29 @@ class LibraryInfoDialogTest {
composeRule.onNodeWithText("Artists sort").assertDoesNotExist() composeRule.onNodeWithText("Artists sort").assertDoesNotExist()
} }
@Test
fun duplicateAndBlankArtistRefsStaySingular() {
composeRule.setContent {
SubtracksTheme {
SongInfoDialog(
song =
song(
artists = listOf(ArtistRef("ar1", "Alpha"), ArtistRef("ar2", "Alpha"), ArtistRef("ar3", "")),
artistSort = "Beta",
),
download = null,
localFile = null,
streamEncoding = null,
onDismiss = {},
genres = emptyList(),
)
}
}
composeRule.onNodeWithText("Artist").assertIsDisplayed()
composeRule.onNodeWithText("Artists").assertDoesNotExist()
}
@Test @Test
fun aMixedArtistTrackUsesPluralLabelsForBothRows() { fun aMixedArtistTrackUsesPluralLabelsForBothRows() {
composeRule.setContent { composeRule.setContent {