const { spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const CONFIG = require('./config'); const { log } = require('./jobs'); const { sanitize, navidromeArtist, dateinamensKuenstler, downloadArtwork, findExistingTrack, } = require('./metadata'); const { romajiForStorage, isTokenizerReady } = require('./romaji'); // Matches hiragana, katakana, CJK — used to detect Japanese content const JP_RE = /[ぁ-んァ-ン一-龯]/; function buildTitleKeywords(title, romaji = '') { // Japanische Titel haben keine Wortgrenzen, die Zwei-laengste-Woerter-Regel greift also nicht. // Frueher lieferte diese Funktion dafuer eine LEERE Liste — und damit hatte der Filter bei einem // japanischen Titel gar keine Titelpruefung mehr. Zusammen mit den Kaskadenschritten ohne // Kuenstlerpruefung blieb allein die Dauer uebrig, und bei einem Soundtrack-Album passt fast // jeder andere Titel desselben Komponisten in dasselbe Dauerfenster. Genau so kam am 24.9.2026 // ein falsches Stueck aus "HUNTER×HUNTER" herein. // // Stattdessen wird jetzt eine Alternative gefordert: der ORIGINALTITEL oder seine Romaji-Fassung. // Art Tracks tragen ueblicherweise das eine oder das andere. Leerzeichen und Bindestriche der // Romaji-Fassung bleiben dabei frei, weil Uploader sie unterschiedlich setzen // ("Ginpatsu no Shounen" / "Ginpatsu-no-Shounen"). if (JP_RE.test(title)) { const escRe = t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const alternativen = [escRe(title.trim())]; const r = (romaji || '').trim(); if (r && r.toLowerCase() !== title.trim().toLowerCase() && !JP_RE.test(r)) { // Die Umschrift des Tokenizers ist EINE Schreibweise von mehreren. Uploader schreiben // "Shonen" statt "Shounen", setzen Bindestriche oder lassen den Katakana-Laengungsstrich // weg. Das Muster laesst diese Abweichungen zu — sonst waere die Alternative so streng, // dass sie praktisch nie greift. alternativen.push( escRe(r) .replace(/ー/g, '') // kommt in lateinischen Titeln nie vor .replace(/\s+/g, '[ -]?') .replace(/ou/gi, '(?:ou|o|\u014d|oh)') .replace(/uu/gi, '(?:uu|u|\u016b)') ); } return [`(?:${alternativen.join('|')})`]; } 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','single','ep','album','remaster','remastered','deluxe','edition','version','live','bonus']); 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())); // Numbers are the most distinctive token a title has and they are usually SHORT, so the // longest-two rule threw them away: "700 Main Street" kept only Street+Main and happily matched // "777 Main Street" by a different artist. Numbers are therefore always required, on top of the // two longest words. // Ausnahme: die Jahreszahl einer Neuauflage ("… - 2020 Remaster") steht auf der Veroeffentlichung, // aber nie im YouTube-Titel — als Pflicht-Keyword wuerde sie den richtigen Upload ablehnen. Nur // wenn ein solches Qualifier-Wort daneben steht; ein Titel, der SELBST eine Jahreszahl ist // ("1999", "Berlin 2020"), behaelt sie. const RELEASE_QUALIFIER = /\b(remaster|remastered|version|edition|anniversary|mono|stereo)\b/i; const isEditionYear = w => /^(19|20)\d{2}$/.test(w) && RELEASE_QUALIFIER.test(title || ''); const isRequiredNum = w => /^\d+$/.test(w) && !isEditionYear(w); const numbers = words.filter(isRequiredNum); const wordsNoNum = words.filter(w => !isRequiredNum(w)); const top2 = wordsNoNum.sort((a, b) => b.length - a.length).slice(0, 2); // Uploaders abbreviate the same word the release spells out ("700 Main St" vs "700 Main Street"), // which rejected the official Art Track in every cascade step. Accept either spelling; \b keeps // the short form from matching inside another word. const ABBREV = { street: 'st', saint: 'st', mount: 'mt', doctor: 'dr', versus: 'vs', number: 'no' }; const toPattern = w => { const abbr = ABBREV[w.toLowerCase()]; if (abbr) return `\\b(?:${w}|${abbr})\\b`; return (w.length >= 4 && w.endsWith('s')) ? w + '?' : w; }; const result = [...numbers.map(n => `\\b${n}\\b`), ...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; } // Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen. // Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und // browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind // Listeneintraege und werden uebersprungen. // // Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE // Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand // der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf // Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier // ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile. function pickYtmVideoUrls(stdout) { const urls = []; let skipped = 0; for (const line of (stdout || '').split('\n')) { const u = line.trim(); if (!u) continue; if (u.includes('watch?v=')) urls.push(u); else if (u.includes('/browse/')) skipped++; } return { urls, skipped }; } function buildVariantFilter(title) { const t = (title || '').toLowerCase(); // !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that // partial matches like "nightcoreals" containing "nightcore" don't falsely reject results. const RULES = [ { key: 'sped', expr: 'title !~= (?i)\\bsped\\b' }, { key: 'speedup', expr: 'title !~= (?i)\\bspeed.{0,2}up\\b' }, { key: 'slowed', expr: 'title !~= (?i)\\bslowed\\b' }, { key: 'reverb', expr: 'title !~= (?i)\\breverb\\b' }, { key: 'nightcore', expr: 'title !~= (?i)\\bnightcore\\b' }, { key: 'karaoke', expr: 'title !~= (?i)\\bkaraoke\\b' }, { key: 'parody', expr: 'title !~= (?i)\\bparody\\b' }, { key: 'remix', expr: 'title !~= (?i)\\bremix\\b' }, { key: 'cover', expr: 'title !~= (?i)\\bcover\\b' }, { key: 'acoustic', expr: 'title !~= (?i)\\bacoustic\\b' }, { key: 'instrumental', expr: 'title !~= (?i)\\binstrumental\\b' }, // Konzertmitschnitte: `!is_live` heisst nur "laeuft nicht gerade als Stream" und laesst eine // Live-AUFNAHME durch. Auf dem echten Kuenstlerkanal ist die sonst nicht vom Studiotrack zu // unterscheiden — so landete "Dat Adam - Ufo Video Days 2015 Live" in der Bibliothek. // skipRe statt includes(): "deliver"/"delivery" enthalten "live" als Teilstring und wuerden // die Regel sonst faelschlich abschalten. { key: 'live', expr: 'title !~= (?i)\\blive\\b', skipRe: /\blive\b/i }, ]; // Eine Regel entfaellt, wenn der GESUCHTE Titel das Wort selbst traegt — wer "Live Forever" oder // bewusst eine Live-Version sucht, soll sie auch bekommen. const parts = RULES.filter(r => r.skipRe ? !r.skipRe.test(t) : !t.includes(r.key)).map(r => r.expr); return parts.length ? parts.join(' & ') : ''; } async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs, sourceUrl }) { const mainArtist = albumArtist || artist; // If the title or artist contains Japanese characters, wait for kuromoji to finish loading. // Without it romajiForStorage returns the original text unchanged, producing Japanese // folder/filenames and preventing sort tags from being written. if (JP_RE.test(title) || JP_RE.test(rawArtist || artist || '')) { if (!isTokenizerReady()) { log(jobId, '⏳ Warte auf kuromoji-Tokenizer…'); await new Promise(resolve => { const t0 = Date.now(); const iv = setInterval(() => { if (isTokenizerReady() || Date.now() - t0 > 30000) { clearInterval(iv); resolve(); } }, 300); }); } } // Strip release-type suffixes Spotify appends to track names (e.g. "recognize - Single" → "recognize"). // Only strip trailing "- word(s)" when those words are release-type qualifiers or bare years, not // real sub-titles (which tend to be longer phrases or start with "feat"/"ft"). // Used for BOTH the filename ("01 - Artist - Title", no "- Single") and the YTM search query. const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i; const cleanTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title; // Folder / filename: use original names (Japanese or whatever was passed in). // Romaji goes ONLY into sort tags (TITLESORT, ARTISTSORT, etc.) via embedMetadata. 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(cleanTitle); const trackPrefix = trackNum ? String(trackNum).padStart(2, '0') + ' - ' : ''; // Filename format: "{01 - }Artist - Title.ext" — nur der erste Kuenstler, siehe // dateinamensKuenstler() in lib/metadata.js. Die volle Liste steht im artist-Tag. const safeArtistForFile = sanitize(dateinamensKuenstler(artist, mainArtist)); const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`; // Dublettenpruefung: findExistingTrack durchsucht NUR den Ordner des uebergebenen Kuenstlers. // Bei Soundtracks und Compilations weicht der Album-Kuenstler vom Titel-Kuenstler ab, und je // nachdem, ob der Song als Album oder einzeln geladen wird, landet er unter dem einen ODER dem // anderen. Die Pruefung sah dann jeweils im falschen Ordner nach und lud ihn erneut. Deshalb wird // bei abweichendem Titel-Kuenstler auch dessen Ordner geprueft — zweiter Durchlauf nur in genau // diesem Fall, gemessen ~0,6 ms zusaetzlich bei knapp 29.000 Dateien. const trackArtist = sanitize((artist || '').split(/[,;&]/)[0].trim()); const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle) || (trackArtist && trackArtist !== safeArtist ? findExistingTrack(outputDir || CONFIG.musicPath, trackArtist, cleanTitle) : null); if (existing) { log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`); return { success: true, dir, skipped: true }; } const outputTemplate = path.join(dir, `${fileBase}.%(ext)s`); // For YTM search: use the original (raw) artist when available — the YTM channel is registered // under the Japanese/original name, not the iTunes-resolved English name. // rawArtist is undefined for non-Japanese tracks, so fall back to mainArtist. const ytmArtist = (rawArtist || mainArtist).replace(/\p{S}/gu, '').trim(); const searchArtist = ytmArtist; const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const searchTitle = cleanTitle; const variantFilter = buildVariantFilter(searchTitle); // Romaji nur, wenn der Tokenizer bereit ist — sonst liefert romajiForStorage den Text // unveraendert zurueck und die Alternative waere doppelt. const titleRomaji = isTokenizerReady() ? romajiForStorage(searchTitle) : ''; const titleKws = buildTitleKeywords(searchTitle, titleRomaji); const variantPart = variantFilter ? ` & ${variantFilter}` : ''; const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : ''; // Soft channel check: first word of original artist name used in early cascade steps to avoid // downloading a same-title song by a different artist (e.g. wrong "Moonlight" for N!GHT). // Skip when artistFirstWord is non-ASCII (Japanese kanji/kana): YouTube channel names are // almost never in kanji, so the filter would reject every valid result. const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0]; const artistChannelPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord)) ? ` & channel ~= (?i)${escRe(artistFirstWord)}` : ''; // yt-dlp fills `artist` (music metadata) on YouTube "Art Tracks" — the label-provided official // audio — even when the channel doesn't contain the artist name (BANGTANTV/HYBE/…). Verified with // "BTS Pied Piper": ONLY the official upload had artist=BTS; every fan/live/lyric reupload had // none. Used as an OR alternative to the channel check so those official uploads still pass strict. // Fuer japanische Namen wird der KANAL nicht geprueft (Kanal-Namen stehen kaum je in Kanji), // das Feld `artist` der Art Tracks dagegen sehr wohl — dort steht der Originalname. Ohne diese // Ausnahme hatte ein japanischer Kuenstler ueberhaupt keine Identitaetspruefung. const artistMetaPart = (artistFirstWord.length >= 3 || (JP_RE.test(artistFirstWord) && artistFirstWord.length >= 2)) ? ` & artist ~= (?i)${escRe(artistFirstWord)}` : ''; // Kanji-Kuenstler: Kanaele und Videotitel schreiben den Namen meist lateinisch. Ohne diese // Alternative bliebe fuer sie nur das artist-Feld in Originalschrift — und steht dort die // lateinische Schreibweise, faellt der richtige Upload durch. const artistRomajiWord = (JP_RE.test(artistFirstWord) && isTokenizerReady()) ? ((romajiForStorage(artistFirstWord) || '').split(/\s+/) .filter(w => w.length >= 3 && !JP_RE.test(w)) .sort((a, b) => b.length - a.length)[0] || '') : ''; const artistRomajiParts = artistRomajiWord ? [` & channel ~= (?i)${escRe(artistRomajiWord)}`, ` & artist ~= (?i)${escRe(artistRomajiWord)}`] : []; // Alle Wege, den Kuenstler zu erkennen. yt-dlp verodert mehrere --match-filter. const identitaet = [artistChannelPart, artistMetaPart, ...artistRomajiParts].filter(Boolean); // Use a space (not " - ") between artist and title: this matches how users type // in the YTM search box and ranks the exact official track first. A dash skews // YTM ranking toward a same-named artist's full discography. const query = `${ytmArtist} ${searchTitle}`; const baseArgs = [ '--extract-audio', '--audio-format', CONFIG.audioFmt, '--audio-quality', CONFIG.audioQuality, '-o', outputTemplate, '--no-playlist', '--no-warnings', '--newline', // Hard upper size limit — prevents saving a wrong 28-min video as a ~52 MB mp3 '--max-filesize', '30M', // Use the web_music player client (falling back to default) so YouTube Music // "Art Track" / topic uploads are reachable. The android_vr client returns // "This video is not available" for many YTM tracks that play fine in the browser. '--extractor-args', 'youtube:player_client=web_music,default', ]; 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', resolve); proc.on('close', code => { // Treat all non-zero exits as "no result" so the cascade continues. // Fatal errors (network, bad args) are logged via stderr above; hasAudio() decides outcome. if (code !== 0 && code !== 101) log(jobId, `⚠️ yt-dlp exit ${code}: ${stderr.slice(-200).trim()}`); resolve(); }); }); // Use original (raw) artist for YTM URL — better ranking since channel is in original language. // Append the "Songs" filter param so results are restricted to YTM's Songs shelf // (no videos/playlists/clips) — the documented way to song-search YTM. This is cleaner // for normal tracks; for ambiguous artist names yt-dlp's search may still not surface the // exact official track (a known extractor limitation — use the direct link in that case). const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D'; const ytmQuery = `${rawArtist || mainArtist} ${searchTitle}`; const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(ytmQuery)}¶ms=${YTM_SONGS_PARAM}`; const expectedAudio = path.join(dir, `${fileBase}.${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; // Direct-link download failed (e.g. YouTube 403 / bot check) — must report FAILURE, not success. // Previously returned { success: true }, so the job was marked "done/Fertig!" and the UI wrongly // said the track was added. if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: false, failed: true, dir }; } return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle }); } // 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. // // NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search // page processing, not just channel expansion, and would wrongly return [] always. const getYtmVideoUrls = (maxItems) => new Promise(resolve => { let out = ''; const proc = spawn(CONFIG.ytdlpBin, [ ytmUrl, '--flat-playlist', '--playlist-items', `1-${maxItems}`, '--print', 'webpage_url', '--no-warnings', // Without the web_music client, yt-dlp parses the YTM search page as a // generic YouTube search and returns unrelated videos instead of the // native Songs shelf (where the actual artist track lives). '--extractor-args', 'youtube:player_client=web_music,default', ], { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); proc.stdout.on('data', d => { out += d.toString(); }); proc.on('error', () => resolve([])); proc.on('close', () => { const { urls, skipped } = pickYtmVideoUrls(out); if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`); else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`); resolve(urls); }); }); const mf = (dur, variant, strict = false) => { let durPart; if (dur) { durPart = `duration > ${dur.min} & duration < ${dur.max} & `; } else if (durationMs) { // Even when relaxing the window (Mittelweg), keep a 2× cap so a 28-min video can't slip through durPart = `duration < ${Math.round(durationMs / 1000 * 2)} & `; } else { durPart = ''; } const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`; // Ohne Titelpruefung bliebe ohne Kuenstlerpruefung nur die Dauer — das ist keine Pruefung, // sondern ein Muenzwurf. Solche Schritte laufen deshalb IMMER mit Kuenstlerpruefung, auch // wenn die Kaskade sie eigentlich lockern wollte. const mitKuenstler = strict || !titleKwPart; if (!mitKuenstler || !identitaet.length) return ['--match-filter', base]; // Mehrere --match-filter verodert yt-dlp: Kanalname ODER `artist`-Metadatum (faengt offizielle // Label-Uploads wie BANGTANTV, deren Kanal den Kuenstler nicht im Namen traegt). return identitaet.flatMap(teil => ['--match-filter', `${base}${teil}`]); }; // Weder Titel- noch Kuenstlermerkmal pruefbar: dann gaebe es nichts, woran sich der richtige // Treffer erkennen liesse, und der erste Suchtreffer passender Laenge wuerde gespeichert. // Lieber sauber scheitern — der Direktlink im Importer ist dafuer der vorgesehene Weg. if (!titleKwPart && !identitaet.length) { log(jobId, '⚠️ Weder Titel- noch Künstlermerkmal prüfbar — bitte Direktlink im Importer nutzen'); return { success: false, notFound: true, dir }; } 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']; // Fetch YTM URLs once upfront so every cascade step filters the SAME result set. // Re-fetching produced different orderings, causing the correct video (found in step 1 // but failing the channel filter) to disappear by step 3 and a wrong one to slip through. const ytmUrls = await getYtmVideoUrls(10); const tryYtm = async (n, mfArgs) => { const urls = n ? ytmUrls.slice(0, n) : ytmUrls; if (!urls.length) return; await spawnYtdlp([...urls, '--max-downloads', '1', ...mfArgs, ...baseArgs]); }; if (durationMs && durationMs > 30000) { const durationSec = Math.round(durationMs / 1000); const margin = Math.max(10, Math.round(durationSec * 0.10)); const dur = { min: Math.max(30, durationSec - margin), max: durationSec + margin }; log(jobId, `🔍 Suche YT Music (~${durationSec}s ±${margin}s): "${query}"`); // 1. YTM top-5 — strict: duration + title + variant + channel check await tryYtm(5, mf(dur, true, true)); if (!hasAudio()) { // 2. YT top-5 — strict: same filters including channel check log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…'); await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]); } if (!hasAudio()) { // 3. YTM all-10 — no channel check (label channels, etc.) log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…'); await tryYtm(0, mf(dur, true)); } if (!hasAudio()) { // 4. YT top-10 — no channel check await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); } // No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist // channel (+ title). This catches legit tracks whose YT upload duration drifts from the // iTunes value, while never grabbing a same-titled video by someone else. If nothing // matches title+channel we download NOTHING and the job fails cleanly (see end of fn) — // far better than silently saving the wrong song (e.g. a "recognize" fan clip). if (!hasAudio()) { // 5. YTM all — no duration, but channel + title required log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…'); await tryYtm(0, mf(null, true, true)); } if (!hasAudio()) { // 6. YT top-10 — no duration, but channel + title required await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]); } } else { log(jobId, `🔍 Suche YT Music: "${query}"`); // 1. YTM top-5 — strict: title + variant + channel check await tryYtm(5, mf(null, true, true)); if (!hasAudio()) { // 2. YT top-5 — strict await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true, true), ...baseArgs]); } if (!hasAudio()) { // 3. YTM all-10 — no channel check (catches label/distributor channels) await tryYtm(0, mf(null, true)); } if (!hasAudio()) { // 4. YT top-5 — no channel check await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(null, true), ...baseArgs]); } // Mittelweg (no-duration): after step 4, do NOT fall through to title-only or // no-filter grabs — those pull wrong covers (observed: Yorushika → Piano Echoes). // Fail cleanly, same as the with-duration branch. Use the Direktlink instead. } const audioFile = hasAudio() ? expectedAudio : null; if (!audioFile) { log(jobId, '⚠️ Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen'); return { success: false, notFound: true, dir }; } return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle }); } // Shared ffmpeg metadata embedding — used by both yt-dlp and squid paths async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }) { const coverPath = await downloadArtwork(artworkUrl, dir); const hasCover = !!(coverPath && fs.existsSync(coverPath)); 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'); // Main tags: store original text (Japanese or whatever was passed in). // rawArtist = original Spotify/source artist (may be Japanese); artist = iTunes-resolved (may be English). // Use rawArtist for the per-track artist tag; mainArtist for album_artist so all tracks // in an album get the same value (prevents Navidrome album-splitting). const tagArtist = navidromeArtist(rawArtist || artist); const tagAlbumArtist = mainArtist.replace(/\s+&\s+/g, ', '); // Sort tags: romaji for display / sorting in apps that support them (TITLESORT, ARTISTSORT, etc.) // artist is already the iTunes-resolved English name when available — use directly (more accurate // than kuromoji). For non-iTunes tracks romajiForStorage acts as kuromoji fallback. const sortTitle = romajiForStorage(title) || title; const sortArtist = romajiForStorage(navidromeArtist(artist)) || navidromeArtist(artist); const sortAlbumArtist = romajiForStorage(mainArtist.replace(/\s+&\s+/g, ', ')) || mainArtist.replace(/\s+&\s+/g, ', '); const sortAlbum = album ? (romajiForStorage(album) || album) : null; ffArgs.push( '-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3', '-metadata', `title=${title}`, '-metadata', `artist=${tagArtist}`, '-metadata', `album_artist=${tagAlbumArtist}`, ); // Always write an album tag, defaulting to "Singles" so it matches the on-disk folder // (album || 'Singles'). Without this, no-album tracks land in a "Singles" folder but show // as "[Unknown Album]" in Navidrome — a tag/folder mismatch that breaks the metadata editor. ffArgs.push('-metadata', `album=${album || 'Singles'}`); // Sort tags — only write when they differ from main (no-op for non-Japanese tracks) if (sortTitle !== title) ffArgs.push('-metadata', `TITLESORT=${sortTitle}`); if (sortArtist !== tagArtist) ffArgs.push('-metadata', `ARTISTSORT=${sortArtist}`); if (sortAlbumArtist !== tagAlbumArtist) ffArgs.push('-metadata', `ALBUMARTISTSORT=${sortAlbumArtist}`); if (sortAlbum && sortAlbum !== album) ffArgs.push('-metadata', `ALBUMSORT=${sortAlbum}`); 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 (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, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls };