#!/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 /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)}¶ms=${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); })();