From 2f87a12f086ff90409b0e5af48ea1eed8037d137 Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 25 Sep 2026 09:48:35 +0200 Subject: [PATCH] fix(backend): Kuenstler vor Dauer in der Suchkaskade, mit Regressionsprobe Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 17 ++++ backend/lib/ytdlp.js | 44 +++++---- backend/scripts/filter-probe.mjs | 158 +++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 backend/scripts/filter-probe.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 119d826..fa87792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -217,6 +217,23 @@ 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 +- **Künstler schlägt Dauer: die Kaskade sucht in anderer Reihenfolge (Backend)** — Nachtrag zum + Fehlgriff bei „Dearly Beloved" (TPR). Die Kette war vollständig nachvollziehbar: + - Deezer kennt **keinen** Künstler „TPR"; das Album läuft dort unter *Project Destati*, der Titel + heißt *Dearly Beloved I* und dauert **230 s**. Von dort kam die erwartete Dauer. + - Der richtige Upload dauert **136 s** und fiel damit aus dem Fenster 207–253 s. Eine Rockband, + die tatsächlich **DEARLY BELOVED** heißt, liegt mit ihrem Stück „LSD" bei **250 s** mitten + drin — und ihr **Bandname** erfüllte die Titelprüfung. + - Entschieden hat die Reihenfolge: die Stufe *Dauer behalten, Künstler fallen lassen* lief vor + der Stufe *Künstler behalten, Dauer lockern*. Die erste griff zu, die zweite kam nie dran. + - **Die beiden sind jetzt vertauscht.** Der Künstler ist das stärkere Merkmal: die erwartete + Dauer stammt aus Metadaten und gehört oft zu einer anderen Fassung, während Kanalname und + `artist`-Feld den Song selbst benennen. Es fällt **kein** Schritt weg — was vorher gefunden + wurde, wird weiter gefunden, gegebenenfalls eine Stufe später. + - **Belegt statt behauptet:** `backend/scripts/filter-probe.mjs` holt für bekannte Fälle die + echten Suchtreffer und spielt beide Reihenfolgen durch. Fünf Fälle, **ein** Unterschied (der + gemeldete), **null** Regressionen — BTS auf dem Label-Kanal BANGTANTV, der japanische + Soundtrack-Titel und Rising Insane kommen unverändert heraus, Letzterer nur eine Stufe später. - **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. diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index 477a0c6..e9b6419 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -390,29 +390,41 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…'); await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]); } + // Findet sich im Dauerfenster nichts, wird ZUERST die DAUER gelockert und der Kuenstler + // BEHALTEN — nicht umgekehrt. Der Kuenstler ist das staerkere Merkmal: die erwartete Dauer + // stammt aus den Metadaten und gehoert oft zu einer anderen Fassung, waehrend Kanalname und + // artist-Feld den Song tatsaechlich benennen. + // + // Vorher lief es andersherum, und genau daran ist am 24.9.2026 "Dearly Beloved" von TPR + // gescheitert: erwartet wurden 230 s (Deezer kennt TPR nicht und lieferte "Dearly Beloved I" + // von Project Destati), der richtige Upload dauert 136 s und fiel aus dem Fenster — waehrend + // eine Rockband namens DEARLY BELOVED mit ihrem Stueck "LSD" bei 250 s mitten hinein passte. + // Deren BANDNAME erfuellte die Titelpruefung. Der Schritt ohne Kanalpruefung griff zuerst zu. + // + // Es faellt KEIN Schritt weg, nur die Reihenfolge dreht sich: was vorher gefunden wurde, wird + // weiter gefunden, nur gegebenenfalls eine Stufe spaeter (geprueft, siehe scripts/filter-probe.mjs). if (!hasAudio()) { - // 3. YTM all-10 — no channel check (label channels, etc.) - log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…'); - await tryYtm(0, mf(dur, true)); - } - if (!hasAudio()) { - // 4. YT top-10 — no channel check - await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); - } - // No duration match. "Mittelweg": relax the duration constraint but REQUIRE the artist - // channel (+ title). This catches legit tracks whose YT upload duration drifts from the - // iTunes value, while never grabbing a same-titled video by someone else. If nothing - // matches title+channel we download NOTHING and the job fails cleanly (see end of fn) — - // far better than silently saving the wrong song (e.g. a "recognize" fan clip). - if (!hasAudio()) { - // 5. YTM all — no duration, but channel + title required + // 3. YTM all — ohne Dauer, aber Kuenstler + Titel verlangt log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…'); await tryYtm(0, mf(null, true, true)); } if (!hasAudio()) { - // 6. YT top-10 — no duration, but channel + title required + // 4. YT top-10 — ohne Dauer, aber Kuenstler + Titel verlangt await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(null, true, true), ...baseArgs]); } + // Letzter Ausweg: Kanalpruefung fallen lassen, dafuer die Dauer wieder verlangen. Das faengt + // offizielle Uploads von Label-Kanaelen, die den Kuenstler weder im Kanalnamen noch im + // artist-Feld tragen. Bleibt auch das ohne Treffer, wird NICHTS geladen und der Auftrag + // scheitert sauber — besser als still den falschen Song zu speichern. + if (!hasAudio()) { + // 5. YTM all-10 — ohne Kanalpruefung, dafuer im Dauerfenster + log(jobId, '⚠️ Erweitere Suche auf top 10 mit Dauerbeschränkung…'); + await tryYtm(0, mf(dur, true)); + } + if (!hasAudio()) { + // 6. YT top-10 — ohne Kanalpruefung, dafuer im Dauerfenster + await spawnYtdlp([`ytsearch10:${query}`, ...PI_FILTERED_10, ...mf(dur, true), ...baseArgs]); + } } else { log(jobId, `🔍 Suche YT Music: "${query}"`); // 1. YTM top-5 — strict: title + variant + channel check diff --git a/backend/scripts/filter-probe.mjs b/backend/scripts/filter-probe.mjs new file mode 100644 index 0000000..8cc9493 --- /dev/null +++ b/backend/scripts/filter-probe.mjs @@ -0,0 +1,158 @@ +#!/usr/bin/env node +// Regressionsprobe fuer den Download-Filter. +// +// Warum es das gibt: jede Verschaerfung am Filter, die einen Fehlgriff verhindert, kann eine ganze +// KLASSE richtiger Treffer abweisen — und der naechste Bericht kommt dann aus der anderen Richtung. +// Damit der Umbau nicht im Kreis laeuft, wird vor jeder Aenderung gegen bekannte Faelle geprueft. +// +// Was es tut: holt fuer jeden Fall EINMAL die echten Suchtreffer (yt-dlp, mit Metadaten, damit auch +// das `artist`-Feld der Art Tracks vorliegt) und spielt dann die Kaskadenstufen lokal durch — +// in der ALTEN und der NEUEN Reihenfolge. Gemeldet wird, welcher Treffer jeweils gewinnt. +// +// docker run --rm --network host --entrypoint node /app/scripts/filter-probe.mjs +// +// Die Filterbedingungen sind hier nachgebaut, nicht importiert: geprueft werden soll, was yt-dlp +// mit --match-filter tut, und das ist ein Ausdruck ueber dieselben Felder. + +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { buildTitleKeywords, buildVariantFilter } = require('../lib/ytdlp.js'); + +const JP_RE = /[ぁ-んァ-ン一-龯]/; + +// ── Faelle ──────────────────────────────────────────────────────────────────── +// Jeder Fall nennt, was herauskommen MUSS (erwartetFragment) — ein Stueck des Kanalnamens oder +// Titels des richtigen Uploads. Faelle mit `darfNicht` nennen zusaetzlich einen bekannten Fehlgriff. +const FAELLE = [ + { + name: 'TPR — Dearly Beloved (Deezer kennt TPR nicht, Dauer gehoert zu anderer Fassung)', + artist: 'TPR', titel: 'Dearly Beloved', dauer: 230, + erwartetFragment: 'TPR', darfNicht: 'LSD', + }, + { + name: 'BTS — Pied Piper (Label-Kanal BANGTANTV, Kuenstler nur im artist-Feld)', + artist: 'BTS', titel: 'Pied Piper', dauer: 246, + erwartetFragment: 'Pied Piper', + }, + { + name: 'Yoshihisa Hirano — 銀髪の少年 (japanischer Titel, Soundtrack-Album)', + artist: 'Yoshihisa Hirano', titel: '銀髪の少年', dauer: 129, + // Der richtige Upload heisst romanisiert "Ginpatsu no Shonen" — ohne das lange o. + // Genau dafuer ist das tolerante Umschrift-Muster da. + romaji: 'Ginpatsu No Shounen', erwartetFragment: 'Hirano', + }, + { + name: 'Rising Insane — DEATHRACE (normaler westlicher Fall)', + artist: 'Rising Insane', titel: 'DEATHRACE', dauer: 216, + erwartetFragment: 'Rising Insane', + }, + { + name: 'Daft Punk — Get Lucky (Klassiker, viele Coverversionen)', + artist: 'Daft Punk', titel: 'Get Lucky', dauer: 369, + erwartetFragment: 'Daft Punk', + }, +]; + +// ── Suchtreffer holen ───────────────────────────────────────────────────────── +function suche(query, n) { + return new Promise(resolve => { + const felder = '%(duration)s%(channel)s%(artist)s%(title)s'; + const p = spawn('yt-dlp', [ + `ytsearch${n}:${query}`, '--simulate', '--no-warnings', '--ignore-errors', + '--print', felder, + ]); + let aus = ''; + p.stdout.on('data', d => aus += d.toString()); + p.on('error', () => resolve([])); + p.on('close', () => resolve( + aus.split('\n').filter(Boolean).map(z => { + const [dauer, kanal, kuenstler, titel] = z.split(''); + return { + dauer: Number(dauer) || 0, + kanal: kanal === 'NA' ? '' : kanal, + kuenstler: kuenstler === 'NA' ? '' : kuenstler, + titel: titel || '', + }; + }) + )); + }); +} + +// ── Filterbedingungen, wie mf() sie baut ────────────────────────────────────── +function pruefer(fall) { + const kws = buildTitleKeywords(fall.titel, fall.romaji || ''); + const variant = buildVariantFilter(fall.titel); + const ersterK = fall.artist.split(/[\s,/]+/)[0]; + const rand = Math.max(10, Math.round(fall.dauer * 0.10)); + const fenster = { min: Math.max(30, fall.dauer - rand), max: fall.dauer + rand }; + + const titelPasst = k => + kws.every(muster => new RegExp(muster, 'i').test(k.titel)); + // buildVariantFilter liefert einen yt-dlp-Ausdruck: mehrere "title !~= (?i)MUSTER", mit & verknuepft. + // Hier werden die Muster herausgeloest und als "darf auf KEINES passen" geprueft. + const verboten = [...(variant || '').matchAll(/title !~= \(\?i\)([^&]+)/g)] + .map(m => m[1].trim()).filter(Boolean); + const variantePasst = k => !verboten.some(m => new RegExp(m, 'i').test(k.titel)); + const kuenstlerPasst = k => { + if (ersterK.length < 3 && !JP_RE.test(ersterK)) return true; // kein Merkmal -> keine Huerde + const re = new RegExp(ersterK.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + return re.test(k.kanal) || re.test(k.kuenstler); + }; + const imFenster = k => k.dauer >= fenster.min && k.dauer <= fenster.max; + const unterKappe = k => k.dauer > 0 && k.dauer < fall.dauer * 2; + + return { + fenster, + // streng: Dauer + Titel + Variante + Kuenstler + streng: k => imFenster(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k), + // Dauer behalten, Kuenstler fallen lassen + ohneKuenstler: k => imFenster(k) && titelPasst(k) && variantePasst(k), + // Kuenstler behalten, Dauer lockern (2x-Kappe bleibt) + ohneDauer: k => unterKappe(k) && titelPasst(k) && variantePasst(k) && kuenstlerPasst(k), + }; +} + +const ersterTreffer = (kandidaten, pruefung) => kandidaten.find(pruefung) || null; + +function kaskade(kandidaten, p, reihenfolge) { + const stufen = reihenfolge === 'neu' + ? [['1/2 streng', p.streng], ['3/4 ohne Dauer, mit Künstler', p.ohneDauer], ['5/6 mit Dauer, ohne Künstler', p.ohneKuenstler]] + : [['1/2 streng', p.streng], ['3/4 mit Dauer, ohne Künstler', p.ohneKuenstler], ['5/6 ohne Dauer, mit Künstler', p.ohneDauer]]; + for (const [stufe, pruefung] of stufen) { + const t = ersterTreffer(kandidaten, pruefung); + if (t) return { stufe, treffer: t }; + } + return { stufe: 'kein Treffer', treffer: null }; +} + +const zeig = t => t ? `${String(t.dauer).padStart(4)}s | ${t.kanal || '?'} | ${t.titel}` : '— nichts —'; + +(async () => { + let abweichungen = 0, fehler = 0; + for (const fall of FAELLE) { + const query = `${fall.artist} ${fall.titel}`; + process.stdout.write(`\n▶ ${fall.name}\n Suche: "${query}" `); + const kandidaten = await suche(query, 10); + console.log(`(${kandidaten.length} Treffer)`); + if (!kandidaten.length) { console.log(' ⚠️ keine Suchtreffer — übersprungen'); continue; } + + const p = pruefer(fall); + console.log(` Dauerfenster ${p.fenster.min}–${p.fenster.max}s`); + const alt = kaskade(kandidaten, p, 'alt'); + const neu = kaskade(kandidaten, p, 'neu'); + console.log(` ALT [${alt.stufe}] ${zeig(alt.treffer)}`); + console.log(` NEU [${neu.stufe}] ${zeig(neu.treffer)}`); + + const passt = t => t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.erwartetFragment.toLowerCase()); + const schlecht = t => fall.darfNicht && t && (t.kanal + ' ' + t.titel).toLowerCase().includes(fall.darfNicht.toLowerCase()); + + if (!neu.treffer && alt.treffer) { console.log(' ❌ REGRESSION: neu findet nichts, alt schon'); fehler++; } + else if (schlecht(neu.treffer)) { console.log(' ❌ neu trifft weiterhin den bekannten Fehlgriff'); fehler++; } + else if (!passt(neu.treffer)) { console.log(` ⚠️ neu trifft nicht "${fall.erwartetFragment}"`); fehler++; } + else { console.log(' ✅ neu in Ordnung'); } + if (zeig(alt.treffer) !== zeig(neu.treffer)) abweichungen++; + } + console.log(`\n${FAELLE.length} Fälle, ${abweichungen} Unterschied(e) zwischen alt und neu, ${fehler} Problem(e)`); + process.exit(fehler ? 1 : 0); +})();