- Backend now caches Ticketmaster events per artist and prefers the larger
recent result, so a flaky partial/empty response can't shrink a good list;
every client (web/Android) stays consistent (1h TTL self-heals).
- Web SoundCloud importer preview: add the missing .gtrack-num grid cell so
the 4-column row no longer squeezes the cover/title (title was wrapping one
char per line).
- CHANGELOG [2.3] opened for this batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Pin ("Falscher Künstler?") is now a small icon-only ⚙ chip on both web
and Android — accent-blue when pinned, subtle white otherwise; no more
text banner over the hero.
- Android artist discography no longer filters to record_type=="album";
it now shows albums, EPs AND singles (ordered albums→EPs→singles),
matching Deezer's own artist page. Web already showed all.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The importer no longer has its own sidebar/bottom-nav entry. The Global
Suche field now handles both jobs: plain text runs the Deezer search;
a pasted Spotify/SoundCloud/YouTube link switches into importer mode
(badge + "Importieren" button + preview), matching the Android app and
tidying the left nav.
- New onGlobalInput/tryImporterResolve; importer DOM moved into
page-discover; old page-spotify-importer removed; routing cleaned up.
- Existing resolvers unchanged (still read the mirrored importer-link-input).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two gaps the pin left, on both platforms:
1. Search still showed the wrong same-name artist's photo. The library search
now prefers the pin's Deezer picture for a pinned artist (web: search.js
artist cards; android: LibrarySearchContent via SearchViewModel overrides).
2. Similar Artists were "weird" — they come from Last.fm keyed to the (merged/
wrong) Navidrome artist. When pinned, use Deezer "related" of the pinned
artist instead (new deezerRelatedArtists / ItunesClient.lookupRelatedArtists).
Also: web Similar Artists now render with profile pictures (avatar chips), like
the Android app, instead of plain text chips.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds GET /scrobble/users (distinct users for the partner picker) and
GET /scrobble/compare?with= to the scrobble service. Taste match = cosine
of the two users' artist-play vectors (0..100); also returns shared artists
(mirrored bars), shared tracks, and each side's unique top artists. Reuses
the existing per-user play-log — no new data capture.
Web UI src/ui/compare.js (overlay from Settings "🎧 Musik-Match"): partner
picker fed by /scrobble/users, match-score ring, per-user totals, shared +
unique lists with clickable artist/album rows. Cosine + shared logic verified
against a hand computation on an in-memory DB. Android pending (parity: 🔜).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deezer's /search/artist is a ranked, incomplete fuzzy match — low-profile
same-name artists (e.g. Nana id 80239872) never appear in results. Add a
"paste Deezer link or ID" field to the pin picker that looks the artist up
directly via /artist/{id} (new deezerLookupArtist) and pins it. Same reliable
route as the importer direct link vs yt-dlp search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- esc() now coerces to string first (String(s ?? '')), so passing a number (e.g. the
Deezer artist id in the pin picker) no longer throws 'replace is not a function'.
- The '⚙ Falscher Künstler?' button moved from under the fan count to the top-right
of the artist banner (absolute, on the hero). SW cache -> ns-v75.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fixes same-name collisions where NovaSonic guessed the wrong Deezer artist (e.g.
'Nana' → a random DJ): fans, top songs, missing albums and the hero image were all
off. A '⚙ Falscher Künstler?' button on the artist page opens a picker (same-name
Deezer candidates with image + fan count); the chosen mapping is stored SERVER-SIDE
(shared across users, like the romaji overrides) in /data/deezer-artist-overrides.json.
Once pinned, the artist page short-circuits the name search to the pinned Deezer id
(drives albums/top-songs/fans) and overrides the hero image with the pinned artist's
Deezer picture (NovaSonic-side, so it works even for tag-merged same-name artists —
Navidrome can't disambiguate them without an MBID). New endpoints
GET/POST/DELETE /api/artist-overrides; api.js load/get/set/remove; loaded at startup.
SW cache -> ns-v74. Needs on-device runtime test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
With 'show Romaji' enabled, dispArtist/dispArtistById/dispArtistParts/dispTitle
substituted the sort tag for the display name unconditionally. For non-Japanese
names the sort tag is just a sort key (leading symbols/articles stripped, so
'☆NoAki☆' → 'NoAki'), which wrongly replaced the real name. Guard the substitution
on the original being Japanese — mirroring what dispAlbumName already did — so
Romaji only applies to Japanese names and everything else shows unchanged.
SW cache -> ns-v73.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Play-log now stores each play's genre (from the song tags; web scrobbleLog +
android PlayLogClient capture song.genre). The stats endpoint returns topGenres
(GROUP BY lower(genre), case-insensitive, blanks ignored); web + android render a
Top-Genres section. Schema migrates via ALTER TABLE. Old plays have no genre
(fills going forward, like cover art). SW cache -> ns-v72.
Android compileDebugKotlin + web build green; genre grouping + null-ignore unit-tested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Player: opening the PWA no longer auto-plays when the user had paused. Pausing via
the media notification while backgrounded routes through togglePlay, which now sets
S._userPaused; _onForeground only auto-resumes an INVOLUNTARY background stop
(_wasPlayingBeforeHide && !_userPaused). playCurrent clears the flag on a new track.
Involuntary-stop/freeze recovery on foreground is preserved.
Stats modal: no more small→big flash when switching period/tab — the body keeps a
stable min-height and retains its current content until the new data arrives (only
the first open shows 'Lade…'); a per-request token drops out-of-order responses.
SW cache -> ns-v71.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Navigator can't step before logging began: the endpoint returns firstPlayedAt
(MIN(played_at) over all the user's plays, unfiltered by window); web + android
disable the back arrow once the shown year/month is at/before that first play.
- Top artists/tracks/albums are now clickable → open the artist / album page (web
navigates and closes the overlay; android navigates via nav routes). Tracks open
their album.
- Android: moved the 'Meine Statistiken' button to the top of Settings (was at the
bottom near logout). SW cache -> ns-v70.
Android compileDebugKotlin + web build green; firstPlayedAt verified server-side.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navigator (web + android): browse Alles / Jahr / Monat with a ‹ › stepper — see
top plays for a specific month or year (e.g. March 2024). Steps into the future
are disabled. Uses the existing /scrobble/stats endpoint with computed from/to +
bucket (month for year view, day for month view). All windows verified.
Cover art fix: the app resolves covers from the Subsonic **coverArt token**, not
the raw entity id — so stats covers (which used albumId/songId) never loaded.
Now the play-log stores each play's coverArt (web + android capture), the stats
endpoint returns it for top tracks/albums, and both clients render coverUrl(coverArt)
with an id fallback. Old plays lack coverArt (blank until re-listened); artist
images still use artistId (may need getArtistInfo2 later if blank). Schema migrates
existing DBs via ALTER TABLE. SW cache -> ns-v69.
Android compileDebugKotlin + web build green; server windows + cover storage unit-tested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 (part B): Android statistics screen consuming GET /scrobble/stats —
tiles (plays + listening time), day/month/year play series, top artists/tracks/
albums, 24h listening clock, period tabs; opened from a Settings button
("Meine Statistiken"). New StatsViewModel + StatsScreen + ScrobbleModels;
PlayLogClient.fetchStats() (tolerant JSON decode, null on any failure);
repo.fetchScrobbleStats(); nav route + SettingsScreen(onOpenStats).
Cover art everywhere: top artists show a circular artist image, tracks/albums
a square cover — resolved client-side from Navidrome via artistId/albumId/songId
(no coverArt knowledge in the scrobble service). Endpoint now also returns
albumId for top tracks so track covers resolve reliably. Web stats list gets the
same thumbnails. SW cache -> ns-v67.
Android compileDebugKotlin + web build green. Needs on-device runtime test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Opens from Settings ("Meine Statistiken") as a self-contained modal overlay
(no router changes). Fetches GET /scrobble/stats and renders: totals tiles
(plays + listening time), a per-bucket play series, top artists/tracks/albums
with mini bars, and a 24h listening clock. Period tabs (all / this year /
last 30 days) switch from/to + bucket. Vanilla inline SVG-free bars, no chart
lib. Reuses .modal-overlay.open + design tokens; sends local tz offset for
day bucketing. Graceful empty/error/unconfigured states. SW cache -> ns-v65.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1+2 of a self-hosted, multi-user listening archive — the basis for
day/month/year stats and a yearly "Wrapped". Runs IN ADDITION to the existing
Navidrome/Last.fm scrobble; never replaces it.
Service (isolated, own container):
- scrobble/scrobble-server.js — node:sqlite play-log (own volume), routes
POST /scrobble/plays (idempotent by uuid), GET /scrobble/history (archive),
DELETE /scrobble/plays?user= (test cleanup), GET /scrobble/health.
Multi-user auth via Navidrome ping.view (cached) -> user not spoofable.
- docker-compose: novasonic-scrobble service (node:22, port 3710) + volume;
backend bumped node:20->22 (EOL); SCROBBLE_URL plumbing (entrypoint, .env.example).
Capture (both platforms, offline-safe queue, idempotent batch flush):
- Fires at the existing scrobbleMode counted-play moment — consistent with
Last.fm, respects the user's setting (no separate 50%/4min rule).
- Web: src/player/scrobbleLog.js (localStorage queue) + subsonicAuthParams()
in api.js + hooks in player.js + flush init in main.js; S.cfg.scrobbleUrl.
- Android: data/PlayLogClient.kt (filesDir queue, reused Ktor client) +
repo.logPlay/flushPlayLog + PlayerController hook + NovaSonicApp flush +
scrobbleUrl in PrefsStore/SettingsViewModel/SettingsScreen (with test button).
Plan + design decisions documented in docs/scrobble-plan.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The finished-download notification resolved the album by name via search3, then fell back to
a same-named album by a DIFFERENT artist (or the first result) when the exact artist match
wasn't found — e.g. right after download, before Navidrome rescans. Many artists share an album
title ("I LIKE IT!"), so the notification navigated to a completely different song.
- Web (notify.js) + Android (AppNavigation): require the artist to match when known (fuzzy for
multi-artist / album-artist drift); drop the name-only and first-result fallbacks. If nothing
matches, show "wird evtl. gerade gescannt" instead of opening a wrong album.
The download itself was correct all along (verified: it grabbed UPSAHL's official "I LIKE IT!"
visualizer) — only the notification's navigation was wrong. SW ns-v63.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix (the reported bug):
- Deezer's /artist/{id}/albums omits the artist object, so discography album cards had an
empty artist name → the download modal fell back to the literal "Unknown Artist", which
poisoned the yt-dlp search ("Unknown Artist <album>") and its artist/channel filter, failing
with "Nicht eindeutig gefunden".
- deezerLookupArtistAlbums now backfills the known artist name (we're on that artist's page).
- openDlModal now defaults album cards to an ALBUM download (was always Track), so a one-click
download no longer grabs an album as a single track named after the album.
Rename (requested cleanup, dependency-checked so nothing breaks):
- All frontend itunes* identifiers → deezer* (functions, the ItunesItem-shape field keys
itunesData/itunesArtistId/itunesFanCount/itunesId, the itunes-album-detail route + DOM ids,
the badge-itunes CSS class). The backend reads none of these fields, so the boundary is safe.
- Verified: build passes; route/DOM-id/CSS producer↔consumer all consistent; only the factual
"Apple's iTunes /search 403" comments keep the historical name. SW ns-v62.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Deezer artist match required only that every query word appear in the candidate name, so
"Flay!" (query word: flay) matched "K.FLAY" (words: k, flay). Sorted by fan count, K.FLAY
(54,822 fans) buried the real "Flay!" (39 fans) — the page showed K.FLAY's fans + top tracks.
Now prefer an EXACT normalized-name match ("flay" == "flay", not "kflay"), falling back to the
loose match only when there's no exact one. Web (detail.js) + Android (ArtistViewModel).
Verified against Deezer: exact match selects the real Flay! (id 12242284, whose top tracks
are actually Flay!). SW ns-v60.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navidrome can hold the same track twice (downloaded as a single AND as an album track), so
getTopSongs returned two identical rows ("PIXELATED KISSES" x2). Collapse the top-songs list
by title+album for display (keeps the highest play count) on web + Android. SW ns-v59.
Note: this is a display fix — the underlying duplicate files remain a library-cleanup matter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deezer returns the same album as several variants (single/EP/album/regional/explicit), and
each variant matched the same Navidrome album, so one owned album rendered 2-3 times
("PIXELATED KISSES" x3).
- Web (detail.js): collapse matched entries to one per owned Navidrome album and one per
not-owned title before classifying (also avoids duplicate track-count fetches).
- Android (categorizeAlbums): distinctBy(collectionId) missed variants with different ids;
now also skip an owned album already classified.
SW ns-v58.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Niche local artists (e.g. N!GHT) whose Navidrome metadata agents have no image showed a
blank hero. Now, when Navidrome has no artist image, the hero falls back to the matched
Deezer artist's photo (Navidrome image → Deezer photo → album cover), on web + Android.
- Web (detail.js): the Deezer-photo fallback previously ran only for non-local artists;
now it runs whenever the Navidrome image is absent, local or not.
- Android: ArtistViewModel captures the matched Deezer artist's photo (deezerImage);
ArtistDetailScreen uses it as the hero fallback.
SW ns-v56.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
fmtCount now shows the full grouped number below a million (toLocaleString de-DE) and only
compacts millions ("1,2 Mio."). The old "Tsd." form read awkwardly on the artist hero. SW ns-v53.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deezer's artist search doesn't rank by popularity, so a name collision (5 artists named
"Joji") returned a niche one first (62 fans) instead of the real Joji (559k) — the artist
page then loaded the wrong discography and owned songs didn't show as downloaded.
- api.js: _dzArtist carries fanCount (nb_fan); artist search sorts by fanCount desc.
- search.js: artist cards show the fan count as subtitle; fan count passed to the artist page.
- detail.js: pick the most-popular name match (not the first); for local Navidrome artists
with a real collision (>=2 same-name candidates), tie-break by SONG OVERLAP — prefer the
Deezer candidate whose top tracks actually overlap the library (each artist has different
songs, so this nails the right person). Costs a few calls only on a collision.
- Artist hero shows "<n> Fans" (immediately for global-search artists, after match for local).
- Android: DzArtist.nb_fan + /search/artist sorted by nb_fan so the right artist is first.
- Also: web global-search "unavailable" message now says Deezer, not Apple. SW ns-v52.
Verified live against Deezer: both fan-sort and overlap select the real Joji (id 5078097).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple's iTunes /search returns 403 from server/datacenter IPs, which had broken global
search, charts, album/artist/single downloads and the metadata-editor autofill in
production. Everything now sources metadata from Deezer (free, no key, more requests,
returns explicit versions + real artist images + durations). Web + Android share one
source, so an artist resolves to one consistent name everywhere. Deezer results are
mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.
Backend:
- New backend/lib/deezer.js: lookupTrackMeta, fetchAlbumTracks, searchAlbumTracks,
fetchArtistAlbums (+ pure pickTrackMatch/mapAlbumTrack for tests).
- metadata.js drops the iTunes lookups; novasonic-server download paths (track, album,
artist, playlist enrich, spotify artwork) + autofill now use Deezer.
- Artist download uses Deezer discography (real albums only), not the iTunes album search.
- Editor autofill endpoint /api/itunes/lookup -> /api/meta/lookup; removed the dead
iTunes /search proxy and the Apple charts RSS proxy.
- backend/test/deezer.test.js: unit tests for numbering, mixed-artist album-artist
stability, explicit flags, the Japanese-title match guard (npm test; RUN_LIVE=1 for live).
Web:
- api.js search/lookups already on Deezer; charts now use Deezer /chart. Artist cards
show the real Deezer photo. Visible "iTunes"/"Apple Music" labels -> "Deezer"
(search badge, charts badge, preview bar, editor button, artist heading). SW ns-v51.
Android:
- New DeezerModels.kt with Dz* models + mappers to ItunesItem/ItunesRssEntry.
- ItunesClient is Deezer-backed (search = tracks+albums+artists in parallel; album/artist
lookups; /chart charts), same method signatures. Visible strings -> Deezer.
Docs: README, CLAUDE.md and CHANGELOG (1.3) updated; Platform Parity Matrix reflects the
shared Deezer source + Android non-local artist page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Search artist cards now use the Deezer artist picture directly (Deezer artists carry
a photo, unlike iTunes entities) instead of a null image + an extra album lookup;
the lookup stays only as a fallback when no picture is present.
- Non-local (Deezer) artist pages: fetch the Deezer artist picture (/artist/{id}) for
the hero photo, which was blank because getArtistImage only runs for Navidrome artists.
- Label "iTunes Künstler" → "Deezer Künstler".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple's iTunes /search returns 403 from server IPs, killing the Erkunden search. Deezer
is free, needs no key, and is actually better: it returns explicit versions, artist
images, track durations and 30s previews.
- Backend: /api/deezer/* proxy (CORS-safe); album download accepts a `tracks` array in
the request body (Deezer albums send their tracks directly, pre-mapped).
- api.js: itunesSearch / itunesLookupAlbum / itunesLookupArtistSongs|Albums now hit
Deezer and map results to the existing ItunesItem shape, so the UI + download flow are
unchanged. Function names kept.
- downloads.js: album downloads fetch the Deezer album's tracks and send them in the body.
iTunes /lookup (by ID) and the charts RSS proxy stay. Known follow-ups: artist-type
download still calls iTunes /search server-side; the metadata-editor iTunes autofill;
and the Android client — all still on iTunes for now.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Confirmed via curl from a clean IP: Apple's iTunes /search returns 403 (blocked
server-side, likely datacenter-IP anti-scraping) while /lookup?id= still returns 200.
So the Erkunden Apple search fails through no fault of ours.
- Charts: the "most-played" RSS (rss.applemarketingtools.com) was fetched directly
from the browser and hit CORS; proxy it via /api/itunes-charts/:kind.
- Error visibility: itunesSearch records the last outcome (HTTP status / network);
the global-search view now shows "Apple-Suche gerade nicht verfügbar" with the
reason (403 = Apple blocks the API, not you) instead of a blank "no results".
- iTunes proxy now forwards the upstream status code instead of always 200.
Deezer (free, no key, ~50 req/5s) is the planned search fallback; MusicBrainz is too
rate-limited (1/s) for interactive search.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Search (web api.js) was JP-first, the download lookups were JP-first, and the Android
client was already US — so the same artist resolved inconsistently: sometimes kanji,
sometimes katakana, sometimes romaji, depending on which path and which store matched.
Switch everything to the US store:
- api.js itunesSearch → country=us (one fetch, matches the Android client)
- metadata.js lookupItunesTrackMeta → US only (drops the JP fetch: consistent names +
halves the download's iTunes calls, which also eases the rate limit)
- searchItunesAlbumTracks → country=us
- remove the JP-katakana guards in the album/artist download paths (redundant now, and
they wrongly romanized genuine Japanese artists like 羽岡佳 → "Kei Haneoka")
Titles still come from the source, so a Japanese title the user picked stays Japanese;
only iTunes-derived fields (album_artist, genre, year, artwork) follow the US store.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deletes left the removed item visible because the list loaders are cached (getPlaylists
~3 min): deletePlaylist navigated to a still-cached list, and deleteSong only reloaded
playlists. Add refreshCurrentView() — drops the API cache and re-runs the active nav
entry's loader (playlists, playlist/album/artist detail, starred, home, …). deleteSong
calls it after the delete+rescan delay; deletePlaylist invalidates getPlaylists before
navigating. Bump SW cache to ns-v46.
Also record the post-1.2 work in the 1.2 changelog (importer Enter, search single-song
+ radio, NowPlaying order, official-audio artist-metadata match, JP-katakana album
artist fix, year back-fill, delete refresh).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Playing a song from search results queued the whole result list. Now it plays only
the clicked song; Radio mode, if on, fills the rest — closer to Spotify. Both
platforms already trigger a radio top-up on the first track (web playQueue →
fillRadioQueue; Android onMediaItemTransition → checkRadioRefill), so no extra wiring
is needed. Album/playlist play still queues the full list. Bump SW cache to ns-v44.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
loadAlbumDetail overwrote the header artist with parseArtists(songs[0]) — the first
track's artist. On soundtracks/compilations that is wrong (e.g. "KPop Demon Hunters"
showed "TWICE" from track 1 "TAKEDOWN"). Use r.album (the getAlbum response, which
carries the album's own album-artist tags in .artists / .artist) so it matches the
Android app: "KPop Demon Hunters Cast • HUNTR/X • Saja Boys".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The popstate listener always called _navGoBack(), but both Back and Forward fire
popstate — so Forward was treated as Back and never restored the page you came from.
_navGoBack also pushState()'d on every back, which wiped the browser's forward stack
entirely.
Route all history through the History API: navigate() pushes a state (nsIdx) only on
genuine new navigation; popstate now applies whichever entry the browser moved to via
_navOnPopState(state.nsIdx), covering Back AND Forward with no pushState in the
handler. In-app back buttons / Backspace go through history.back() so there's a single
path and the browser stack stays in sync. NowPlaying overlays still close first on a
back press.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apple Music / MusicBrainz render multi-artist credits as "A, B, C & D" — the last
artist is joined with " & " instead of the list separator, so it stayed glued to the
previous one (e.g. "REI AMI & KPop Demon Hunters Cast" became a single artist that
Navidrome's ";" split could not break apart).
_fmtArtists now also rewrites " & " to the list separator, but only when the string
is already a list (contains ; , • ·), so real band names like "Simon & Garfunkel"
stay intact. The iTunes and MusicBrainz autofill results are now run through
_fmtArtists too (previously set raw), so the artist field comes out ";"-separated
and matches the Navidrome Tags.artist.Split config.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The picker listed playlists with a generic list icon; now each row shows the
playlist's cover art (falling back to the icon when there's none), so you can
tell them apart at a glance when adding a song.
- web (modals.js): 34px cover thumbnail per row.
- android (PlaylistPickerSheet): 40dp AsyncImage cover, coverUrl passed from AppNavigation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Android: MusicRepository.coverArtUrl/streamUrl built a fresh SubsonicClient
per call — each spins up (and never closes) a Ktor HttpClient. That ran for
every cover on every recomposition, causing heavy GC/alloc churn (and a leak)
that showed up as scroll/tab lag. Reuse the cached ClientHolder client for URL
building instead.
Web: the home scroll-row arrows called getBoundingClientRect on every scroll
event (layout thrashing). Split it: only toggle arrow visibility on scroll
(rAF-throttled); do the getBoundingClientRect vertical-centering only on
resize/content change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rapid skips + lyrics prefetch fired the full lrclib cascade many times over,
so lrclib throttled the IP and requests timed out (canceled) on both web and
Android. Reduce the request volume:
- Web: de-dupe concurrent fetches per song (prefetch + tab-open now share one
cascade); bump the per-request timeout 6s → 8s.
- Android: per-song Mutex so prefetch + on-open load don't both run the cascade;
send a proper User-Agent (lrclib asks for one and treats it better); debounce
the prefetch (1.5s, cancelled on the next skip) instead of firing immediately
on every track change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ping Navidrome with scrobble?submission=false when a track starts so
getNowPlaying reflects it instantly (feeds e.g. a Discord rich-presence
plugin / other clients' Now Playing). Independent of the existing delayed
"played" scrobble used for play counts and Last.fm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The NP overlay only refreshed its cover while open, so skipping with it closed
left the <img> on the last-opened song; reopening briefly showed that old cover
until the current one loaded. Now the NP cover element is kept pointed at the
current song even while closed (loads in the background), so opening it always
shows the right artwork.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reintroduce the two-element handoff, this time as a skip-speed optimization
(not for the OS tile). A second <audio> element pre-buffers the next track;
on a forward skip playCurrent hands playback over to it instead of reloading
the source, so playback starts immediately instead of ~1s. Covers are
pre-warmed in parallel so the new artwork is ready too.
Listeners are bound to both elements once (handlers guard with el !== S.audio).
Safety: handoff only when the standby is buffered (readyState >= 2); otherwise
falls back to the unchanged source-swap path, and a failed handoff play()
recovers with a fresh source load. Web-only; the native bridge path is untouched.
Note: this does not change the OS media-tile flicker (a separate, MSE-only fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removing the second <audio> element fixed most of the OS media-tile flicker,
but skips now load the next track fresh → a brief gap where Windows shows the
generic site entry. Prefetch the next track's first ~4MB with fetch() (not a
media element, so no Chromium session flip) to warm the HTTP cache + TLS
connection; the src-swap then starts faster and the gap shrinks. Abortable on
queue change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Navidrome's player (react-jinke fork) uses one persistent <audio> element and
does not flicker the OS media tile, so the source-swap itself isn't the cause.
Our one structural difference was a second buffering <audio> (_preAudio) for
preload. A second media element seems to make Chromium hand the OS media
session over on skip → Windows tile flicker. This drops it; we rely on the main
element's preload="auto" + browser HTTP cache. Cover art is still prewarmed.
Trade-off: next-track start may be marginally slower. Easy to revert if it
doesn't fix the flicker.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a second <audio> element; S.audio always points at the active one and
listeners are bound to both once (handlers guard with el !== S.audio).
preloadNext buffers the next track into the standby element, and playCurrent
hands playback over to it on skip instead of swapping the source — so the
active element never empties and Windows keeps the media tile (also makes
forward skips instant/gapless).
Safety: handoff only when the standby is buffered (readyState >= 2); falls
back to the unchanged source-swap path otherwise, if there's no second
element, or if handoff play() rejects. Web-only — the native bridge path is
untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
prefetchLyrics now fires ~1.5s after a song change instead of immediately,
so the lrclib requests no longer compete with audio-stream buffering when a
new track starts. Timer is cleared on rapid song changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>