const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const CONFIG = require('./config'); const { log } = require('./jobs'); const { sanitize, navidromeArtist, downloadArtwork, fetchLyrics, findExistingTrack, } = require('./metadata'); // Extract a plain word from an artist name for yt-dlp --match-filter uploader check. function artistFilterWord(name) { const first = (name || '').split(/[,/•]/)[0].trim(); const runs = first.match(/\w+/g) || []; const best = runs.filter(r => r.length >= 3).sort((a, b) => b.length - a.length)[0]; return best || ''; } // Extract distinctive words from a track title for yt-dlp title ~= (?i) filters. function buildTitleKeywords(title) { const VARIANT_WORDS = new Set(['sped','speedup','slowed','reverb','nightcore','karaoke','parody','remix','cover','acoustic','instrumental']); const STOPWORDS = new Set(['the','a','an','and','or','of','in','on','at','to','for','feat','ft','with']); const MIX_QUALIFIERS = ['radio', 'extended', 'club', 'vip', 'original', 'edit']; const lower = (title || '').toLowerCase(); const strippedTitle = (title || '').replace(/\([^)]*\)/g, '').trim(); const words = strippedTitle .replace(/[^\w\s]/g, ' ') .split(/\s+/) .filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase())); const top2 = words.sort((a, b) => b.length - a.length).slice(0, 2); const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w; const result = top2.map(toPattern); const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null; if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier); return result; } // Build yt-dlp --match-filter conditions to reject variant versions. function buildVariantFilter(title) { const t = (title || '').toLowerCase(); const RULES = [ { key: 'sped', expr: 'title !*= sped' }, { key: 'speedup', expr: 'title !*= speedup' }, { key: 'slowed', expr: 'title !*= slowed' }, { key: 'reverb', expr: 'title !*= reverb' }, { key: 'nightcore', expr: 'title !*= nightcore' }, { key: 'karaoke', expr: 'title !*= karaoke' }, { key: 'parody', expr: 'title !*= parody' }, { key: 'remix', expr: 'title !*= remix' }, { key: 'cover', expr: 'title !*= cover' }, { key: 'acoustic', expr: 'title !*= acoustic' }, { key: 'instrumental', expr: 'title !*= instrumental' }, ]; const parts = RULES.filter(r => !t.includes(r.key)).map(r => r.expr); return parts.length ? parts.join(' & ') : ''; } async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs }) { const mainArtist = albumArtist || artist; const safeArtist = sanitize(mainArtist.split(',')[0].trim()); const safeAlbum = sanitize(album || 'Singles'); const dir = path.join(outputDir || CONFIG.musicPath, safeArtist, safeAlbum); fs.mkdirSync(dir, { recursive: true }); const safeTitle = sanitize(title); const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; const existing = findExistingTrack(dir, trackPrefix, safeTitle); if (existing) { log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`); return { success: true, dir, skipped: true }; } const outputTemplate = path.join(dir, `${trackPrefix}${safeTitle}.%(ext)s`); const searchArtist = mainArtist.replace(/[^\p{L}\p{N}\s,&\-]/gu, '').trim(); const query = `${searchArtist} - ${title}`; const baseArgs = [ '--extract-audio', '--audio-format', CONFIG.audioFmt, '--audio-quality', CONFIG.audioQuality, '-o', outputTemplate, '--no-playlist', '--no-warnings', '--newline', '--match-filter', 'webpage_url !*= /browse/', ]; const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1']; const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1']; const PI_UNFILTERED = ['--playlist-items', '1']; const spawnYtdlp = (args) => new Promise((resolve, reject) => { const proc = spawn(CONFIG.ytdlpBin, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); let stderr = '', lastPct = ''; proc.stdout.on('data', d => { d.toString().split('\n').filter(l => l.trim()).forEach(line => { if (line.includes('%') && line === lastPct) return; if (line.includes('%')) lastPct = line; log(jobId, line.trim()); }); }); proc.stderr.on('data', d => { stderr += d.toString(); d.toString().split('\n') .filter(l => l.trim() && !l.startsWith('[debug]')) .forEach(l => log(jobId, l.trim())); }); proc.on('error', reject); proc.on('close', code => (code === 0 || code === 101) ? resolve() : reject(new Error(`yt-dlp code ${code}: ${stderr.slice(-300)}`)) ); }); const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D'; const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(query)}¶ms=${YTM_SONGS_PARAM}`; const expectedAudio = path.join(dir, `${trackPrefix}${safeTitle}.${CONFIG.audioFmt}`); const hasAudio = () => fs.existsSync(expectedAudio); const tryYtdlp = async (args) => { await spawnYtdlp(args); }; const variantFilter = buildVariantFilter(title); const titleKws = buildTitleKeywords(title); const variantPart = variantFilter ? ` & ${variantFilter}` : ''; const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : ''; const mf = (dur, variant) => { const durPart = dur ? `duration > ${dur.min} & duration < ${dur.max} & ` : ''; return ['--match-filter', `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`]; }; if (durationMs) { const durationSec = Math.round(durationMs / 1000); const margin = Math.max(15, Math.round(durationSec * 0.15)); const dur = { min: Math.max(30, durationSec - margin), max: durationSec + margin }; log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`); await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(dur, true), ...baseArgs]); if (!hasAudio()) { log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…'); await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true), ...baseArgs]); } if (!hasAudio()) { log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…'); await tryYtdlp([ytmUrl, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); } if (!hasAudio()) { await tryYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); } if (!hasAudio()) { log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauerbeschränkung…'); await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } if (!hasAudio()) { await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } if (!hasAudio()) { log(jobId, '⚠️ Letzter gefilterter Versuch…'); await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, false), ...baseArgs]); } if (!hasAudio()) { await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]); } if (!hasAudio()) { log(jobId, '⚠️ Letzter Versuch: keine Filter…'); await tryYtdlp([ytmUrl, ...PI_UNFILTERED, ...baseArgs]); } if (!hasAudio()) { await tryYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]); } } else { log(jobId, `🔍 Suche YT Music: "${query}"`); await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); if (!hasAudio()) { await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } if (!hasAudio()) { log(jobId, '⚠️ Letzter gefilterter Versuch…'); await tryYtdlp([ytmUrl, ...PI_FILTERED, ...mf(null, false), ...baseArgs]); } if (!hasAudio()) { await tryYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, false), ...baseArgs]); } if (!hasAudio()) { log(jobId, '⚠️ Letzter Versuch: keine Filter…'); await tryYtdlp([ytmUrl, ...PI_UNFILTERED, ...baseArgs]); } if (!hasAudio()) { await tryYtdlp([`ytsearch1:${query}`, ...PI_UNFILTERED, ...baseArgs]); } } const audioFile = hasAudio() ? expectedAudio : null; if (!audioFile) { log(jobId, '⚠️ Keine Audio-Datei gefunden'); return { success: true, dir }; } return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title, lyricsArtist: artist }); } // Shared ffmpeg metadata embedding — used by both yt-dlp and squid paths async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }) { const [coverPath, lyricsData] = await Promise.all([ downloadArtwork(artworkUrl, dir), fetchLyrics(artist, title, album), ]); const hasCover = !!(coverPath && fs.existsSync(coverPath)); if (lyricsData?.synced) { const lrcPath = path.join(dir, `${trackPrefix}${safeTitle}.lrc`); fs.writeFileSync(lrcPath, lyricsData.synced, 'utf8'); log(jobId, '🎵 Synced lyrics gefunden'); } else if (lyricsData?.plain) { log(jobId, '📝 Lyrics gefunden'); } log(jobId, `🏷 Metadaten + Cover → ${path.basename(audioFile)}`); const fileExt = path.extname(audioFile); const tmpFile = audioFile.slice(0, -fileExt.length) + '.metatmp' + fileExt; await new Promise(resolve => { const ffArgs = ['-i', audioFile]; if (hasCover) ffArgs.push('-i', coverPath); ffArgs.push('-map', '0:a'); if (hasCover) ffArgs.push('-map', '1:v'); ffArgs.push( '-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3', '-metadata', `title=${title}`, '-metadata', `artist=${navidromeArtist(artist)}`, '-metadata', `album_artist=${mainArtist}`, ); if (album) ffArgs.push('-metadata', `album=${album}`); if (year) ffArgs.push('-metadata', `date=${String(year)}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`); if (genre) ffArgs.push('-metadata', `genre=${genre}`); if (discNum) ffArgs.push('-metadata', `disc=${discTotal ? `${discNum}/${discTotal}` : String(discNum)}`); if (compilation) ffArgs.push('-metadata', `compilation=1`); if (explicit) ffArgs.push('-metadata', 'ITUNESADVISORY=1'); if (lyricsData?.plain) ffArgs.push('-metadata', `lyrics=${lyricsData.plain}`); if (hasCover) { ffArgs.push( '-metadata:s:v', 'title=Album cover', '-metadata:s:v', 'comment=Cover (front)', ); } ffArgs.push(tmpFile, '-y'); const ff = spawn('ffmpeg', ffArgs); let ffErr = ''; ff.stderr.on('data', d => ffErr += d.toString()); ff.on('error', () => resolve()); ff.on('close', code => { if (code === 0 && fs.existsSync(tmpFile)) { fs.renameSync(tmpFile, audioFile); if (hasCover) try { fs.unlinkSync(coverPath); } catch {} const base = path.basename(audioFile, path.extname(audioFile)); for (const ext of ['webm', 'webm.part', 'm4a', 'opus']) { try { fs.unlinkSync(path.join(dir, `${base}.${ext}`)); } catch {} } log(jobId, `✅ Fertig${hasCover ? ' (Cover 1000x1000 eingebettet)' : ''}`); } else { try { fs.unlinkSync(tmpFile); } catch {} log(jobId, `⚠️ ffmpeg code ${code}`); if (ffErr) log(jobId, ffErr.slice(-200)); } resolve(); }); }); return { success: true, dir }; } module.exports = { downloadTrack, embedMetadata };