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:
1 parent
e4cdbe44e7
commit
ee65183555
7 files changed
+284
-17
No files matched your search
+10
-1
@@ -47,7 +47,7 @@ function buildVariantFilter(title) {
|
||||
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 safeArtist = sanitize(mainArtist.split(',')[0].trim());
|
||||
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 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
|
||||
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
|
||||
// result and treating its uploads as the search result set.
|
||||
|
||||
@@ -226,7 +226,8 @@ app.post('/api/spotify/resolve', async (req, res) => {
|
||||
|
||||
app.post('/api/download', async (req, res) => {
|
||||
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' });
|
||||
|
||||
const jobId = clientJobId || ('job_' + Date.now());
|
||||
@@ -263,7 +264,7 @@ app.post('/api/download', async (req, res) => {
|
||||
} else {
|
||||
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) {
|
||||
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
|
||||
} else {
|
||||
@@ -350,6 +351,7 @@ app.post('/api/download', async (req, res) => {
|
||||
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || artworkUrl || '',
|
||||
explicit: track.trackExplicitness === 'explicit',
|
||||
durationMs: itunes.durationMs || track.durationMs || undefined,
|
||||
sourceUrl: track.sourceUrl || undefined,
|
||||
});
|
||||
}));
|
||||
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));
|
||||
});
|
||||
|
||||
// ─── 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 ──────────────────────────────────────────────────────
|
||||
app.get('/api/musicbrainz/lookup', async (req, res) => {
|
||||
const { artist, title } = req.query;
|
||||
|
||||
+41
-13
@@ -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
|
||||
</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
|
||||
<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 class="nav-section" id="nav-abs-section" style="display:none">
|
||||
@@ -378,26 +378,27 @@
|
||||
<!-- 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 class="page-title">Song <em>Importer</em></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 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()">
|
||||
<input id="spotify-link-input" type="url" placeholder="Spotify Link (Track, Album oder Playlist)…" style="font-size:14px" oninput="clearSpotifyPreview()">
|
||||
</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 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">Kein Spotify-Account erforderlich — funktioniert direkt über das Backend.</div>
|
||||
<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">
|
||||
Track, Album oder Playlist-Link einfügen — Metadaten werden direkt von Spotify geladen und Songs via yt-dlp in die Library heruntergeladen.
|
||||
</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-enrich-status" style="display:none"></div>
|
||||
<div id="spotify-tracks-section" style="display:none">
|
||||
@@ -407,6 +408,33 @@
|
||||
<div id="spotify-tracks-list" class="song-list"></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>
|
||||
|
||||
</main>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
||||
} from './views.js';
|
||||
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll } from './spotify.js';
|
||||
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll } from './soundcloud.js';
|
||||
import {
|
||||
absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey,
|
||||
absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter,
|
||||
@@ -307,6 +308,7 @@ Object.assign(window, {
|
||||
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
|
||||
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
|
||||
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
|
||||
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
|
||||
executeSpotifyDownload,
|
||||
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
|
||||
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, saveMetadata,
|
||||
|
||||
@@ -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
@@ -8,6 +8,7 @@ import { closeNowPlaying, closeNpMenu } from './nowplaying.js';
|
||||
import { makeCard, makeAlbumCard, makeSongItem, makeGlobalTrackItem, mkSection } from './components.js';
|
||||
import { fetchRomajiIndex } from './api.js';
|
||||
import { loadSpotifyImporter } from './spotify.js';
|
||||
import { loadSoundCloudImporter } from './soundcloud.js';
|
||||
|
||||
// ─── NAVIGATION HISTORY ───
|
||||
export function _navSave() {
|
||||
@@ -52,7 +53,7 @@ export function navigate(page, data=null) {
|
||||
if(page==='playlists') loadPlaylists();
|
||||
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
|
||||
if(page==='recommendations') loadRecommendations();
|
||||
if(page==='spotify-importer') loadSpotifyImporter();
|
||||
if(page==='spotify-importer') { loadSpotifyImporter(); loadSoundCloudImporter(); }
|
||||
if(page==='audiobooks') {
|
||||
import('./abs.js').then(m => {
|
||||
if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId);
|
||||
|
||||
@@ -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:focus{border-color:var(--accent)}
|
||||
.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.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}
|
||||
|
||||
Reference in new issue
Block a user