fix(backend): Download-Filter konnte ohne Titel- oder Kuenstlermerkmal zugreifen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1432a960fb
commit
06b13caea9
3 files changed
+111
-15
No files matched your search
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
Reference in new issue
Block a user