diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index 5322dff..8af4c6e 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -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. diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index d7105d1..13858a9 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -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; diff --git a/index.html b/index.html index 449736f..95fdea1 100644 --- a/index.html +++ b/index.html @@ -84,7 +84,7 @@ ZufΓ€llig