fix(artist pin): apply pins in search + fix similar artists (web + android)

Two gaps the pin left, on both platforms:

1. Search still showed the wrong same-name artist's photo. The library search
   now prefers the pin's Deezer picture for a pinned artist (web: search.js
   artist cards; android: LibrarySearchContent via SearchViewModel overrides).

2. Similar Artists were "weird" — they come from Last.fm keyed to the (merged/
   wrong) Navidrome artist. When pinned, use Deezer "related" of the pinned
   artist instead (new deezerRelatedArtists / ItunesClient.lookupRelatedArtists).

Also: web Similar Artists now render with profile pictures (avatar chips), like
the Android app, instead of plain text chips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-13 18:42:15 +02:00
1 parent 1ee85482f3
commit 26de85a140
9 files changed
+116 -20

No files matched your search

@@ -90,6 +90,18 @@ object ItunesClient {
return tracks.data.map { it.toItunesItem() }
}
/**
* Related Deezer artists (real pictures + fan counts). Used for the "Similar Artists" row when
* a pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome
* artist, so it goes "weird"; Deezer's related are correct for the pinned artist.
*/
suspend fun lookupRelatedArtists(artistId: Long, limit: Int = 12): List<ItunesItem> {
val artists: DzList<DzArtist> = http.get("$BASE/artist/$artistId/related") {
parameter("limit", limit)
}.body()
return artists.data.map { it.toItunesItem() }
}
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
@@ -323,11 +323,42 @@ fun ArtistDetailScreen(
}
}
// Similar artists (exclude the current artist)
// Similar artists. When pinned, use Deezer "related" for the pinned artist — Last.fm's list
// is keyed to the (possibly merged/wrong) Navidrome artist, so it goes "weird".
val usePinnedRelated = state.pin != null && state.pinnedRelated.isNotEmpty()
val similarArtists = state.info?.similarArtist
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
?: emptyList()
if (similarArtists.isNotEmpty()) {
if (usePinnedRelated) {
item {
SectionLabel("Similar Artists")
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(bottom = 20.dp),
) {
items(state.pinnedRelated, key = { it.artistId ?: 0L }) { rel ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(70.dp),
) {
AsyncImage(
model = rel.artworkUrl100,
contentDescription = rel.artistName,
contentScale = ContentScale.Crop,
modifier = Modifier.size(60.dp).clip(CircleShape).background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(
rel.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
}
} else if (similarArtists.isNotEmpty()) {
item {
SectionLabel("Similar Artists")
LazyRow(
@@ -310,9 +310,11 @@ private fun LibrarySearchContent(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(artists, key = { it.id }) { a ->
// search3 artist results usually omit coverArt → fall back to the artist
// id, which Navidrome's getCoverArt accepts and resolves to the artist image.
ArtistCard(name = a.name, imageUrl = coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image
// is the wrong person). Otherwise: search3 usually omits coverArt → fall back
// to the artist id, which Navidrome's getCoverArt resolves to the artist image.
val pinImg = state.overrides[a.id]?.image?.takeIf { it.isNotBlank() }
ArtistCard(name = a.name, imageUrl = pinImg ?: coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
}
}
Spacer(Modifier.height(8.dp))
@@ -51,6 +51,9 @@ data class ArtistUiState(
// and the picker state (link/id paste + Deezer search candidates).
val pin: ArtistOverride? = null,
val pinnedImage: String? = null,
// When pinned, the Similar-Artists row uses Deezer "related" (correct for the pinned artist)
// instead of Last.fm's list (keyed to the wrong/merged Navidrome artist).
val pinnedRelated: List<ItunesItem> = emptyList(),
val showPinPicker: Boolean = false,
val pinSearching: Boolean = false,
val pinCandidates: List<ItunesItem> = emptyList(),
@@ -128,14 +131,18 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
pinnedImage = pin.image.takeIf { it.isNotBlank() } ?: _state.value.pinnedImage,
)
coroutineScope {
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(pinnedId) }.getOrDefault(emptyList()) }
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(pinnedId, 10) }.getOrDefault(emptyList()) }
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(pinnedId) }.getOrDefault(emptyList()) }
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(pinnedId, 10) }.getOrDefault(emptyList()) }
val relatedD = async { runCatching { ItunesClient.lookupRelatedArtists(pinnedId, 12) }.getOrDefault(emptyList()) }
_state.value = _state.value.copy(
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(), itunesLoading = false,
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(),
pinnedRelated = relatedD.await(), itunesLoading = false,
)
}
return
}
// Not pinned → clear any stale pinned-related from a previous pin.
_state.value = _state.value.copy(pinnedRelated = emptyList())
val searchResp = runCatching {
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
@@ -3,6 +3,7 @@ package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.ArtistOverride
import com.novasonic.app.api.model.SearchResult3
import com.novasonic.app.app
import kotlinx.coroutines.Job
@@ -16,6 +17,8 @@ data class SearchUiState(
val searchResult: SearchResult3? = null,
val isLoading: Boolean = false,
val error: String? = null,
// Artist pins (by Navidrome artist id) so a pinned artist shows the correct picture in results.
val overrides: Map<String, ArtistOverride> = emptyMap(),
)
class SearchViewModel(app: Application) : AndroidViewModel(app) {
@@ -27,6 +30,13 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) {
private var searchJob: Job? = null
init {
viewModelScope.launch {
val ov = runCatching { repo.artistOverrides() }.getOrDefault(emptyMap())
if (ov.isNotEmpty()) _state.value = _state.value.copy(overrides = ov)
}
}
fun onQueryChange(q: String) = onSearch(q)
fun onSearch(q: String) {