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

+22
View File
@@ -217,6 +217,28 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
erreichbar war) und zählt für die Seitenleiste jetzt zur Bibliothek statt zu Entdecken.
### Fixed
- **Der Download-Filter konnte den falschen Titel speichern — zwei Löcher, ein Grund (Backend)** —
gemeldet mit „銀髪の少年" (Yoshihisa Hirano, HUNTER×HUNTER) und „Dearly Beloved" (TPR): beide Male
kam etwas anderes an.
- **Die Kaskadenschritte 3 und 4 lassen die Künstlerprüfung fallen** — bewusst, damit
Label-Kanäle durchkommen. Übrig bleibt dann Titel + Dauer. Bei „Dearly Beloved" reicht das
nicht: es gibt Rocksongs, die genau so heißen, und einer davon lag im Dauerfenster.
- **Für japanische Titel gab es überhaupt keine Titelprüfung.** `buildTitleKeywords` lieferte
dafür eine leere Liste (japanische Titel haben keine Wortgrenzen, und Art Tracks sind oft
romanisiert). Zusammen mit Schritt 3/4 blieb damit **allein die Dauer** übrig — und auf einem
Soundtrack-Album passt fast jedes andere Stück desselben Komponisten in dasselbe Fenster.
Genau das ist bei „銀髪の少年" passiert.
- **Behoben in drei Schritten:** japanische Titel bekommen ein Muster aus *Originaltitel oder
Romaji-Fassung* (Art Tracks tragen üblicherweise das eine oder das andere; Leerzeichen und
Bindestriche bleiben frei, weil Uploader sie unterschiedlich setzen). Ein Schritt **ohne**
Titelprüfung läuft jetzt immer **mit** Künstlerprüfung, egal was die Kaskade lockern wollte.
Und japanische Künstlernamen werden gegen das `artist`-Feld geprüft — der Kanalname steht kaum
je in Kanji, das Feld der Art Tracks dagegen schon; vorher hatten sie gar keine Prüfung.
- Bleibt am Ende weder ein Titel- noch ein Künstlermerkmal übrig, wird **gar nicht gesucht**:
ohne Merkmal landet sonst der erste Treffer passender Länge auf der Platte. Der Direktlink im
Importer ist dafür der vorgesehene Weg.
- Fünf neue Prüfungen in `backend/test/ytdlp.test.js` (59 grün), darunter „anderes Stück
desselben Albums darf nicht passen".
- **Das Mischen-Symbol zeigte nie an, dass es an ist (Android)** — und der Grund lag nicht in der
Anzeige. `toggleShuffle` baute den Rest der Warteschlange als Schleife aus `removeMediaItem` +
`addMediaItem` um; dazwischen hatte der Controller vorübergehend weniger Titel als unsere Liste.
+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'];
+42
View File
@@ -107,3 +107,45 @@ test('nothing but list entries yields no candidates', () => {
assert.deepEqual(pickYtmVideoUrls(only).urls, []);
assert.deepEqual(pickYtmVideoUrls('').urls, []);
});
// ─── Japanische Titel bekommen wieder eine Titelpruefung ────────────────────
// Regression zum 24.9.2026: "銀髪の少年" (HUNTER×HUNTER, Yoshihisa Hirano) lieferte ein anderes
// Stueck desselben Komponisten. Ursache: fuer japanische Titel gab buildTitleKeywords eine LEERE
// Liste zurueck, und die Kaskadenschritte 3/4 lassen die Kuenstlerpruefung fallen — uebrig blieb
// allein das Dauerfenster, in das auf einem Soundtrack-Album fast jeder Titel passt.
test('japanischer Titel liefert jetzt ein Muster statt gar nichts', () => {
const kws = buildTitleKeywords('銀髪の少年');
assert.equal(kws.length, 1);
assert.ok(new RegExp(kws[0], 'i').test('銀髪の少年'), 'Originaltitel muss passen');
assert.ok(!new RegExp(kws[0], 'i').test('闘う者たち'), 'anderes Stueck darf NICHT passen');
});
test('Romaji-Fassung wird als Alternative akzeptiert', () => {
const kws = buildTitleKeywords('銀髪の少年', 'ginpatsu no shounen');
const re = new RegExp(kws[0], 'i');
assert.ok(re.test('銀髪の少年'), 'Original');
assert.ok(re.test('Ginpatsu no Shounen'), 'Romaji mit Leerzeichen');
assert.ok(re.test('Ginpatsu-no-Shounen'), 'Romaji mit Bindestrichen');
assert.ok(re.test('Hunter x Hunter OST - Ginpatsu no Shounen'), 'als Teil eines laengeren Titels');
assert.ok(!re.test('Requiem Aranea'), 'anderes Stueck desselben Albums');
});
test('Romaji, die gar keine sind, werden nicht als Alternative genommen', () => {
// romajiForStorage gibt den Text unveraendert zurueck, wenn der Tokenizer fehlt
const kws = buildTitleKeywords('銀髪の少年', '銀髪の少年');
assert.equal(kws.length, 1);
assert.ok(!kws[0].includes('|'), 'keine doppelte Alternative');
});
test('Sonderzeichen im japanischen Titel bleiben woertlich', () => {
const kws = buildTitleKeywords('君の名は。(Movie ver.)');
const re = new RegExp(kws[0], 'i');
assert.ok(re.test('君の名は。(Movie ver.)'));
assert.ok(!re.test('君の名はX Movie verY'), 'Punkt darf nicht als Platzhalter wirken');
});
test('lateinische Titel bleiben unveraendert', () => {
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
assert.deepEqual(buildTitleKeywords('Dearly Beloved'), ['Beloved', 'Dearly']);
});