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();
|
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
@@ -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
@@ -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
@@ -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: [],
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|||||||
Reference in new issue
Block a user