845 Commits
Author SHA1 Message Date
HoneyandClaude Opus 4.8 b584159568 fix: reliable concerts (backend events cache) + web importer preview layout
- 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>
2026-08-21 23:02:56 +02:00
HoneyandClaude Opus 4.8 f56d47800c fix(android): even concert rows — fixed calendar-style date block
The "16. Sep. 2026" date wrapped inside the 52dp column, so rows with/without
a time ended up different heights and the divider/venue misaligned. The date
block is now a fixed 56dp calendar block: big day number, small month/year and
time, each single-line — every row is the same height and lines up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 20:00:49 +02:00
HoneyandClaude Opus 4.8 2208ce77b6 fix(events): show the venue-local concert time consistently
Backend sent the UTC `dateTime` (a 20:00 local show read as 08:00Z), and
Android's ZonedDateTime.parse choked on zone-less values → some rows had a
wrong time, others none at all.

- Backend: prefer localDate+localTime (venue-local, naive) over the UTC instant.
- Android: parse a naive LocalDateTime first, fall back to ZonedDateTime for
  older "…Z" values. Web already renders the naive string as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 19:59:26 +02:00
HoneyandClaude Opus 4.8 f27eaf9c82 fix(android): surface concert-fetch errors instead of swallowing them
Events were fetched with runCatching{}.getOrDefault(emptyList()), so any
network/deserialization failure looked identical to "no concerts". The
backend + Ticketmaster work (verified), so the failure is client-side.

- EventsClient: expectSuccess + timeouts so a non-2xx (e.g. an SSO/HTML
  redirect) becomes a real exception instead of an obscure body() failure.
- ArtistViewModel: capture the failure into eventsError (+ Logcat) rather
  than defaulting to empty.
- ConcertsSheet: show "Konzerte konnten nicht geladen werden" + the reason
  when the fetch failed, vs. "Keine Konzerte angekündigt" when genuinely none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 19:46:16 +02:00
HoneyandClaude Opus 4.8 7b4599c50a feat(artist): minimal icon-only pin button + show all Deezer releases (Android)
- 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>
2026-08-21 19:19:17 +02:00
HoneyandClaude Opus 4.8 934253a036 feat(web): merge Song Importer into Global Suche (one unified field)
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>
2026-08-21 17:59:44 +02:00
HoneyandClaude Opus 4.8 0a6c81b4a1 docs: tidy CHANGELOG (drop duplicated [0.9] block) + refresh README
- CHANGELOG: remove the accidentally duplicated Changed/Fixed/Investigated
  block that sat orphaned before [0.8]; [0.9] now appears once.
- README: artist-pages bullet now mentions the same-name artist pin
  (Deezer link/id) and that a pinned artist's similar list comes from Deezer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 00:54:19 +02:00
HoneyandClaude Opus 4.8 593c9fdb7b fix(backend): YouTube downloads — install Deno + pull yt-dlp nightly
Downloads were 403ing on two fronts: (1) recent yt-dlp needs a JS runtime to
solve YouTube's signature/nsig challenge (Deno, added in cb7a3d7), and (2) the
current yt-dlp *stable* had a bug that 403'd the android_vr client on every video
(yt-dlp#17456, fixed in nightly). Switch the backend from the stable yt-dlp
binary to nightly via pip (`--pre 'yt-dlp[default]'`). Verified end-to-end incl.
a real frontend download. No cookies / PO-token service / proxy needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-19 00:40:31 +02:00
HoneyandClaude Opus 4.8 cb7a3d77be fix(backend): install Deno so yt-dlp can solve YouTube's signature/nsig challenge
Recent yt-dlp (which the backend re-pulls latest on every restart) needs an
external JavaScript runtime to solve YouTube's signature/nsig challenge — without
it, "Signature solving failed" → formats get skipped/HTTP 403. Install Deno in the
backend container (+ unzip for its installer) and put it on PATH for the node
process so spawned yt-dlp finds it. No extra container, no account, no proxy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-18 23:58:22 +02:00
HoneyandClaude Opus 4.8 635ba8d353 revert: drop the YouTube cookies/PO-token/proxy workarounds (back to pre-saga state)
None of it got downloads working on the flagged datacenter IP (web_music media
403, then 429 rate-limiting), so revert the whole lot to keep things clean:
- config.js: remove potProviderUrl + ytdlpProxy
- ytdlp.js: remove potArgs/proxyArgs; player_client back to web_music,default
- novasonic-server.js: remove pot/proxy args from importer resolve
- docker-compose.yml: remove the bgutil-provider service + backend env/plugin/depends_on
- CHANGELOG: drop the [2.2] section

Kept the one genuine fix: a failed direct-link download now reports failure
(moved its note into [2.1]).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 20:24:13 +02:00
HoneyandClaude Opus 4.8 31d4a72e62 fix(backend): download via default client (web_music media 403s); add optional proxy
The PO token provider works (web_music gets a token) but web_music's media URLs
still 403 from a datacenter IP. Prefer the default player clients for the
DOWNLOAD (their URLs go through with a PO token), keeping web_music only as a
fallback for Art-Track extraction; the YTM search still leads with web_music.

Also add an optional YTDLP_PROXY (--proxy) — the durable fix if the IP stays
flagged (403/429) even with a PO token. Empty → unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 17:07:34 +02:00
HoneyandClaude Opus 4.8 4daf87b550 docs(changelog): split download fixes into [2.2] (PO token provider + false-success)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:34:15 +02:00
HoneyandClaude Opus 4.8 8fc6a3aaef feat(backend): PO token provider for YouTube downloads; drop the cookies approach
Replace the cookies-file stopgap (needs a throwaway account + manual refresh)
with a self-hosted PO token provider — the cookie-less way past YouTube's
datacenter-IP block (bot check + media 403).

- docker-compose: new bgutil-provider service (brainicism/bgutil-ytdlp-pot-provider)
  + install the bgutil-ytdlp-pot-provider plugin in the backend + POT_PROVIDER_URL
  env + depends_on.
- ytdlp.js / server.js: when POT_PROVIDER_URL is set, pass
  --extractor-args youtubepot-bgutilhttp:base_url=... to the download cascade, the
  YTM search probe and the importer resolve. Empty → omitted (unchanged behaviour).
- Removed the cookiesFile config + --cookies wiring so there's no dead code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:28:32 +02:00
HoneyandClaude Opus 4.8 ef019b9b9d fix(backend): direct-link download failure was reported as success
The direct-URL (sourceUrl) path returned { success: true } even when no audio
file was produced (e.g. YouTube 403 / bot check), so the job was marked
"done/Fertig!" and web + Android both said the track was added. It now returns
{ failed: true } → the server sets status 'error' with a clear message, which
both frontends already render as a failure. Search-cascade path was already
correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:20:52 +02:00
HoneyandClaude Opus 4.8 0990dfadb5 fix(backend): pass YouTube cookies to yt-dlp to beat the datacenter bot check
YouTube now returns "Sign in to confirm you're not a bot" for yt-dlp from
datacenter IPs, blocking downloads, search and link-resolve. When a cookies file
is present (YTDLP_COOKIES, default /data/youtube-cookies.txt) every yt-dlp call
passes --cookies so it authenticates. Checked at call time (drop the file in, no
code change); absent → unchanged behaviour. Applied to the download cascade, the
YTM search probe, and the importer resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 12:02:26 +02:00
HoneyandClaude Opus 4.8 e82dec7e01 docs(changelog): 2.1 — Musik-Match, Android artist pin, pin/search/similar fixes, player + swipe polish
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-17 11:45:28 +02:00
HoneyandClaude Opus 4.8 6f455b565f fix(android): smoother Erkunden swipe + top charts no longer vertically centered
- Pre-compose the neighbour page (beyondViewportPageCount = 1) so the first
  swipe doesn't hitch while the heavy LazyColumn builds.
- Drive the TabRow indicator off pagerState.currentPage so it moves with the
  swipe (at the half-way point) instead of only on settle — the lag read as
  "not smooth".
- Wrap each pager page in Box(fillMaxSize) so short content (the Top-Alben
  charts row) sits at the top again, not vertically centered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 22:01:34 +02:00
HoneyandClaude Opus 4.8 7f5d25a03b feat(android): swipe between Erkunden sub-tabs (Bibliothek ↔ Global)
Wrap the Erkunden content in a HorizontalPager so a left/right swipe switches
between the Bibliothek and Global search tabs, two-way synced with the TabRow: a
chip tap animates the pager; a settled swipe updates the selected tab (and stops
any preview, like a tap). Removes the idea from the backlog now that it's built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:52:11 +02:00
HoneyandClaude Opus 4.8 87ee8cd097 fix(android player): seek broke after seamless track transitions (durationMs stuck at 0)
After skipping through already-buffered tracks the player stays READY, so
onPlaybackStateChanged never fires and syncState() isn't re-called; ctrl.duration
was TIME_UNSET(→0) at the transition, leaving state.durationMs stuck at 0. The
seek bar then computed fraction*0 = 0 and snapped playback to the start every time
(or to a stale value → "only 10s"). Restarting the app re-read the real duration,
which is why it recovered.

Fix: durationMs now self-heals — the 500ms ticker refreshes it too, and
resolveDurationMs() falls back to the current song's known duration while
ctrl.duration is still unset. Also record the Erkunden swipe-between-tabs idea.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 21:47:58 +02:00
HoneyandClaude Opus 4.8 803331e807 feat(android): pinned similar artists tap through to library; note Following view idea
Pinned similar artists (Deezer related) were non-clickable on Android because
Deezer artists carry no Navidrome id. On tap, resolve the name against the
library (exact case-insensitive match) and navigate to that Navidrome artist if
present, else a short "not in your library" toast.

Also: extend the CHANGELOG "Artist following" backlog idea with a dedicated
"Following" view (grid of followed artists) + follow/unfollow toggle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-14 01:12:47 +02:00
HoneyandClaude Opus 4.8 26de85a140 fix(artist pin): apply pins in search + fix similar artists (web + android)
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>
2026-08-13 18:42:15 +02:00
HoneyandClaude Opus 4.8 1ee85482f3 feat(android): artist pin (same-name disambiguation) — parity with web
Android now reads AND writes the shared server-side artist overrides
(/api/artist-overrides), so pins set on web apply here and can be set/removed
in-app — closing the web-only gap.

- ArtistOverride model + ArtistOverrideClient (GET/POST/DELETE on the backend);
  MusicRepository caches the map and exposes get/set/remove.
- ArtistViewModel resolves the pin up front; when pinned it enriches the artist
  page straight from the pinned Deezer id (albums/top-tracks/fans/image) instead
  of the name-guess. Picker: Deezer search candidates + a "paste Deezer link/id"
  route (reliable when /search/artist can't surface a low-profile same-name
  artist), plus clear-pin.
- ArtistDetailScreen: "⚙ Falscher Künstler? / Gepinnt" pill top-right of the hero,
  pinned Deezer picture overrides the hero image, ModalBottomSheet picker.
- ItunesClient.lookupArtist(id) (Deezer-backed) for the link-paste flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 18:28:29 +02:00
HoneyandClaude Opus 4.8 a5e5d414b4 docs: record ideas in the bottom Planned/Backlog, mention Musik-Match in README
Move the share-links / Wrapped / household-leaderboard / ListenBrainz ideas
into the existing "Planned / Backlog" section at the bottom of the CHANGELOG
(not a new top section), and add a short Musik-Match clause to the README
listening-stats feature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 01:18:12 +02:00
HoneyandClaude Opus 4.8 e34a934460 docs(changelog): add Ideas / Backlog section (share-links, Wrapped, leaderboard, ListenBrainz)
The CHANGELOG is the home for feature ideas going forward. Records the
share-by-link deep-link idea (PWA on PC / Android app on phone via App Links +
assetlinks.json), plus the already-tracked Wrapped, household leaderboard, and
optional ListenBrainz relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 01:16:09 +02:00
HoneyandClaude Opus 4.8 19f82ad076 fix(android settings): move gap below Musik-Match, not above it
The Column already spaces children 12dp; the extra Spacer(8dp) between
"Meine Statistiken" and "Musik-Match" stacked to ~20dp, making that gap look
too big while Musik-Match↔Speichern stayed tight. Group the two stat buttons
together (12dp) and add the separating space (4dp) before the Save button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 01:08:19 +02:00
HoneyandClaude Opus 4.8 c933847201 feat(android): Head-to-Head "Musik-Match" screen (parity with web)
Android CompareScreen mirroring the web feature: partner picker fed by
GET /scrobble/users, then GET /scrobble/compare?with= → a match-score ring
(Canvas arc), per-user totals, mirrored shared-artist bars, shared tracks,
and each side's unique top artists. Rows navigate to artist/album. Reuses the
one PlayLogClient HttpClient (fetchUsers/fetchCompare). Opened from Settings
"🎧 Musik-Match". Parity matrix updated (Android ✅).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-13 00:52:33 +02:00
HoneyandClaude Opus 4.8 cbedbba6fc feat(scrobble): Head-to-Head "Musik-Match" — compare two users' listening
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>
2026-08-13 00:46:29 +02:00
HoneyandClaude Opus 4.8 ba2306656e feat(artist pin): pin by Deezer link/ID when search can't surface the artist
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>
2026-08-13 00:38:33 +02:00
HoneyandClaude Opus 4.8 5f251d555f fix(artist pin): esc() crashed on numeric Deezer id; move pin button to banner top-right
- 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>
2026-08-13 00:16:14 +02:00
HoneyandClaude Opus 4.8 02960cf886 feat(artist): pin a local artist to a specific Deezer artist (same-name fix, shared)
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>
2026-08-12 21:38:42 +02:00
HoneyandClaude Opus 4.8 b788fedec3 fix(download): dedup missed re-downloads when the title contains ' - ' (KH triple/quadruple)
findExistingTrack split the filename at the first ' - ' assuming 'Artist - Title',
so a title with internal dashes ('Kingdom Hearts 2 - Dearly Beloved - Main Theme')
or a garbage/absent artist prefix ('01-01 - …') was mis-parsed → the duplicate went
undetected and got re-downloaded (3-4x). Now match the query title against BOTH the
full stem AND the part after the first ' - ', so titles with dashes and prefix-only
filenames are recognized. Normal 'Artist - Title' files still match; no false
positives; remix-vs-base distinction preserved. Unit-tested on the exact KH case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 17:06:14 +02:00
HoneyandClaude Opus 4.8 75dceba97b fix(editor): 'Deezer' autofill button is Deezer-only again (MusicBrainz has its own button)
Stufe B routed /api/meta/lookup through the combined resolver, so the editor's
'Deezer' button could silently return MusicBrainz data while the status said
'von Deezer'. The editor already has a dedicated 'MusicBrainz' button, so keep the
two explicit and honest: Deezer button = Deezer only. The combined
Deezer→MusicBrainz→clean resolver stays on the DOWNLOAD paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 16:53:25 +02:00
HoneyandClaude Opus 4.8 e61d57425e docs(CLAUDE): document the single-track metadata resolver (Deezer→MusicBrainz→clean fallback)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 01:51:54 +02:00
HoneyandClaude Opus 4.8 6d084a7b7a fix(download): stop wrong album/cover from same-titled tracks; add MusicBrainz fallback
Root cause: deezer pickTrackMatch fell back to a blind title-only match, grabbing a
same-titled song by a completely different artist (e.g. 'Memories' by Rab Noakes for
'☆NoAki☆') and applying its album/cover/year. The download's clean fallback existed
but was never reached because the lookup almost always returned *something*.

Stufe A — deezer.js: the title-only fallback now REQUIRES an artist correspondence
(equal, or one name contained in the other after normalization). No match → {} →
the existing clean fallback finally runs (album_artist = song artist, album =
'Singles', cover = source thumbnail, year/genre untouched). Test that encoded the old
blind behaviour replaced; NoAki + loose-match regression tests added.

Stufe B — musicbrainz.js: a rate-limited (~1/s, User-Agent) MusicBrainz recording
lookup with the SAME artist discipline + Cover Art Archive cover. trackmeta.js is the
single resolver (Deezer → MusicBrainz → clean fallback); the three single-track call
sites use it (album batch stays Deezer-only to avoid MB rate-limit slowdowns).

Unit-tested (17/17) + live-verified: NoAki→clean fallback, real track→Deezer, MB finds
real albums when Deezer misses. Backend-only; no frontend/Android change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 01:51:29 +02:00
HoneyandClaude Opus 4.8 16f76a4236 fix(web): show non-Japanese names as-is with Romaji on (e.g. ☆NoAki☆ kept its stars)
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>
2026-08-12 00:48:22 +02:00
HoneyandClaude Opus 4.8 d195518dfb fix(android settings): remove open delay — seed theme instead of a loading gate
The previous flicker fix gated the whole settings form behind a 'loaded' spinner,
which read as a small lag when opening Settings (it waited for DataStore's first
emit even though it's already warm). Instead, expose the applied theme id
(NsColors.themeId, set in applyNsTheme) and seed the ViewModel's initial state with
it, so the theme picker no longer jumps from the default — no gate, no spinner, no
delay. Remaining text fields fill within a frame from the warm DataStore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 00:40:38 +02:00
HoneyandClaude Opus 4.8 711a084686 fix(android): scrobble respects 1/3 & 2/3 again; no settings flicker on open
Scrobble timing: at onMediaItemTransition ctrl.duration is still TIME_UNSET (→0),
so getScrobbleDelayMs's third/two_thirds modes hit the 15s floor and scrobbled way
too early (both Navidrome + our play-log). Use the song's KNOWN duration
(song.duration), falling back to the player only if absent. Web was unaffected
(it already used the song's duration).

Settings flicker: the ViewModel started with default state (theme = Dark Ocean)
and rendered a frame before DataStore emitted the real values, so the theme picker
visibly jumped. Gate the form on a 'loaded' flag set after the first emit; show a
spinner until then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 00:24:55 +02:00
HoneyandClaude Opus 4.8 0c7132a7f2 feat(scrobble stats): genre ranking (Top-Genres) — web + android
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>
2026-08-11 01:35:00 +02:00
HoneyandClaude Opus 4.8 249b693936 feat(android auto): tapping a song plays its whole list from that song
Browse songs now carry their context in the mediaId (songctx:<contextId>|<songId>).
A new onSetMediaItems override expands a single tapped song into its full list
(Favoriten / album / playlist) and returns it with the tapped index as the start
position, so playback continues through the rest instead of stopping after one song.
onAddMediaItems shares the same resolver; onGetItem resolves context songs too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 01:25:38 +02:00
HoneyandClaude Opus 4.8 d35fb01306 fix(android auto): tapping a song (e.g. in Favoriten) did nothing — re-attach stream URI
Android Auto strips a MediaItem's URI (localConfiguration) when a browse-tree item
crosses the process boundary, so onAddMediaItems received the tapped song without a
playable URI and the else branch passed it through unchanged → no playback. Rebuild
the item with the stream URI resolved from its mediaId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-10 20:22:09 +02:00
HoneyandClaude Opus 4.8 1d98b273e7 fix(web player + stats modal): no auto-resume after user pause; stable stats modal
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>
2026-08-10 18:06:18 +02:00
HoneyandClaude Opus 4.8 3129333f9a feat(scrobble stats): navigator lower bound, clickable rows, android button to top
- 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>
2026-08-08 21:50:27 +02:00
HoneyandClaude Opus 4.8 86eb893f40 feat(scrobble): time-travel navigator (year/month) + fix stats cover art
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>
2026-08-08 13:11:34 +02:00
HoneyandClaude Opus 4.8 14c0dea7e4 fix(scrobble): 'Alles' (all-time) stats returned nothing — from=0 mapped to now
The client sends from=0 for the all-time range, but the string "0" is truthy, so it
was routed through _toEpochSec which maps 0 -> now, producing an empty window. Parse
'from' directly (0/absent/invalid -> all-time) instead. Affects web + Android.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-08 12:55:29 +02:00
HoneyandClaude Opus 4.8 561014c83f docs: v2.0 — document the scrobble / listening-stats system
- CHANGELOG: new [2.0] entry (own timestamped play-log service, stats view on
  web + android with cover art, remix-dedup fix, user-select UI fix, node 20->22);
  Platform Parity Matrix gains play-log / stats / stats-cover rows (+ Wrapped 🔜).
- README: Listening stats feature, scrobble/ in the project tree, SCROBBLE_URL
  env + scrobble container table, port 3710 + SSO-exclusion note in setup.
- CLAUDE.md: new 'Listening stats / scrobble system' section (isolation rationale,
  node:sqlite gotchas, ping.view multi-user auth, capture point, cover resolution,
  SSO deploy gotcha).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 19:33:46 +02:00
HoneyandClaude Opus 4.8 906b3b48cb ui(scrobble): label the totals tile 'Plays' instead of 'Wiedergaben' (web + android)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 19:29:55 +02:00
HoneyandClaude Opus 4.8 fff6f9e4da feat(scrobble): Android stats screen + cover art in stats (web + android)
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>
2026-08-07 18:41:27 +02:00
HoneyandClaude Opus 4.8 cc1ec1653f fix(ui): stop accidental text-selection of UI chrome when dragging near controls
Dragging the mouse near a field edge (Global Suche, the importer, etc.) started
a text selection of the surrounding page titles/labels instead of feeling like an
app. Disable user-select on the #app shell; re-enable it for real text fields
(input/textarea/contenteditable) and copyable content (lyrics). Setup screen is
outside #app and unaffected. SW cache -> ns-v66.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 17:40:18 +02:00
HoneyandClaude Opus 4.8 26880016e9 feat(scrobble): web statistics view (Phase 3 UI)
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>
2026-08-07 17:17:14 +02:00
HoneyandClaude Opus 4.8 e782b75bd9 feat(scrobble): /scrobble/stats aggregation endpoint (Phase 3 backend)
Per-user listening stats via on-the-fly GROUP BY strftime (no rollup tables):
totals (plays + seconds), top artists/albums/tracks, a day/month/year time
series, and a 24h listening clock. Local-day bucketing via a tz offset.
Params exactly matched per query (node:sqlite throws on extra named params);
from defaults to all-time (avoiding the _toEpochSec(0)->now trap). Unit-verified.

Next: the web statistics view. Wrapped endpoint is Phase 4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 17:09:42 +02:00