getYtmVideoUrls wertete jedes /browse/UC in der Ausgabe als "yt-dlp hat einen Artist-Channel aufgeklappt" und gab [] zurueck — samt der bereits gesammelten Song-URLs. 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 mit einem Live-Mitschnitt. An 8 echten Suchen gemessen feuerte das in 7 Faellen, Schritt 1 der Kaskade war also praktisch tot. Mit --flat-playlist kann gar nichts aufgeklappt werden, ein Kanal ist EINE Zeile. pickYtmVideoUrls() behaelt daher alle watch?v=-Zeilen und ueberspringt browse/UC (Kanal) wie browse/MPREb_ (Album). Ueber 20k Zufallsausgaben geprueft: die neue Funktion ist eine Obermenge der alten, kann also nur Kandidaten hinzufuegen. Drei echte Downloads liefen end-to-end sauber durch (Dauer sekundengenau auf Deezer, korrekte Tags), alle aus Schritt 1 statt aus dem Fallback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
467 lines
25 KiB
JavaScript
467 lines
25 KiB
JavaScript
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, findExistingTrack,
|
||
} = require('./metadata');
|
||
const { romajiForStorage, isTokenizerReady } = require('./romaji');
|
||
|
||
// Matches hiragana, katakana, CJK — used to detect Japanese content
|
||
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||
|
||
function buildTitleKeywords(title) {
|
||
// For Japanese titles: Art Tracks on YTM/YouTube are often romanized
|
||
// ("Souiunatsu" for "そういう夏") — a Japanese keyword filter would always
|
||
// reject the correct video. Channel + duration are the quality gates instead.
|
||
if (JP_RE.test(title)) return [];
|
||
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 - }Artist1, Artist2 - Title.ext"
|
||
const safeArtistForFile = sanitize((artist || '').replace(/;/g, ','));
|
||
const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`;
|
||
|
||
const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle);
|
||
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);
|
||
const titleKws = buildTitleKeywords(searchTitle);
|
||
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.
|
||
const artistMetaPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
|
||
? ` & artist ~= (?i)${escRe(artistFirstWord)}` : '';
|
||
|
||
// 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 : ''}`;
|
||
// Non-strict, or no artist constraint (short / Japanese name): single filter.
|
||
if (!strict || !artistChannelPart) return ['--match-filter', base];
|
||
// Strict: pass if the channel name matches the artist OR yt-dlp's music `artist` metadata does
|
||
// (catches official label-channel uploads like BANGTANTV). Two --match-filter args = OR in yt-dlp.
|
||
return [
|
||
'--match-filter', `${base}${artistChannelPart}`,
|
||
'--match-filter', `${base}${artistMetaPart}`,
|
||
];
|
||
};
|
||
|
||
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 };
|