From f7a5247f17ff64f5b439efa6ca7b5842b00b16ee Mon Sep 17 00:00:00 2001 From: Honey Date: Mon, 27 Apr 2026 00:56:11 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Spotify=20Importer=20=E2=80=94=20link?= =?UTF-8?q?=20=E2=86=92=20yt-dlp=20download=20+=20optional=20Navidrome=20p?= =?UTF-8?q?laylist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New 'Spotify Importer' page under Entdecken (sidebar + mobile nav) - Backend: POST /api/spotify/resolve — resolves track/album/playlist URLs via Spotify API - Backend: new 'playlist' download type accepting pre-resolved track list (no iTunes re-fetch) - Frontend: paste Spotify link → preview with cover, type badge, track list - Track: single download or full album via iTunes fallback - Album: download all tracks with Spotify metadata - Playlist: download all tracks + optional 'Als Navidrome-Playlist speichern' checkbox - Navidrome playlist creation: searches library by title/artist after download, creates playlist - Spotify Client ID + Secret stored in app settings (setup screen + settings modal) - iTunes metadata tags kept for all downloaded files (existing pipeline unchanged) Co-Authored-By: Claude Sonnet 4.6 --- backend/novasonic-server.js | 117 +++++++++++++++++++++++++++++ index.html | 58 +++++++++++++++ src/main.js | 23 +++--- src/state.js | 2 +- src/ui.js | 74 ++++++++++++++++++- src/views.js | 142 +++++++++++++++++++++++++++++++++++- 6 files changed, 398 insertions(+), 18 deletions(-) diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index 282c60a..79dd569 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -387,12 +387,96 @@ function enqueue(task) { processQueue(); } +// ─── SPOTIFY ────────────────────────────────────────────────────────────────── +let _spTok = null, _spTokExp = 0, _spCachedId = ''; + +async function getSpotifyToken(clientId, clientSecret) { + if (_spTok && clientId === _spCachedId && Date.now() < _spTokExp - 30000) return _spTok; + const creds = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + const r = await fetch('https://accounts.spotify.com/api/token', { + method: 'POST', + headers: { Authorization: `Basic ${creds}`, 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'grant_type=client_credentials', + signal: AbortSignal.timeout(8000), + }); + if (!r.ok) throw new Error(`Spotify Auth fehlgeschlagen (${r.status}) — Client ID/Secret prüfen`); + const d = await r.json(); + _spTok = d.access_token; _spTokExp = Date.now() + d.expires_in * 1000; _spCachedId = clientId; + return _spTok; +} + +function parseSpotifyUrl(url) { + const m = url.match(/open\.spotify\.com\/(track|album|playlist)\/([A-Za-z0-9]+)/); + if (!m) throw new Error('Ungültige Spotify-URL — erwartet: open.spotify.com/track|album|playlist/...'); + return { type: m[1], id: m[2] }; +} + // ─── ROUTES ────────────────────────────────────────────────────────────────── app.get('/api/health', (req, res) => { res.json({ status: 'ok', ytdlpVersion, musicPath: CONFIG.musicPath, audioFormat: CONFIG.audioFmt, activeJobs: activeDownloads, queueLength: downloadQueue.length }); }); +app.post('/api/spotify/resolve', async (req, res) => { + const { url, clientId, clientSecret } = req.body; + if (!url) return res.status(400).json({ error: 'url required' }); + if (!clientId || !clientSecret) return res.status(400).json({ error: 'Spotify credentials fehlen (clientId + clientSecret in Einstellungen eintragen)' }); + try { + const { type, id } = parseSpotifyUrl(url); + const tok = await getSpotifyToken(clientId, clientSecret); + const spH = { Authorization: `Bearer ${tok}` }; + const spFetch = (u) => fetch(u, { headers: spH, signal: AbortSignal.timeout(10000) }); + + if (type === 'track') { + const r = await spFetch(`https://api.spotify.com/v1/tracks/${id}`); + if (!r.ok) throw new Error(`Spotify API ${r.status}`); + const d = await r.json(); + return res.json({ type: 'track', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: d.album?.images?.[0]?.url || '', + tracks: [{ trackName: d.name, artistName: d.artists?.[0]?.name || '', collectionName: d.album?.name || '', + artworkUrl100: d.album?.images?.[0]?.url || '', releaseDate: d.album?.release_date || '', + trackNumber: d.track_number, trackExplicitness: d.explicit ? 'explicit' : 'notExplicit', previewUrl: d.preview_url || null }] }); + } + + if (type === 'album') { + const r = await spFetch(`https://api.spotify.com/v1/albums/${id}`); + if (!r.ok) throw new Error(`Spotify API ${r.status}`); + const d = await r.json(); + const art = d.images?.[0]?.url || ''; + return res.json({ type: 'album', name: d.name, artist: d.artists?.[0]?.name || '', artworkUrl: art, + tracks: (d.tracks?.items || []).map(t => ({ trackName: t.name, artistName: t.artists?.[0]?.name || d.artists?.[0]?.name || '', + collectionName: d.name, artworkUrl100: art, releaseDate: d.release_date || '', + trackNumber: t.track_number, trackExplicitness: t.explicit ? 'explicit' : 'notExplicit', previewUrl: t.preview_url || null })) }); + } + + if (type === 'playlist') { + const r = await spFetch(`https://api.spotify.com/v1/playlists/${id}?fields=name,description,images,owner,tracks.total`); + if (!r.ok) throw new Error(`Spotify API ${r.status}`); + const pl = await r.json(); + const tracks = []; + let nextUrl = `https://api.spotify.com/v1/playlists/${id}/tracks?limit=100&fields=next,items(is_local,track(name,artists,album,track_number,explicit,preview_url))`; + while (nextUrl) { + const tr = await spFetch(nextUrl); + if (!tr.ok) break; + const td = await tr.json(); + for (const item of (td.items || [])) { + if (!item?.track || item.is_local) continue; + tracks.push({ trackName: item.track.name, artistName: item.track.artists?.[0]?.name || '', + collectionName: item.track.album?.name || '', artworkUrl100: item.track.album?.images?.[0]?.url || '', + releaseDate: item.track.album?.release_date || '', trackNumber: item.track.track_number, + trackExplicitness: item.track.explicit ? 'explicit' : 'notExplicit', previewUrl: item.track.preview_url || null }); + } + nextUrl = td.next || null; + } + return res.json({ type: 'playlist', name: pl.name, artist: pl.owner?.display_name || '', + artworkUrl: pl.images?.[0]?.url || '', description: pl.description || '', tracks }); + } + + res.status(400).json({ error: `Unbekannter Typ: ${type}` }); + } catch(e) { + res.status(500).json({ error: e.message }); + } +}); + app.post('/api/download', async (req, res) => { const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit } = req.body; if (!artist || !title) return res.status(400).json({ error: 'artist and title required' }); @@ -465,6 +549,39 @@ app.post('/api/download', async (req, res) => { updateJob(jobId, { progress: Math.round((done / artistAlbums.length) * 95) }); } updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" fertig!` }); + + } else if (type === 'playlist') { + const plTracks = req.body.tracks || []; + if (!plTracks.length) throw new Error('Keine Tracks übergeben'); + log(jobId, `🎵 Playlist "${title}" — ${plTracks.length} Tracks`); + let done = 0, skipped = 0; + for (const track of plTracks) { + try { + const result = await downloadTrack({ + jobId, + artist: track.artistName || artist || '', + albumArtist: track.artistName || artist || '', + title: track.trackName || '', + album: track.collectionName || '', + year: track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined, + trackNum: track.trackNumber || undefined, + genre: track.primaryGenreName || undefined, + artworkUrl: track.artworkUrl100 || artworkUrl || '', + explicit: track.trackExplicitness === 'explicit', + }); + if (result?.skipped) skipped++; else done++; + updateJob(jobId, { progress: Math.round(((done + skipped) / plTracks.length) * 90) }); + } catch(e) { + log(jobId, `⚠️ Fehler bei "${track.trackName || '?'}": ${e.message}`); + } + } + const allSkipped = skipped === plTracks.length; + updateJob(jobId, { + status: 'done', progress: 100, skipped: allSkipped, + message: allSkipped + ? `Alle ${skipped} Tracks bereits vorhanden` + : `Fertig! ${done} geladen${skipped ? `, ${skipped} übersprungen` : ''}`, + }); } } catch (err) { console.error(`[${jobId}] Error:`, err.message); diff --git a/index.html b/index.html index 86c5486..820790b 100644 --- a/index.html +++ b/index.html @@ -46,6 +46,15 @@
Für Download-to-Server Funktion. Backend separat starten (novasonic-server.js)
+
+ + +
Kostenlos unter developer.spotify.com/dashboard
+
+
+ + +
@@ -111,6 +120,9 @@ + + +
+ +
+
+ + +
+
+
+
So funktioniert's
+
1. Spotify Link für Track, Album oder Playlist einfügen
+
2. Metadaten werden direkt von Spotify geladen
+
3. Songs via yt-dlp herunterladen → direkt in Navidrome Library
+
Spotify Client ID + Secret in den Einstellungen → Discover APIs eintragen.
Kostenlos unter developer.spotify.com/dashboard
+
+ +
+ @@ -473,6 +518,15 @@
Für Download-to-Server via yt-dlp
+
+ + +
Kostenlos unter developer.spotify.com/dashboard
+
+
+ + +
Backend Status
@@ -708,6 +762,10 @@ Zufällig +
diff --git a/src/main.js b/src/main.js index 673ce98..30da7d6 100644 --- a/src/main.js +++ b/src/main.js @@ -14,7 +14,7 @@ import { navigate, _navGoBack, _navLoad, loadHomeData, onLocalSearch, onMobileSearch, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, - loadRandom, + loadRandom, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, } from './views.js'; import { openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, @@ -22,23 +22,25 @@ import { checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, showSongInfo, closeInfoModal, openDlModal, closeDlModal, - selectDlType, executeDownload, openEditPlModal, closeEditPlModal, + selectDlType, executeDownload, executeSpotifyDownload, openEditPlModal, closeEditPlModal, saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, toggleMobileSearch, closeMobileSearch, switchSetupTab, } from './ui.js'; // ─── CONNECT ─── async function doConnect() { - const url = document.getElementById('srv-url').value.trim().replace(/\/$/,''); - const user = document.getElementById('srv-user').value.trim(); - const pass = document.getElementById('srv-pass').value; - const lfKey = document.getElementById('lastfm-key').value.trim(); - const backUrl = document.getElementById('backend-url').value.trim().replace(/\/$/,''); + const url = document.getElementById('srv-url').value.trim().replace(/\/$/,''); + const user = document.getElementById('srv-user').value.trim(); + const pass = document.getElementById('srv-pass').value; + const lfKey = document.getElementById('lastfm-key').value.trim(); + const backUrl = document.getElementById('backend-url').value.trim().replace(/\/$/,''); + const spId = document.getElementById('spotify-client-id').value.trim(); + const spSecret = document.getElementById('spotify-client-secret').value.trim(); if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; } const btn = document.getElementById('btn-connect'); btn.disabled=true; btn.textContent='Verbinde…'; document.getElementById('setup-error').style.display='none'; - S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl}; + S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl, spotifyClientId:spId, spotifyClientSecret:spSecret}; try { await api('ping'); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); @@ -55,7 +57,7 @@ function disconnect() { localStorage.removeItem('ns_cfg'); localStorage.removeItem('ns_last_played'); sessionStorage.removeItem('ns_nav'); - S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''}; + S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', spotifyClientId:'', spotifyClientSecret:''}; S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1; S.audio.pause(); S.audio.src=''; document.getElementById('app').style.display='none'; @@ -183,7 +185,8 @@ Object.assign(window, { openSettings, closeSettings, saveSettings, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, // Views - loadRandom, + loadRandom, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, + executeSpotifyDownload, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, showSongInfo, closeInfoModal, openDlModal, closeDlModal, selectDlType, executeDownload, diff --git a/src/state.js b/src/state.js index 36216b5..1508a09 100644 --- a/src/state.js +++ b/src/state.js @@ -1,6 +1,6 @@ // Shared mutable state — imported as { S } in every module export const S = { - cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '' }, + cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', spotifyClientId: '', spotifyClientSecret: '' }, // Playback queue: [], diff --git a/src/ui.js b/src/ui.js index bea733b..9daa8c0 100644 --- a/src/ui.js +++ b/src/ui.js @@ -59,8 +59,10 @@ export function openSettings() { document.getElementById('s-url').value = S.cfg.url || ''; document.getElementById('s-user').value = S.cfg.user || ''; document.getElementById('s-pass').value = ''; - document.getElementById('s-lastfm').value = S.cfg.lastfmKey || ''; - document.getElementById('s-backend').value = S.cfg.backendUrl || ''; + document.getElementById('s-lastfm').value = S.cfg.lastfmKey || ''; + document.getElementById('s-backend').value = S.cfg.backendUrl || ''; + document.getElementById('s-spotify-id').value = S.cfg.spotifyClientId || ''; + document.getElementById('s-spotify-secret').value = ''; document.getElementById('settings-modal').classList.add('open'); checkBackend(); } @@ -70,8 +72,11 @@ export function saveSettings() { S.cfg.url = document.getElementById('s-url').value.trim().replace(/\/$/,''); S.cfg.user = document.getElementById('s-user').value.trim(); if(pass) S.cfg.pass = pass; - S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim(); - S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,''); + S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim(); + S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,''); + S.cfg.spotifyClientId = document.getElementById('s-spotify-id').value.trim(); + const spSec = document.getElementById('s-spotify-secret').value; + if (spSec) S.cfg.spotifyClientSecret = spSec; localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); closeSettings(); toast('✅ Gespeichert'); } @@ -278,6 +283,67 @@ export async function executeDownload() { } } +// ─── SPOTIFY DOWNLOAD ─── +export async function executeSpotifyDownload({ type, title, artist, artworkUrl, tracks, createPlaylist, playlistName }) { + if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; } + const jobId = 'job_' + Date.now(); + addJob(jobId, title, artist); + try { + const res = await fetch(`${S.cfg.backendUrl}/api/download`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jobId, type, title, artist, artworkUrl, tracks }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + pollJob(jobId); + if (createPlaylist && playlistName && tracks?.length) { + _pollAndCreatePlaylist(jobId, tracks, playlistName); + } + } catch(e) { + updateJob(jobId, 'error', 'Backend-Verbindung fehlgeschlagen: ' + e.message); + toast('❌ Backend nicht erreichbar: ' + e.message); + } +} + +async function _pollAndCreatePlaylist(jobId, tracks, playlistName) { + while (true) { + await new Promise(r => setTimeout(r, 2500)); + const job = S.jobs[jobId]; + if (!job || job.status === 'done' || job.status === 'error') break; + } + if (S.jobs[jobId]?.status !== 'done') return; + await _createNdPlaylist(tracks, playlistName); +} + +async function _createNdPlaylist(tracks, name) { + try { + toast('🎵 Erstelle Navidrome-Playlist…'); + const songIds = []; + const norm = s => s?.toLowerCase().trim() || ''; + for (const t of tracks) { + if (!t.trackName) continue; + try { + const r = await api('search3', `&query=${encodeURIComponent(t.trackName)}&songCount=5&albumCount=0&artistCount=0`); + const songs = r.searchResult3?.song || []; + const match = songs.find(s => + norm(s.title) === norm(t.trackName) && + norm(s.artist).split(' ')[0] === norm(t.artistName).split(' ')[0] + ) || songs.find(s => norm(s.title) === norm(t.trackName)); + if (match) songIds.push(match.id); + } catch {} + } + if (!songIds.length) { + toast('⚠ Keine Tracks in Navidrome gefunden — zuerst Library scannen, dann Playlist manuell anlegen'); + return; + } + const songParams = songIds.map(id => `&songId=${encodeURIComponent(id)}`).join(''); + await api('createPlaylist', `&name=${encodeURIComponent(name)}${songParams}`); + toast(`✅ Playlist "${name}" mit ${songIds.length}/${tracks.length} Tracks erstellt`); + } catch(e) { + toast('Playlist-Erstellung fehlgeschlagen: ' + e.message); + } +} + // ─── JOBS ─── function addJob(id, title, artist) { S.jobs[id] = {title, artist, progress:0, status:'pending'}; diff --git a/src/views.js b/src/views.js index 84cd085..ea2a9b1 100644 --- a/src/views.js +++ b/src/views.js @@ -26,12 +26,12 @@ export function _navLoad() { export function navigate(page, data=null) { document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue')); - const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active'; + const cls = ['discover','new-releases','recommendations','spotify-importer'].includes(page) ? 'active-blue' : 'active'; document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls)); const pageEl = document.getElementById(`page-${page}`); if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; } closeSidebar(); - if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover'); + if(['discover','new-releases','recommendations','random','spotify-importer'].includes(page)) switchNavMode('discover'); else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library'); if(page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists(); @@ -45,7 +45,8 @@ export function navigate(page, data=null) { if(page==='new-releases') loadNewReleases(); if(page==='playlists') loadPlaylists(); if(page==='playlist-detail' && data) loadPlaylistDetail(data); - if(page==='recommendations') loadRecommendations(); + if(page==='recommendations') loadRecommendations(); + if(page==='spotify-importer') loadSpotifyImporter(); if(!S._navJumping) { S._navStack = S._navStack.slice(0, S._navCursor + 1); @@ -894,6 +895,141 @@ async function loadItunesFallbackRecommendations(el, artist, title, html) { sec.appendChild(list); el.appendChild(sec); } +// ─── SPOTIFY IMPORTER ─── +export function loadSpotifyImporter() { + if (!document.getElementById('spotify-link-input')?.value) { + const hint = document.getElementById('spotify-importer-hint'); + const preview = document.getElementById('spotify-preview'); + if (hint) hint.style.display = ''; + if (preview) preview.style.display = 'none'; + } +} + +export function clearSpotifyPreview() { + if (!document.getElementById('spotify-link-input')?.value) { + const hint = document.getElementById('spotify-importer-hint'); + const preview = document.getElementById('spotify-preview'); + if (hint) hint.style.display = ''; + if (preview) preview.style.display = 'none'; + } +} + +export async function resolveSpotifyLink() { + const url = document.getElementById('spotify-link-input')?.value.trim(); + if (!url) return; + if (!S.cfg.backendUrl) { toast('⚠ Backend URL in Einstellungen konfigurieren'); return; } + if (!S.cfg.spotifyClientId) { toast('⚠ Spotify Client ID in Einstellungen eintragen'); return; } + if (!S.cfg.spotifyClientSecret) { toast('⚠ Spotify Client Secret in Einstellungen eintragen'); return; } + + const hint = document.getElementById('spotify-importer-hint'); + const preview = document.getElementById('spotify-preview'); + const inner = document.getElementById('spotify-preview-inner'); + const section = document.getElementById('spotify-tracks-section'); + + if (hint) hint.style.display = 'none'; + preview.style.display = ''; + section.style.display = 'none'; + inner.innerHTML = '
'; + + try { + const res = await fetch(`${S.cfg.backendUrl}/api/spotify/resolve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, clientId: S.cfg.spotifyClientId, clientSecret: S.cfg.spotifyClientSecret }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${res.status}`); + } + const data = await res.json(); + window._spotifyResolved = data; + _renderSpotifyPreview(data); + } catch(e) { + inner.innerHTML = `
${esc(e.message)}
`; + } +} + +export function spotifyDlAll(cbId, nameId) { + const data = window._spotifyResolved; + if (!data) return; + // Import executeSpotifyDownload from ui.js at call time (avoid circular at module level) + window.executeSpotifyDownload({ + type: 'playlist', + title: document.getElementById(nameId)?.value || data.name, + artist: data.artist, + artworkUrl: data.artworkUrl, + tracks: data.tracks, + createPlaylist: document.getElementById(cbId)?.checked || false, + playlistName: document.getElementById(nameId)?.value || data.name, + }); +} + +function _renderSpotifyPreview(data) { + const inner = document.getElementById('spotify-preview-inner'); + const section = document.getElementById('spotify-tracks-section'); + const list = document.getElementById('spotify-tracks-list'); + const titleEl = document.getElementById('spotify-tracks-title'); + + const typeLabel = { track: 'Spotify Track', album: 'Spotify Album', playlist: 'Spotify Playlist' }[data.type] || 'Spotify'; + const art = (data.artworkUrl || '').replace(/\/\d+x\d+bb/, '/300x300bb').replace('100x100', '300x300'); + const count = data.tracks?.length || 0; + const uid = Date.now(); + const plCbId = `sp-cb-${uid}`, plNmId = `sp-pln-${uid}`; + + let actionsHtml = ''; + if (data.type === 'track' && count > 0) { + actionsHtml = ` + + `; + } else if (data.type === 'album') { + actionsHtml = ` + `; + } else if (data.type === 'playlist') { + actionsHtml = ` +
+ + + +
`; + } + + inner.innerHTML = ` +
+
+ ${art ? `` : '
🎵
'} +
+
+
${typeLabel}
+
${esc(data.name)}
+
${esc(data.artist)}${count > 1 ? ` · ${count} Tracks` : ''}
+ ${actionsHtml} +
+
`; + + titleEl.textContent = `${count} Tracks`; + list.innerHTML = ''; + (data.tracks || []).forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, data.tracks))); + section.style.display = ''; +} + // ─── HELPERS ─── export function expandArtistBio() { const el = document.getElementById('artist-bio-text');