feat: SoundCloud downloader + rename page to Song Importer

- New src/soundcloud.js: resolves SoundCloud URLs via yt-dlp --dump-single-json,
  renders track/playlist preview with per-track download buttons
- Backend POST /api/soundcloud/resolve: extracts metadata (title, artist, genre,
  artwork, duration) directly from SoundCloud — no iTunes lookup needed
- ytdlp.js: sourceUrl param — if set, downloads directly from URL instead of
  ytsearch cascade (used for SoundCloud tracks)
- Spotify importer page renamed to Song Importer, now contains both
  Spotify and SoundCloud sections in one view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Sonnet 4.6 committed 2026-05-23 20:35:48 +02:00
1 parent e4cdbe44e7
commit ee65183555
7 files changed
+284 -17

No files matched your search

+10 -1
View File
@@ -47,7 +47,7 @@ function buildVariantFilter(title) {
return parts.length ? parts.join(' & ') : ''; return parts.length ? parts.join(' & ') : '';
} }
async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs }) { async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs, sourceUrl }) {
const mainArtist = albumArtist || artist; const mainArtist = albumArtist || artist;
const safeArtist = sanitize(mainArtist.split(',')[0].trim()); const safeArtist = sanitize(mainArtist.split(',')[0].trim());
const safeAlbum = sanitize(album || 'Singles'); const safeAlbum = sanitize(album || 'Singles');
@@ -109,6 +109,15 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t
const expectedAudio = path.join(dir, `${trackPrefix}${safeTitle}.${CONFIG.audioFmt}`); const expectedAudio = path.join(dir, `${trackPrefix}${safeTitle}.${CONFIG.audioFmt}`);
const hasAudio = () => fs.existsSync(expectedAudio); const hasAudio = () => fs.existsSync(expectedAudio);
// Direct URL download (e.g. SoundCloud) — skip the ytsearch cascade entirely
if (sourceUrl) {
log(jobId, `🔗 Direkter Download: ${sourceUrl}`);
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
const audioFile = hasAudio() ? expectedAudio : null;
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: true, dir }; }
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title });
}
// Fetch YTM search result URLs as a flat list (no channel expansion) and return only // Fetch YTM search result URLs as a flat list (no channel expansion) and return only
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel // actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
// result and treating its uploads as the search result set. // result and treating its uploads as the search result set.
+66 -2
View File
@@ -226,7 +226,8 @@ app.post('/api/spotify/resolve', async (req, res) => {
app.post('/api/download', async (req, res) => { app.post('/api/download', async (req, res) => {
const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit, const { type, artist, title, album, artworkUrl, collectionId, jobId: clientJobId, explicit,
trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal } = req.body; trackNum: bodyTrackNum, totalTracks: bodyTotalTracks, discNum: bodyDiscNum, discTotal: bodyDiscTotal,
sourceUrl } = 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' });
const jobId = clientJobId || ('job_' + Date.now()); const jobId = clientJobId || ('job_' + Date.now());
@@ -263,7 +264,7 @@ app.post('/api/download', async (req, res) => {
} else { } else {
log(jobId, `📋 Album aus Spotify: "${meta.album}"`); log(jobId, `📋 Album aus Spotify: "${meta.album}"`);
} }
const result = await downloadTrack({ jobId, artist, title, ...meta }); const result = await downloadTrack({ jobId, artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
if (result?.skipped) { if (result?.skipped) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true }); updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
} else { } else {
@@ -350,6 +351,7 @@ app.post('/api/download', async (req, res) => {
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || artworkUrl || '', artworkUrl: itunes.artworkUrl || track.artworkUrl100 || artworkUrl || '',
explicit: track.trackExplicitness === 'explicit', explicit: track.trackExplicitness === 'explicit',
durationMs: itunes.durationMs || track.durationMs || undefined, durationMs: itunes.durationMs || track.durationMs || undefined,
sourceUrl: track.sourceUrl || undefined,
}); });
})); }));
for (let j = 0; j < batchResults.length; j++) { for (let j = 0; j < batchResults.length; j++) {
@@ -389,6 +391,68 @@ app.get('/api/jobs', (req, res) => {
res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50)); res.json(Array.from(jobs.values()).sort((a, b) => b.createdAt - a.createdAt).slice(0, 50));
}); });
// ─── SOUNDCLOUD RESOLVE ──────────────────────────────────────────────────────
app.post('/api/soundcloud/resolve', async (req, res) => {
const { url } = req.body;
if (!url || !url.includes('soundcloud.com')) return res.status(400).json({ error: 'SoundCloud URL required' });
try {
const { execFile } = require('child_process');
const data = await new Promise((resolve, reject) => {
execFile(
CONFIG.ytdlpBin,
['--flat-playlist', '--dump-single-json', '--no-warnings', url],
{ timeout: 30000, maxBuffer: 5 * 1024 * 1024 },
(err, stdout, stderr) => {
if (err) return reject(new Error(`yt-dlp: ${stderr?.trim() || err.message}`));
try { resolve(JSON.parse(stdout.trim())); }
catch { reject(new Error('yt-dlp: ungültige Ausgabe')); }
}
);
});
const isPlaylist = data._type === 'playlist' || Array.isArray(data.entries);
if (isPlaylist) {
const entries = data.entries || [];
const tracks = entries.map(e => ({
trackName: e.title || '',
artistName: e.uploader || e.artist || data.uploader || '',
artworkUrl: e.thumbnail || '',
duration: e.duration || 0,
genre: e.genre || '',
sourceUrl: e.url || e.webpage_url || '',
}));
res.json({
type: 'playlist',
name: data.title || data.uploader || 'SoundCloud Playlist',
artist: data.uploader || '',
artworkUrl: data.thumbnail || tracks[0]?.artworkUrl || '',
tracks,
});
} else {
const track = {
trackName: data.title || '',
artistName: data.uploader || data.artist || '',
artworkUrl: data.thumbnail || '',
duration: data.duration || 0,
genre: data.genre || '',
album: data.album || '',
year: data.upload_date ? parseInt(data.upload_date.slice(0, 4), 10) : undefined,
sourceUrl: url,
};
res.json({
type: 'track',
name: track.trackName,
artist: track.artistName,
artworkUrl: track.artworkUrl,
tracks: [track],
});
}
} catch (e) {
console.error('[soundcloud/resolve]', e.message);
res.status(500).json({ error: e.message });
}
});
// ─── MUSICBRAINZ LOOKUP ────────────────────────────────────────────────────── // ─── MUSICBRAINZ LOOKUP ──────────────────────────────────────────────────────
app.get('/api/musicbrainz/lookup', async (req, res) => { app.get('/api/musicbrainz/lookup', async (req, res) => {
const { artist, title } = req.query; const { artist, title } = req.query;
+41 -13
View File
@@ -84,7 +84,7 @@
<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"> <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 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>Song Importer
</div> </div>
</div> </div>
<div class="nav-section" id="nav-abs-section" style="display:none"> <div class="nav-section" id="nav-abs-section" style="display:none">
@@ -378,26 +378,27 @@
<!-- SPOTIFY IMPORTER --> <!-- SPOTIFY IMPORTER -->
<div class="page" id="page-spotify-importer"> <div class="page" id="page-spotify-importer">
<div class="page-header"> <div class="page-header">
<div class="page-title">Spotify <em>Importer</em></div> <div class="page-title">Song <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>
<div class="search-wrap" style="padding:0 0 20px">
<!-- SPOTIFY SECTION -->
<div class="importer-section-label" style="color:#1DB954">
<svg width="14" height="14" viewBox="0 0 24 24" fill="#1DB954"><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" stroke="#000" stroke-width="2" fill="none"/></svg>
Spotify
</div>
<div class="search-wrap" style="padding:0 0 12px">
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1"> <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> <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()"> <input id="spotify-link-input" type="url" placeholder="Spotify Link (Track, Album oder Playlist)…" style="font-size:14px" oninput="clearSpotifyPreview()">
</div> </div>
<button class="btn-outline" style="padding:0 20px;font-size:14px;font-weight:700;white-space:nowrap;flex-shrink:0;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000;border-color:transparent" onclick="resolveSpotifyLink()">Importieren</button> <button class="btn-outline" style="padding:0 16px;font-size:13px;font-weight:700;white-space:nowrap;flex-shrink:0;background:#1DB954;color:#000;border-color:transparent" onclick="resolveSpotifyLink()">Importieren</button>
</div> </div>
</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 id="spotify-importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:4px">
<div style="font-weight:700;color:var(--text);margin-bottom:8px">So funktioniert's</div> Track, Album oder Playlist-Link einfügen — Metadaten werden direkt von Spotify geladen und Songs via yt-dlp in die Library heruntergeladen.
<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">Kein Spotify-Account erforderlich — funktioniert direkt über das Backend.</div>
</div> </div>
<div id="spotify-preview" style="display:none;margin-top:20px"> <div id="spotify-preview" style="display:none;margin-bottom:8px">
<div id="spotify-preview-inner"></div> <div id="spotify-preview-inner"></div>
<div id="spotify-enrich-status" style="display:none"></div> <div id="spotify-enrich-status" style="display:none"></div>
<div id="spotify-tracks-section" style="display:none"> <div id="spotify-tracks-section" style="display:none">
@@ -407,6 +408,33 @@
<div id="spotify-tracks-list" class="song-list"></div> <div id="spotify-tracks-list" class="song-list"></div>
</div> </div>
</div> </div>
<!-- SOUNDCLOUD SECTION -->
<div class="importer-section-label" style="color:#ff5500;margin-top:20px">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#ff5500" stroke-width="2"><path d="M2 16.5c0 1.1.9 2 2 2h14c2.2 0 4-1.8 4-4s-1.8-4-4-4c-.3 0-.6 0-.9.1C16.8 8.4 14.6 7 12 7c-3.3 0-6 2.7-6 6H4c-1.1 0-2 .9-2 1.5z"/></svg>
SoundCloud
</div>
<div class="search-wrap" style="padding:0 0 12px">
<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"><path d="M2 16.5c0 1.1.9 2 2 2h14c2.2 0 4-1.8 4-4s-1.8-4-4-4c-.3 0-.6 0-.9.1C16.8 8.4 14.6 7 12 7c-3.3 0-6 2.7-6 6H4c-1.1 0-2 .9-2 1.5z"/></svg>
<input id="sc-link-input" type="url" placeholder="SoundCloud Link (Track oder Playlist)…" style="font-size:14px" oninput="clearSoundCloudPreview()">
</div>
<button class="btn-outline" style="padding:0 16px;font-size:13px;font-weight:700;white-space:nowrap;flex-shrink:0;background:#ff5500;color:#fff;border-color:transparent" onclick="resolveSoundCloudLink()">Importieren</button>
</div>
</div>
<div id="sc-importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:4px">
Track oder Playlist-Link einfügen — Metadaten (Titel, Artist, Genre, Artwork) werden direkt von SoundCloud geladen und der Audio-Stream heruntergeladen.
</div>
<div id="sc-preview" style="display:none;margin-bottom:8px">
<div id="sc-preview-inner"></div>
<div id="sc-tracks-section" style="display:none">
<div class="section-header" style="margin-top:8px">
<span class="section-title" id="sc-tracks-title">Tracks</span>
</div>
<div id="sc-tracks-list" class="song-list"></div>
</div>
</div>
</div> </div>
</main> </main>
+2
View File
@@ -19,6 +19,7 @@ import {
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
} from './views.js'; } from './views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js'; import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js';
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll } from './soundcloud.js';
import { import {
absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey, absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey,
absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter, absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter,
@@ -307,6 +308,7 @@ Object.assign(window, {
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist, loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres, plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll, loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
executeSpotifyDownload, executeSpotifyDownload,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt, openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata, showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata,
+162
View File
@@ -0,0 +1,162 @@
import { S } from './state.js';
import { esc, toast } from './utils.js';
// Circular imports — safe (only used inside function bodies)
import { executeSpotifyDownload } from './ui.js';
export function loadSoundCloudImporter() {
const input = document.getElementById('sc-link-input');
if (!input?.value) _showHint();
}
export function clearSoundCloudPreview() {
if (!document.getElementById('sc-link-input')?.value) _showHint();
}
function _showHint() {
document.getElementById('sc-importer-hint').style.display = '';
document.getElementById('sc-preview').style.display = 'none';
}
export async function resolveSoundCloudLink() {
const url = document.getElementById('sc-link-input')?.value.trim();
if (!url) return;
if (!url.includes('soundcloud.com')) { toast('⚠ Kein SoundCloud-Link'); return; }
if (!S.cfg.backendUrl) { toast('⚠ Backend URL in Einstellungen konfigurieren'); return; }
const hint = document.getElementById('sc-importer-hint');
const preview = document.getElementById('sc-preview');
const inner = document.getElementById('sc-preview-inner');
const section = document.getElementById('sc-tracks-section');
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/soundcloud/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
signal: AbortSignal.timeout(35000),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error || `HTTP ${res.status}`);
}
const data = await res.json();
window._scResolved = data;
_renderSoundCloudPreview(data);
} catch(e) {
if (e.name === 'TimeoutError' || e.name === 'AbortError') {
inner.innerHTML = `<div style="color:var(--danger);padding:16px;background:var(--surface);border-radius:10px">Zeitüberschreitung — Backend antwortet nicht. Läuft yt-dlp?</div>`;
return;
}
inner.innerHTML = `<div style="color:var(--danger);padding:16px;background:var(--surface);border-radius:10px">${esc(e.message)}</div>`;
}
}
export function scDlAll() {
const data = window._scResolved;
if (!data) return;
executeSpotifyDownload({
type: 'playlist',
title: data.name,
artist: data.artist,
artworkUrl: data.artworkUrl,
tracks: data.tracks,
});
}
export function scDlSingle() {
const data = window._scResolved;
if (!data?.tracks?.[0]) return;
const t = data.tracks[0];
executeSpotifyDownload({
type: 'track',
title: t.trackName,
artist: t.artistName,
artworkUrl: t.artworkUrl,
tracks: [t],
});
}
export function scDlTrack(idx) {
const data = window._scResolved;
const t = data?.tracks?.[idx];
if (!t) return;
executeSpotifyDownload({
type: 'track',
title: t.trackName,
artist: t.artistName,
artworkUrl: t.artworkUrl,
tracks: [t],
});
}
function _renderSoundCloudPreview(data) {
const inner = document.getElementById('sc-preview-inner');
const section = document.getElementById('sc-tracks-section');
const list = document.getElementById('sc-tracks-list');
const titleEl = document.getElementById('sc-tracks-title');
const count = data.tracks?.length || 0;
const art = data.artworkUrl || '';
let actionsHtml = '';
if (data.type === 'track' && count > 0) {
const t = data.tracks[0];
actionsHtml = `
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600;margin-top:2px"
onclick="scDlSingle()">
⬇ Track laden
</button>`;
} else {
actionsHtml = `
<button class="btn-outline blue" style="font-size:13px;padding:9px 18px;font-weight:600;margin-top:2px"
onclick="scDlAll()">
⬇ Alle ${count} Tracks laden
</button>`;
}
const artHtml = art
? `<img src="${art}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: '';
inner.innerHTML = `
<div style="display:flex;gap:16px;align-items:flex-start;background:var(--surface);border-radius:14px;padding:18px 16px;margin-bottom:4px;border:1px solid var(--border);border-top:3px solid #ff5500">
<div style="width:90px;height:90px;border-radius:10px;overflow:hidden;flex-shrink:0;background:var(--surface2);position:relative">
${artHtml}
<div style="width:100%;height:100%;display:${art ? 'none' : 'flex'};align-items:center;justify-content:center;font-size:32px;position:absolute;top:0;left:0">🎵</div>
</div>
<div style="flex:1;min-width:0">
<div style="font-size:10px;font-weight:700;color:#ff5500;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:5px">
SoundCloud ${data.type === 'playlist' ? 'Playlist' : 'Track'}
</div>
<div style="font-size:18px;font-weight:700;margin-bottom:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;line-height:1.2">${esc(data.name)}</div>
<div style="font-size:13px;color:var(--text2);margin-bottom:10px">${esc(data.artist)}${count > 1 ? `<span style="color:var(--text3)"> · ${count} Tracks</span>` : ''}</div>
${actionsHtml}
</div>
</div>`;
titleEl.textContent = `${count} Track${count !== 1 ? 's' : ''}`;
list.innerHTML = '';
(data.tracks || []).forEach((t, i) => {
const el = document.createElement('div');
el.className = 'gtrack-item';
const artT = t.artworkUrl || '';
el.innerHTML = `
<div class="gtrack-art">${artT ? `<img src="${artT}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
<div class="gtrack-info">
<div class="gtrack-title">${esc(t.trackName || '—')}</div>
<div class="gtrack-sub">${esc(t.artistName || '—')}${t.genre ? ' · ' + esc(t.genre) : ''}${t.duration ? ' · ' + Math.round(t.duration / 60) + ' min' : ''}</div>
</div>
<div class="gtrack-actions">
<button class="icon-btn green" title="Herunterladen" onclick="event.stopPropagation();scDlTrack(${i})">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>`;
list.appendChild(el);
});
section.style.display = '';
}
+2 -1
View File
@@ -8,6 +8,7 @@ import { closeNowPlaying, closeNpMenu } from './nowplaying.js';
import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js'; import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js';
import { fetchRomajiIndex } from './api.js'; import { fetchRomajiIndex } from './api.js';
import { loadSpotifyImporter } from './spotify.js'; import { loadSpotifyImporter } from './spotify.js';
import { loadSoundCloudImporter } from './soundcloud.js';
// ─── NAVIGATION HISTORY ─── // ─── NAVIGATION HISTORY ───
export function _navSave() { export function _navSave() {
@@ -52,7 +53,7 @@ export function navigate(page, data=null) {
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(page==='spotify-importer') { loadSpotifyImporter(); loadSoundCloudImporter(); }
if(page==='audiobooks') { if(page==='audiobooks') {
import('./abs.js').then(m => { import('./abs.js').then(m => {
if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId); if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId);
+1
View File
@@ -775,6 +775,7 @@ button:focus{outline:none}
.list-search{width:100%;box-sizing:border-box;padding:8px 12px 8px 32px;background:var(--surface);border:1px solid var(--border2);border-radius:8px;color:var(--text1);font-size:13px;font-family:var(--font);outline:none;transition:border-color .15s} .list-search{width:100%;box-sizing:border-box;padding:8px 12px 8px 32px;background:var(--surface);border:1px solid var(--border2);border-radius:8px;color:var(--text1);font-size:13px;font-family:var(--font);outline:none;transition:border-color .15s}
.list-search:focus{border-color:var(--accent)} .list-search:focus{border-color:var(--accent)}
.list-search::placeholder{color:var(--text3)} .list-search::placeholder{color:var(--text3)}
.importer-section-label{display:flex;align-items:center;gap:7px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:1.2px;margin-bottom:10px}
#spotify-enrich-status{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:8px 12px;margin-bottom:8px;background:var(--surface);border:1px solid var(--border2);border-radius:8px;transition:opacity .4s} #spotify-enrich-status{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);padding:8px 12px;margin-bottom:8px;background:var(--surface);border:1px solid var(--border2);border-radius:8px;transition:opacity .4s}
#spotify-enrich-status.done{color:var(--accent)} #spotify-enrich-status.done{color:var(--accent)}
#spotify-enrich-status .enrich-spinner{width:12px;height:12px;border:2px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0} #spotify-enrich-status .enrich-spinner{width:12px;height:12px;border:2px solid var(--border2);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite;flex-shrink:0}