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
@@ -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'};
|
||||
|
||||
Reference in new issue
Block a user