feat(android): artist pin (same-name disambiguation) — parity with web

Android now reads AND writes the shared server-side artist overrides
(/api/artist-overrides), so pins set on web apply here and can be set/removed
in-app — closing the web-only gap.

- ArtistOverride model + ArtistOverrideClient (GET/POST/DELETE on the backend);
  MusicRepository caches the map and exposes get/set/remove.
- ArtistViewModel resolves the pin up front; when pinned it enriches the artist
  page straight from the pinned Deezer id (albums/top-tracks/fans/image) instead
  of the name-guess. Picker: Deezer search candidates + a "paste Deezer link/id"
  route (reliable when /search/artist can't surface a low-profile same-name
  artist), plus clear-pin.
- ArtistDetailScreen: "⚙ Falscher Künstler? / Gepinnt" pill top-right of the hero,
  pinned Deezer picture overrides the hero image, ModalBottomSheet picker.
- ItunesClient.lookupArtist(id) (Deezer-backed) for the link-paste flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-13 18:28:29 +02:00
1 parent a5e5d414b4
commit 1ee85482f3
6 files changed
+351 -3

No files matched your search

@@ -0,0 +1,59 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.ArtistOverride
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Reads/writes the shared artist-pin overrides on the download backend
* (GET/POST /api/artist-overrides, DELETE /api/artist-overrides/:id).
* The pins fix same-name artist collisions; see [ArtistOverride].
*/
class ArtistOverrideClient(private val backendUrl: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; coerceInputValues = true }) }
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
}
private fun base() = backendUrl.trimEnd('/')
@Serializable
private data class SetReq(
val ndArtistId: String,
val deezerArtistId: String,
val name: String,
val image: String,
val fanCount: Long?,
)
/** Full map { ndArtistId -> override }. */
suspend fun loadAll(): Map<String, ArtistOverride> =
http.get("${base()}/api/artist-overrides").body()
/** Upsert a pin. Returns true on 2xx. */
suspend fun set(ndArtistId: String, ov: ArtistOverride): Boolean {
val resp: HttpResponse = http.post("${base()}/api/artist-overrides") {
contentType(ContentType.Application.Json)
setBody(SetReq(ndArtistId, ov.deezerArtistId, ov.name, ov.image, ov.fanCount))
}
return resp.status.isSuccess()
}
/** Remove a pin. Returns true on 2xx. */
suspend fun remove(ndArtistId: String): Boolean {
val resp: HttpResponse = http.delete("${base()}/api/artist-overrides/${ndArtistId.encodeURLPath()}")
return resp.status.isSuccess()
}
fun close() = http.close()
}
@@ -72,6 +72,16 @@ object ItunesClient {
return albums.data.filter { it.record_type == "album" }.map { it.toItunesItem() }
}
/**
* A single Deezer artist by id (name / picture / fan count), as an ItunesItem. The reliable
* route when /search/artist can't surface a low-profile same-name artist — used by the
* artist-pin "paste Deezer link/id" flow. Null on a bad id / error.
*/
suspend fun lookupArtist(artistId: Long): ItunesItem? {
val a = runCatching { http.get("$BASE/artist/$artistId").body<DzArtist>() }.getOrNull()
return if (a?.id != null) a.toItunesItem() else null
}
/** Top tracks for a Deezer artist. */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
@@ -0,0 +1,17 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
/**
* A manual "pin" mapping a local Navidrome artist → a specific Deezer artist, fixing same-name
* collisions where NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
* Stored server-side (shared across users) on the download backend at /api/artist-overrides, keyed
* by the Navidrome artist id. Mirrors the web PWA's override shape exactly.
*/
@Serializable
data class ArtistOverride(
val deezerArtistId: String = "",
val name: String = "",
val image: String = "",
val fanCount: Long? = null,
)
@@ -64,6 +64,42 @@ class MusicRepository(private val context: Context) {
playLog.fetchStats(from, to, tz, bucket, limit)
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
// ── Artist pins (shared, server-side same-name disambiguation) ───────────────
@Volatile private var _artistOverrides: Map<String, ArtistOverride>? = null
/** Full pin map, cached after first load. Empty if no backend / unreachable. */
suspend fun artistOverrides(): Map<String, ArtistOverride> {
_artistOverrides?.let { return it }
val url = getBackendUrl()
if (url.isBlank()) return emptyMap<String, ArtistOverride>().also { _artistOverrides = it }
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val m = runCatching { c.loadAll() }.getOrDefault(emptyMap())
c.close()
return m.also { _artistOverrides = it }
}
suspend fun getArtistOverride(ndArtistId: String): ArtistOverride? = artistOverrides()[ndArtistId]
suspend fun setArtistOverride(ndArtistId: String, ov: ArtistOverride): Boolean {
val url = getBackendUrl()
if (url.isBlank()) return false
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val ok = runCatching { c.set(ndArtistId, ov) }.getOrDefault(false)
c.close()
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) + (ndArtistId to ov)
return ok
}
suspend fun removeArtistOverride(ndArtistId: String): Boolean {
val url = getBackendUrl()
if (url.isBlank()) return false
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val ok = runCatching { c.remove(ndArtistId) }.getOrDefault(false)
c.close()
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) - ndArtistId
return ok
}
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
suspend fun getGenres() = api().getGenres()
@@ -111,6 +111,22 @@ fun ArtistDetailScreen(
)
}
// Artist-pin picker
if (state.showPinPicker) {
ArtistPinSheet(
artistName = state.artist?.name ?: "",
pinned = state.pin,
searching = state.pinSearching,
candidates = state.pinCandidates,
onPick = vm::pinCandidate,
onPinLink = { link -> vm.pinByLink(link) { ok ->
if (!ok) Toast.makeText(context, "Deezer-Künstler nicht gefunden", Toast.LENGTH_SHORT).show()
} },
onClear = vm::clearPin,
onDismiss = vm::dismissPinPicker,
)
}
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
@@ -218,7 +234,10 @@ fun ArtistDetailScreen(
// Hero card
item {
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
// Navidrome/Last.fm image is the wrong person).
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
?: coverUrl(artist.coverArt)
Box(
Modifier
@@ -234,6 +253,20 @@ fun ArtistDetailScreen(
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
// "Wrong artist?" pin — top-right over the hero.
Surface(
onClick = { vm.openPinPicker() },
shape = RoundedCornerShape(999.dp),
color = Color(0x66000000),
modifier = Modifier.align(Alignment.TopEnd).padding(10.dp),
) {
Text(
if (state.pin != null) "⚙ Gepinnt" else "⚙ Falscher Künstler?",
fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
color = Color.White.copy(0.92f),
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
@@ -629,3 +662,93 @@ internal fun AllAlbumsOverlay(
}
}
}
// ─── Artist-pin picker ────────────────────────────────────────────────────────
// Pin a local (Navidrome) artist to a specific Deezer artist, fixing same-name collisions.
// A pasted Deezer link/id is the reliable route when /search/artist can't surface the artist.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ArtistPinSheet(
artistName: String,
pinned: com.novasonic.app.api.model.ArtistOverride?,
searching: Boolean,
candidates: List<com.novasonic.app.api.model.ItunesItem>,
onPick: (com.novasonic.app.api.model.ItunesItem) -> Unit,
onPinLink: (String) -> Unit,
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var link by remember { mutableStateOf("") }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = NsSurface) {
Column(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 20.dp)
.padding(bottom = 16.dp),
) {
Text("Richtigen Künstler wählen", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 18.sp, color = NsOnSurface)
Text("Für „$artistName\"", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 2.dp, bottom = 14.dp))
if (pinned != null) {
OutlinedButton(
onClick = onClear,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
) {
Text("Pin entfernen (aktuell: ${pinned.name.ifBlank { "gepinnt" }})",
color = NsError, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
}
// Paste a Deezer link/id → pin directly (reliable when search misses the artist).
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = link, onValueChange = { link = it },
placeholder = { Text("Deezer-Link oder ID", fontFamily = Outfit, fontSize = 13.sp) },
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = { if (link.isNotBlank()) onPinLink(link) },
shape = RoundedCornerShape(10.dp),
) { Text("Pinnen", fontFamily = Outfit, fontWeight = FontWeight.Bold) }
}
Text("Nicht in der Liste? Künstler auf deezer.com öffnen und Link einfügen.",
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp))
when {
searching -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(28.dp))
}
candidates.isEmpty() -> Text("Keine Deezer-Künstler gefunden.", fontFamily = Outfit,
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 12.dp))
else -> Column(Modifier.fillMaxWidth()) {
candidates.forEach { c ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { onPick(c) }.padding(vertical = 7.dp),
) {
Box(Modifier.size(48.dp).clip(CircleShape).background(NsSurface2)) {
AsyncImage(model = c.artworkUrl100, contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(c.artistName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(c.fanCount?.let { "${fmtFanCount(it)} Fans" } ?: "Deezer",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
}
}
}
}
}
}
@@ -8,6 +8,7 @@ import com.novasonic.app.api.EventsClient
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ItunesClient
import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.ArtistOverride
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.api.model.ArtistInfo
import com.novasonic.app.api.model.DownloadRequest
@@ -46,6 +47,13 @@ data class ArtistUiState(
// Preview + download
val previewingId: Long? = null,
val downloadSheetItem: ItunesItem? = null,
// Artist pin (same-name disambiguation): the active pin, the image it overrides the hero with,
// and the picker state (link/id paste + Deezer search candidates).
val pin: ArtistOverride? = null,
val pinnedImage: String? = null,
val showPinPicker: Boolean = false,
val pinSearching: Boolean = false,
val pinCandidates: List<ItunesItem> = emptyList(),
)
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
@@ -84,17 +92,22 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
val info = infoDef.await()
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
val pin = pinDef.await()
_state.value = ArtistUiState(
artist = artist, info = info, topSongs = top,
isLoading = false, itunesLoading = true,
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
)
// Fetch iTunes + events in parallel without blocking the main screen
launch { loadItunesData(artist.name) }
launch { loadItunesData(artist.name, pin) }
launch {
val backendUrl = repo.getBackendUrl()
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
@@ -106,7 +119,24 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
}
}
private suspend fun loadItunesData(artistName: String) {
private suspend fun loadItunesData(artistName: String, pin: ArtistOverride? = null) {
// Pinned: skip the name-guess entirely and enrich straight from the pinned Deezer artist id.
val pinnedId = pin?.deezerArtistId?.toLongOrNull()
if (pinnedId != null) {
_state.value = _state.value.copy(
fanCount = pin.fanCount ?: _state.value.fanCount,
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()) }
_state.value = _state.value.copy(
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(), itunesLoading = false,
)
}
return
}
val searchResp = runCatching {
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
}.getOrNull()
@@ -275,6 +305,79 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
fun stopPreview() = preview.stop()
// ── Artist pin (same-name disambiguation) ────────────────────────────────
fun openPinPicker() {
_state.value = _state.value.copy(showPinPicker = true, pinCandidates = emptyList(), pinSearching = true)
val name = _state.value.artist?.name ?: return
viewModelScope.launch {
val res = runCatching { ItunesClient.search(name, entity = "musicArtist", limit = 8) }.getOrNull()
val cands = res?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
_state.value = _state.value.copy(pinCandidates = cands, pinSearching = false)
}
}
fun dismissPinPicker() { _state.value = _state.value.copy(showPinPicker = false) }
/** Pin one of the searched candidates. */
fun pinCandidate(item: ItunesItem) {
val dzId = item.artistId ?: return
applyPin(ArtistOverride(
deezerArtistId = dzId.toString(),
name = item.artistName ?: _state.value.artist?.name ?: "",
image = item.artworkUrl100 ?: "",
fanCount = item.fanCount,
))
}
/** Pin by a pasted Deezer link or bare id (the reliable route when search can't surface it). */
fun pinByLink(raw: String, onResult: (Boolean) -> Unit) {
val id = parseDeezerArtistId(raw)?.toLongOrNull()
if (id == null) { onResult(false); return }
viewModelScope.launch {
val a = runCatching { ItunesClient.lookupArtist(id) }.getOrNull()
if (a?.artistId == null) { onResult(false); return@launch }
applyPin(ArtistOverride(
deezerArtistId = a.artistId.toString(),
name = a.artistName ?: _state.value.artist?.name ?: "",
image = a.artworkUrl100 ?: "",
fanCount = a.fanCount,
))
onResult(true)
}
}
fun clearPin() {
val id = artistId ?: return
viewModelScope.launch {
runCatching { repo.removeArtistOverride(id) }
_state.value = _state.value.copy(pin = null, pinnedImage = null, showPinPicker = false, itunesLoading = true)
loadItunesData(_state.value.artist?.name ?: "", null)
}
}
private fun applyPin(ov: ArtistOverride) {
val id = artistId ?: return
viewModelScope.launch {
runCatching { repo.setArtistOverride(id, ov) }
_state.value = _state.value.copy(
pin = ov,
pinnedImage = ov.image.takeIf { it.isNotBlank() },
showPinPicker = false,
itunesLoading = true,
)
loadItunesData(_state.value.artist?.name ?: "", ov)
}
}
// Pull a Deezer artist id out of a pasted URL (deezer.com/.../artist/12345) or a bare id.
private fun parseDeezerArtistId(raw: String): String? {
val s = raw.trim()
if (s.isEmpty()) return null
Regex("artist/(\\d+)").find(s)?.let { return it.groupValues[1] }
return if (s.matches(Regex("\\d+"))) s else null
}
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
// global bar) after leaving this screen.
}