From eee98d193f8016df9641c9caab94b3d3907ed69f Mon Sep 17 00:00:00 2001 From: marc Date: Fri, 4 Sep 2026 22:51:32 +0200 Subject: [PATCH] fix(backend): Kanaltreffer in der YTM-Suche verwirft nicht mehr die Trefferliste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getYtmVideoUrls wertete jedes /browse/UC in der Ausgabe als "yt-dlp hat einen Artist-Channel aufgeklappt" und gab [] zurueck — samt der bereits gesammelten Song-URLs. Bei "DAT ADAM UFO" stand der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf Platz 1; uebrig blieb der YouTube-Fallback mit einem Live-Mitschnitt. An 8 echten Suchen gemessen feuerte das in 7 Faellen, Schritt 1 der Kaskade war also praktisch tot. Mit --flat-playlist kann gar nichts aufgeklappt werden, ein Kanal ist EINE Zeile. pickYtmVideoUrls() behaelt daher alle watch?v=-Zeilen und ueberspringt browse/UC (Kanal) wie browse/MPREb_ (Album). Ueber 20k Zufallsausgaben geprueft: die neue Funktion ist eine Obermenge der alten, kann also nur Kandidaten hinzufuegen. Drei echte Downloads liefen end-to-end sauber durch (Dauer sekundengenau auf Deezer, korrekte Tags), alle aus Schritt 1 statt aus dem Fallback. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 8 +++++++ backend/lib/ytdlp.js | 48 +++++++++++++++++++++++--------------- backend/test/ytdlp.test.js | 31 ++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f7112ab..06bf41f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,14 @@ On the deploy host the music lives at `/opt/navidrome/music`. `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. +- **A channel hit in the YTM results is a line to skip, not a reason to abort.** `getYtmVideoUrls` + used to treat any `/browse/UC…` in the output as "yt-dlp expanded an artist channel" and returned + `[]` — discarding the song URLs it had already collected. Measured on 8 real searches, that fired + in **7 of 8**, so step 1 (YTM, where the official Art Tracks live) was effectively dead and almost + every download came from the YouTube fallback. With `--flat-playlist` a channel is printed as ONE + line, so expansion cannot happen; `pickYtmVideoUrls()` now keeps every `watch?v=` line and skips + `browse/UC…` (channel) and `browse/MPREb_…` (album) alike. It is a strict superset of the old + behaviour — fuzz-checked over 20k random outputs — so it can only add candidates, never remove any. - **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 10b3c35..e2a69c8 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -55,6 +55,28 @@ function buildTitleKeywords(title) { return result; } +// Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen. +// Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und +// browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind +// Listeneintraege und werden uebersprungen. +// +// Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE +// Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand +// der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf +// Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier +// ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile. +function pickYtmVideoUrls(stdout) { + const urls = []; + let skipped = 0; + for (const line of (stdout || '').split('\n')) { + const u = line.trim(); + if (!u) continue; + if (u.includes('watch?v=')) urls.push(u); + else if (u.includes('/browse/')) skipped++; + } + return { urls, skipped }; +} + function buildVariantFilter(title) { const t = (title || '').toLowerCase(); // !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that @@ -231,14 +253,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb // actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel // result and treating its uploads as the search result set. // - // When YTM returns an artist channel as the top hit, yt-dlp prints the channel's - // browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and - // return [] so the caller falls through to the ytsearch fallback. // NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search // page processing, not just channel expansion, and would wrongly return [] always. const getYtmVideoUrls = (maxItems) => new Promise(resolve => { - const urls = []; - let channelExpanded = false; + let out = ''; const proc = spawn(CONFIG.ytdlpBin, [ ytmUrl, '--flat-playlist', @@ -250,21 +268,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb // native Songs shelf (where the actual artist track lives). '--extractor-args', 'youtube:player_client=web_music,default', ], { env: { ...process.env, PYTHONUNBUFFERED: '1' } }); - proc.stdout.on('data', d => { - const text = d.toString(); - if (text.includes('/browse/UC')) { channelExpanded = true; return; } - if (!channelExpanded) { - text.split('\n').forEach(line => { - const u = line.trim(); - if (u.includes('watch?v=')) urls.push(u); - }); - } - }); + proc.stdout.on('data', d => { out += d.toString(); }); proc.on('error', () => resolve([])); proc.on('close', () => { - if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`); - else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`); - resolve(channelExpanded ? [] : urls); + const { urls, skipped } = pickYtmVideoUrls(out); + if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`); + else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`); + resolve(urls); }); }); @@ -453,4 +463,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra return { success: true, dir }; } -module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter }; +module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls }; diff --git a/backend/test/ytdlp.test.js b/backend/test/ytdlp.test.js index 6767f7b..4d6ce24 100644 --- a/backend/test/ytdlp.test.js +++ b/backend/test/ytdlp.test.js @@ -76,3 +76,34 @@ test('"live" inside another word does not disable the rule', () => { assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false); assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true); }); + +const { pickYtmVideoUrls } = require('../lib/ytdlp.js'); + +// Echte Ausgabe der YTM-Suche "DAT ADAM UFO": Songs, ein Album (MPREb_) und der +// Kuenstlerkanal (UC…) auf Platz 9. +const YTM_OUT = [ + 'https://music.youtube.com/watch?v=ZG4XhPn3Yb4', + 'https://music.youtube.com/watch?v=2KVU6aikZF0', + 'https://music.youtube.com/browse/MPREb_IhxJPixC1QT', + 'https://music.youtube.com/watch?v=phLOTtJaqZk', + 'https://music.youtube.com/watch?v=p935nH6aj8Y', + 'https://music.youtube.com/watch?v=Pb2YcDBm5fA', + 'https://music.youtube.com/browse/MPREb_S7MMKVMduma', + 'https://music.youtube.com/watch?v=mNfsX2RbZ2M', + 'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA', + 'https://music.youtube.com/watch?v=O034JUlf1J4', +].join('\n'); + +test('a channel hit no longer discards the whole result list', () => { + const { urls, skipped } = pickYtmVideoUrls(YTM_OUT); + assert.equal(urls.length, 7); + assert.equal(skipped, 3); // 2 Alben + 1 Kanal + assert.match(urls[0], /ZG4XhPn3Yb4/); // der offizielle Art Track + assert.ok(!urls.some(u => u.includes('/browse/'))); +}); + +test('nothing but list entries yields no candidates', () => { + const only = 'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA'; + assert.deepEqual(pickYtmVideoUrls(only).urls, []); + assert.deepEqual(pickYtmVideoUrls('').urls, []); +});