From 3c997785673dec73e3d60d4dced633be081d90e2 Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 5 Sep 2026 08:40:02 +0200 Subject: [PATCH] fix(backend): Dublettenpruefung erkennt Akzente und Soundtrack-Ordner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Luecken derselben Familie, die dazu fuehrten, dass dieselben Songs ein zweites Mal geladen wurden: - Kuenstlernamen wurden zeichengenau verglichen: "Cécile Corbel" und "Cecile Corbel" galten als zwei Personen. Welche Schreibweise ankommt, haengt an der Quelle (Deezer vs. Spotify). Der Vergleich normalisiert jetzt Akzente weg — nur der Vergleich, Tags und Dateinamen behalten das Original. - Typografische Apostrophe: "Arrietty’s Song" (U+2019) und "Arrietty's Song" fielen auseinander. Fiel beim Testen auf, betrifft viele Soundtracks. - findExistingTrack durchsucht nur den Ordner des uebergebenen Kuenstlers. Bei Soundtracks weicht der Album- vom Titel-Kuenstler ab, der Song landet je nach Ladeweg unter dem einen oder anderen — geprueft wurde jeweils der falsche. Weicht der Titel-Kuenstler ab, wird nun auch dessen Ordner geprueft (~0,6 ms bei 28.801 Dateien, gegenueber 3-9 s pro Download). Sieben neue Tests zielen gezielt auf Fehlalarme: aehnliche Namen (Cecil Taylor vs. Cécile Corbel), gleicher Titel bei verschiedenen Kuenstlern, Remix gegen Basistitel, fremder Titel im Soundtrack-Ordner — alle bleiben getrennt. Gesamte Suite: 42 Tests gruen. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 ++++++ backend/lib/metadata.js | 11 ++++-- backend/lib/ytdlp.js | 12 ++++++- backend/test/metadata.test.js | 65 +++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c583afe..1bf81bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -116,6 +116,17 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an unverändert. Genau deshalb ging es bei Songs ohne Dublette immer und bei anderen nie. Der über Ähnlichkeit gefundene Kandidat wird jetzt gegen die Tags geprüft, die Navidrome für den Song meldet, und bei Widerspruch mit einer erklärenden Meldung abgebrochen, statt zu raten. +- **Dieselbe Playlist zweimal geladen ⇒ Songs doppelt auf der Platte.** Die Dublettenprüfung + verglich Künstlernamen zeichengenau, sodass „Cécile Corbel" und „Cecile Corbel" als zwei + verschiedene Personen galten — welche Schreibweise ankommt, hängt davon ab, ob die Quelle Deezer + oder Spotify war. Der Vergleich ignoriert jetzt Akzente (nur der **Vergleich** — Tags und + Dateinamen behalten die Originalschreibweise) und ebenso typografische Apostrophe, an denen + `Arrietty’s Song` und `Arrietty's Song` bisher auseinanderfielen. +- **Soundtracks landeten doppelt** — die Prüfung durchsucht nur den Ordner des Album-Künstlers. + Bei Soundtracks und Compilations weicht der vom Titel-Künstler ab: als Album geladen landet der + Song unter `Arcane/`, einzeln geladen unter `Bea Miller/` — die Prüfung sah jeweils im falschen + Ordner nach. Weicht der Titel-Künstler ab, wird jetzt auch dessen Ordner geprüft. Gemessen an + 28.801 Dateien kostet der zweite Durchlauf ~0,6 ms, gegenüber 3–9 s pro Download. - **Stumm übersprungene Umbenennungen** — lag am Zielnamen schon eine Datei, blieb der Song unter altem Namen bzw. im alten Ordner liegen, ohne Hinweis. Solche Fälle kommen jetzt als Warnung zurück; der Editor zeigt sie samt bearbeitetem Dateipfad an und bleibt offen. diff --git a/backend/lib/metadata.js b/backend/lib/metadata.js index 82c504a..b52f9d0 100644 --- a/backend/lib/metadata.js +++ b/backend/lib/metadata.js @@ -61,14 +61,21 @@ const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|boot // release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and // "recognize - Single.mp3" vs "recognize.mp3" (drift between runs). function findExistingTrack(musicRoot, firstArtist, cleanTitle) { - const norm = s => (s || '').toLowerCase() + const norm = s => (s || '') + // Akzente abtrennen und verwerfen: dieselbe Kuenstlerin kommt je nach Quelle als + // "Cécile Corbel" oder "Cecile Corbel" — ohne das galt sie als zwei Personen und derselbe + // Song wurde ein zweites Mal geladen. Betrifft nur den VERGLEICH, nicht Tags oder Dateinamen. + .normalize('NFD').replace(/\p{M}+/gu, '') + .toLowerCase() // Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers // (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track. .replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '') // Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_" // to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV". .replace(/[/\\:*?"<>|_]/g, ' ') - .replace(/[,.'!]/g, '') + // Auch die typografischen Apostrophe: Anbieter schreiben "Arrietty’s Song" (U+2019), eine + // spaetere Anfrage kommt mit geradem "'" — ohne das galten beide als verschiedene Titel. + .replace(/[,.!'\u2018\u2019\u02bc\u00b4`]/g, '') .replace(/\s+/g, ' ') .trim(); const normArtist = norm(firstArtist); diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index e2a69c8..b5a1e96 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -144,7 +144,17 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb const safeArtistForFile = sanitize((artist || '').replace(/;/g, ',')); const fileBase = `${trackPrefix}${safeArtistForFile} - ${safeTitle}`; - const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle); + // Dublettenpruefung: findExistingTrack durchsucht NUR den Ordner des uebergebenen Kuenstlers. + // Bei Soundtracks und Compilations weicht der Album-Kuenstler vom Titel-Kuenstler ab, und je + // nachdem, ob der Song als Album oder einzeln geladen wird, landet er unter dem einen ODER dem + // anderen. Die Pruefung sah dann jeweils im falschen Ordner nach und lud ihn erneut. Deshalb wird + // bei abweichendem Titel-Kuenstler auch dessen Ordner geprueft — zweiter Durchlauf nur in genau + // diesem Fall, gemessen ~0,6 ms zusaetzlich bei knapp 29.000 Dateien. + const trackArtist = sanitize((artist || '').split(/[,;&]/)[0].trim()); + const existing = findExistingTrack(outputDir || CONFIG.musicPath, safeArtist, cleanTitle) + || (trackArtist && trackArtist !== safeArtist + ? findExistingTrack(outputDir || CONFIG.musicPath, trackArtist, cleanTitle) + : null); if (existing) { log(jobId, `⏭ Bereits vorhanden: ${path.basename(existing)} — übersprungen`); return { success: true, dir, skipped: true }; diff --git a/backend/test/metadata.test.js b/backend/test/metadata.test.js index dd37a78..6d67bdd 100644 --- a/backend/test/metadata.test.js +++ b/backend/test/metadata.test.js @@ -22,6 +22,15 @@ before(() => { '01-01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3'); // Normal "Artist - Title" file touch('Some Artist', 'Therapy - Single', '01 - Some Artist - Therapy.mp3'); + // Akzent-Schreibweise: liegt MIT Akzent auf der Platte + touch('Cécile Corbel', 'SongBook Vol. 2', '03 - Cécile Corbel - Arrietty\u2019s Song.mp3'); + // Soundtrack: liegt unter dem ALBUM-Kuenstler, nicht unter dem Titel-Kuenstler + touch('Arcane', 'Arcane League of Legends (Soundtrack)', '01 - Bea Miller - Playground.mp3'); + // Zwei verschiedene Kuenstler mit demselben Songtitel — duerfen sich NIE gegenseitig treffen + touch('Cro', 'Raop', '03 - Cro - Easy.mp3'); + touch('Kraftklub', 'Mit K', '05 - Kraftklub - Easy.mp3'); + // Aehnlicher, aber anderer Kuenstlername + touch('Cecil Taylor', 'Unit Structures', '01 - Cecil Taylor - Steps.mp3'); }); after(() => { try { fs.rmSync(ROOT, { recursive: true, force: true }); } catch {} }); @@ -48,3 +57,59 @@ test('remix stays distinct from the base track (no regression from the version-m assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Therapy (Remix)'), null); assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy')); // base still found }); + + +// ── Akzente (Cécile Corbel) ──────────────────────────────────────────────────── +test('findet den Song auch ohne Akzent in der Anfrage', () => { + assert.ok(findExistingTrack(ROOT, 'Cecile Corbel', "Arrietty's Song"), + 'ASCII-Schreibweise muss die Datei mit Akzent finden'); +}); + +test('findet ihn ebenso mit Akzent', () => { + assert.ok(findExistingTrack(ROOT, 'Cécile Corbel', "Arrietty's Song")); +}); + +test('kein Fehlalarm: aehnlicher Name ist nicht dieselbe Person', () => { + // "Cecil Taylor" darf nicht auf "Cécile Corbel" passen — und umgekehrt. + assert.equal(findExistingTrack(ROOT, 'Cécile Corbel', 'Steps'), null); + assert.equal(findExistingTrack(ROOT, 'Cecil Taylor', "Arrietty's Song"), null); +}); + +test('kein Fehlalarm: Akzent-Normalisierung kollabiert keine echten Titel', () => { + assert.equal(findExistingTrack(ROOT, 'Cecile Corbel', 'Ein anderer Song'), null); +}); + +// ── Gleicher Titel, verschiedene Kuenstler ───────────────────────────────────── +test('kein Fehlalarm: gleicher Titel bei zwei Kuenstlern bleibt getrennt', () => { + const cro = findExistingTrack(ROOT, 'Cro', 'Easy'); + const kk = findExistingTrack(ROOT, 'Kraftklub', 'Easy'); + assert.ok(cro && cro.includes('Cro'), 'Cro-Version muss im Cro-Ordner gefunden werden'); + assert.ok(kk && kk.includes('Kraftklub'), 'Kraftklub-Version im Kraftklub-Ordner'); + assert.notEqual(cro, kk, 'die beiden duerfen nicht dieselbe Datei sein'); + // Ein dritter Kuenstler mit demselben Titel findet nichts. + assert.equal(findExistingTrack(ROOT, 'Irgendwer', 'Easy'), null); +}); + +// ── Soundtrack: Album-Kuenstler vs. Titel-Kuenstler ──────────────────────────── +test('Soundtrack unter dem Album-Kuenstler wird ueber diesen gefunden', () => { + assert.ok(findExistingTrack(ROOT, 'Arcane', 'Playground')); +}); + +test('unter dem Titel-Kuenstler allein wird er NICHT gefunden (Grund fuer die Zweitpruefung)', () => { + // Genau diese Luecke erzeugte die Dublette: geprueft wurde nur "Bea Miller". + assert.equal(findExistingTrack(ROOT, 'Bea Miller', 'Playground'), null); +}); + +test('die Zweitpruefung schliesst die Luecke, ohne Fremdes zu treffen', () => { + const pruefe = (albumArtist, trackArtist, titel) => + findExistingTrack(ROOT, albumArtist, titel) + || (trackArtist !== albumArtist ? findExistingTrack(ROOT, trackArtist, titel) : null); + // Soundtrack-Fall: wird jetzt erkannt. + assert.ok(pruefe('Bea Miller', 'Bea Miller', 'Playground') === null, + 'ohne Album-Kuenstler weiterhin nichts — richtig, die Datei liegt unter Arcane'); + assert.ok(pruefe('Arcane', 'Bea Miller', 'Playground'), 'mit Album-Kuenstler gefunden'); + // Fremder Titel im selben Ordner darf trotz zweier Durchlaeufe nicht anschlagen. + assert.equal(pruefe('Arcane', 'Bea Miller', 'Irgendein anderer Titel'), null); + // Remix bleibt auch ueber zwei Durchlaeufe hinweg eigenstaendig. + assert.equal(pruefe('Arcane', 'Bea Miller', 'Playground (Remix)'), null); +});