fix(backend): Download-Filter konnte ohne Titel- oder Kuenstlermerkmal zugreifen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-24 23:31:29 +02:00
1 parent 1432a960fb
commit 06b13caea9
3 files changed
+111 -15

No files matched your search

+47 -15
View File
@@ -12,11 +12,27 @@ 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 [];
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)) {
alternativen.push(escRe(r).replace(/\s+/g, '[ -]?'));
}
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'];
@@ -173,7 +189,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
const searchTitle = cleanTitle;
const variantFilter = buildVariantFilter(searchTitle);
const titleKws = buildTitleKeywords(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('') : '';
@@ -188,8 +207,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// 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))
// 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)}` : '';
// Alle Wege, den Kuenstler zu erkennen. yt-dlp verodert mehrere --match-filter.
const identitaet = [artistChannelPart, artistMetaPart].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
@@ -300,16 +324,24 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
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}`,
];
// 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'];