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();
}
// ─── 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);