feat: Spotify Importer — link → yt-dlp download + optional Navidrome playlist

- 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 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-04-27 00:56:11 +02:00
1 parent 9f68deea94
commit f7a5247f17
6 files changed
+398 -18

No files matched your search

+117
View File
@@ -387,12 +387,96 @@ function enqueue(task) {
processQueue(); 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 ────────────────────────────────────────────────────────────────── // ─── ROUTES ──────────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ status: 'ok', ytdlpVersion, musicPath: CONFIG.musicPath, audioFormat: CONFIG.audioFmt, activeJobs: activeDownloads, queueLength: downloadQueue.length }); 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) => { app.post('/api/download', async (req, res) => {
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit } = req.body; 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' }); 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, { progress: Math.round((done / artistAlbums.length) * 95) });
} }
updateJob(jobId, { status: 'done', progress: 100, message: `Künstler "${artist}" fertig!` }); 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) { } catch (err) {
console.error(`[${jobId}] Error:`, err.message); console.error(`[${jobId}] Error:`, err.message);
+58
View File
@@ -46,6 +46,15 @@
<input id="backend-url" type="url" placeholder="http://192.168.1.x:3700"> <input id="backend-url" type="url" placeholder="http://192.168.1.x:3700">
<div class="hint">Für Download-to-Server Funktion. Backend separat starten (novasonic-server.js)</div> <div class="hint">Für Download-to-Server Funktion. Backend separat starten (novasonic-server.js)</div>
</div> </div>
<div class="field">
<label>Spotify Client ID <span style="color:var(--text3);font-weight:400;text-transform:none">(optional)</span></label>
<input id="spotify-client-id" type="text" placeholder="Für Spotify Importer">
<div class="hint">Kostenlos unter developer.spotify.com/dashboard</div>
</div>
<div class="field">
<label>Spotify Client Secret <span style="color:var(--text3);font-weight:400;text-transform:none">(optional)</span></label>
<input id="spotify-client-secret" type="password" placeholder="••••••••••••••••">
</div>
</div> </div>
<button class="btn-primary" id="btn-connect" onclick="doConnect()">Verbinden</button> <button class="btn-primary" id="btn-connect" onclick="doConnect()">Verbinden</button>
<div class="setup-error" id="setup-error"></div> <div class="setup-error" id="setup-error"></div>
@@ -111,6 +120,9 @@
<div class="nav-item" onclick="navigate('random')" data-page="random"> <div class="nav-item" onclick="navigate('random')" data-page="random">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>Zufällig <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>Zufällig
</div> </div>
<div class="nav-item" onclick="navigate('spotify-importer')" data-page="spotify-importer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1"/></svg>Spotify Importer
</div>
</div> </div>
<div class="nav-section"> <div class="nav-section">
<div class="nav-section-title">Player</div> <div class="nav-section-title">Player</div>
@@ -320,6 +332,39 @@
</div> </div>
</div> </div>
<!-- SPOTIFY IMPORTER -->
<div class="page" id="page-spotify-importer">
<div class="page-header">
<div class="page-title">Spotify <em>Importer</em></div>
<span style="background:rgba(29,185,84,.15);color:#1DB954;border:1px solid rgba(29,185,84,.3);font-size:11px;padding:4px 10px;border-radius:20px;font-weight:600;flex-shrink:0">Spotify</span>
</div>
<div class="search-wrap" style="padding:0 0 20px">
<div style="display:flex;gap:8px">
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1"/></svg>
<input id="spotify-link-input" type="url" placeholder="Spotify Link einfügen (Track, Album oder Playlist)…" style="font-size:15px" oninput="clearSpotifyPreview()">
</div>
<button class="btn-primary" style="padding:0 18px;font-size:14px;white-space:nowrap;flex-shrink:0" onclick="resolveSpotifyLink()">Importieren</button>
</div>
</div>
<div id="spotify-importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px;font-size:13px;color:var(--text2);line-height:1.8">
<div style="font-weight:700;color:var(--text);margin-bottom:8px">So funktioniert's</div>
<div>1. Spotify Link für Track, Album oder Playlist einfügen</div>
<div>2. Metadaten werden direkt von Spotify geladen</div>
<div>3. Songs via yt-dlp herunterladen → direkt in Navidrome Library</div>
<div style="margin-top:10px;color:var(--text3);font-size:12px">Spotify Client ID + Secret in den Einstellungen → Discover APIs eintragen.<br>Kostenlos unter <strong style="color:var(--text2)">developer.spotify.com/dashboard</strong></div>
</div>
<div id="spotify-preview" style="display:none;margin-top:20px">
<div id="spotify-preview-inner"></div>
<div id="spotify-tracks-section" style="display:none">
<div class="section-header" style="margin-top:8px">
<span class="section-title" id="spotify-tracks-title">Tracks</span>
</div>
<div id="spotify-tracks-list" class="song-list"></div>
</div>
</div>
</div>
</main> </main>
</div> </div>
@@ -473,6 +518,15 @@
<input id="s-backend" type="url" placeholder="http://192.168.x.x:3700"> <input id="s-backend" type="url" placeholder="http://192.168.x.x:3700">
<div class="hint">Für Download-to-Server via yt-dlp</div> <div class="hint">Für Download-to-Server via yt-dlp</div>
</div> </div>
<div class="field">
<label>Spotify Client ID</label>
<input id="s-spotify-id" type="text" placeholder="Für Spotify Importer">
<div class="hint">Kostenlos unter developer.spotify.com/dashboard</div>
</div>
<div class="field">
<label>Spotify Client Secret</label>
<input id="s-spotify-secret" type="password" placeholder="(unverändert lassen)">
</div>
<div class="settings-row" style="margin-top:8px;border-radius:9px"> <div class="settings-row" style="margin-top:8px;border-radius:9px">
<div> <div>
<div style="font-size:13px;font-weight:500">Backend Status</div> <div style="font-size:13px;font-weight:500">Backend Status</div>
@@ -708,6 +762,10 @@
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg> <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
Zufällig Zufällig
</button> </button>
<button class="bnav-item" onclick="navigate('spotify-importer')" data-page="spotify-importer">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1"/></svg>
Importer
</button>
</div> </div>
</div> </div>
</nav> </nav>
+13 -10
View File
@@ -14,7 +14,7 @@ import {
navigate, _navGoBack, _navLoad, loadHomeData, onLocalSearch, onMobileSearch, navigate, _navGoBack, _navLoad, loadHomeData, onLocalSearch, onMobileSearch,
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum, onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio, searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio,
loadRandom, loadRandom, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
} from './views.js'; } from './views.js';
import { import {
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum, openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
@@ -22,23 +22,25 @@ import {
checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu, checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist,
createPlaylistPrompt, showSongInfo, closeInfoModal, openDlModal, closeDlModal, createPlaylistPrompt, showSongInfo, closeInfoModal, openDlModal, closeDlModal,
selectDlType, executeDownload, openEditPlModal, closeEditPlModal, selectDlType, executeDownload, executeSpotifyDownload, openEditPlModal, closeEditPlModal,
saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover, saveEditPlaylist, editPlUploadCover, editPlCoverSelected, editPlRemoveCover,
toggleMobileSearch, closeMobileSearch, switchSetupTab, toggleMobileSearch, closeMobileSearch, switchSetupTab,
} from './ui.js'; } from './ui.js';
// ─── CONNECT ─── // ─── CONNECT ───
async function doConnect() { async function doConnect() {
const url = document.getElementById('srv-url').value.trim().replace(/\/$/,''); const url = document.getElementById('srv-url').value.trim().replace(/\/$/,'');
const user = document.getElementById('srv-user').value.trim(); const user = document.getElementById('srv-user').value.trim();
const pass = document.getElementById('srv-pass').value; const pass = document.getElementById('srv-pass').value;
const lfKey = document.getElementById('lastfm-key').value.trim(); const lfKey = document.getElementById('lastfm-key').value.trim();
const backUrl = document.getElementById('backend-url').value.trim().replace(/\/$/,''); 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; } if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; }
const btn = document.getElementById('btn-connect'); const btn = document.getElementById('btn-connect');
btn.disabled=true; btn.textContent='Verbinde…'; btn.disabled=true; btn.textContent='Verbinde…';
document.getElementById('setup-error').style.display='none'; 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 { try {
await api('ping'); await api('ping');
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
@@ -55,7 +57,7 @@ function disconnect() {
localStorage.removeItem('ns_cfg'); localStorage.removeItem('ns_cfg');
localStorage.removeItem('ns_last_played'); localStorage.removeItem('ns_last_played');
sessionStorage.removeItem('ns_nav'); 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.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1;
S.audio.pause(); S.audio.src=''; S.audio.pause(); S.audio.src='';
document.getElementById('app').style.display='none'; document.getElementById('app').style.display='none';
@@ -183,7 +185,8 @@ Object.assign(window, {
openSettings, closeSettings, saveSettings, checkBackend, openSettings, closeSettings, saveSettings, checkBackend,
openSidebar, closeSidebar, switchNavMode, showCtxMenu, openSidebar, closeSidebar, switchNavMode, showCtxMenu,
// Views // Views
loadRandom, loadRandom, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
executeSpotifyDownload,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
showSongInfo, closeInfoModal, showSongInfo, closeInfoModal,
openDlModal, closeDlModal, selectDlType, executeDownload, openDlModal, closeDlModal, selectDlType, executeDownload,
+1 -1
View File
@@ -1,6 +1,6 @@
// Shared mutable state — imported as { S } in every module // Shared mutable state — imported as { S } in every module
export const S = { export const S = {
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '' }, cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', spotifyClientId: '', spotifyClientSecret: '' },
// Playback // Playback
queue: [], queue: [],
+70 -4
View File
@@ -59,8 +59,10 @@ export function openSettings() {
document.getElementById('s-url').value = S.cfg.url || ''; document.getElementById('s-url').value = S.cfg.url || '';
document.getElementById('s-user').value = S.cfg.user || ''; document.getElementById('s-user').value = S.cfg.user || '';
document.getElementById('s-pass').value = ''; document.getElementById('s-pass').value = '';
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || ''; document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
document.getElementById('s-backend').value = S.cfg.backendUrl || ''; 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'); document.getElementById('settings-modal').classList.add('open');
checkBackend(); checkBackend();
} }
@@ -70,8 +72,11 @@ export function saveSettings() {
S.cfg.url = document.getElementById('s-url').value.trim().replace(/\/$/,''); S.cfg.url = document.getElementById('s-url').value.trim().replace(/\/$/,'');
S.cfg.user = document.getElementById('s-user').value.trim(); S.cfg.user = document.getElementById('s-user').value.trim();
if(pass) S.cfg.pass = pass; if(pass) S.cfg.pass = pass;
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim(); S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,''); 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)); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
closeSettings(); toast('✅ Gespeichert'); 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 ─── // ─── JOBS ───
function addJob(id, title, artist) { function addJob(id, title, artist) {
S.jobs[id] = {title, artist, progress:0, status:'pending'}; S.jobs[id] = {title, artist, progress:0, status:'pending'};
+139 -3
View File
@@ -26,12 +26,12 @@ export function _navLoad() {
export function navigate(page, data=null) { export function navigate(page, data=null) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue')); 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)); document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
const pageEl = document.getElementById(`page-${page}`); const pageEl = document.getElementById(`page-${page}`);
if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; } if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; }
closeSidebar(); 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'); else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
if(page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists(); 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==='new-releases') loadNewReleases();
if(page==='playlists') loadPlaylists(); if(page==='playlists') loadPlaylists();
if(page==='playlist-detail' && data) loadPlaylistDetail(data); if(page==='playlist-detail' && data) loadPlaylistDetail(data);
if(page==='recommendations') loadRecommendations(); if(page==='recommendations') loadRecommendations();
if(page==='spotify-importer') loadSpotifyImporter();
if(!S._navJumping) { if(!S._navJumping) {
S._navStack = S._navStack.slice(0, S._navCursor + 1); 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); 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 = '<div class="spinner"></div>';
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 = `<div style="color:var(--danger);padding:16px;background:var(--surface);border-radius:10px">${esc(e.message)}</div>`;
}
}
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 = `
<button class="btn-outline blue" style="font-size:13px;padding:9px 16px"
onclick="executeSpotifyDownload({type:'track',title:window._spotifyResolved.tracks[0].trackName,artist:window._spotifyResolved.tracks[0].artistName,artworkUrl:window._spotifyResolved.artworkUrl,tracks:window._spotifyResolved.tracks})">
⬇ Track herunterladen
</button>
<button class="btn-outline" style="font-size:12px;padding:9px 14px;margin-left:6px"
onclick="openDlModal({artistName:window._spotifyResolved.tracks[0].artistName,trackName:window._spotifyResolved.tracks[0].collectionName||window._spotifyResolved.tracks[0].trackName,collectionName:window._spotifyResolved.tracks[0].collectionName||'',artworkUrl100:window._spotifyResolved.tracks[0].artworkUrl100||window._spotifyResolved.artworkUrl||'',isAlbum:true})">
💿 Album
</button>`;
} else if (data.type === 'album') {
actionsHtml = `
<button class="btn-outline blue" style="font-size:13px;padding:9px 16px"
onclick="executeSpotifyDownload({type:'playlist',title:window._spotifyResolved.name,artist:window._spotifyResolved.artist,artworkUrl:window._spotifyResolved.artworkUrl,tracks:window._spotifyResolved.tracks})">
⬇ Album laden (${count} Tracks)
</button>`;
} else if (data.type === 'playlist') {
actionsHtml = `
<div style="margin-top:2px">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;margin-bottom:8px">
<input type="checkbox" id="${plCbId}" style="width:15px;height:15px;accent-color:#1DB954" onchange="document.getElementById('${plCbId}_wrap').style.display=this.checked?'':'none'">
Als Navidrome-Playlist speichern
</label>
<div id="${plCbId}_wrap" style="display:none;margin-bottom:10px">
<input type="text" id="${plNmId}" value="${esc(data.name)}" placeholder="Playlist-Name…"
style="background:var(--surface2);border:1px solid var(--border2);border-radius:8px;padding:8px 12px;font-size:13px;width:100%;box-sizing:border-box;color:var(--text);outline:none">
</div>
<button class="btn-outline blue" style="font-size:13px;padding:9px 16px"
onclick="spotifyDlAll('${plCbId}','${plNmId}')">
⬇ Alle ${count} Tracks herunterladen
</button>
</div>`;
}
inner.innerHTML = `
<div style="display:flex;gap:16px;align-items:flex-start;background:var(--surface);border-radius:14px;padding:16px;margin-bottom:4px;border:1px solid var(--border)">
<div style="width:90px;height:90px;border-radius:10px;overflow:hidden;flex-shrink:0;background:var(--surface2)">
${art ? `<img src="${art}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:32px\\'>🎵</div>'">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:32px">🎵</div>'}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:11px;font-weight:600;color:#1DB954;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px">${typeLabel}</div>
<div style="font-size:17px;font-weight:700;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(data.name)}</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:12px">${esc(data.artist)}${count > 1 ? ` · ${count} Tracks` : ''}</div>
${actionsHtml}
</div>
</div>`;
titleEl.textContent = `${count} Tracks`;
list.innerHTML = '';
(data.tracks || []).forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, data.tracks)));
section.style.display = '';
}
// ─── HELPERS ─── // ─── HELPERS ───
export function expandArtistBio() { export function expandArtistBio() {
const el = document.getElementById('artist-bio-text'); const el = document.getElementById('artist-bio-text');