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

+70 -4
View File
@@ -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'};