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.
|
erreichbar war) und zählt für die Seitenleiste jetzt zur Bibliothek statt zu Entdecken.
|
||||||
|
|
||||||
### Fixed
|
### 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
|
- **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` +
|
Anzeige. `toggleShuffle` baute den Rest der Warteschlange als Schleife aus `removeMediaItem` +
|
||||||
`addMediaItem` um; dazwischen hatte der Controller vorübergehend weniger Titel als unsere Liste.
|
`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
|
// Matches hiragana, katakana, CJK — used to detect Japanese content
|
||||||
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
const JP_RE = /[ぁ-んァ-ン一-龯]/;
|
||||||
|
|
||||||
function buildTitleKeywords(title) {
|
function buildTitleKeywords(title, romaji = '') {
|
||||||
// For Japanese titles: Art Tracks on YTM/YouTube are often romanized
|
// Japanische Titel haben keine Wortgrenzen, die Zwei-laengste-Woerter-Regel greift also nicht.
|
||||||
// ("Souiunatsu" for "そういう夏") — a Japanese keyword filter would always
|
// Frueher lieferte diese Funktion dafuer eine LEERE Liste — und damit hatte der Filter bei einem
|
||||||
// reject the correct video. Channel + duration are the quality gates instead.
|
// japanischen Titel gar keine Titelpruefung mehr. Zusammen mit den Kaskadenschritten ohne
|
||||||
if (JP_RE.test(title)) return [];
|
// 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 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 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 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 searchTitle = cleanTitle;
|
||||||
|
|
||||||
const variantFilter = buildVariantFilter(searchTitle);
|
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 variantPart = variantFilter ? ` & ${variantFilter}` : '';
|
||||||
const titleKwPart = titleKws.length ? titleKws.map(k => ` & title ~= (?i)${k}`).join('') : '';
|
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
|
// 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
|
// "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.
|
// 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)}` : '';
|
? ` & 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
|
// 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
|
// 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 = '';
|
durPart = '';
|
||||||
}
|
}
|
||||||
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
|
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
|
||||||
// Non-strict, or no artist constraint (short / Japanese name): single filter.
|
// Ohne Titelpruefung bliebe ohne Kuenstlerpruefung nur die Dauer — das ist keine Pruefung,
|
||||||
if (!strict || !artistChannelPart) return ['--match-filter', base];
|
// sondern ein Muenzwurf. Solche Schritte laufen deshalb IMMER mit Kuenstlerpruefung, auch
|
||||||
// Strict: pass if the channel name matches the artist OR yt-dlp's music `artist` metadata does
|
// wenn die Kaskade sie eigentlich lockern wollte.
|
||||||
// (catches official label-channel uploads like BANGTANTV). Two --match-filter args = OR in yt-dlp.
|
const mitKuenstler = strict || !titleKwPart;
|
||||||
return [
|
if (!mitKuenstler || !identitaet.length) return ['--match-filter', base];
|
||||||
'--match-filter', `${base}${artistChannelPart}`,
|
// Mehrere --match-filter verodert yt-dlp: Kanalname ODER `artist`-Metadatum (faengt offizielle
|
||||||
'--match-filter', `${base}${artistMetaPart}`,
|
// 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 = ['--playlist-items', '1-5', '--max-downloads', '1'];
|
||||||
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
|
const PI_FILTERED_10 = ['--playlist-items', '1-10', '--max-downloads', '1'];
|
||||||
const PI_UNFILTERED = ['--playlist-items', '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(only).urls, []);
|
||||||
assert.deepEqual(pickYtmVideoUrls('').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