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