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