diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt index eac5d89..e964749 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/ItunesClient.kt @@ -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 { + val artists: DzList = 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 { val albums: DzList = http.get("$BASE/chart/0/albums") { diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt index 7c693de..a0c0f20 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistDetailScreen.kt @@ -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( diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt index 5c59821..e5b9b04 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt @@ -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)) diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt index 1220cd6..b178725 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistViewModel.kt @@ -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 = emptyList(), val showPinPicker: Boolean = false, val pinSearching: Boolean = false, val pinCandidates: List = 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) diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt index 58323c2..07d5611 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/SearchViewModel.kt @@ -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 = 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) { diff --git a/public/sw.js b/public/sw.js index 53a53b2..939d669 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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. diff --git a/src/api.js b/src/api.js index 2d4accb..28c3f16 100644 --- a/src/api.js +++ b/src/api.js @@ -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). diff --git a/src/views/detail.js b/src/views/detail.js index 6b4a748..26f782a 100644 --- a/src/views/detail.js +++ b/src/views/detail.js @@ -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 = `
Ähnliche Künstler
-
${similar.map(a => - ` - ${esc(a.name)}`).join('')}
`; + // ── 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 = `
Ähnliche Künstler
+
${simList.map(a => ` +
+
+ ${a.img ? `` : `
🎤
`} +
+
${esc(a.name)}
+
`).join('')}
`; + } } // ── Top Songs ── diff --git a/src/views/search.js b/src/views/search.js index a63bbdb..4d7bb23 100644 --- a/src/views/search.js +++ b/src/views/search.js @@ -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 = ``; - }); + }; + // 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); }