From 72f608704386f16654ce6970cec371713e6addf4 Mon Sep 17 00:00:00 2001 From: Honey Date: Sun, 26 Apr 2026 19:10:52 +0200 Subject: [PATCH] feat: unified artist page with full iTunes/Navidrome album categorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - loadArtistDetail now works for both Navidrome artists and iTunes-only artists (id=null + itunesArtistId). loadItunesArtistResults from global search now navigates to the same artist-detail page. - Album sections: "In Library" (fully downloaded), "Unvollständig" (partial — e.g. 2/12 tracks), "Noch nicht heruntergeladen" (iTunes-only). Partial albums show X/Y badge and a direct download button on hover. - iTunes albums now fetched via lookup endpoint (up to 200 albums) instead of search (max 20), fixing the missing-albums problem. - "Alle X Songs in Library" expand button fetches all songs across albums and renders them grouped by album with play/context-menu support. - Back button uses _navGoBack() for context-aware navigation. - CSS: .section-count, .album-partial-badge, .card-dl-btn (hover reveal) Co-Authored-By: Claude Sonnet 4.6 --- index.html | 3 +- src/api.js | 6 + src/views.js | 340 ++++++++++++++++++++++++++++++--------------------- styles.css | 4 + 4 files changed, 213 insertions(+), 140 deletions(-) diff --git a/index.html b/index.html index 60e1cd3..dcc292a 100644 --- a/index.html +++ b/index.html @@ -174,8 +174,7 @@
-
Alben
-
+
diff --git a/src/api.js b/src/api.js index 9f63d04..7cec218 100644 --- a/src/api.js +++ b/src/api.js @@ -61,6 +61,12 @@ export async function itunesLookupArtistSongs(artistId, limit=15) { return (d.results || []).filter(x => x.wrapperType === 'track'); } +export async function itunesLookupArtistAlbums(artistId, limit=200) { + const r = await fetch(`${ITUNES}/lookup?id=${artistId}&entity=album&limit=${limit}&country=us`); + const d = await r.json(); + return (d.results || []).filter(x => x.wrapperType === 'collection' && x.collectionType === 'Album'); +} + export async function itunesTopCharts() { const urls = [ 'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/albums.json', diff --git a/src/views.js b/src/views.js index 4a30636..10b3df1 100644 --- a/src/views.js +++ b/src/views.js @@ -1,7 +1,7 @@ import { S } from './state.js'; import { esc, formatDur, toast } from './utils.js'; import { api, coverUrl } from './api.js'; -import { itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesTopCharts, itunesTopSongs } from './api.js'; +import { itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, itunesTopCharts, itunesTopSongs } from './api.js'; // Circular imports — safe (only used inside function bodies) import { playQueue, dlSongLocal, toggleStar, playPreview } from './player.js'; import { showCtxMenu, openEditPlModal, openPlModal, openDlModal, closeSidebar, switchNavMode, closeNowPlaying, closeNpMenu } from './ui.js'; @@ -220,81 +220,91 @@ export async function loadAlbums() { // ─── ARTIST DETAIL ─── export async function loadArtistDetail(artist) { document.getElementById('artist-detail-name').textContent = artist.name; - document.getElementById('artist-back-btn').onclick = () => navigate('artists'); - const hero = document.getElementById('artist-detail-hero'); + document.getElementById('artist-back-btn').onclick = () => _navGoBack(); + + const hero = document.getElementById('artist-detail-hero'); + const bioEl = document.getElementById('artist-bio-section'); + const simEl = document.getElementById('artist-similar-section'); + const tsEl = document.getElementById('artist-topsongs-section'); + const albEl = document.getElementById('artist-albums-section'); hero.style.background = 'linear-gradient(135deg, var(--surface2), var(--surface))'; - ['artist-bio-section','artist-similar-section','artist-topsongs-section'].forEach(id => { - const el = document.getElementById(id); if(el) el.innerHTML = ''; - }); - const g = document.getElementById('artist-albums-grid'); - g.innerHTML = '
'; - document.getElementById('artist-itunes-extra')?.remove(); + const overlay = hero.querySelector('.artist-hero-overlay'); + if(overlay) overlay.style.background = ''; + [bioEl, simEl, tsEl, albEl].forEach(el => { if(el) el.innerHTML = ''; }); + if(albEl) albEl.innerHTML = '
'; - const [imgRes, albumRes, infoRes, topRes, itunesArtistRes, itunesAlbumsRes] = await Promise.allSettled([ - getArtistImage(artist.id), - api('getArtist', `&id=${artist.id}`), - api('getArtistInfo2', `&id=${artist.id}&count=8`), - api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`), - itunesSearch(artist.name, 'musicArtist', 1), - itunesSearch(artist.name, 'album', 20), + const hasND = !!artist.id; + const knownItunes = artist.itunesArtistId || null; + + // Phase 1 — parallel + const [imgRes, albumRes, infoRes, topRes, iArtistRes] = await Promise.allSettled([ + hasND ? getArtistImage(artist.id) : Promise.resolve(null), + hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null), + hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null), + hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null), + knownItunes + ? Promise.resolve([{ artistId: knownItunes }]) + : itunesSearch(artist.name, 'musicArtist', 1), ]); - const itunesArtistId = itunesArtistRes.status==='fulfilled' ? (itunesArtistRes.value[0]?.artistId ?? null) : null; - const itunesTracksRes = itunesArtistId - ? await Promise.allSettled([itunesLookupArtistSongs(itunesArtistId, 10)])[0] - : { status: 'rejected' }; - const img = imgRes.status==='fulfilled' ? imgRes.value : null; + const itunesArtistId = knownItunes + ?? (iArtistRes.status === 'fulfilled' ? (iArtistRes.value[0]?.artistId ?? null) : null); + + // Phase 2 — iTunes data (needs artist ID) + const [iAlbumsRes, iTracksRes] = itunesArtistId + ? await Promise.allSettled([ + itunesLookupArtistAlbums(itunesArtistId), + itunesLookupArtistSongs(itunesArtistId, 15), + ]) + : [{ status: 'rejected' }, { status: 'rejected' }]; + + // ── Hero ── + const img = imgRes.status === 'fulfilled' ? imgRes.value : null; if(img) { hero.style.background = `url('${img}') center/cover no-repeat`; - hero.querySelector('.artist-hero-overlay').style.background = - 'linear-gradient(to right,rgba(7,7,15,.9) 0%,rgba(7,7,15,.5) 60%,rgba(7,7,15,.2) 100%)'; + if(overlay) overlay.style.background = 'linear-gradient(to right,rgba(7,7,15,.9) 0%,rgba(7,7,15,.5) 60%,rgba(7,7,15,.2) 100%)'; } - if(albumRes.status==='fulfilled') { - g.innerHTML = ''; - (albumRes.value.artist?.album || []).forEach(a => g.appendChild(makeAlbumCard({...a,_fromArtist:artist}))); - } else { - g.innerHTML = `

${albumRes.reason?.message}

`; - } - - const info = infoRes.status==='fulfilled' ? infoRes.value.artistInfo2 : null; - if(info?.biography) { - const bioEl = document.getElementById('artist-bio-section'); + // ── Bio ── + const info = infoRes.status === 'fulfilled' ? infoRes.value?.artistInfo2 : null; + if(info?.biography && bioEl) { const bio = info.biography.replace(/]+>/g,'').replace(/<\/a>/g,'').replace(/<[^>]+>/g,'').trim(); const short = bio.length > 400 ? bio.slice(0,400)+'…' : bio; - if(short && bioEl) { + if(short) { window._artistBioFull = bio; bioEl.innerHTML = `
Über
-
${esc(short)}${bio.length>400 +
${esc(short)}${bio.length > 400 ? ` ` : ''}
`; } } + // ── Similar artists ── const similar = info?.similarArtist || []; - if(similar.length) { - const simEl = document.getElementById('artist-similar-section'); - if(simEl) { - simEl.innerHTML = `
Ähnliche Künstler
-
${similar.map(a => - ` - ${esc(a.name)} - `).join('')} -
`; - } + if(similar.length && simEl) { + simEl.innerHTML = `
Ähnliche Künstler
+
${similar.map(a => + ` + ${esc(a.name)}`).join('')}
`; } - const topSongs = topRes.status==='fulfilled' ? (topRes.value.topSongs?.song||[]) : []; - if(topSongs.length) { - const tsEl = document.getElementById('artist-topsongs-section'); - if(tsEl) { - tsEl.innerHTML = `
Top Songs
`; - const list = document.createElement('div'); - list.style.marginBottom = '20px'; + // ── Top Songs ── + const ndAlbums = albumRes.status === 'fulfilled' ? (albumRes.value?.artist?.album || []) : []; + const topSongs = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : []; + const iTracks = iTracksRes.status === 'fulfilled' ? (iTracksRes.value || []).filter(t => t.wrapperType === 'track') : []; + const iAlbums = iAlbumsRes.status === 'fulfilled' ? (iAlbumsRes.value || []) : []; + + if((topSongs.length || iTracks.length) && tsEl) { + tsEl.innerHTML = ''; + + if(topSongs.length) { + const h = document.createElement('div'); + h.className = 'section-title'; h.style.marginBottom = '10px'; h.textContent = 'Top Songs'; + tsEl.appendChild(h); + const list = document.createElement('div'); list.style.marginBottom = '8px'; topSongs.forEach((s, i) => { const cv = coverUrl(s.coverArt, 60); - const row = document.createElement('div'); - row.className = 'topsong-item'; + const row = document.createElement('div'); row.className = 'topsong-item'; row.innerHTML = `
${i+1}
${cv ? `` : '
🎵
'}
${esc(s.title)}
${esc(s.album||'')}
@@ -304,52 +314,153 @@ export async function loadArtistDetail(artist) { list.appendChild(row); }); tsEl.appendChild(list); + if(ndAlbums.length) { + const total = ndAlbums.reduce((n, a) => n + (a.songCount || 0), 0); + const btn = document.createElement('button'); + btn.className = 'btn-outline'; btn.style.cssText = 'font-size:12px;padding:7px 14px;margin-bottom:16px;margin-top:4px'; + btn.textContent = `Alle ${total} Songs in Library`; + btn.onclick = () => _expandArtistAllSongs(btn, ndAlbums, artist); + tsEl.appendChild(btn); + } } - } - // ── iTunes: global top tracks ── - const itunesTracks = itunesTracksRes.status==='fulfilled' - ? (itunesTracksRes.value||[]).filter(t => t.wrapperType==='track' && t.artistName?.toLowerCase()===artist.name.toLowerCase()) - : []; - if(itunesTracks.length) { - const tsEl = document.getElementById('artist-topsongs-section'); - if(tsEl) { + if(iTracks.length) { const heading = topSongs.length ? 'Weltweit populär (iTunes)' : 'Populäre Titel'; const sec = document.createElement('div'); sec.innerHTML = `
${heading}
`; const list = document.createElement('div'); list.className = 'song-list'; list.style.marginBottom = '20px'; - itunesTracks.forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, itunesTracks))); - sec.appendChild(list); - tsEl.appendChild(sec); + iTracks.forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, iTracks))); + sec.appendChild(list); tsEl.appendChild(sec); } } - // ── iTunes: more albums ── - const itunesAlbums = itunesAlbumsRes.status==='fulfilled' ? (itunesAlbumsRes.value||[]) : []; - const serverAlbumNames = new Set((albumRes.status==='fulfilled' ? (albumRes.value.artist?.album||[]) : []).map(a => a.name.toLowerCase())); - const extraAlbums = itunesAlbums.filter(a => - (a.artistId === undefined || a.artistName?.toLowerCase() === artist.name.toLowerCase()) && - !serverAlbumNames.has(a.collectionName?.toLowerCase()) - ); - if(extraAlbums.length) { - const g = document.getElementById('artist-albums-grid'); - if(g) { - const wrap = document.createElement('div'); wrap.id = 'artist-itunes-extra'; - const heading = document.createElement('div'); - heading.className = 'section-title'; - heading.style.cssText = 'margin-top:24px;margin-bottom:12px'; - heading.textContent = 'Weitere Alben (iTunes)'; - const extraGrid = document.createElement('div'); extraGrid.className = 'grid grid-4'; - extraAlbums.slice(0,16).forEach(a => { - const art = a.artworkUrl100?.replace('100x100','300x300'); - const c = makeCard(a.collectionName, String(a.releaseDate?.slice(0,4)||''), art, '💿'); - c.onclick = () => loadItunesAlbum(a); - extraGrid.appendChild(c); - }); - wrap.appendChild(heading); wrap.appendChild(extraGrid); - g.after(wrap); + // ── Albums ── + if(albEl) { albEl.innerHTML = ''; _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist); } +} + +function _normAlbum(s) { + return (s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) { + const downloaded = [], partial = [], notDl = []; + const usedNdIds = new Set(); + + for(const ia of iAlbums) { + const iName = _normAlbum(ia.collectionName); + const iCount = ia.trackCount || 0; + const match = ndAlbums.find(a => { + const n = _normAlbum(a.name); + return n === iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName)); + }); + if(!match) { + notDl.push(ia); + } else if(iCount > 0 && match.songCount >= iCount) { + downloaded.push({ nd: match, itunes: ia }); usedNdIds.add(match.id); + } else { + partial.push({ nd: match, itunes: ia, have: match.songCount || 0, total: iCount }); usedNdIds.add(match.id); } } + // Navidrome albums with no iTunes match go to downloaded + for(const na of ndAlbums) { + if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, itunes: null }); + } + + downloaded.sort((a, b) => (b.nd.year||0) - (a.nd.year||0)); + partial.sort((a, b) => (b.nd.year||0) - (a.nd.year||0)); + notDl.sort((a, b) => new Date(b.releaseDate||0) - new Date(a.releaseDate||0)); + + function addGrid(title, count) { + const h = document.createElement('div'); + h.className = 'section-title'; + h.style.cssText = 'margin-bottom:12px;margin-top:20px;display:flex;align-items:center;gap:8px'; + h.innerHTML = `${title}${count}`; + el.appendChild(h); + const g = document.createElement('div'); g.className = 'grid grid-4'; el.appendChild(g); + return g; + } + + if(downloaded.length) { + const g = addGrid('In Library', downloaded.length); + downloaded.forEach(({ nd, itunes }) => g.appendChild(makeAlbumCard({ ...nd, _fromArtist: artist }))); + } + + if(partial.length) { + const g = addGrid('Unvollständig', partial.length); + partial.forEach(({ nd, itunes, have, total }) => { + const art = itunes?.artworkUrl100?.replace('100x100','300x300') || coverUrl(nd.coverArt, 300); + const c = makeCard(nd.name, `${have} / ${total} Tracks`, art, '💿'); + c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(nd); }; + c.onclick = () => navigate('album-detail', { ...nd, _fromArtist: artist }); + const artEl = c.querySelector('.card-art'); + const badge = document.createElement('div'); + badge.className = 'album-partial-badge'; badge.textContent = `${have}/${total}`; + artEl.appendChild(badge); + if(itunes) { + const btn = document.createElement('button'); + btn.className = 'card-dl-btn'; btn.title = 'Fehlende Tracks laden'; + btn.innerHTML = ``; + btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: artist.name, trackName: nd.name, collectionName: nd.name, artworkUrl100: itunes.artworkUrl100||'', isAlbum: true, itunesData: itunes, collectionId: itunes.collectionId }); }; + artEl.appendChild(btn); + } + g.appendChild(c); + }); + } + + if(notDl.length) { + const g = addGrid('Noch nicht heruntergeladen', notDl.length); + notDl.forEach(ia => { + const art = ia.artworkUrl100?.replace('100x100','300x300'); + const c = makeCard(ia.collectionName, ia.releaseDate?.slice(0,4)||'', art, '💿'); + c.onclick = () => loadItunesAlbum(ia); + const artEl = c.querySelector('.card-art'); + const btn = document.createElement('button'); + btn.className = 'card-dl-btn'; btn.title = 'Album laden'; + btn.innerHTML = ``; + btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: ia.artistName, trackName: ia.collectionName, collectionName: ia.collectionName, artworkUrl100: ia.artworkUrl100||'', isAlbum: true, itunesData: ia, collectionId: ia.collectionId }); }; + artEl.appendChild(btn); + g.appendChild(c); + }); + } + + // No iTunes data at all — show Navidrome albums plainly + if(!downloaded.length && !partial.length && !notDl.length && ndAlbums.length) { + const g = addGrid('Alben', ndAlbums.length); + ndAlbums.forEach(a => g.appendChild(makeAlbumCard({ ...a, _fromArtist: artist }))); + } +} + +async function _expandArtistAllSongs(btn, albums, artist) { + btn.disabled = true; btn.textContent = 'Lädt…'; + const results = await Promise.allSettled(albums.map(a => api('getAlbum', `&id=${a.id}`))); + const container = document.createElement('div'); container.style.marginBottom = '16px'; + btn.replaceWith(container); + const allSongs = results + .filter(r => r.status === 'fulfilled') + .flatMap(r => { const alb = r.value?.album; return (alb?.song || []).map(s => ({ ...s, _alb: alb })); }) + .sort((a, b) => { + if(a._alb?.year !== b._alb?.year) return (b._alb?.year||0) - (a._alb?.year||0); + if(a._alb?.id !== b._alb?.id) return (a._alb?.name||'').localeCompare(b._alb?.name||''); + return (a.trackNumber||0) - (b.trackNumber||0); + }); + let curId = ''; + allSongs.forEach((s, i) => { + if(s._alb?.id !== curId) { + curId = s._alb?.id || ''; + const h = document.createElement('div'); + h.style.cssText = 'font-size:12px;font-weight:700;color:var(--text2);padding:8px 0 2px;border-top:1px solid var(--border);margin-top:4px'; + h.textContent = s._alb?.name || ''; container.appendChild(h); + } + const cv = coverUrl(s.coverArt, 60); + const row = document.createElement('div'); row.className = 'topsong-item'; + row.innerHTML = `
${s.trackNumber||''}
+
${cv?``:'
🎵
'}
+
${esc(s.title)}
+
${s.playCount?s.playCount.toLocaleString():''}
`; + row.onclick = () => playQueue(allSongs, i); + row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, allSongs, i); }); + container.appendChild(row); + }); } // ─── ALBUM DETAIL ─── @@ -623,55 +734,8 @@ export async function loadItunesAlbum(album) { } catch(e) { toast('Fehler: ' + e.message); } } -export async function loadItunesArtistResults(artistId, artistName) { - const el = document.getElementById('global-search-results'); - el.innerHTML = '
'; - try { - const [albums, topTracks] = await Promise.all([ - itunesSearch(artistName, 'album', 20), - itunesLookupArtistSongs(artistId, 15), - ]); - const artistAlbums = albums.filter(a => a.artistId===artistId || a.artistName===artistName); - const artistTracks = topTracks.filter(t => t.wrapperType==='track' && (t.artistId===artistId || t.artistName===artistName)); - const coverImg = artistAlbums[0]?.artworkUrl100?.replace('100x100','600x600') || ''; - el.innerHTML = ''; - - // Hero header - const hero = document.createElement('div'); - hero.style.cssText = 'position:relative;border-radius:16px;overflow:hidden;margin-bottom:20px;min-height:120px;background:var(--surface2)'; - if(coverImg) hero.style.background = `url('${coverImg}') center/cover no-repeat`; - hero.innerHTML = ` -
-
- -
${esc(artistName)}
-
`; - el.appendChild(hero); - - // Top songs - if(artistTracks.length) { - const s = mkSection('Populäre Titel'); - const list = document.createElement('div'); list.className = 'song-list'; - artistTracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, artistTracks))); - s.appendChild(list); el.appendChild(s); - } - - // Albums - const finalAlbums = artistAlbums.length ? artistAlbums : albums; - if(finalAlbums.length) { - const s = mkSection('Alben'); - const grid = document.createElement('div'); grid.className = 'grid grid-4'; - finalAlbums.forEach(a => { - const art = a.artworkUrl100?.replace('100x100','300x300'); - const c = makeCard(a.collectionName, a.artistName, art, '💿'); - c.onclick = () => loadItunesAlbum(a); - grid.appendChild(c); - }); - s.appendChild(grid); el.appendChild(s); - } - } catch(e) { el.innerHTML = `

${e.message}

`; } +export function loadItunesArtistResults(artistId, artistName) { + navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId }); } // ─── NEW RELEASES ─── diff --git a/styles.css b/styles.css index f055744..6f387a3 100644 --- a/styles.css +++ b/styles.css @@ -622,6 +622,10 @@ nav{flex:1;overflow-y:auto;padding:4px 10px} .lyrics-line.past{color:rgba(255,255,255,.45)} .lyrics-line.active{color:#fff;font-size:21px;font-weight:600} .lyrics-plain{font-size:15px;line-height:1.9;color:rgba(255,255,255,.65);white-space:pre-wrap;text-align:center} +.section-count{font-size:11px;font-weight:500;color:var(--text3);background:var(--surface2);padding:2px 8px;border-radius:10px;flex-shrink:0} +.album-partial-badge{position:absolute;bottom:5px;left:5px;background:rgba(0,0,0,.72);color:#fff;font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;pointer-events:none;z-index:2} +.card-dl-btn{position:absolute;top:5px;right:5px;z-index:3;width:26px;height:26px;border-radius:50%;background:rgba(0,0,0,.65);border:none;color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;opacity:0;transition:opacity .2s;backdrop-filter:blur(6px)} +.card:hover .card-dl-btn,.card-dl-btn:focus{opacity:1} .np-queue-item{display:flex;align-items:center;gap:10px;padding:9px 6px;border-radius:10px;cursor:pointer;transition:background .15s} .np-queue-item:hover{background:rgba(255,255,255,.06)} .np-queue-item.nq-active .nqi-title{color:var(--accent)}