From 1ee85482f3cb139de7353e19d34c0e24eef51325 Mon Sep 17 00:00:00 2001 From: Honey Date: Thu, 13 Aug 2026 18:28:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(android):=20artist=20pin=20(same-name=20di?= =?UTF-8?q?sambiguation)=20=E2=80=94=20parity=20with=20web?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../novasonic/app/api/ArtistOverrideClient.kt | 59 +++++++++ .../com/novasonic/app/api/ItunesClient.kt | 10 ++ .../novasonic/app/api/model/ArtistOverride.kt | 17 +++ .../com/novasonic/app/data/MusicRepository.kt | 36 +++++ .../app/ui/screen/ArtistDetailScreen.kt | 125 +++++++++++++++++- .../app/viewmodel/ArtistViewModel.kt | 107 ++++++++++++++- 6 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/kotlin/com/novasonic/app/api/ArtistOverrideClient.kt create mode 100644 android/app/src/main/kotlin/com/novasonic/app/api/model/ArtistOverride.kt diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/ArtistOverrideClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/ArtistOverrideClient.kt new file mode 100644 index 0000000..011adcc --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/ArtistOverrideClient.kt @@ -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 = + 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() +} 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 b500147..eac5d89 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 @@ -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() }.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 { val tracks: DzList = http.get("$BASE/artist/$artistId/top") { diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ArtistOverride.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ArtistOverride.kt new file mode 100644 index 0000000..3a67940 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ArtistOverride.kt @@ -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, +) diff --git a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt index 62591ab..65b4cc3 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/data/MusicRepository.kt @@ -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? = null + + /** Full pin map, cached after first load. Empty if no backend / unreachable. */ + suspend fun artistOverrides(): Map { + _artistOverrides?.let { return it } + val url = getBackendUrl() + if (url.isBlank()) return emptyMap().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() 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 fa95f72..7c693de 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 @@ -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, + 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) + } + } + } + } + } + } + } +} 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 ef3695d..1220cd6 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 @@ -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 = 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. }