From 06b13caea9c120fad58312aef6c5a7585694bcb6 Mon Sep 17 00:00:00 2001 From: marc Date: Thu, 24 Sep 2026 23:31:29 +0200 Subject: [PATCH] fix(backend): Download-Filter konnte ohne Titel- oder Kuenstlermerkmal zugreifen Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 ++++++++++++++ backend/lib/ytdlp.js | 62 +++++++++++++++++++++++++++++--------- backend/test/ytdlp.test.js | 42 ++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2342c09..e696b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index e63fff0..cb80e61 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -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']; diff --git a/backend/test/ytdlp.test.js b/backend/test/ytdlp.test.js index 4d6ce24..6e230ee 100644 --- a/backend/test/ytdlp.test.js +++ b/backend/test/ytdlp.test.js @@ -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']); +});