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:
1 parent
9f68deea94
commit
f7a5247f17
6 files changed
+398
-18
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user