Compare commits

...
4 Commits
Author SHA1 Message Date
marcandClaude Opus 5 a05c15df93 docs(changelog): YTM-Wächter, Album-Zurück und Scrobble-Gruppierung
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:52:03 +02:00
marcandClaude Opus 5 da32d2911a fix(scrobble): Metadaten-Bearbeitung zerlegt die Statistik nicht mehr
Top-Titel gruppierte nach song_id. Wird eine Datei beim Bearbeiten umbenannt,
vergibt Navidrome eine neue id — der Titel erschien danach zweimal mit geteilten
Zaehlern. Kuenstler/Alben/Genres gruppierten nach lower(), sodass schon ein
doppeltes Leerzeichen zwei Eintraege ergab.

Schluessel ist jetzt durchgehend normalisiert (lower + trim + Leerzeichen
gefaltet), Top-Titel gruppiert nach Titel+Kuenstler statt nach song_id. Die
Anzeigefelder kommen aus dem JUENGSTEN Play statt aus MAX(), sonst zeigte die
Statistik nach einer Korrektur weiter die alte Schreibweise. Am laufenden Dienst
geprueft: 40 Plays vor und 10 nach einer Umbenennung erscheinen als ein Eintrag
mit 50 Plays, neuer Schreibweise und neuer songId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:47 +02:00
marcandClaude Opus 5 39d47b78d4 fix(web): Zurueck im Album fuehrt dorthin zurueck, wo man geklickt hat
album-back-btn war die einzige Detailseite, die per navigate() VORWAERTS auf die
Albenliste sprang statt einen Schritt im Verlauf zurueck. Dadurch landete man
nicht dort, wo man das Album angeklickt hatte (z. B. Home), und es kam ein
zusaetzlicher History-Eintrag auf den Stapel, durch den sich der Browser-Zurueck-
Knopf erst arbeiten musste. Jetzt _navGoBack() wie bei Artist, Genre, Deezer- und
Partial-Album; die Albenliste bleibt nur das Ziel, wenn es keinen Verlauf gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:47 +02:00
marcandClaude Opus 5 eee98d193f fix(backend): Kanaltreffer in der YTM-Suche verwirft nicht mehr die Trefferliste
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 <noreply@anthropic.com>
2026-09-04 22:51:32 +02:00
6 changed files with 146 additions and 36 deletions

No files matched your search

+17
View File
@@ -67,6 +67,23 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
## [Unreleased]
### Fixed
- **YT-Music-Suche war praktisch tot** — `getYtmVideoUrls` verwarf die komplette Trefferliste, sobald
irgendwo ein Kanal-Eintrag (`browse/UC…`) auftauchte, samt der bereits gesammelten Song-URLs. An
acht echten Suchen gemessen passierte das in **sieben** Fällen: Schritt 1 der Kaskade — die Stufe
mit den offiziellen Art Tracks — lieferte nie etwas, faktisch lief jeder Download über den
YouTube-Fallback. Das erklärt eine ganze Serie falscher Treffer (u. a. „Easy" als Mixtape- statt
Albumversion). Kanal- und Album-Einträge werden jetzt übersprungen statt abzubrechen; über 20 000
Zufallsausgaben geprüft ist die neue Auswahl eine Obermenge der alten, kann also nur Kandidaten
hinzufügen.
- **Zurück im Album führte in die Albenliste** — statt dorthin, wo man das Album angeklickt hatte.
Der Knopf navigierte vorwärts, legte dabei einen History-Eintrag an und brachte so auch den
Browser-Zurück-Knopf durcheinander. Jetzt geht er einen Schritt im Verlauf zurück wie jede andere
Detailseite.
- **Metadaten bearbeiten zerlegte die Hörstatistik** — Top-Titel gruppierten nach `song_id`, und beim
Bearbeiten umbenannte Dateien bekommen von Navidrome eine neue ID: der Titel erschien doppelt mit
geteilten Zählern, Künstler ebenso bei geänderter Schreibweise. Gruppiert wird jetzt über einen
normalisierten Schlüssel (Titel + Künstler), Anzeigenamen kommen aus dem jüngsten Play. Playlists
waren nie betroffen, die werden beim Bearbeiten ohnehin auf die neue ID übertragen.
- **Downloader grabbed a same-shaped title with a different number** — searching DAT ADAM's
"700 Main Street" saved **"777 Main Street" by an unrelated artist**. `buildTitleKeywords` required
only the two longest words (Street, Main); "700" was dropped for being short, and the cascade step
+8
View File
@@ -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
+29 -19
View File
@@ -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 };
+31
View File
@@ -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, []);
});
+55 -16
View File
@@ -172,6 +172,14 @@ app.get('/scrobble/history', async (req, res) => {
// ?tz= seconds east of UTC for local-day bucketing (default 0)
// ?bucket= day | month | year — granularity of the time series (default day)
// ?limit= top-list size (default 20, max 100)
// Gruppierungsschluessel fuer Texte: Klein-/Grossschreibung, Rand-Leerzeichen und mehrfache
// Leerzeichen duerfen keine zwei Eintraege aus einem machen. Genau das passiert sonst, sobald
// jemand Metadaten bearbeitet ("Dat Adam" -> "DAT ADAM" waeren zwei Kuenstler in der Statistik).
// SQLite kennt kein Regex, deshalb wird die Leerzeichen-Faltung zweimal angewandt (deckt bis zu
// vier aufeinanderfolgende Leerzeichen ab — mehr kommt in Tags praktisch nicht vor).
const normKey = (col) =>
`lower(trim(replace(replace(replace(COALESCE(${col},''), char(9), ' '), ' ', ' '), ' ', ' ')))`;
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
@@ -202,31 +210,62 @@ app.get('/scrobble/stats', async (req, res) => {
// navigator so you can't step before logging began.
const firstPlayedAt = db.prepare('SELECT MIN(played_at) AS t FROM plays WHERE user = :user').get({ user }).t || null;
// Anzeigename aus dem JUENGSTEN Play, nicht per MAX(): sonst gewinnt lexikografisch die alte
// Schreibweise und die Statistik zeigt nach einer Korrektur weiter den alten Namen.
const topArtists = db.prepare(`
SELECT MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
GROUP BY lower(artist) ORDER BY plays DESC, seconds DESC LIMIT :limit
WITH neueste AS (
SELECT ${normKey('artist')} AS k, artist, artist_id, MAX(played_at)
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
), zaehler AS (
SELECT ${normKey('artist')} AS k, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
)
SELECT n.artist AS name, n.artist_id AS artistId, z.plays AS plays, z.seconds AS seconds
FROM neueste n JOIN zaehler z ON z.k = n.k
ORDER BY z.plays DESC, z.seconds DESC LIMIT :limit
`).all(winL);
const topAlbums = db.prepare(`
SELECT MAX(album) AS name, MAX(album_id) AS albumId, MAX(artist) AS artist,
MAX(cover_art) AS coverArt, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> ''
GROUP BY lower(album), lower(COALESCE(artist,'')) ORDER BY plays DESC LIMIT :limit
WITH neueste AS (
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2,
album, album_id, artist, cover_art, MAX(played_at)
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
), zaehler AS (
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
)
SELECT n.album AS name, n.album_id AS albumId, n.artist AS artist,
n.cover_art AS coverArt, z.plays AS plays
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
ORDER BY z.plays DESC LIMIT :limit
`).all(winL);
// Nicht nach song_id gruppieren: wird eine Datei beim Metadaten-Bearbeiten umbenannt, vergibt
// Navidrome eine NEUE id — der Titel erschiene dann zweimal mit geteilten Zaehlern. Schluessel ist
// deshalb Titel+Kuenstler; Anzeigefelder (inkl. song_id fuer die Navigation) kommen aus dem
// JUENGSTEN Play, also mit den frisch bearbeiteten Tags. Das `MAX(played_at)` mit ansonsten
// blanken Spalten ist dokumentiertes SQLite-Verhalten: es liefert die Zeile des Maximums.
const topTracks = db.prepare(`
SELECT MAX(title) AS title, MAX(artist) AS artist, MAX(album) AS album,
MAX(album_id) AS albumId, MAX(artist_id) AS artistId, MAX(cover_art) AS coverArt,
song_id AS songId, COUNT(*) AS plays
WITH neueste AS (
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2,
title, artist, album, album_id, artist_id, cover_art, song_id, MAX(played_at)
FROM plays WHERE ${WHERE}
GROUP BY song_id ORDER BY plays DESC LIMIT :limit
GROUP BY k1, k2
), zaehler AS (
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
FROM plays WHERE ${WHERE}
GROUP BY k1, k2
)
SELECT n.title AS title, n.artist AS artist, n.album AS album, n.album_id AS albumId,
n.artist_id AS artistId, n.cover_art AS coverArt, n.song_id AS songId, z.plays AS plays
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
ORDER BY z.plays DESC LIMIT :limit
`).all(winL);
const topGenres = db.prepare(`
SELECT MAX(genre) AS name, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND genre IS NOT NULL AND genre <> ''
GROUP BY lower(genre) ORDER BY plays DESC LIMIT :limit
GROUP BY ${normKey('genre')} ORDER BY plays DESC LIMIT :limit
`).all(winL);
const series = db.prepare(`
@@ -280,14 +319,14 @@ app.get('/scrobble/compare', async (req, res) => {
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
const artistsOf = u => db.prepare(`
SELECT lower(artist) AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
SELECT ${normKey('artist')} AS k, MAX(artist) AS name, MAX(artist_id) AS artistId, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
GROUP BY lower(artist)
GROUP BY ${normKey('artist')}
`).all({ user: u, from, to });
const tracksOf = u => db.prepare(`
SELECT song_id AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
SELECT MAX(song_id) AS songId, MAX(title) AS title, MAX(artist) AS artist, MAX(artist_id) AS artistId,
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
FROM plays WHERE ${WHERE} GROUP BY song_id
FROM plays WHERE ${WHERE} GROUP BY ${normKey('title')}, ${normKey('artist')}
`).all({ user: u, from, to });
const totalsOf = u => db.prepare(
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
+6 -1
View File
@@ -572,8 +572,13 @@ export async function loadPartialAlbumDetail({ nd, deezer }) {
// ─── ALBUM DETAIL ───
export async function loadAlbumDetail(album) {
if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
// Zurück heißt zurück: einen Eintrag im Verlauf zurückgehen, wie auf jeder anderen Detailseite.
// Vorher navigierte dieser Knopf VORWÄRTS auf die Albenliste (bzw. den Artist) — das landete nicht
// dort, wo man das Album angeklickt hatte, und schob zusätzlich einen neuen History-Eintrag auf den
// Stapel, sodass der Browser-Zurück-Knopf sich durch diesen Müll arbeiten musste statt nach Home.
// Nur wenn es keinen Verlauf gibt (Direkteinstieg auf ein Album), bleibt die Albenliste das Ziel.
document.getElementById('album-back-btn').onclick = () =>
album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums');
S._navCursor > 0 ? _navGoBack() : navigate('albums');
document.getElementById('album-detail-name').textContent = dispAlbumName(album);
const artistEl = document.getElementById('album-detail-artist');
artistEl.innerHTML = parseArtists(album)