Files

190 lines
9.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.
//
// WICHTIG: beide Quellen werden abgefragt, YT MUSIC ZUERST. Die erste Fassung dieser Probe sah nur
// die gewoehnliche YouTube-Suche und meldete deshalb fuer "Get Lucky" einen Grammy-Mitschnitt als
// Treffer — in Wahrheit liefert YT Music dort die offizielle Tonspur an erster Stelle, und genau
// die laedt die Kaskade. Eine Probe, die eine Stufe auslaesst, erfindet Fehler.
//
// 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 ─────────────────────────────────────────────────────────
const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D';
/** YT-Music-Suche: erst die URLs der Songs-Leiste, dann deren Metadaten. */
async function sucheYtm(query, n) {
const url = `https://music.youtube.com/search?q=${encodeURIComponent(query)}&params=${YTM_SONGS_PARAM}`;
const urls = await new Promise(resolve => {
const p = spawn('yt-dlp', [
url, '--flat-playlist', '--playlist-items', `1-${n}`, '--print', 'webpage_url',
'--no-warnings', '--extractor-args', 'youtube:player_client=web_music,default',
]);
let aus = '';
p.stdout.on('data', d => aus += d.toString());
p.on('error', () => resolve([]));
p.on('close', () => resolve(aus.split('\n').map(z => z.trim()).filter(z => z.includes('watch?v='))));
});
if (!urls.length) return [];
return metadaten(urls);
}
function suche(query, n) {
return metadaten([`ytsearch${n}:${query}`]);
}
/** Metadaten zu einer Liste von URLs (oder einer Suchangabe) holen. */
function metadaten(ziele) {
return new Promise(resolve => {
const felder = '%(duration)s%(channel)s%(artist)s%(title)s';
const p = spawn('yt-dlp', [
...ziele, '--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(ytm, yt, 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) {
// Jede Stufe fragt erst YT Music, dann die gewoehnliche YouTube-Suche — so laeuft die Kaskade.
const t = ersterTreffer(ytm, pruefung) || ersterTreffer(yt, pruefung);
if (t) return { stufe: `${stufe}${ytm.includes(t) ? ' · YTM' : ' · YT'}`, 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 ytm = await sucheYtm(query, 10);
const yt = await suche(query, 10);
console.log(`(YTM ${ytm.length}, YouTube ${yt.length})`);
if (!ytm.length && !yt.length) { console.log(' ⚠️ keine Suchtreffer — übersprungen'); continue; }
const p = pruefer(fall);
console.log(` Dauerfenster ${p.fenster.min}–${p.fenster.max}s`);
const alt = kaskade(ytm, yt, p, 'alt');
const neu = kaskade(ytm, yt, 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);
})();