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 @@
+
Player
@@ -320,6 +332,39 @@
+
+
+
+
+
+
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');