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:
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) {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'ns-v77';
|
||||
const CACHE = 'ns-v78';
|
||||
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
||||
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
||||
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
||||
|
||||
@@ -412,6 +412,15 @@ export async function deezerLookupArtistSongs(artistId, limit=15) {
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Related Deezer artists (real pictures + fan counts). Used for the "Ähnliche Künstler" row when a
|
||||
// pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome artist.
|
||||
export async function deezerRelatedArtists(artistId, limit=12) {
|
||||
try {
|
||||
const r = await fetch(`${_deezerBase()}/artist/${artistId}/related?limit=${limit}`);
|
||||
return ((await r.json()).data || []).map(_dzArtist);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Deezer's /artist/{id}/albums list OMITS the artist object, so _dzAlbum yields artistName:''.
|
||||
// We already know whose page this is → backfill artistName so downstream download/detail cards
|
||||
// don't fall back to the literal "Unknown Artist" (which poisoned the yt-dlp search).
|
||||
|
||||
+29
-8
@@ -1,6 +1,6 @@
|
||||
import { S } from '../state.js';
|
||||
import { esc, toast, fmtCount } from '../utils.js';
|
||||
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
|
||||
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
|
||||
import { openArtistPin } from '../ui/artistPin.js';
|
||||
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
|
||||
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
|
||||
@@ -230,13 +230,34 @@ export async function loadArtistDetail(artist) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Similar artists ──
|
||||
const similar = info?.similarArtist || [];
|
||||
if(similar.length && simEl) {
|
||||
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
|
||||
<div class="similar-chips">${similar.map(a =>
|
||||
`<span class="similar-chip" onclick="navigate('artist-detail',{id:'${esc(a.id||a.name)}',name:'${esc(a.name)}'})">
|
||||
${esc(a.name)}</span>`).join('')}</div>`;
|
||||
// ── Similar artists (with profile pictures) ──
|
||||
// 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". Deezer related carry real photos.
|
||||
if (simEl) {
|
||||
let simList;
|
||||
if (_pin?.deezerArtistId) {
|
||||
const rel = await deezerRelatedArtists(_pin.deezerArtistId, 12);
|
||||
simList = rel.map(r => ({
|
||||
name: r.artistName, img: r.artworkUrl100 || '',
|
||||
nav: { id: null, name: r.artistName, deezerArtistId: r.artistId, deezerFanCount: r.fanCount ?? null },
|
||||
}));
|
||||
} else {
|
||||
simList = (info?.similarArtist || []).map(a => ({
|
||||
name: a.name, img: coverUrl(a.coverArt || a.id, 160) || '',
|
||||
nav: { id: a.id || a.name, name: a.name },
|
||||
}));
|
||||
}
|
||||
if (simList.length) {
|
||||
const _navAttr = n => esc(JSON.stringify(n)).replace(/"/g, '"');
|
||||
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
|
||||
<div class="scroll-row">${simList.map(a => `
|
||||
<div class="artist-card" style="cursor:pointer;width:88px" onclick='navigate("artist-detail", ${_navAttr(a.nav)})'>
|
||||
<div class="card-art" style="width:88px;height:88px;border-radius:50%;overflow:hidden">
|
||||
${a.img ? `<img src="${esc(a.img)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">` : `<div class="no-art">🎤</div>`}
|
||||
</div>
|
||||
<div class="card-title" style="text-align:center;font-size:12px;margin-top:6px">${esc(a.name)}</div>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top Songs ──
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
import { S } from '../state.js';
|
||||
import { esc, toast, fmtCount } from '../utils.js';
|
||||
import { api, deezerSearch, deezerLastError, deezerLookupAlbum, fetchRomajiIndex, dispArtistById } from '../api.js';
|
||||
import { api, deezerSearch, deezerLastError, deezerLookupAlbum, fetchRomajiIndex, dispArtistById, getArtistOverride } from '../api.js';
|
||||
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
|
||||
import { playPreview } from '../player/player.js';
|
||||
import { openDlModal } from '../ui/ui.js';
|
||||
@@ -64,12 +64,16 @@ export async function doLocalSearch(q, el) {
|
||||
c.classList.add('artist-card');
|
||||
c.onclick = () => navigate('artist-detail', a);
|
||||
g.appendChild(c);
|
||||
getArtistImage(a.id).then(img => {
|
||||
const _setArt = img => {
|
||||
if(!img) return;
|
||||
const artEl = c.querySelector('.card-art');
|
||||
if(!artEl) return;
|
||||
artEl.innerHTML = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">`;
|
||||
});
|
||||
};
|
||||
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image is the wrong person).
|
||||
const _pin = getArtistOverride(a.id);
|
||||
if (_pin?.image) _setArt(_pin.image);
|
||||
else getArtistImage(a.id).then(_setArt);
|
||||
});
|
||||
s.appendChild(g); el.appendChild(s);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user