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:
1 parent
5528947a93
commit
640e475c51
4 files changed
+39
-18
No files matched your search
+4
-1
@@ -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
|
- **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`
|
- **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
|
### 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`
|
- 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
|
- 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
|
- **Web + Android** — yt-dlp backend update / maintenance
|
||||||
|
|
||||||
### Known Bugs
|
### 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.)
|
- **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 (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.
|
- **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) {
|
for (ia in uniqueIAlbums) {
|
||||||
val iName = normAlbum(ia.collectionName)
|
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 iCount = ia.trackCount ?: 0
|
||||||
val match = ndAlbums.find { a ->
|
val match = ndAlbums.find { a ->
|
||||||
val n = normAlbum(a.name)
|
val n = normAlbum(a.name)
|
||||||
@@ -65,11 +68,11 @@ fun categorizeAlbums(ndAlbums: List<Album>, iAlbums: List<ItunesItem>): ArtistAl
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
match == null -> notDl.add(ia)
|
match == null -> notDl.add(ia)
|
||||||
iCount > 0 && match.songCount >= iCount -> {
|
iCount > match.songCount -> {
|
||||||
downloaded.add(Pair(match, ia)); usedNdIds.add(match.id)
|
partial.add(Triple(match, ia, Pair(match.songCount, iCount))); usedNdIds.add(match.id)
|
||||||
}
|
}
|
||||||
else -> {
|
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
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'ns-v54';
|
const CACHE = 'ns-v55';
|
||||||
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
|
||||||
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
|
||||||
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
|
||||||
|
|||||||
+28
-13
@@ -282,36 +282,51 @@ export async function loadArtistDetail(artist) {
|
|||||||
actionBar.appendChild(evBtn);
|
actionBar.appendChild(evBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(albEl) { albEl.innerHTML = ''; _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist); }
|
if(albEl) _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _normAlbum(s) {
|
function _normAlbum(s) {
|
||||||
return (s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
return (s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
async function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
|
||||||
const downloaded = [], partial = [], notDl = [];
|
el.innerHTML = '<div class="spinner"></div>';
|
||||||
const usedNdIds = new Set();
|
|
||||||
|
|
||||||
for(const ia of iAlbums) {
|
// Match each Deezer album to a Navidrome album by normalized name.
|
||||||
|
const entries = iAlbums.map(ia => {
|
||||||
const iName = _normAlbum(ia.collectionName);
|
const iName = _normAlbum(ia.collectionName);
|
||||||
const iCount = ia.trackCount || 0;
|
|
||||||
const match = ndAlbums.find(a => {
|
const match = ndAlbums.find(a => {
|
||||||
const n = _normAlbum(a.name);
|
const n = _normAlbum(a.name);
|
||||||
return n === iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName));
|
return n === iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName));
|
||||||
});
|
});
|
||||||
if(!match) {
|
return { ia, match };
|
||||||
notDl.push(ia);
|
});
|
||||||
} else if(iCount > 0 && match.songCount >= iCount) {
|
|
||||||
downloaded.push({ nd: match, itunes: ia }); usedNdIds.add(match.id);
|
// Deezer's /artist/{id}/albums list omits nb_tracks, so the total is unknown here — which made every
|
||||||
} else {
|
// owned album look "incomplete" (e.g. "Moonlight 1 / 0"). Resolve the real track count from the
|
||||||
partial.push({ nd: match, itunes: ia, have: match.songCount || 0, total: iCount }); usedNdIds.add(match.id);
|
// 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) {
|
for(const na of ndAlbums) {
|
||||||
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, itunes: null });
|
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));
|
downloaded.sort((a, b) => (b.nd.year||0) - (a.nd.year||0));
|
||||||
partial.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));
|
notDl.sort((a, b) => new Date(b.releaseDate||0) - new Date(a.releaseDate||0));
|
||||||
|
|||||||
Reference in new issue
Block a user