From 33b3df609accad982199cdae106bdb90ece56420 Mon Sep 17 00:00:00 2001 From: marc Date: Thu, 3 Sep 2026 22:35:44 +0200 Subject: [PATCH] fix(backend): Live-Mitschnitte aus der Suche ausschliessen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit is_live meint nur "laeuft gerade als Stream" — eine Konzert-AUFNAHME kam ungehindert durch und landete als Albumtrack in der Bibliothek ("Dat Adam - Ufo Video Days 2015 Live" auf dem echten Kuenstlerkanal, Dauer im Fenster). Der Variantenfilter bekommt daher eine live-Regel. Sie schaltet sich ab, wenn der gesuchte Titel selbst live enthaelt; die Pruefung nutzt \blive\b statt includes, sonst wuerde "deliver" die Regel aushebeln. Drei Tests dazu. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 6 ++++++ CLAUDE.md | 7 +++++++ backend/lib/ytdlp.js | 12 ++++++++++-- backend/test/ytdlp.test.js | 31 +++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08bb5a4..71f29c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,12 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an to a qualifier ("… - 2020 Remaster") is exempt from the number rule: it appears on the release but never in the YouTube title. Measured against a 29-title sample, 24 titles keep their previous behaviour exactly. +- **Downloader saved a concert recording as the album track** — searching DAT ADAM's "UFO" stored + "Dat Adam - Ufo Video Days 2015 Live". It passed every gate legitimately: right channel, 209 s + inside the duration window, "Ufo" in the title, and `!is_live` rejects only *running streams*, not + live recordings. The variant blocklist (remix/cover/acoustic/…) now includes `live`. As with the + other rules it switches itself off when the searched title says live, so "Live Forever" and + deliberately requested live versions are unaffected. --- diff --git a/CLAUDE.md b/CLAUDE.md index 6c0b6f3..f7112ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,6 +103,13 @@ On the deploy host the music lives at `/opt/navidrome/music`. next to a qualifier** ("… - 2020 Remaster") is dropped again — it sits on the release but never in the YouTube title, so requiring it would reject the correct upload. A title that IS a year ("1999", "Berlin 2020") keeps it. Unit-tested in `backend/test/ytdlp.test.js`. +- **Live recordings are excluded by title, not by `is_live`.** `!is_live` only means "not a running + stream" — a concert RECORDING passes it. On the artist's own channel nothing else distinguishes it + from the studio track, which is how "Dat Adam - Ufo Video Days 2015 Live" (channel `Dat Adam`, + 209 s, title contains UFO) got saved as the album track. `buildVariantFilter` therefore carries a + `live` rule like remix/cover/…. It self-disables when the searched title itself says live, so + "Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b` + regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off. - **Size cap:** `--max-filesize 30M` on every yt-dlp call; Mittelweg steps also cap at 2× expected duration. Prevents saving a 28-min video (52 MB) when the wrong result slips through. - **Leading aesthetic characters:** artists often style their name with leading punctuation diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index 6a9a0b8..10b3c35 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -71,8 +71,16 @@ function buildVariantFilter(title) { { key: 'cover', expr: 'title !~= (?i)\\bcover\\b' }, { key: 'acoustic', expr: 'title !~= (?i)\\bacoustic\\b' }, { key: 'instrumental', expr: 'title !~= (?i)\\binstrumental\\b' }, + // Konzertmitschnitte: `!is_live` heisst nur "laeuft nicht gerade als Stream" und laesst eine + // Live-AUFNAHME durch. Auf dem echten Kuenstlerkanal ist die sonst nicht vom Studiotrack zu + // unterscheiden — so landete "Dat Adam - Ufo Video Days 2015 Live" in der Bibliothek. + // skipRe statt includes(): "deliver"/"delivery" enthalten "live" als Teilstring und wuerden + // die Regel sonst faelschlich abschalten. + { key: 'live', expr: 'title !~= (?i)\\blive\\b', skipRe: /\blive\b/i }, ]; - const parts = RULES.filter(r => !t.includes(r.key)).map(r => r.expr); + // Eine Regel entfaellt, wenn der GESUCHTE Titel das Wort selbst traegt — wer "Live Forever" oder + // bewusst eine Live-Version sucht, soll sie auch bekommen. + const parts = RULES.filter(r => r.skipRe ? !r.skipRe.test(t) : !t.includes(r.key)).map(r => r.expr); return parts.length ? parts.join(' & ') : ''; } @@ -445,4 +453,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra return { success: true, dir }; } -module.exports = { downloadTrack, embedMetadata, buildTitleKeywords }; +module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter }; diff --git a/backend/test/ytdlp.test.js b/backend/test/ytdlp.test.js index 223a05c..6767f7b 100644 --- a/backend/test/ytdlp.test.js +++ b/backend/test/ytdlp.test.js @@ -45,3 +45,34 @@ test('a year that IS the title stays required', () => { assert.equal(accepts('1999', 'Prince - 1999 (Official Music Video)'), true); assert.equal(accepts('1999', 'Prince - 1984 (Official Music Video)'), false); }); + +const { buildVariantFilter } = require('../lib/ytdlp.js'); + +// Der Variantenfilter ist eine yt-dlp-Ausdruckskette "title !~= (?i)\bx\b & …". +// Diese Hilfsfunktion wertet sie so aus, wie yt-dlp es tut. +const variantAccepts = (searchTitle, candidate) => { + const f = buildVariantFilter(searchTitle); + if (!f) return true; + return f.split(' & ').every(part => { + const m = /^title !~= \(\?i\)(.+)$/.exec(part); + return !new RegExp(m[1], 'i').test(candidate); + }); +}; + +test('rejects a live recording — is_live only covers running streams', () => { + // Der echte Fall: auf dem Kuenstlerkanal, Dauer im Fenster, Titel passend. + assert.equal(variantAccepts('UFO', 'Dat Adam - Ufo Video Days 2015 Live'), false); + assert.equal(variantAccepts('UFO', 'DAT ADAM - 700 Main St | ROCK AM RING 2017 [LIVE]'), false); + assert.equal(variantAccepts('UFO', 'UFO'), true); +}); + +test('a deliberately live title keeps its live results', () => { + assert.equal(variantAccepts('Live Forever', 'Oasis - Live Forever'), true); + assert.equal(variantAccepts('Unplugged in New York (Live)', 'Nirvana - All Apologies (Live)'), true); +}); + +test('"live" inside another word does not disable the rule', () => { + // "deliver" enthaelt "live" als Teilstring — die Regel muss trotzdem greifen. + assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false); + assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true); +});