fix(backend): Kuenstler vor Dauer in der Suchkaskade, mit Regressionsprobe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
748b1446a2
commit
2f87a12f08
3 files changed
+203
-16
No files matched your search
@@ -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.
|
erreichbar war) und zählt für die Seitenleiste jetzt zur Bibliothek statt zu Entdecken.
|
||||||
|
|
||||||
### Fixed
|
### 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)** —
|
- **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
|
gemeldet mit „銀髪の少年" (Yoshihisa Hirano, HUNTER×HUNTER) und „Dearly Beloved" (TPR): beide Male
|
||||||
kam etwas anderes an.
|
kam etwas anderes an.
|
||||||
|
|||||||
+28
-16
@@ -390,29 +390,41 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
|
|||||||
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
|
log(jobId, '⚠️ Kein YTM-Treffer — Fallback zu YouTube…');
|
||||||
await spawnYtdlp([`ytsearch5:${query}`, ...PI_FILTERED, ...mf(dur, true, true), ...baseArgs]);
|
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()) {
|
if (!hasAudio()) {
|
||||||
// 3. YTM all-10 — no channel check (label channels, etc.)
|
// 3. YTM all — ohne Dauer, aber Kuenstler + Titel verlangt
|
||||||
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
|
|
||||||
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
|
log(jobId, '⚠️ Kein Treffer im Dauerbereich — suche ohne Dauer, aber mit Künstler-Channel…');
|
||||||
await tryYtm(0, mf(null, true, true));
|
await tryYtm(0, mf(null, true, true));
|
||||||
}
|
}
|
||||||
if (!hasAudio()) {
|
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]);
|
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 {
|
} else {
|
||||||
log(jobId, `🔍 Suche YT Music: "${query}"`);
|
log(jobId, `🔍 Suche YT Music: "${query}"`);
|
||||||
// 1. YTM top-5 — strict: title + variant + channel check
|
// 1. YTM top-5 — strict: title + variant + channel check
|
||||||
|
|||||||
@@ -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 <backend-image> /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);
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user