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(' & ') : '';
}
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.
+66 -2
View File
@@ -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;