From 97d33a781a19dcb80ecbaf01020ef6acd50b4acd Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:58:59 +0000 Subject: [PATCH] library: fall back to the plural artist names for display Keep the server's combined legacy artist/albumArtist string when it is present, prefer the OpenSubsonic displayArtist when it is not, and otherwise join the plural artist refs with Navidrome's separator. The normalized value is stored in the display columns, so song rows, album cards, now playing, menu subtitles, downloads, search and artist ordering all get it. --- .../data/source/subsonic/SubsonicXml.kt | 40 ++++++++-- .../data/source/subsonic/SubsonicXmlTest.kt | 74 +++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index 0c469f91..1cce96e2 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -44,6 +44,7 @@ object SubsonicXml { artistId = attrs.attr("artistId").ifEmpty { null }, name = attrs.attr("name"), albumArtist = attrs.attr("artist").ifEmpty { null }, + displayArtist = attrs.attr("displayArtist").ifEmpty { null }, created = attrs.dateAttr("created") ?: 0L, coverArt = attrs.attr("coverArt").ifEmpty { null }, genre = attrs.attr("genre").ifEmpty { null }, @@ -69,6 +70,7 @@ object SubsonicXml { title = attrs.attr("title"), album = attrs.attr("album").ifEmpty { null }, artist = attrs.attr("artist").ifEmpty { null }, + displayArtist = attrs.attr("displayArtist").ifEmpty { null }, duration = attrs.longAttr("duration"), track = attrs.longAttr("track"), disc = attrs.longAttr("discNumber"), @@ -168,6 +170,7 @@ internal class AlbumDraft( var artistId: String? = null, var name: String = "", var albumArtist: String? = null, + var displayArtist: String? = null, var created: Long = 0L, var coverArt: String? = null, var genre: String? = null, @@ -203,13 +206,14 @@ internal class AlbumDraft( artistContainer = false } - fun toAlbum(): Album = - Album( + fun toAlbum(): Album { + val refs = artistFallback(artists, artistId, albumArtist) + return Album( sourceId = sourceId, id = id, artistId = artistId, name = name, - albumArtist = albumArtist, + albumArtist = displayName(albumArtist, displayArtist, refs), created = created, coverArt = coverArt, genre = genre, @@ -217,8 +221,9 @@ internal class AlbumDraft( starred = starred, songCount = songCount, discTitles = discTitles.toMap(), - artists = artistFallback(artists, artistId, albumArtist), + artists = refs, ) + } } internal class SongDraft( @@ -229,6 +234,7 @@ internal class SongDraft( private val title: String = "", private val album: String? = null, private val artist: String? = null, + private val displayArtist: String? = null, private val duration: Long? = null, private val track: Long? = null, private val disc: Long? = null, @@ -279,15 +285,16 @@ internal class SongDraft( nestedArtists?.let { list -> attrs.artistRef()?.let { list += it } } } - fun toSong(): Song = - Song( + fun toSong(): Song { + val refs = artistFallback(artists, artistId, artist) + return Song( sourceId = sourceId, id = id, albumId = albumId, artistId = artistId, title = title, album = album, - artist = artist, + artist = displayName(artist, displayArtist, refs), duration = duration, track = track, disc = disc, @@ -301,8 +308,9 @@ internal class SongDraft( trackPeak = trackPeak, albumPeak = albumPeak, genres = genres.toList(), - artists = artistFallback(artists, artistId, artist), + artists = refs, ) + } } private fun Attributes.artistRef(): ArtistRef? { @@ -323,6 +331,22 @@ private fun artistFallback( return listOf(ArtistRef(artistId, name.orEmpty())) } +private const val ARTIST_JOINER = " • " + +private fun displayName( + legacy: String?, + openSubsonic: String?, + refs: List, +): String? = + legacy?.takeIf { it.isNotBlank() } + ?: openSubsonic?.takeIf { it.isNotBlank() } + ?: refs + .map { it.name } + .filter { it.isNotBlank() } + .distinct() + .joinToString(ARTIST_JOINER) + .takeIf { it.isNotEmpty() } + internal fun Attributes.attr(name: String): String = getValue(name) ?: "" internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull() diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt index c2140bba..ddc9027c 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt @@ -240,6 +240,80 @@ class SubsonicXmlTest { ) } + @Test + fun fallsBackToThePluralNamesWhenTheLegacyDisplayArtistIsBlank() { + val albums = + albums( + """ + + + + + + + + + """.trimIndent(), + ) + + assertEquals("A • B", albums.single().albumArtist) + assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), albums.single().artists) + } + + @Test + fun prefersTheOpenSubsonicDisplayArtistOverThePluralNames() { + val albums = + albums( + """ + + + + + + + + + """.trimIndent(), + ) + + assertEquals("A feat. B", albums.single().albumArtist) + } + + @Test + fun fallsBackToThePluralSongArtistsWhenTheLegacyNameIsBlank() { + val songs = + songs( + """ + + + + + + + """.trimIndent(), + ) + + assertEquals("A • B", songs.single().artist) + assertEquals(listOf(ArtistRef("ar1", "A"), ArtistRef("ar2", "B")), songs.single().artists) + } + + @Test + fun prefersTheOpenSubsonicDisplayArtistForSongs() { + val songs = + songs( + """ + + + + + + + """.trimIndent(), + ) + + assertEquals("A feat. B", songs.single().artist) + } + @Test fun parsesTimestampsWithFractionsAndOffsets() { assertEquals(1789906657L, IsoDate.parse("2026-09-20T12:17:37.370827413Z"))