feat(web+android): Künstlern folgen, Alben merken, Top Songs per Künstler-ID

Folgen ist kein eigener Speicher, sondern Navidromes Stern auf dem
Künstler (star&id=<ndArtistId>) — Navidrome wirft id, albumId und
artistId beim Markieren in eine Liste und behandelt sie gleich, der
vorhandene Aufruf trug also ohne Änderung. Zustand aus `starred` in
getArtist/getAlbum, beide Plattformen schalten optimistisch um.

Android nutzt den vorhandenen StarState, der ohnehin über IDs arbeitet.

Dazu: getTopSongs bekommt seit Navidrome 0.64 zusätzlich `id`. Der
Namensweg sucht mit LIKE und Max:1, bei gleichnamigen Künstlern gewann
der erste Treffer. Jetzt id + artist zusammen; in Erkunden bleibt es beim
Namen, weil die artistId dort Deezers ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-16 17:51:53 +02:00
1 parent de674dbe3f
commit 5f043beb5d
11 files changed
+177 -11

No files matched your search

@@ -149,9 +149,16 @@ class SubsonicClient(
get("getArtistInfo2.view", mapOf("id" to id, "count" to count)).artistInfo2
?: com.novasonic.app.api.model.ArtistInfo()
suspend fun getTopSongs(artist: String, count: Int = 10): List<com.novasonic.app.api.model.Song> =
get("getTopSongs.view", mapOf("artist" to artist, "count" to count)).topSongs?.song
?: emptyList()
/**
* id + artist zusammen: Navidrome nimmt seit 0.64 die Kuenstler-ID und faellt nur dann auf den
* Namen zurueck, wenn es sie nicht kennt; aeltere Versionen ignorieren id. Der Namensweg sucht
* mit LIKE und Max:1 — bei gleichnamigen Kuenstlern gewann bisher der erste Treffer.
*/
suspend fun getTopSongs(artist: String, count: Int = 10, artistId: String? = null): List<com.novasonic.app.api.model.Song> =
get("getTopSongs.view", buildMap {
put("artist", artist); put("count", count)
artistId?.takeIf { it.isNotBlank() }?.let { put("id", it) }
}).topSongs?.song ?: emptyList()
suspend fun getGenres(): List<com.novasonic.app.api.model.Genre> =
get("getGenres.view").genres?.genre ?: emptyList()
@@ -101,7 +101,10 @@ class MusicRepository(private val context: Context) {
}
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
/** artistId ist die NAVIDROME-Kuenstler-ID (nicht Deezers) — ohne sie sucht Navidrome
* ueber den Namen und nimmt bei Gleichnamigen den ersten Treffer. */
suspend fun getTopSongs(artistName: String, artistId: String? = null) =
api().getTopSongs(artistName, artistId = artistId)
suspend fun getGenres() = api().getGenres()
suspend fun getSongsByGenre(genre: String) = api().getSongsByGenre(genre)
suspend fun addSongToPlaylist(playlistId: String, songId: String) = api().addSongToPlaylist(playlistId, songId)
@@ -11,6 +11,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Download
import com.novasonic.app.data.StarState
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.*
@@ -177,6 +180,25 @@ fun AlbumScreen(
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
}
// Album merken — derselbe Weg wie beim Song: Navidrome behandelt Song-, Album- und
// Kuenstler-IDs beim Markieren gleich.
val overrides by StarState.overrides.collectAsStateWithLifecycle()
val gemerkt = overrides[album.id] ?: (album.starred != null)
OutlinedButton(
onClick = { vm.merkenUmschalten(album.id, album.starred != null) },
modifier = Modifier.height(46.dp).width(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (gemerkt) NsAccent else NsAccent.copy(alpha = 0.55f))
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(
if (gemerkt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
if (gemerkt) "Gemerkt" else "Album merken",
Modifier.size(20.dp), tint = NsAccent,
)
}
val isFullyDownloaded = downloadGroup != null &&
downloadGroup!!.downloadedTracks >= downloadGroup!!.totalTracks &&
downloadGroup!!.totalTracks > 0
@@ -20,6 +20,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Event
import com.novasonic.app.data.StarState
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
@@ -320,6 +323,18 @@ fun ArtistDetailScreen(
onClick = { vm.shuffleAll() },
)
}
item {
// Folgen = Navidromes Stern auf dem Kuenstler. StarState kennt keine Songs,
// sondern IDs — und Navidrome behandelt Song-, Album- und Kuenstler-IDs beim
// Markieren gleich, der vorhandene Weg traegt also ohne Aenderung.
val overrides by StarState.overrides.collectAsStateWithLifecycle()
val folgt = overrides[artist.id] ?: (artist.starred != null)
ArtistActionPill(
icon = if (folgt) Icons.Filled.Favorite else Icons.Filled.FavoriteBorder,
label = if (folgt) "Folge ich" else "Folgen",
hervorgehoben = folgt,
) { vm.folgenUmschalten(artist.id, artist.starred != null) }
}
item {
val eventCount = state.events.size
val konzertLabel = if (eventCount > 0) "Konzerte ($eventCount)" else "Konzerte"
@@ -585,20 +600,26 @@ fun ArtistDetailScreen(
}
@Composable
private fun ArtistActionPill(icon: ImageVector, label: String, onClick: () -> Unit) {
private fun ArtistActionPill(
icon: ImageVector,
label: String,
hervorgehoben: Boolean = false,
onClick: () -> Unit,
) {
val farbe = if (hervorgehoben) NsAccent else NsOnSurface
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(NsSurface)
.border(1.dp, NsBorder, RoundedCornerShape(20.dp))
.border(1.dp, if (hervorgehoben) NsAccent else NsBorder, RoundedCornerShape(20.dp))
.clickable(onClick = onClick)
.padding(horizontal = 18.dp, vertical = 9.dp),
) {
Icon(icon, null, tint = NsOnSurface, modifier = Modifier.size(16.dp))
Icon(icon, null, tint = farbe, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text(label, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface)
fontSize = 13.sp, color = farbe)
}
}
@@ -21,6 +21,10 @@ data class AlbumUiState(
class AlbumViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
/** Album merken — optimistisch ueber StarState, faellt bei einem Fehlschlag zurueck. */
fun merkenUmschalten(albumId: String, aktuellMarkiert: Boolean) =
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, albumId, aktuellMarkiert)
private val offlineRepo = app.app.offlineRepository
private val albumId: String? = savedState["albumId"]
@@ -63,6 +63,12 @@ data class ArtistUiState(
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
/** Folgen = Navidromes Stern auf dem Kuenstler. Laeuft ueber StarState, damit der Zustand
* sofort umspringt und bei einem Fehlschlag von selbst zurueckfaellt. */
fun folgenUmschalten(artistId: String, aktuellMarkiert: Boolean) =
com.novasonic.app.data.StarState.toggle(repo, viewModelScope, artistId, aktuellMarkiert)
private val player = app.app.playerController
private val preview = app.app.previewController
private val artistId: String? = savedState["artistId"]
@@ -101,7 +107,7 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
val artist = artistDef.await()
val info = infoDef.await()
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
val topDef = async { runCatching { repo.getTopSongs(artist.name, artist.id) }.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() }
@@ -203,6 +203,8 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
val artistName = artist.artistName ?: return@launch
val ndAlbumsD: Deferred<List<Album>> = async { runCatching { repo.getNdAlbumsForArtist(artistName) }.getOrDefault(emptyList()) }
// Bewusst ohne id: artistId ist hier DEEZERS Kuenstler-ID, nicht Navidromes. Sie hier
// mitzugeben wuerde Navidrome eine fremde ID unterschieben.
val topSongsD: Deferred<List<Song>> = async { runCatching { repo.getTopSongs(artistName) }.getOrDefault(emptyList()) }
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }