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

+68 -2
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js';
import { esc, toast, fmtCount } from '../utils.js';
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
import { api, invalidateApiCache, 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';
@@ -112,7 +112,11 @@ export async function loadArtistDetail(artist) {
: hasND ? getArtistImage(artist.id) : Promise.resolve(null);
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
// 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 einfach). Der Namensweg
// sucht mit LIKE und Max:1 — bei zwei gleichnamigen Kuenstlern in der Bibliothek gewinnt also der
// erste Treffer, und das war nicht zwingend der gemeinte.
const topP = hasND ? api('getTopSongs', `&id=${encodeURIComponent(artist.id)}&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5);
const eventsP = fetchEvents(artist.name);
@@ -331,6 +335,30 @@ export async function loadArtistDetail(artist) {
evBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> Konzerte${events.length ? ` <span style="font-size:11px;opacity:.7">(${events.length})</span>` : ''}`;
evBtn.onclick = () => openEventsModal(events, artist.name);
actionBar.appendChild(evBtn);
// Folgen = Navidromes Stern auf dem Künstler. Kein eigener Speicher, sofort auf allen Geräten
// und in Navidromes eigener Oberfläche sichtbar.
if (hasND && artist.id) {
const folgeBtn = document.createElement('button');
folgeBtn.className = 'artist-action-btn';
actionBar.appendChild(folgeBtn);
_verdrahteMerken(
folgeBtn, artist.id, _ndArtist?.starred,
'Folge ich', 'Folgen',
`❤ Du folgst ${artist.name}`, `Folgst ${artist.name} nicht mehr`,
);
// Ohne Pinnung läuft die Deezer-Zuordnung über den Namen — bei gleichnamigen Künstlern
// bekäme man dann dauerhaft die Neuheiten des Falschen gemeldet.
if (!_pin && !_ndArtist?.starred) {
folgeBtn.addEventListener('click', () => {
setTimeout(() => {
if (folgeBtn.classList.contains('is-starred')) {
toast('Tipp: Über ⚙ im Kopfbereich den richtigen Deezer-Künstler festpinnen');
}
}, 900);
}, { once: true });
}
}
}
if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist);
@@ -479,6 +507,40 @@ export async function loadArtistAllSongs({ albums, artist }) {
});
}
// ─── MARKIEREN (Künstler + Album) ───
// Navidrome wirft beim Markieren `id`, `albumId` und `artistId` in eine Liste und behandelt sie
// gleich (media_annotation.go) — derselbe Aufruf taugt also für Songs, Alben und Künstler. Der
// Zustand kommt aus `starred` in getArtist/getAlbum.
const _HERZ = (voll) => `<svg width="13" height="13" viewBox="0 0 24 24" fill="${voll ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>`;
/**
* Verdrahtet einen Knopf als Markieren-Umschalter. Optimistisch: der Knopf springt sofort um und
* kehrt zurück, wenn der Aufruf scheitert — sonst fühlt sich das Markieren träge an.
*/
function _verdrahteMerken(btn, id, markiert, textAn, textAus, meldungAn, meldungAus) {
if (!btn || !id) return;
let an = !!markiert;
const malen = () => {
const label = btn.querySelector('span');
btn.innerHTML = _HERZ(an) + `<span>${an ? textAn : textAus}</span>`;
btn.classList.toggle('is-starred', an);
void label;
};
malen();
btn.onclick = async () => {
const vorher = an;
an = !an; malen();
try {
await api(vorher ? 'unstar' : 'star', `&id=${encodeURIComponent(id)}`);
invalidateApiCache('getStarred2');
toast(an ? meldungAn : meldungAus);
} catch (e) {
an = vorher; malen();
toast('Fehler: ' + e.message);
}
};
}
export async function loadDeezerAlbumDetail(album) {
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
@@ -604,6 +666,10 @@ export async function loadAlbumDetail(album) {
const songs = r.album?.song || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
_verdrahteMerken(
document.getElementById('btn-star-album'), album.id, r.album?.starred,
'Gemerkt', 'Merken', '❤ Album gemerkt', 'Aus den Favoriten entfernt',
);
// r.album has sortName (album sort tag) — use it; nav album may not have sortName
document.getElementById('album-detail-name').textContent = dispAlbumName(r.album);
// Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the