fix: owned albums wrongly shown as "Unvollständig 1/0" on artist page

Deezer's /artist/{id}/albums list omits nb_tracks, so the album total read as 0 and every
owned album (e.g. N!GHT's "Moonlight"/"Yume") was classified as incomplete "1 / 0 Tracks".

- Web (detail.js): resolve the real Deezer track count via the album endpoint for MATCHED
  (owned) albums only — a couple of calls, not the whole discography — and treat an unknown
  total as complete. Genuine partials still work when the true count is known.
- Android (categorizeAlbums): mark partial only when a known total exceeds what you own;
  unknown total counts as complete (never a false "1 / 0").
- CHANGELOG: documented the fix; corrected the Android lyrics known bug (auto-scroll follows
  playback but isn't smooth — it jumps rather than glides). SW ns-v55.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-07-31 17:05:14 +02:00
1 parent 5528947a93
commit 640e475c51
4 files changed
+41 -20

No files matched your search

+4 -1
View File
@@ -78,6 +78,9 @@ Big change: **the entire metadata/search stack moved from Apple's iTunes to Deez
- **Android** — **Global 30s-preview control bar**: a preview started on an album/artist/Explore screen used to keep playing with no way to stop it once you navigated away (the local artist page had its *own* uncontrollable preview too). All previews now run through one app-level player with a bar (above the mini player, on every screen) to pause/resume/stop it; starting real playback cancels the preview
- **Backend** — Unit tests for the Deezer mapping/matching logic (`backend/test/deezer.test.js`, run with `npm test`), incl. per-track numbering, mixed-artist album-artist stability, explicit flags and the Japanese-title match guard. A live smoke test runs with `RUN_LIVE=1`
### Fixed
- **Web + Android** — Owned albums were wrongly shown as **"Unvollständig 1 / 0 Tracks"** on the artist page. Deezer's `/artist/{id}/albums` list omits `nb_tracks`, so the total read as 0 and every matched album looked incomplete. Web now resolves the real track count from the Deezer album endpoint for **matched (owned) albums only** and treats an unknown total as complete; Android treats an unknown total as complete (never a false partial)
### Notes
- Deploy: `git pull`, then rebuild the frontend (`docker compose up -d --build novasonic-frontend`) and restart the backend (`docker compose restart novasonic-backend`). Service worker bumped to `ns-v54`
- Titles a user picked stay as chosen; only derived fields (album, album-artist, genre, year, artwork) follow Deezer
@@ -412,7 +415,7 @@ Primary causes identified:
- **Web + Android** — yt-dlp backend update / maintenance
### Known Bugs
- **Android (lyrics)** — Synced-lyrics **auto-scroll still does not follow playback**: the current line is highlighted but the list doesn't scroll to keep it centered, so you have to scroll by hand. Web autoscroll works. Not yet fixed.
- **Android (lyrics)** — Synced-lyrics auto-scroll is **not smooth**: it follows playback and highlights the right line, but the list jumps to it rather than gliding. Web autoscroll is smooth. Not yet fixed.
- **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.)
- **Backend (download)** — The duplicate check requires at least a `firstArtist` folder match. If the same song was previously stored under a very different artist name (e.g. before an iTunes-name fix), it will not be detected as a duplicate and will be re-downloaded.
- **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist.
@@ -58,6 +58,9 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
for (ia in uniqueIAlbums) {
val iName = normAlbum(ia.collectionName)
// Deezer's /artist/{id}/albums omits nb_tracks, so trackCount is usually unknown (0) here.
// Only mark "Unvollständig" when we KNOW the album has more tracks than we own — an unknown
// total counts as complete, never a false "1 / 0".
val iCount = ia.trackCount ?: 0
val match = ndAlbums.find { a ->
val n = normAlbum(a.name)
@@ -65,11 +68,11 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
}
when {
match == null -> notDl.add(ia)
iCount > 0 && match.songCount >= iCount -> {
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
iCount > match.songCount -> {
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
}
else -> {
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v54';
const CACHE = 'ns-v55';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+30 -15
View File
@@ -282,36 +282,51 @@ export async function loadArtistDetail(artist) {
actionBar.appendChild(evBtn);
}
if(albEl) { albEl.innerHTML = ''; _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist); }
if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist);
}
function _normAlbum(s) {
return (s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
const downloaded = [], partial = [], notDl = [];
const usedNdIds = new Set();
async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
el.innerHTML = '<div class="spinner"></div>';
for(const ia of iAlbums) {
const iName = _normAlbum(ia.collectionName);
const iCount = ia.trackCount || 0;
const match = ndAlbums.find(a => {
// Match each Deezer album to a Navidrome album by normalized name.
const entries = iAlbums.map(ia => {
const iName = _normAlbum(ia.collectionName);
const match = ndAlbums.find(a => {
const n = _normAlbum(a.name);
return n === iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName));
});
if(!match) {
notDl.push(ia);
} else if(iCount > 0 && match.songCount >= iCount) {
downloaded.push({ nd: match, itunes: ia }); usedNdIds.add(match.id);
} else {
partial.push({ nd: match, itunes: ia, have: match.songCount || 0, total: iCount }); usedNdIds.add(match.id);
}
return { ia, match };
});
// Deezer's /artist/{id}/albums list omits nb_tracks, so the total is unknown here — which made every
// owned album look "incomplete" (e.g. "Moonlight 1 / 0"). Resolve the real track count from the
// album endpoint, but ONLY for matched (owned) albums, so it's a couple of calls, not the whole
// discography. An unknown total is then treated as complete, never a false "Unvollständig".
await Promise.all(entries
.filter(e => e.match && !(e.ia.trackCount > 0) && e.ia.collectionId)
.map(async e => {
const tracks = await itunesLookupAlbum(e.ia.collectionId).catch(() => []);
if (tracks.length) e.ia = { ...e.ia, trackCount: tracks.length };
}));
const downloaded = [], partial = [], notDl = [];
const usedNdIds = new Set();
for(const { ia, match } of entries) {
if(!match) { notDl.push(ia); continue; }
const have = match.songCount || 0;
const total = ia.trackCount || 0;
if(total > have) { partial.push({ nd: match, itunes: ia, have, total }); usedNdIds.add(match.id); }
else { downloaded.push({ nd: match, itunes: ia }); usedNdIds.add(match.id); }
}
for(const na of ndAlbums) {
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, itunes: null });
}
el.innerHTML = '';
downloaded.sort((a, b) => (b.nd.year||0) - (a.nd.year||0));
partial.sort((a, b) => (b.nd.year||0) - (a.nd.year||0));
notDl.sort((a, b) => new Date(b.releaseDate||0) - new Date(a.releaseDate||0));