Compare commits

...
135 Commits
Author SHA1 Message Date
HoneyandClaude Opus 4.8 6da40d9e63 docs(scripts): add .env.release.example template for publish-apk
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 00:52:49 +02:00
HoneyandClaude Opus 4.8 2fd4d978d0 feat(android): in-app updater (backend/Gitea APK), auto-bump versionCode
The app checks {backend}/api/apk/info on launch and, if a newer versionCode
than BuildConfig.VERSION_CODE is offered, shows an update dialog that
downloads {backend}/api/apk into a single wiped-each-time cache file and
hands it to the system installer (FileProvider + REQUEST_INSTALL_PACKAGES).
No APK hoarding; in-place install preserves data (stable signing key needed).

- backend /api/apk/info now returns versionCode (parsed from the release body).
- publish-apk.mjs auto-increments versionCode, sets versionName, and writes
  versionCode/versionName into the Gitea release body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 00:46:11 +02:00
HoneyandClaude Opus 4.8 845f125d76 feat: remember scroll + search position on Back (web + android)
Going into an album/artist and back now restores the scroll position and
keeps the typed query, instead of resetting to the top.

- Web: navigation.js snapshots each history entry's #main-content scroll and
  any open local (header) search, restoring them on Back/Forward; a genuine
  new navigation still starts at the top.
- Android: the Alben/Künstler lists (LibraryViewModel) and the global/local
  search-results lists (ExploreViewModel/SearchViewModel) hoist their
  LazyListState/LazyGridState, so scroll survives a drill-in/back for the
  tab's lifetime.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 00:06:46 +02:00
HoneyandClaude Opus 4.8 3ceba8a365 feat(android): partial-album detail gets Play/Shuffle + track 3-dot menu
You can still enjoy an incomplete album: the downloaded-tracks section now has
Abspielen + Zufall buttons (play/shuffle the downloaded songs) and each
downloaded row gets the 3-dot context menu, threaded from both artist screens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 23:58:24 +02:00
HoneyandClaude Opus 4.8 3000dc1622 feat(scripts): one-command APK build + upload to the fixed Gitea release
`npm run publish-apk` builds the APK (assembleDebug by default — the release
build type is unsigned) and uploads it to the apk-latest Gitea release,
replacing the old .apk asset, so the web download button always serves the
latest build. Config via env or a gitignored scripts/.env.release
(GITEA_URL/REPO/TOKEN, APK_GRADLE_TASK). Release title tracks versionName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 18:23:27 +02:00
HoneyandClaude Opus 4.8 33d24c6ed4 feat(backend): APK download proxies a fixed Gitea release (local fallback)
The /api/apk + /api/apk/info routes now fetch a fixed Gitea release
("apk-latest") server-side over the LAN and stream its .apk asset, falling
back to /data/novasonic.apk. Gitea can stay internal-only — the backend holds
the token, the browser only ever talks to the backend. Follows redirects,
constant ~30 MB (overwrite the one release's asset each build).

env: GITEA_URL, GITEA_REPO, GITEA_TOKEN (private repo only), GITEA_APK_TAG.
docker-compose documents them; unset = local-file fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 18:16:13 +02:00
HoneyandClaude Opus 4.8 908b50bcd9 feat(web): download the Android APK from Settings
Backend serves a staged APK so co-users self-serve the latest build:
- GET /api/apk streams APK_PATH (default /data/novasonic.apk) with the
  android package content-type + attachment disposition; 404 if absent.
- GET /api/apk/info reports availability, size, mtime, and an optional
  version from /data/novasonic.version.txt.
- Settings gets a "🤖 Android App" section that appears only when an APK is
  present and shows version/size/date.

Maintainer updates the APK with:
  docker cp app-release.apk novasonic-backend:/data/novasonic.apk

Android source is already tracked; only build artifacts stay out of git.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-25 18:00:36 +02:00
HoneyandClaude Opus 4.8 7996971ba0 fix(web): search results panel spans full content width
The header search overlay was anchored to the search bar's width, leaving a
dead strip on the right where scrolling hit the background page. Anchor it to
the sticky #mobile-header instead (which already sits right of the sidebar), so
it spans the full main-content width — sidebar stays visible, no dead strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-23 12:46:50 +02:00
HoneyandClaude Opus 4.8 026b392657 fix: Japanese/CJK albums vanished from artist discography (web + android)
The album-name normaliser stripped everything but a-z0-9, so any JP/CJK
title collapsed to "". All of an artist's Japanese singles then normalised
identically, de-duplicated into one entry and "matched" each other — e.g.
inabakumori (稲葉曇) showed only 2 of 9 releases. Normalisers now keep
Unicode letters/numbers (\p{L}\p{N}) in:
- web _normAlbum (detail.js)
- android normAlbum (categorizeAlbums), normTrack (partial-album split),
  the enrichPartialCounts helpers, and the Deezer artist-name match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 18:49:59 +02:00
HoneyandClaude Opus 4.8 ed641e999c fix(web): artist hero name no longer clips last letter; fold 2.3 into 2.2
- .artist-hero-name: add padding-right so the heavy/negative-letter-spacing
  final glyph isn't cut by overflow:hidden.
- CHANGELOG: merge the 2.3 batch into a single 2.2 (2026-08-22) section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 00:48:33 +02:00
HoneyandClaude Opus 4.8 67642f8ab8 fix(web): header search dropdown no longer covers the sidebar
Anchor #header-search-overlay to the search bar (position:absolute inside a
relative .header-search-wrap) instead of position:fixed full-viewport width,
so the results drop down at the bar's width and the left sidebar stays visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 00:41:29 +02:00
HoneyandClaude Opus 4.8 b7c96451d8 feat(android): download notifications everywhere + swipe tabs (NowPlaying, Library)
- Downloads started from global search / Bibliothek now poll via the
  app-lifetime NotificationStore, so they show success/failure like the
  importer and artist page did (was a silent toast only).
- Swipe between tabs, synced with the tab bar, matching Erkunden:
  * Now Playing: Player <-> Lyrics <-> Queue (cover's skip-track swipe
    still wins as a child gesture).
  * Bibliothek: Playlists <-> Alben <-> Künstler <-> Offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:19:20 +02:00
HoneyandClaude Opus 4.8 d11de8a69e feat(android): importer 30s preview + partial-album detection
- Importer: each resolved track gets a play/stop button that plays its
  Deezer/Spotify preview via the app-level preview bar (pauses library
  playback, highlights the row). Parity with the web importer.
- Artist "Unvollständig" now works: Deezer's /artist/{id}/albums omits
  nb_tracks, so owned albums never registered as partial. The artist VM
  now resolves the real total for matched albums via a per-album lookup
  (like web), so the partial section + downloaded/missing split populate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 23:10:28 +02:00
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
HoneyandClaude Opus 4.8 eb55184118 fix(download): remix/live/acoustic/instrumental no longer skipped as duplicate of base track
findExistingTrack normalized titles by stripping ALL bracketed content, so
"Therapy (Remix)" collapsed to "Therapy" and was skipped because the base
track existed. Keep brackets that contain a version marker (remix/live/acoustic/
instrumental/vip/flip/edit/mix/…) so distinct recordings are recognized as new;
still strip noise brackets (feat. X, Official Video). Verified via Deezer that the
remix track carries the marker in its title. Unit-tested both directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 17:06:00 +02:00
HoneyandClaude Opus 4.8 71190488ca feat(scrobble): own timestamped play-log service (stats/Wrapped foundation)
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>
2026-08-06 22:54:25 +02:00
HoneyandClaude Opus 4.8 e69fbaf75e fix(android): back focus lost after tabbing out — re-assert overlay back callbacks on resume
With NowPlaying (or an ABS player) open, backgrounding and resuming the app made the next back
press pop the NavHost invisibly to Home before dismissing the overlay. Returning from the
background re-registers the NavController's own OnBackPressedCallback on top of the dispatcher,
stealing back-priority from the overlays.

AppNavigation now bumps a resumeTick on Lifecycle.Event.ON_RESUME and keys every overlay
DisposableEffect (NowPlaying, ABS player, ABS item, Erkunden overlay) on resumeTick +
currentBackEntry, so each overlay re-adds its callback above the NavController after a resume or
navigation. Needs on-device verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 10:00:04 +02:00
HoneyandClaude Opus 4.8 17bb527e1d fix: download notification opened the wrong song (same-name album)
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>
2026-08-01 02:30:53 +02:00
HoneyandClaude Opus 4.8 1e206e8df3 fix(web): 'Unknown Artist' download from artist page + rename itunes*→deezer* identifiers
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>
2026-08-01 00:31:09 +02:00
HoneyandClaude Opus 4.8 bc87a04da5 docs(changelog): note the 'Unknown Artist' download failure from artist-page album cards
Empty artist name (Deezer /artist/albums omits the artist object) + openDlModal defaulting to
type=track for album cards + downloads.js 'Unknown Artist' literal → yt-dlp searches
'Unknown Artist <album>' and fails. Documented as a Known Bug; no code change yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:19:23 +02:00
HoneyandClaude Opus 4.8 96861bea06 docs(web): unify the artist / album-artist separator hints in the editor
Both fields already accept ; and , (the backend normalizer splits on •·;,). The hints now
read uniformly — "mehrere mit ; oder , trennen" — and explain the actual difference: artist
is split into individual artists, album-artist stays one combined credit. SW ns-v61.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 00:10:04 +02:00
HoneyandClaude Opus 4.8 e4f030c98d fix: artist page resolved "Flay!" to the far more popular "K.FLAY"
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>
2026-07-31 23:53:42 +02:00
HoneyandClaude Opus 4.8 b3cf01dd3b fix: duplicate rows in artist "Top Songs" when a track exists twice in the library
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>
2026-07-31 17:48:02 +02:00
HoneyandClaude Opus 4.8 fbee695aad fix: album shown 2-3x in "In Library" on the artist page
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>
2026-07-31 17:41:33 +02:00
HoneyandClaude Opus 4.8 cc4d3b72d2 chore: bump SW to ns-v57 after reverting the artist-image fallback
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 17:21:31 +02:00
Honey e059c358b3 Revert "feat: Deezer artist photo fallback for local artists with no Navidrome image"
This reverts commit 569af968c5.
2026-07-31 17:20:45 +02:00
HoneyandClaude Opus 4.8 569af968c5 feat: Deezer artist photo fallback for local artists with no Navidrome image
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>
2026-07-31 17:07:53 +02:00
HoneyandClaude Opus 4.8 640e475c51 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>
2026-07-31 17:05:14 +02:00
HoneyandClaude Opus 4.8 5528947a93 feat: fans on local Navidrome artists (android) + harmonize web fan layout
- Android local artist page (ArtistDetailScreen/ArtistViewModel) now resolves the Deezer
  artist (fan-sorted, so same-name collisions pick the popular one) and shows "<n> Fans"
  under the name — parity with the web local artist page.
- Also migrated ArtistViewModel's preview to the app-level PreviewController: the local
  artist page had its OWN uncontrollable MediaPlayer preview, which had the same
  "keeps playing after navigating away" bug. Now every preview uses the global bar.
- Web: the artist-hero fan line was laid out in a row next to the name (flex without
  column direction) and used an odd tinted color — now stacked cleanly under the name in
  a muted white. SW ns-v54.
- CHANGELOG: documented the above + added a Known Bug (Android synced-lyrics auto-scroll
  still doesn't follow playback).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 16:31:55 +02:00
HoneyandClaude Opus 4.8 9167bfaf0e feat(android): global 30s-preview control bar + artist fan count
Preview bar:
- 30s previews used to keep playing with no control once you left the album/artist/Explore
  screen they started on. Moved the preview player from ExploreViewModel to an app-level
  PreviewController, and added a global PreviewBar (above the mini player, on every screen)
  to pause/resume/stop it. Starting real library playback cancels the preview.

Artist fan count (parity with web):
- ItunesItem carries fanCount (Deezer nb_fan); shown on search artist cards and the artist
  page ("559.542 Fans" / "1,2 Mio. Fans"). Combined with the existing nb_fan sort this makes
  same-name collisions (5x "Joji") resolve to the right artist on Android too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 16:24:46 +02:00
HoneyandClaude Opus 4.8 c92ac3d719 fix(web): cleaner German fan-count format (559.542 instead of "559,5 Tsd.")
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>
2026-07-31 16:16:03 +02:00
HoneyandClaude Opus 4.8 065d2f7319 feat: disambiguate same-name artists by Deezer fan count + show fans
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>
2026-07-31 16:11:45 +02:00
HoneyandClaude Opus 4.8 1e69831dd8 feat: move the whole metadata/search stack from iTunes to Deezer (1.3)
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>
2026-07-31 12:48:33 +02:00
HoneyandClaude Opus 4.8 a65613b5a1 fix(web): Deezer artist images + rename the search label
- 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>
2026-07-30 22:01:59 +02:00
HoneyandClaude Opus 4.8 ad433c4dbc fix(backend): Deezer proxy route crashed the whole server on Express 5
`app.get('/api/deezer/*', …)` — a bare "*" — throws "Missing parameter name" in
Express 5's path-to-regexp at startup, so the server never came up and EVERY endpoint
(abs-proxy, romaji-overrides, deezer, …) failed with a CORS/ERR_FAILED in the browser.
Use a RegExp route instead, which registers cleanly on both Express 4 and 5. Verified
against express 5.2.1: bare "*" crashes, the regex is OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 21:54:36 +02:00
HoneyandClaude Opus 4.8 68a9cdd7d1 feat(web): switch the Apple/Erkunden search to Deezer
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>
2026-07-30 20:54:10 +02:00
HoneyandClaude Opus 4.8 ccf704754a fix(web): proxy Apple charts (CORS) + clear message when Apple search is blocked
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>
2026-07-30 20:32:08 +02:00
HoneyandClaude Opus 4.8 9c8b4775ea fix: use the US iTunes store everywhere for consistent artist naming
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>
2026-07-29 10:37:07 +02:00
HoneyandClaude Opus 4.8 d98978a6c7 feat(web): refresh the current view after deleting a song or playlist
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>
2026-07-26 23:27:29 +02:00
HoneyandClaude Opus 4.8 966b318d9e fix(backend): back-fill the release year from iTunes on single-track downloads
embedMetadata already writes date/track/totaltracks/disc, but the single-track path's
"album already known" branch back-filled genre/total/disc/duration/track# from iTunes
and omitted the year — so a source track without a year got no `date` tag written.
Add !meta.year to the fetch trigger and merge genreMeta.year, so the release year is
set at download time like the other fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 23:15:41 +02:00
HoneyandClaude Opus 4.8 72b612f5ff fix(backend): don't tag Western artists with the JP store's katakana album-artist
The iTunes lookup is JP-first (for correct Japanese titles/artwork), but the Japanese
store returns katakana-localized names for Western artists — so DJ Snake's single got
album_artist / folder "DJスネイク • アルーナジョージ" while the title/artist stayed English.

- lookupItunesTrackMeta: take the NAMES (album, album-artist, track, artist) from the US
  store when the search isn't Japanese; keep JP names for actual Japanese songs.
- album & artist download paths: if iTunes' collectionArtistName is Japanese but the
  request artist isn't, keep the request's English name for the tag and folder.

Already-downloaded files aren't rewritten — re-download or fix via the editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:54:15 +02:00
Honey 8cd3e9ac23 Revert "fix(backend): cache the iTunes proxy to avoid Apple rate-limiting the search"
This reverts commit a3c8b1afd1.
2026-07-26 22:42:46 +02:00
HoneyandClaude Opus 4.8 a3c8b1afd1 fix(backend): cache the iTunes proxy to avoid Apple rate-limiting the search
The web Apple-Music search proxies through /api/itunes (browsers can't call
itunes.apple.com — no CORS). The proxy forwarded every request uncached, so heavy
download activity (each download does several album/track lookups) plus client
searches blew past Apple's ~20 req/min/IP limit and the search started returning
nothing. Add a 30-min in-memory cache keyed by the target URL; only genuine 200
responses (with the resultCount signature) are cached, never throttle/error pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:39:21 +02:00
HoneyandClaude Opus 4.8 04a29b684d fix(backend): match official audio by yt-dlp artist metadata, not just channel name
The strict cascade steps required the YouTube channel name to contain the artist, so
official uploads on label channels (BANGTANTV, HYBE LABELS, …) were rejected and the
no-channel fallback grabbed a fan reupload instead (observed: BTS "Pied Piper").

Add yt-dlp's music `artist` field as an OR alternative to the channel check in the
strict steps (two --match-filter args = OR). A metadata dump of "BTS Pied Piper"
confirmed only the official upload has artist=BTS (fan/live/lyric reuploads: NA), and
it's the #1 result — so it now passes strict step 2. channel_is_verified was rejected
as a signal: lyric videos and fan MVs also run on verified channels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:19:09 +02:00
HoneyandClaude Opus 4.8 f097dddc64 feat(web): Enter in the importer field starts the import
Pressing Enter in the importer link input now triggers resolveImporterLink()
(same as the Importieren button). Bump SW cache to ns-v45.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:11:44 +02:00
HoneyandClaude Opus 4.8 1e242796fe feat(web+android): search play queues only the tapped song (radio fills the rest)
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>
2026-07-25 22:00:58 +02:00
HoneyandClaude Opus 4.8 14fd5119f2 fix(android): NowPlaying shows album above artists, matching the web player
Reordered the NowPlaying track info to title → album → artists (was title → artists
→ album), so the Android layout matches the PC/web player.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:09:27 +02:00
HoneyandClaude Opus 4.8 f21174bd2c docs: changelog 1.2 + CLAUDE.md notes (web router, album_artist derivation)
CHANGELOG: 1.2 entry for the post-1.1 work — web forward/back navigation, album
detail album-artist, metadata autofill "&" normalization, Android Erkunden back
re-assert + directional tab transitions, and the backend mixed-artist / album_artist
download fixes.

CLAUDE.md: album/artist download album_artist comes from iTunes collectionArtistName
(not the request/lead-single artist); client-side History router applies nsIdx on
popstate for both directions and must never pushState in the back path.

README left unchanged — the post-1.1 changes are fixes/polish, not new features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:29:40 +02:00
HoneyandClaude Opus 4.8 b1a32f5130 feat(android): directional tab transitions in the bottom nav
Tab switches slid in a fixed direction (and sometimes flipped between push/pop feel,
so the direction felt random). Now the slide follows the target tab's position in the
bottom bar: tapping a tab to the right slides in from the right, a tab to the left
slides in from the left — applied across all four enter/exit/popEnter/popExit
transitions so it's consistent regardless of how Compose classifies the move. Detail
screen push/pop transitions are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:24:42 +02:00
HoneyandClaude Opus 4.8 179e0eb902 fix(web): center NowPlaying star vertically with the whole track-info block
The favorite star lived inside .np-title-row, so it sat pinned to the title line
rather than centered against the title + album + artist block. Move it out to be a
flex sibling of .np-text inside .np-info (align-items:center), so it centers on the
full text block. Bump SW cache to ns-v43.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 01:42:09 +02:00
HoneyandClaude Opus 4.8 e78ce43019 fix(backend): artist download also derives album_artist from iTunes album artist
Apply the same collectionArtistName derivation to the per-album loop of the artist
download, so a collaboration/soundtrack in an artist's discography is not tagged with
the searched artist. Each track still gets its own iTunes artistName; album_artist
stays the iTunes album-level artist (consistent, no Navidrome splitting).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:07:40 +02:00
HoneyandClaude Opus 4.8 48a13fbccb fix(backend): album_artist from iTunes collectionArtistName, not the lead single's artist
The album download tagged album_artist with the request's `artist`, which can be a
single track's performer — the lead single "TAKEDOWN" is by TWICE, so a whole
KPop-Demon-Hunters soundtrack got album_artist=TWICE on every track. Derive the album
artist from the iTunes tracks' collectionArtistName (the album-level artist, identical
across the album) and fall back to the request artist only if iTunes omits it. This is
the documented intent (mainArtist = collectionArtistName when available) and keeps
album_artist consistent so Navidrome doesn't split the album.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 00:02:35 +02:00
HoneyandClaude Opus 4.8 4d44a476a5 docs: note per-track artist search for mixed-artist album downloads
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:42:50 +02:00
HoneyandClaude Opus 4.8 bdf2867bdd fix(backend): album downloads search by per-track artist, not the album artist
downloadAlbumTracks passed each track's artistName as `artist` but no `rawArtist`,
so in ytdlp.js the YTM/YouTube search query and the channel filter used
`mainArtist = albumArtist || artist` — always the album-artist blob. On mixed-artist
soundtracks that matched nothing: e.g. TWICE's "Strategy" and MeloMance's "Love,
Maybe" were searched as "KPop Demon Hunters Cast, HUNTR/X & Saja Boys <title>" with
`channel ~= KPop`, and failed with "Nicht eindeutig gefunden".

Pass rawArtist = track.artistName so the search + channel filter use the actual
performer ("TWICE Strategy", channel ~= TWICE). The album_artist tag still comes from
`albumArtist`, so album grouping is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 23:42:20 +02:00
HoneyandClaude Opus 4.8 e8b82c177f fix(web): album detail shows the album artist, not the first track's artist
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>
2026-07-24 23:28:39 +02:00
HoneyandClaude Opus 4.8 ea98d81146 chore(web): bump service-worker cache to ns-v42
Forces installed PWAs to pull the new build (metadata "&" split + forward
navigation fixes) instead of serving stale cached assets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 22:54:36 +02:00
HoneyandClaude Opus 4.8 79100c5321 fix(web): browser/mouse Forward navigation now works (not only Back)
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>
2026-07-24 22:49:40 +02:00
HoneyandClaude Opus 4.8 3f57e2ae26 fix(web): metadata autofill normalizes trailing " & " in multi-artist lists
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>
2026-07-24 22:41:53 +02:00
HoneyandClaude Opus 4.8 315b8aaacc fix(android): re-assert Erkunden overlay back priority on every navigation
The root-level overlay back callback was keyed only on the overlay state, so after an
album drill-down or tab switch the NavHost could re-register its own back callback
above ours and regain priority — one back press then popped Erkunden → Home instead
of dismissing the overlay ("sometimes" bug). Key the DisposableEffect on
currentBackEntry as well, so the callback is removed and re-added (bumped to the top
of the OnBackPressedDispatcher) after every navigation while an overlay is open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:56:10 +02:00
HoneyandClaude Opus 4.8 c5649ac22f docs: record 1.1 hard-won knowledge in CLAUDE.md; refresh README
CLAUDE.md:
- Download pipeline: leading aesthetic characters in artist names must be trimmed
  before the iTunes lookup (a missed match cascades into a wrong-duration YouTube
  grab), and an album tag is always written (defaulting to Singles) so Navidrome
  and the metadata editor agree.
- New Android section covering the traps this release cost the most time on: a
  BackHandler inside a NavHost destination loses back priority (overlay back must
  be registered at the app root), overlay state must live in the ViewModel rather
  than remember/rememberSaveable, bottomBar and ModalBottomSheet inset handling,
  reusing the Ktor client, global optimistic StarState, and iTunes artists having
  no artwork.

README: offline downloads also cover single songs and work without a server
connection; StarState added to the data/ listing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:15:21 +02:00
HoneyandClaude Opus 4.8 a80ac3deb0 docs: changelog for 1.1
Polish release covering Android navigation and offline resilience, download
accuracy for stylised artist names, and the lrclib lyrics-throttling fix.

Also updates the parity matrix (single-song offline downloads, player bar on all
screens) and corrects the Windows media-tile known bug — the two-element gapless
handoff was kept for skip speed, not reverted as the entry claimed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:13:36 +02:00
HoneyandClaude Opus 4.8 4503bbe54e fix(android): uniform icon-only action buttons in playlist detail
Since the offline-download button was added, four buttons shared one row while Play
and Shuffle also carried text labels — the labels got squeezed out and the buttons
looked compressed. All four are now equal-width and icon-only (play, shuffle, edit,
download); Play stays filled as the primary action, the rest tint accent when
active/done. Icons carry contentDescriptions for accessibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:21:41 +02:00
HoneyandClaude Opus 4.8 9270d31608 fix(android): playbar clears system nav bar on detail screens; Erkunden all-albums survives drill-down
- On detail screens (album/artist/playlist) the MiniPlayer was the bottom-most bar
  with no NavigationBar to absorb the system-navigation inset, so it sat behind the
  phone's gesture/nav bar. Pad the bar with navigationBarsPadding() when the tab bar
  is absent.
- The Erkunden (iTunes) artist "show all albums" view used local remember state, so
  drilling into an album and back reset it to the main artist view. Hoist the open
  section into ExploreViewModel.artistAllAlbums so it survives both nav-route and
  overlay round-trips, and wire its back into the root overlay-back handler.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:21:01 +02:00
HoneyandClaude Opus 4.8 e50b4dc93c fix(android): Erkunden (Apple) overlay back no longer jumps to Home
The iTunes artist / album detail in the Erkunden tab are in-screen overlays on the
NavHost "erkunden" destination, not real routes. Their BackHandler lost back priority
to the NavHost, so one back press popped Erkunden → Home instead of dismissing the
overlay (artist→search, album→artist).

Handle the overlay's back at the app root via OnBackPressedCallback (the same proven
mechanism as the NowPlaying overlay): ExploreScreen reports its current overlay close
action up through onOverlayBackChange, and AppNavigation registers a root-level back
callback for it. Also covers an album opened straight from search (no artist), which
had no back handling at all. Removes the temporary nav-debug strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 11:11:52 +02:00
HoneyandClaude Opus 4.8 b07212fdce chore(android): TEMP nav back-stack debug strip (diagnosing back→home bug)
Shows the live NavController back stack at the top of the screen so we can see
whether detail screens are being pushed correctly or the back button isn't being
handled. To be reverted once the back-navigation issue is diagnosed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 10:10:50 +02:00
HoneyandClaude Opus 4.8 419f80568c feat(android): playbar everywhere, song-search context menu, offline tab loads offline
- MiniPlayer now shows on every screen (album/artist/playlist detail), not just
  top-level tabs; the tab bar stays top-level-only.
- Local library song search results get the 3-dot context menu (add to playlist,
  favorite, queue, download, …) via onShowContextMenu.
- Offline tab is fed by the local Room DB and no longer waits on the online library:
  it stays usable while the library is loading or the server is unreachable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 09:21:02 +02:00
HoneyandClaude Opus 4.8 03046cd631 fix(android): show artist images in Apple-Music search (Erkunden)
iTunes artist entities carry no artwork, so search results rendered as bare grey
circles. Mirror the web PWA: after showing results, fetch one album per artist and
borrow its cover (entity=album, limit=1), applied only if the user is still viewing
the same artist set. Also give ArtistCard a Person-icon fallback instead of an empty
grey dot, and fall back to the artist id for Subsonic (library) artist covers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 10:04:48 +02:00
HoneyandClaude Opus 4.8 03bc736725 fix(android): iTunes download sheet opens fully so the download button is reachable
The "was herunterladen" sheet (track/album/artist + Herunterladen) opened only
half-expanded, hiding the download button behind the system nav bar until you
dragged it up. skipPartiallyExpanded=true opens it fully (button already clears
the nav bar via navigationBarsPadding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:46:44 +02:00
HoneyandClaude Opus 4.8 abb4047966 fix(android): full-height song context sheet + styled playlist-picker input
- Song context sheet now opens fully expanded (skipPartiallyExpanded) so the
  "Herunterladen" option at the bottom is reachable without an extra drag-up.
- The "new playlist" field in the playlist picker now matches the app's search
  bars: rounded 12dp, filled surface, leading icon, NsAccent/NsOnSurface3 borders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 00:42:31 +02:00
HoneyandClaude Opus 4.8 17a28b3011 feat(playlists): show playlist cover in the "add to playlist" picker (web + Android)
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>
2026-07-05 23:21:18 +02:00
HoneyandClaude Opus 4.8 1775ec5b9b fix(search): trim leading aesthetic punctuation in artist lookups
Artists like ".diedlonely" (leading dot, †, ✝, _ …) weren't surfaced by iTunes,
so the download's metadata lookup fell through to a titleOnly match — grabbing a
same-titled track by another artist (e.g. the "In the Bleak Midwinter" Christmas
carol: Holiday genre, 176s single) and steering the YouTube search to the wrong
(slowed) version.

- backend/metadata.js: strip leading non-alphanumeric from the search artist and
  the first-word comparison so the real artist matches (and the right album/genre/
  duration are used).
- android ExploreViewModel: strip leading symbols from the search term so the
  Explore tab finds these artists.

Keeps the original name for display/storage — only the search/compare is trimmed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:34:44 +02:00
HoneyandClaude Opus 4.8 d50e22b62d perf: stop per-cover HttpClient churn (Android) + scroll-arrow reflow (web)
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>
2026-07-02 23:02:11 +02:00
HoneyandClaude Opus 4.8 ef25ac3100 fix(android): use the heart icon for favorite in Now Playing (was a star)
Every other favorite toggle uses Favorite/FavoriteBorder (heart); Now Playing
was the odd one out with Star/StarBorder. Make it a heart everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 22:53:56 +02:00
HoneyandClaude Opus 4.8 ec4d5d0572 fix(android): instant star/favorite feedback everywhere
Tapping the star only fired the API and never updated any UI state, so the
icon didn't change (no feedback). Add a global reactive StarState with
optimistic overrides: the star flips instantly across every screen showing
that song (SongRow lists, Now Playing, context sheet), the API runs in the
background, and the override reverts if the call fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:30:17 +02:00
HoneyandClaude Opus 4.8 8741c2fb88 fix(android): back from album returns to the "all albums" view, not the artist page
The artist screen's "show all albums" overlay state was plain remember(), so
navigating into an album and pressing back reset it — dumping you on the
collapsed artist page instead of the list you came from. Persist which section
is open via rememberSaveable (a String? title) so it survives the round-trip,
and re-derive the overlay entries from the (surviving) categorized albums.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:35:01 +02:00
HoneyandClaude Opus 4.8 445a7bf583 fix(backend): consistent album for no-album downloads + tolerant editor resolve
Two-part fix for "[Unknown Album]" vs "Singles" mismatch that broke the
metadata editor (File not found):

- Download: always write an album tag, defaulting to "Singles" so it matches
  the on-disk folder (album || 'Singles'). No-album tracks no longer show as
  "[Unknown Album]" while living in a "Singles" folder. (Future downloads.)
- Editor resolve: Subsonic getSong returns a *virtual* tag-built path (e.g.
  "[Unknown Album]/Title.mp3" with raw "|"), not the physical file ("Singles/
  Artist - Title_YTPMV.mp3"). Add findExistingTrack as a final fallback, and
  make its normalizer map the sanitized chars (/ \ : * ? " < > | and _) to a
  space so a title tag matches the sanitized filename. (Fixes existing tracks.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 20:33:20 +02:00
HoneyandClaude Opus 4.8 63a7260c94 fix(lyrics): stop hammering lrclib (caused IP throttling → no lyrics)
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>
2026-06-28 21:12:01 +02:00
HoneyandClaude Opus 4.8 ddb9418603 feat(web): send a "now playing" scrobble at track start
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>
2026-06-28 15:58:30 +02:00
HoneyandClaude Opus 4.8 7f7d7740e4 docs: plan artist following + new-release/concert notifications
Follow artists and get notified on new releases (iTunes/MusicBrainz/Last.fm)
and nearby concerts (reusing the existing Ticketmaster integration), surfaced
in the in-app notification bell, with a download action from the alert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:47:01 +02:00
HoneyandClaude Opus 4.8 17a38e9c64 docs: plan Discover Mixes (auto-download, temporary) in backlog
Themed/random discovery mixes (Rock/Charts/Random) that auto-download via the
yt-dlp backend, auto-delete after a configurable retention time, with an option
to keep tracks in the permanent library. Captures sources, retention, keep flow,
and open questions for later implementation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:42:54 +02:00
HoneyandClaude Opus 4.8 7cba39dfaf fix(web): NowPlaying showed a stale cover after rapid skips
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>
2026-06-27 11:32:19 +02:00
HoneyandClaude Opus 4.8 46e252b494 perf(web): instant skips via gapless handoff to a pre-buffered element
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>
2026-06-27 11:17:16 +02:00
Honey be73f717ac Revert "experiment(web): drop separate preload <audio> element (single-element like Navidrome)"
This reverts commit f252586398.
2026-06-26 21:50:06 +02:00
Honey e1ec1f3660 Revert "polish(web): warm next track via fetch to shrink media-tile gap"
This reverts commit 6131eea9ec.
2026-06-26 21:00:44 +02:00
HoneyandClaude Opus 4.8 6131eea9ec polish(web): warm next track via fetch to shrink media-tile gap
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>
2026-06-26 20:50:44 +02:00
HoneyandClaude Opus 4.8 f252586398 experiment(web): drop separate preload <audio> element (single-element like Navidrome)
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>
2026-06-26 18:01:38 +02:00
96 changed files with 6927 additions and 974 deletions

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
# Pre-fills the URLs on the setup screen
NAVIDROME_URL=https://music.yourdomain.com
BACKEND_URL=https://novasonic-api.yourdomain.com
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # play-log / stats service (port 3710)
# Absolute path to your Navidrome music library on the host
MUSIC_PATH=/srv/navidrome/music
+1
View File
@@ -2,3 +2,4 @@
*.log
node_modules/
dist/
scripts/.env.release
+305 -21
View File
@@ -24,7 +24,11 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| Multi-artist navigation | ✅ | ✅ |
| Album detail page | ✅ | ✅ |
| Artist page (Navidrome) | ✅ | ✅ |
| Artist page (iTunes fallback) | ✅ | ❌ |
| Artist page (Deezer fallback) | ✅ | ✅ |
| Global search source | Deezer | Deezer |
| Same-name artist disambiguation | ✅ | ✅ |
| Artist fan count shown | ✅ | ✅ |
| 30s preview control bar | ✅ | ✅ |
| Concert events (Ticketmaster) | ✅ | ✅ |
| Genre browsing | ✅ | ✅ |
| Global search | ✅ | ✅ |
@@ -46,13 +50,293 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
| ABS notification (modern style) | ❌ | ✅ |
| Android Auto | — | ✅ |
| Offline download (albums/playlists) | ❌ | ✅ |
| Offline download (single songs) | ❌ | ✅ |
| Offline playback | ❌ | ✅ |
| Offline detail / manage downloads | ❌ | ✅ |
| Player bar on all screens | ✅ | ✅ |
| Timestamped play-log (own scrobble) | ✅ | ✅ |
| Listening statistics view | ✅ | ✅ |
| Stats cover art (artist/album) | ✅ | ✅ |
| Musik-Match (Head-to-Head) | ✅ | ✅ |
| Yearly Wrapped | 🔜 | 🔜 |
| Romaji / Japanese sort | ✅ | ❌ |
| Settings (Navidrome, ABS, etc.) | ✅ | ✅ |
---
## [2.2] — 2026-08-22
A cleanup-and-parity release: the **Song Importer moves into the Global Search field** (one field,
like Android), concert events become **reliable across web/Android**, several Android gaps close, and
**YouTube downloads are recovered** after they broke on two independent fronts.
### Added
- **In-app updater (Android)** — on launch the app asks the backend (`/api/apk/info`) whether a newer
**versionCode** than this build is available and, if so, offers "Laden & installieren": it downloads
the APK from `/api/apk` (which proxies the fixed Gitea release) into a single cache file, then hands
it to the system installer. No APK pile-up (the cache file is wiped before each download); an update
keeps app data / offline downloads (same package + signature = in-place install). The user still
confirms the system install and grants "unknown apps" once — Android requires that for sideloading.
`publish-apk` now **auto-increments `versionCode`** each build and writes it into the release so the
check is a reliable integer compare (commit the bumped `build.gradle.kts`).
- **Android APK download in Settings (Web)** — a "🤖 Android App" section in Settings offers the
latest APK so co-users self-serve instead of the maintainer hand-delivering builds. The button
appears only when an APK is available and shows version/size/date. The backend serves it
(`GET /api/apk`, metadata at `/api/apk/info`) from one of two sources, in order:
1. **A fixed Gitea release** (tag `apk-latest`) whose `.apk` asset you overwrite each build —
constant ~30 MB, uploadable from anywhere. The backend fetches it **server-side** over the LAN
(`GITEA_URL`/`GITEA_REPO`/`GITEA_TOKEN`/`GITEA_APK_TAG` env), so Gitea can stay internal-only and
the token never reaches the browser. Release *assets* live in Gitea's attachment store, not the
git history, so `clone`/`pull` stay small.
2. **Local fallback** — `docker cp app-release.apk novasonic-backend:/data/novasonic.apk` (optional
`/data/novasonic.version.txt`).
The Android *source* is already in the repo; only build artifacts stay out of git.
- **Unified Global Search field (Web)** — the separate Song-Importer page/sidebar entry is gone. The
one search field now auto-detects: a **link** (Spotify/SoundCloud/YouTube) opens the importer
preview, plain **text** runs a Deezer search. Cleaner left nav, same behaviour as Android.
- **30s preview in the importer (Android)** — each resolved track now has a ▶/⏹ button that plays its
Deezer/Spotify preview clip through the same app-level preview bar as Explore (library playback
pauses; the row highlights while playing). Parity with the web importer.
- **Scroll & search position remembered on Back (Web + Android)** — going into an album/artist and
back now returns you to the same scroll spot with your typed query still there, instead of jumping
to the top. Web: each history entry's scroll (and the open local/header search) is snapshotted and
restored on Back/Forward; a genuine new navigation still starts at the top. Android: the Alben /
Künstler lists and the global/local search-results scroll are hoisted into their ViewModels, so
they survive a drill-in/back for as long as you're in that tab.
- **Partial album — Play / Shuffle + track menu (Android)** — an incomplete album's downloaded
tracks now have **Abspielen** and **Zufall** buttons and a per-row 3-dot menu, so you can still
play/shuffle what you have while the rest downloads.
- **Swipe between tabs (Android)** — a left/right swipe now switches tabs in **Now Playing**
(Player ↔ Lyrics ↔ Queue) and in the **Bibliothek** (Playlists ↔ Alben ↔ Künstler ↔ Offline),
two-way synced with the tab bar, matching the existing Erkunden swipe. On Now Playing the cover's
own swipe (skip track) still wins, since it's a child gesture.
### Fixed
- **Concert events reliability (Web + Android)** — Ticketmaster's free `keyword` search intermittently
returns a partial subset (or none), so back-to-back calls disagreed (web showed 7, a later live
call got only 2, Android sometimes showed 0). The web had a private 30-min cache masking this; the
cache now lives **on the backend** and **prefers the larger recent result**, so a flaky response
can't shrink a good list and every client stays consistent (1 h TTL self-heals).
- **Concert date/time (Web + Android)** — shows the **venue-local** time (Ticketmaster's `localDate`/
`localTime`) instead of the UTC instant, so a 20:00 show no longer displayed as 08:00. Android
parses the naive local datetime (was crashing on zone-less values → missing times).
- **Concert rows uneven (Android)** — rebuilt each row with a fixed-width calendar-style date block
(day big, month/year + time small), so every row is the same height and the divider/venue align.
- **Concert load failures were silent (Android)** — the fetch swallowed errors and showed an empty
list. Errors now surface ("Konzerte konnten nicht geladen werden" + reason), with a request
timeout and non-2xx treated as a real failure.
- **Web importer preview layout** — the SoundCloud track row was missing its grid number-cell, so the
4-column `.gtrack-item` grid squeezed the cover into 28px and the title into 52px (title wrapped
one character per line). Added the missing cell.
- **Header search dropdown covered the sidebar (Web)** — `#header-search-overlay` was
`position:fixed; left:0; right:0` (full viewport), overlapping the left sidebar. It's now anchored
to the search bar (`position:absolute` inside the wrap), so it drops down at the bar's width and the
sidebar stays visible.
- **Android artist albums incomplete** — `lookupArtistAlbums` filtered to `record_type == "album"`,
dropping EPs and singles that Deezer (and the web app) show. Now returns all, sorted album → EP →
single.
- **Japanese/CJK albums vanished from the artist discography (Web + Android)** — the album-name
normaliser stripped everything except `a-z0-9`, so any Japanese/CJK title collapsed to an empty
string. All of an artist's JP singles then normalised identically (`""`), so they de-duplicated
into one entry and even "matched" each other — e.g. inabakumori (稲葉曇) showed only 2 of 9 releases,
the rest hidden behind one album. The normaliser now keeps Unicode letters/numbers (`\p{L}\p{N}`),
so JP titles stay distinct and the full discography (In Library / Unvollständig / Noch nicht
heruntergeladen) shows correctly. Same fix applied to the partial-album track match and the
Deezer-artist name match.
- **Android "Unvollständig" (partial albums) never showed** — Deezer's `/artist/{id}/albums` omits
`nb_tracks`, so an owned album's real total was unknown (0) and it never got flagged incomplete;
every partly-downloaded album looked complete. The artist VM now resolves the real track count for
**matched (owned)** albums via a per-album lookup (like web), so the "Unvollständig" section — with
the downloaded/total badge and the downloaded-vs-missing split screen — populates correctly on both
the library and Deezer artist pages.
- **"Falscher Künstler?" pin button minimised (Web + Android)** — reduced to a small ⚙ icon-only pill
in the artist hero's corner instead of a full-width labelled button.
- **Downloads from search/Bibliothek were silent (Android)** — a download started from the global
search / Bibliothek-Global tab fired only a transient toast and then registered no
success/failure notification (only the importer and artist page did). It now goes through the same
app-lifetime `NotificationStore` poll, so it notifies on completion even after you leave the screen.
- **YouTube downloads (HTTP 403 + "Signature solving failed")** — two things had broken downloads:
1. Recent yt-dlp needs an **external JavaScript runtime** to solve YouTube's signature/nsig
challenge — without it, formats were skipped and the media 403'd. The backend now installs
**Deno**, which yt-dlp uses automatically.
2. A bug in the current yt-dlp **stable** 403'd the `android_vr` client on *every* video
([yt-dlp#17456](https://github.com/yt-dlp/yt-dlp/issues/17456), fixed in nightly). The backend
now pulls yt-dlp from the **nightly** channel (`pip install --pre 'yt-dlp[default]'`).
No cookies, PO-token service, or proxy needed — the earlier 403 that looked like an IP block was
this stable bug.
---
## [2.1] — 2026-08-14
Builds on the 2.0 listening archive with a **head-to-head "Musik-Match"**, brings the **artist pin**
to Android at parity with web, and fixes a batch of same-name-artist and player rough edges.
### Added
- **Musik-Match (Head-to-Head) — Web + Android** — compare two users' listening taste from the
shared play-log. New scrobble routes `GET /scrobble/users` (partner picker) and
`GET /scrobble/compare?with=` — the match score is the **cosine of the two users' artist-play
vectors** (0–100). Shows shared artists (mirrored bars), shared tracks, and each side's unique top
artists. Opened from Settings ("🎧 Musik-Match"). Only aggregates are exposed, never one user's raw
history.
- **Artist pin on Android (parity with web)** — read AND write the shared server-side pins
(`/api/artist-overrides`) that fix same-name-artist collisions. A "⚙ Falscher Künstler?/Gepinnt"
pill on the artist hero opens a picker (Deezer search + a **paste-a-Deezer-link/id** route, the
reliable way when `/search/artist` can't surface a low-profile same-name artist); when pinned, the
page enriches straight from the pinned Deezer id (albums / top tracks / fans / image). Pins set on
either platform apply on both.
- **Swipe between Erkunden sub-tabs (Android)** — a left/right swipe switches Bibliothek ↔ Global,
two-way synced with the tab bar (HorizontalPager).
- **Similar Artists with profile pictures (Web)** — the artist page's "Ähnliche Künstler" now render
as avatar chips, matching the Android app (previously plain text).
### Fixed
- **A failed direct-link download reported success** — the direct-URL path returned `success` even
when no audio was produced (e.g. a YouTube 403), so the job showed "Fertig!" and the UI (web +
Android) said the track was added. It now reports failure with a clear message.
- **Artist pin — search & similar artists (Web + Android)** — a pinned artist now shows the correct
Deezer photo in library search (not the wrong same-name person's), and its "Similar Artists" use
Deezer's **related** for the pinned artist instead of Last.fm's list (which was keyed to the
merged/wrong Navidrome artist and went "weird").
- **Android player — seek broke after seamless track transitions** — skipping through
already-buffered tracks left `durationMs` stuck at 0 (the player stayed READY, so `syncState()`
wasn't re-called and `ctrl.duration` was still `TIME_UNSET`), so the seek bar snapped playback to
the start (or to a stale offset). `durationMs` now self-heals via the ticker + a fallback to the
song's known duration. No app restart needed.
- **Erkunden swipe polish (Android)** — pre-compose the neighbour page so the first swipe doesn't
hitch, drive the tab indicator off the pager so it moves with the swipe, and keep the Top-Alben
charts anchored to the top (were vertically centered).
- **Web** — the pin picker crashed (`esc()` on a numeric Deezer id) and the pin button moved to the
banner's top-right; pinning by a pasted Deezer link/id was added.
- **Android** — Settings spacing: the gap now sits below "Musik-Match", grouping it with "Meine
Statistiken".
---
## [2.0] — 2026-08-07
Big change: **NovaSonic gets its own self-hosted, multi-user listening archive.** Navidrome only stores a play *count* and the *last* play date — no timestamped history — so per-day/month/year stats and a yearly "Wrapped" were impossible. Koito and Spindle (the off-the-shelf options) are effectively single-user. Since NovaSonic *is* the player, it now records every counted play into an **isolated scrobble service** with its own database, and shows the results in a **statistics view on web and Android**. This runs **in addition to** the existing Navidrome/Last.fm scrobble — it never replaces it.
### Added
- **New `novasonic-scrobble` service** — a small, isolated container (`node:22-slim` + built-in `node:sqlite`, own volume) that owns the play-log. Kept separate from the download backend on purpose: the backend is heavy (yt-dlp/ffmpeg) and restarts regularly, while an archive needs uptime + data integrity. Routes:
- `POST /scrobble/plays` — batch ingest, **idempotent by uuid** (safe offline-retry)
- `GET /scrobble/history` — raw chronological log (the archive)
- `GET /scrobble/stats` — totals, top artists/albums/tracks, day/month/year series, 24h listening clock (on-the-fly `GROUP BY strftime`, local-tz bucketing)
- `DELETE /scrobble/plays?user=` — purge one user's rows (test cleanup)
- `GET /scrobble/health` — liveness
- **Multi-user**: every request carries the same Subsonic token creds the app already uses; the service verifies them via Navidrome `ping.view` (cached), so the stored `user` is real, not spoofable. No new login.
- **Play capture (Web + Android)** — fires at the *existing* counted-play moment (respects the user's `scrobbleMode`, consistent with Last.fm), with an **offline-safe queue** (localStorage on web, a JSON file on Android) flushed as an idempotent batch on start and when connectivity returns.
- **Statistics view (Web + Android)** — opened from Settings ("📊 Meine Statistiken"): totals tiles (**Plays** + listening time), a day/month/year play series, top artists/tracks/albums, and a 24h listening clock, with period tabs (Alles / Dieses Jahr / Letzte 30 Tage).
- **Cover art in stats (Web + Android)** — top artists show a round artist image, tracks/albums a square cover, resolved client-side from Navidrome (the scrobble service stays cover-agnostic).
- **`SCROBBLE_URL`** environment variable (frontend) + a **Scrobble URL** field with a test button in Settings on both platforms.
### Changed
- **Backend** — base image bumped `node:20-slim` → **`node:22-slim`** (Node 20 reaches end-of-life April 2026; enables the built-in `node:sqlite`).
### Fixed
- **Downloads** — remix / live / acoustic / instrumental tracks were skipped as duplicates of the base track: duplicate detection stripped *all* bracketed content, collapsing "Song (Remix)" → "Song". Version-marker brackets are now preserved, so a distinct recording downloads (noise brackets like `(feat. X)` / `(Official Video)` are still stripped). Verified against Deezer + unit-tested both directions.
- **UI** — dragging the mouse near a field edge (Global search, importer) started selecting the surrounding titles/labels instead of feeling like an app. `user-select` is now disabled on the `#app` shell; real text fields and lyrics stay selectable.
### Notes
- The scrobble subdomain **must be excluded from any SSO / forward-auth gateway** (Authelia/Authentik/etc.), exactly like the download backend — it self-authenticates via `ping.view`, and a gateway in front 302-redirects the browser's API calls to the login portal (breaks `fetch`).
- Deploy: add `SCROBBLE_URL` to `.env`, `docker compose up -d novasonic-scrobble`, and route port **3710** through your reverse proxy.
- Planned next: a yearly **Wrapped** (`/scrobble/wrapped` + story page) and an optional **ListenBrainz relay** — not in this release.
---
## [1.3] — 2026-07-31
Big change: **the entire metadata/search stack moved from Apple's iTunes to Deezer** — on web, backend downloads *and* Android. Apple's iTunes `/search` returns **403 from server/datacenter IPs**, which had broken global search, charts, artist/album/single downloads and the metadata-editor autofill in production. Deezer is free, needs no key, allows more requests, and returns explicit versions + real artist images + track durations. Web and Android now share the **same** source, so an artist resolves to one consistent name everywhere.
### Changed
- **Web + Android + Backend** — Global search, album/artist lookups, artist top tracks and the "Neu & Beliebt" charts all run on **Deezer** (`api.deezer.com`). Results are mapped to the existing item shape, so the UI is unchanged; artist cards now show real photos (iTunes artist entities had none)
- **Backend (download)** — Single-track, album and **artist** downloads, plus the metadata-editor autofill, now enrich from Deezer:
- `backend/lib/deezer.js` — new module: `lookupTrackMeta`, `fetchAlbumTracks`, `searchAlbumTracks`, `fetchArtistAlbums`
- Album/artist `album_artist` still comes from the album-level artist (Deezer `album.artist`), keeping soundtracks from splitting
- Genre comes from the Deezer album (one album-level genre); per-track artist is preserved for mixed-artist albums
- The autofill endpoint moved `/api/itunes/lookup` → **`/api/meta/lookup`**
- **Web + Android** — All user-visible "iTunes" / "Apple Music" labels are now "Deezer" (search badge, charts badge, preview bar, editor autofill button, artist "populär" heading)
- **Backend** — Removed the dead iTunes `/search` proxy and the Apple charts RSS proxy; the Deezer proxy (`/api/deezer/*`) now also serves charts and the editor lookup
- **Web** — Renamed the leftover `itunes*` frontend identifiers to `deezer*` (`itunesSearch` → `deezerSearch`, `itunesLookup*` → `deezerLookup*`, `itunesData`/`itunesArtistId` → `deezerData`/`deezerArtistId`, the `itunes-album-detail` route/DOM ids, the `badge-itunes` CSS class, …). Purely internal — no behaviour change; only the factual "Apple's iTunes /search 403" comments keep the old name
### Added
- **Web + Android** — **Same-name artist disambiguation**: Deezer's artist search isn't ranked by popularity, so a name collision (5 artists named "Joji") returned a niche one first — the artist page then loaded the wrong discography and owned songs didn't show as downloaded. Artists are now sorted by Deezer fan count, and on a local artist page (web) with a real collision the pick is tie-broken by **song overlap** with your library (each artist has different songs). The **fan count is shown** on search artist cards and on both the Deezer *and* local Navidrome artist pages (web + Android)
- **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
- **Android** — After **tabbing out and back into the app** with NowPlaying (or an ABS player) open, pressing back first popped the NavHost **invisibly to Home** and only *then* dismissed the overlay — the focus was lost. Returning from the background re-registers the NavController's own back callback on top of the dispatcher, stealing back-priority from the overlays. The overlay back-callbacks are now re-asserted on `ON_RESUME` (and on navigation), so back closes the overlay first again
- **Web + Android** — A finished-download **notification led to the wrong song**: it resolved the album by name via `search3`, and when the exact artist match wasn't found (e.g. the new album isn't scanned yet), it fell back to a same-named album by a **different** artist (or the first result). Several artists share an album title ("I LIKE IT!"), so tapping the notification opened a completely different song. Now the artist must match when known (fuzzy for multi-artist/album-artist drift); otherwise it shows "wird evtl. gerade gescannt" instead of opening a wrong album
- **Web** — Downloading a not-yet-owned album from an artist page searched YouTube for "**Unknown Artist** &lt;album&gt;" and failed. Deezer's `/artist/{id}/albums` omits the `artist` object, so the discography cards had an empty artist name → the modal fell back to the literal `"Unknown Artist"`. Fixed by backfilling the known artist name in `deezerLookupArtistAlbums` and defaulting the download modal to **Album** for album cards (was always **Track**)
- **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
---
## [1.2] — 2026-07-25
Follow-up to 1.1: web history navigation, correct download tagging for mixed-artist albums, and UI polish.
### Added
- **Android** — Directional bottom-nav tab transitions: the slide follows the tapped tab's position (a tab to the right slides in from the right, one to the left from the left) instead of a fixed, sometimes-random-feeling direction. Applied across all four enter/exit/pop transitions; detail-screen push/pop is unchanged
- **Web** — Pressing **Enter** in the importer link field starts the import (same as the Importieren button)
### Fixed
- **Web** — Browser / mouse **Forward** navigation now works (previously only Back did): `popstate` applies the target history entry (`nsIdx`) for both directions, and the back handler no longer `pushState()`s over the browser's forward stack. In-app back buttons route through `history.back()` so the browser and app stacks stay in sync
- **Web** — Deleting a song or playlist refreshes the current view immediately (drops the cached list) instead of leaving the removed item visible for up to ~3 minutes
- **Web** — Album detail header showed the **first track's** artist (e.g. "TWICE" on a soundtrack) instead of the album artist; it now reads the album's own album-artist fields from `getAlbum` (matches the Android app)
- **Web** — Metadata autofill (iTunes / MusicBrainz) normalizes the trailing `" & "` in a multi-artist credit ("A, B & C" → "A; B; C"), but only when the value is already a list, so band names like "Simon & Garfunkel" stay intact
- **Android** — Erkunden (Apple) overlay back no longer *sometimes* jumped to Home: the root-level back callback is re-asserted on every navigation, so it can't lose back-priority to the NavHost after an album drill-down or tab switch
- **Backend (download)** — Mixed-artist albums search **per track**: the YTM/YouTube query and channel filter use each track's own performer, so soundtrack tracks by other artists (TWICE's "Strategy", MeloMance's "Love, Maybe") are found instead of failing with "Nicht eindeutig gefunden"
- **Backend (download)** — `album_artist` is derived from iTunes' album-level `collectionArtistName` (identical across the album) instead of the request's artist — which could be the lead single's performer, so a whole KPop soundtrack was tagged `album_artist = TWICE`. Applies to album and artist downloads; each track still gets its own per-track artist tag
- **Backend (download)** — Official audio is matched by yt-dlp's `artist` metadata, not just the channel name, so uploads on label channels (BANGTANTV, HYBE, "… - Topic") win over fan reuploads — e.g. BTS "Pied Piper" now grabs the official track. (`channel_is_verified` was rejected as a signal: lyric videos and fan MVs are also on verified channels)
- **Backend (download)** — Western artists are no longer tagged with the Japanese store's katakana album-artist ("DJスネイク" for DJ Snake): the JP-first iTunes lookup now keeps Japanese names only for Japanese songs and uses the US/English names otherwise (affects the `album_artist` tag and the on-disk folder)
- **Backend (download)** — Release year is back-filled from iTunes on single-track downloads where the source omitted it (the year tag was previously left empty)
### Changed
- **Web + Android** — Playing a song from search results queues only that song; Radio mode (if on) fills the rest — closer to Spotify. Album / playlist play still queues the whole list
- **Android** — NowPlaying track info is ordered title → album → artists, matching the web player
- **Web** — NowPlaying favorite star is vertically centered with the whole title / album / artist block instead of pinned to the title line
---
## [1.1] — 2026-07-19
Polish release: Android navigation and offline resilience, download accuracy for artists with stylised names, and a fix for the lyrics outage caused by lrclib rate-limiting.
### Added
- **Android** — Mini player is now visible on every screen (album, artist, playlist detail), not only the top-level tabs; the tab bar stays top-level-only
- **Android** — Song search results (local library) got the 3-dot context menu: play, insert next / queue, add to playlist, favorite, go to album, song info, download
- **Android** — Artist images in the Apple-Music (Erkunden) search: iTunes artist entities carry no artwork at all, so each artist now borrows the cover of one of their albums; a person icon replaces the empty grey circle where none is found
- **Web + Android** — Playlist cover art is shown in the "add to playlist" picker, so playlists are identifiable at a glance
- **Web** — "Now playing" scrobble is sent at track start (`scrobble?submission=false`), so Navidrome shows the current track while it plays instead of only on completion
### Fixed
- **Web + Android** — Lyrics stopped loading entirely: a burst of requests (prefetch + the 4-request cascade + fast skipping) got the client IP throttled by lrclib, so every lookup hung until it timed out. Fixed with per-song in-flight de-duplication, a prefetch debounced to 1.5 s after track start, a descriptive User-Agent, and a longer 8 s timeout
- **Android** — App lag and stutter while scrolling and switching tabs: `coverArtUrl()` and `streamUrl()` built a fresh Ktor `HttpClient` for every cover-art request and never closed it — heavy allocation and GC churn. Both now reuse the cached client
- **Android** — Star / favorite gave no visual feedback: a global `StarState` now flips the icon optimistically the moment it's tapped (everywhere it appears) and rolls back if the API call fails
- **Android** — Back from an Erkunden (Apple) artist or album jumped straight to Home. Those screens are in-screen overlays on the `erkunden` destination and their `BackHandler` lost back priority to the NavHost, which popped the whole tab. Back is handled at the app root now: album → artist, artist → search
- **Android** — The Erkunden artist's "show all albums" view was lost when drilling into an album and back (it lived in local `remember` state); the open section now lives in the ViewModel and survives both route and overlay round-trips
- **Android** — Album back-navigation from a local artist returned to the collapsed artist page instead of the "all albums" view it was opened from
- **Android** — The mini player sat behind the phone's gesture/navigation bar on detail screens, where there is no tab bar to absorb the system inset
- **Android** — The download sheet for new songs and the song context sheet opened only half-expanded, hiding the download button behind the system navigation bar until dragged up; both open fully now
- **Android** — The Offline tab was blocked by the online library's loading spinner, so downloads were unreachable while the library loaded or the server was unreachable. It reads from the local Room DB and now loads independently
- **Backend (download)** — Artist lookups ignored leading aesthetic punctuation (e.g. `.diedlonely`), so the iTunes match missed, fell through to a title-only match, and grabbed an unrelated same-titled track — whose wrong duration then also misled the YouTube search filter. Leading non-alphanumeric characters are trimmed before lookup
- **Backend (metadata)** — The metadata editor showed "[Unknown Album]" where the library shows "Singles": tracks downloaded without an album had no album tag written at all. The album tag now always gets a value (defaulting to `Singles`), and file resolution falls back to `findExistingTrack` for stale or rebased paths
- **Web** — Now Playing showed a stale cover after rapid skips
- **Web** — Home row scroll arrows caused layout thrash while scrolling: arrow visibility is a rAF-throttled class toggle now, with `getBoundingClientRect()` only on resize / content change
### Changed
- **Android** — Favorite is a heart everywhere; the Now Playing screen still used a star
- **Android** — Playlist detail actions are uniform, equal-width and icon-only (play / shuffle / edit / download). Since the offline-download button was added, four buttons shared one row and the Play/Shuffle labels were being clipped away
- **Android** — The "add to playlist" search field matches the styling of the other search bars
- **Web** — Track switching uses a gapless hand-off between two `<audio>` elements, making skips effectively instant (it does not fix the Windows media-tile flicker — see Known Bugs)
---
## [1.0] — 2026-06-25
🎉 **First stable release.** NovaSonic is feature-complete across web (PWA) and Android: streaming, lyrics, artist pages, playlists, importer/downloads, Audiobookshelf, and — new in this release — full offline downloads on Android. Further work lands as point releases.
@@ -115,25 +399,6 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
5. MBIDs not set — still missing; not the primary splitting cause
---
- **Backend** — Japanese tracks now stored with original Japanese names in folders, filenames, and all main tags (title/artist/album/album\_artist); Romaji goes only into sort tags (TITLESORT / ARTISTSORT / ALBUMARTISTSORT / ALBUMSORT) for sorting and search in Navidrome. Previously, romaji was applied to filenames and tags too, causing name drift, duplicate misses, and wrong iTunes resolutions (e.g. "Hisaishi Yuzuru" instead of "Joe Hisaishi")
- **Backend** — Metadata editor: files and album/artist folders are renamed using the original name typed in the form (may be Japanese), not romaji; sort tags still receive the romaji conversion
- **Backend** — `album_artist` tag in downloaded tracks now consistently uses `mainArtist` (the iTunes-resolved album artist) rather than the raw per-track artist. This prevents Navidrome from splitting one album into multiple when track artists vary (e.g. compilations)
- **Backend (download)** — No-duration cascade now ends after step 4 (same Mittelweg philosophy as the with-duration branch): no title-only or filter-free fallback grabs that pull wrong covers (observed: Yorushika 車窓 → Piano Echoes Shasou)
- **Backend (download)** — `--max-filesize 30M` added to all yt-dlp calls; Mittelweg/no-duration steps also cap at 2× expected duration — a 28-min video can no longer be saved as a ~52 MB mp3
### Fixed
- **Backend (download)** — Duplicate detection now matches case-insensitively (DJ OKAWARI vs DJ Okawari) and across all album folders under the artist folder, comparing normalized title without track prefix or release/variant suffix — prevents re-downloading songs whose filename drifted between runs
- **Backend (download)** — iTunes `collectionArtistName` (album artist) is now captured and passed through the download pipeline as the `album_artist` tag; fixes compilation albums showing "Various Artists" for some tracks and the individual track artist for others
- **Metadata editor (autofill)** — iTunes button now fills all available fields: title (`trackName`), artist (`artistName`), album-artist (`collectionArtistName`), album, genre, year, track #, disc #, total tracks, cover
- **Metadata editor (autofill)** — MusicBrainz button now fills album-artist (from release artist-credit) and disc # in addition to the existing fields
### Investigated (Point 4 — album splitting)
Primary causes identified:
1. Missing `album_artist` tag / inconsistent value across tracks — fixed above (mainArtist + collectionArtistName)
2. `tagAlbumArtist` was using `rawArtist` (per-track Spotify name) instead of `mainArtist` — fixed
3. Album name comes from iTunes `collectionName` — consistent within a collection, no fix needed
4. Year from iTunes `releaseDate` — album-level date, consistent within a collection
5. MBIDs not set — still missing; not the primary splitting cause
## [0.8] — 2026-06-19
@@ -302,14 +567,33 @@ Primary causes identified:
## Planned / Backlog
### Features
- **Web + Android** — **Share by link** — share a song/album to another user of the same instance via one `https://…` link that deep-links into the **PWA on desktop** and the **Android app on phone** (like Spotify/YouTube links). Android **App Links** (`intent-filter` + hosted `/.well-known/assetlinks.json` with the *release* signing SHA-256) open the app directly; PWA `launch_handler`/`handle_links` capture links on desktop. Link format leaning query param `?song=<id>` (no server SPA-rewrite; PWA reads it on load). Carries only the song id — no secrets; playback uses the recipient's own Navidrome creds. Songs first → albums/artists/playlists. Web: `navigator.share()`; Android: `ACTION_SEND`.
- **Web + Android** — **Yearly Wrapped** — a yearly story (`/scrobble/wrapped` + web story page + Android pager), built on the existing stats aggregations.
- **Web + Android** — **Household leaderboard (>2 users)** — "who listened most this month" + top artists across all users; the broader multi-user version of the already-built 2-user Musik-Match. Needs wider cross-user reads (admin/household mode, or an aggregate endpoint exposing per-user totals only, never raw histories).
- **Backend (optional)** — **ListenBrainz relay** — mirror counted plays to ListenBrainz.
- **Web + Android** — **Discover Mixes (auto-download, temporary)** — themed/random mixes for discovering unknown music, Spotify-style (e.g. "Rock", "Charts", "Random Discovery"). Idea:
- Generate a mix from a theme/genre/charts (sources: Last.fm / ListenBrainz similar + iTunes charts), then **auto-download** the tracks via the yt-dlp backend so they play locally.
- **Temporary by default:** tracks **auto-delete after a configurable time** (e.g. 7 days) so the library doesn't fill up with stuff you didn't keep.
- **Keep what you like:** per-track (or whole-mix) option to **promote into the permanent library** instead of letting it expire.
- Goal: completely random/unfamiliar music you don't already know — like Spotify's curated/discovery playlists, but self-hosted.
- Open questions: where downloaded-but-temporary tracks live vs. the main library (separate folder + retention job), how Navidrome should/shouldn't index them, and dedupe against tracks you already own.
- **Web + Android** — **Artist following + new-release & concert notifications** — follow artists and get notified when they drop something new or play nearby. Idea:
- A **follow list** of artists (stored per user, e.g. in app settings / a small backend table).
- A **"Following" view** listing all artists you follow (grid of artist photos), as an entry point to their pages — a dedicated overview so you can see and manage who you follow, plus a follow/unfollow toggle on the artist page.
- **New-release alerts:** periodic check for new albums/singles by followed artists (sources: iTunes lookup / MusicBrainz / Last.fm), surfaced in the existing in-app notification bell (and optionally push).
- **Concert alerts:** reuse the existing **Ticketmaster** events integration to notify when a followed artist has nearby/upcoming shows.
- Ties into the importer: a "download this new release" action straight from the notification.
- **Android** — Playlist drag-and-drop / sort order
- **Android** — Artist page iTunes fallback (for artists not in Navidrome)
- **Android** — Romaji / Japanese sort support
- **Web + Android** — Backend settings UI rework (move, reorganize)
- **Web + Android** — yt-dlp backend update / maintenance
- **Web + Andoird** - Safe last played Queue, where it stopped and show last played song loaded (rn only on web kinda with the last song played loaded)
- **Android** - Andorid Auto Sync last played and queue too with Phone then, rn its not really syncing or safin what last played on Android Auto, also check if selecting a song to play on Android Auto works that song is getting played and in a playlist it will play the queue a this point then like it works already in Android and Web
### Known Bugs
- **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. (A two-element gapless handoff was tried and reverted — switching elements still ends/starts the OS session.)
- **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.
- **Backend (metadata/sorting)** — MBIDs are not embedded in downloaded tracks; Navidrome cannot use them for authoritative album grouping (low priority — album splitting is mostly solved via consistent `album_artist`).
+131 -3
View File
@@ -20,10 +20,16 @@ plus a **Node.js/Express backend** that downloads tracks via yt-dlp and proxies
## Layout
- `src/` — web PWA (vanilla ES modules). State in `src/state.js` (the `S` object);
Subsonic/iTunes API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
Subsonic/Deezer API in `src/api.js`; views in `src/views/`, UI in `src/ui/`.
- **Client-side router** (`src/views/navigation.js`, History API): `navigate()` pushStates `{nsIdx}`
only on genuine new navigation; `popstate` applies the target entry's `nsIdx` for **both** back and
forward. Never `pushState` inside the `popstate`/back path — it wipes the browser's forward stack
(that was the "forward doesn't work" bug). In-app back buttons call `history.back()` (one path).
- `backend/novasonic-server.js` — Express API (downloads, metadata edit/delete, events/ABS proxy).
- `backend/lib/` — `ytdlp.js` (download + cascade + ffmpeg tagging), `downloader.js`,
`metadata.js` (iTunes lookup, file resolution helpers), `config.js`, `jobs.js`, `romaji.js`.
`deezer.js` (Deezer metadata: track/album/artist lookups + matching),
`metadata.js` (artwork, file resolution helpers, duplicate detection), `config.js`, `jobs.js`, `romaji.js`.
- `scrobble/scrobble-server.js` — **separate** listening-stats service (own container + DB), see below.
- `android/` — native Kotlin app (separate; backend-dependent web features like the
metadata editor are **not** in the Android app).
@@ -42,6 +48,19 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
## Download pipeline (hard-won knowledge)
- Downloads are **yt-dlp only** (the old squid.wtf/Qobuz backend was removed).
- **Metadata source = Deezer, not iTunes** (as of 1.3). Apple's iTunes `/search` returns **403 from
server/datacenter IPs**, which broke search + downloads + charts + editor autofill in production.
All metadata now comes from Deezer (`backend/lib/deezer.js` server-side; `_deezerBase()` in
`src/api.js`; Android `ItunesClient` is Deezer-backed). Free, no key, more requests, returns
explicit versions + real artist images + durations. Deezer generally gives one consistent name per
artist, so the old JP/US-store inconsistency (Western artists as katakana "DJスネイク" on some tracks,
kanji on others) is gone. Titles come from the source, so a Japanese title the user picked stays
Japanese; only Deezer-derived fields (album\_artist, genre, year, artwork) follow Deezer. The
Deezer results are mapped to the legacy "ItunesItem" shape, so the UI + download flow are unchanged.
`lookupTrackMeta`/`fetchAlbumTracks`/`searchAlbumTracks`/`fetchArtistAlbums` replace the old
`lookupItunesTrackMeta`/`fetchItunesTracks`/`searchItunesAlbumTracks`. Editor autofill endpoint:
`/api/meta/lookup` (was `/api/itunes/lookup`). Matching logic (`pickTrackMatch`) is unit-tested in
`backend/test/deezer.test.js` (`npm test`).
- **File naming:** `{NN - }Artist - Title.ext` with the release suffix stripped from the
title (no `- Single`/`- EP`). The **album folder keeps the full album name**. Original names
are used for folder, filename, and all main tags (artist/title/album/album\_artist) — even
@@ -49,6 +68,13 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
- **Search cascade** (`ytdlp.js`): YTM search first (`web_music` player client, Songs filter
param), then regular YouTube; each candidate filtered by duration (±10%, min 10s),
title keyword, artist channel, and variant exclusions (remix/cover/sped/…).
- **Official audio via `artist` metadata (not just channel).** The strict steps accept a candidate
if the channel name matches the artist **OR** yt-dlp's music `artist` field does. YouTube "Art
Tracks" (label-provided official audio) carry `artist=<Artist>` even when the channel is
`BANGTANTV`/`HYBE LABELS`/… (no artist name). Confirmed on "BTS Pied Piper": only the official
upload had `artist=BTS`; every fan/live/lyric reupload had `artist=NA`. `channel_is_verified` was
rejected as a signal — lyric videos and fan MVs also run on verified channels. Implemented as two
`--match-filter` args (yt-dlp OR's them) in `mf()`.
- **"Mittelweg" rule** (with AND without duration): when nothing matches in the filtered steps,
relax only the duration constraint while **still requiring the artist channel**. If nothing
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
@@ -56,9 +82,40 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
- **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
(`.diedlonely`, `†…`). `deezer.js` strips leading non-alphanumerics (`stripLeadSym`) before the
Deezer lookup and in `firstWord`. Without it the lookup misses, falls through to a title-only
match, and returns an unrelated same-titled track — whose wrong duration then *also* misleads the
YouTube duration filter (observed: a 96 s track resolved to a 176 s slowed version). The same trim
runs client-side in Android's `ExploreViewModel.search()`.
- **Mixed-artist albums search per track.** `downloadAlbumTracks` passes `rawArtist = track.artistName`
so the YTM/YouTube search query and the channel filter use each track's *own* performer, not the
album artist. `ytdlp.js` derives both from `rawArtist || mainArtist`, and `mainArtist = albumArtist`,
so without this every track on a soundtrack searched the album-artist blob + its channel filter and
matched nothing (e.g. "Strategy" = TWICE, "Love, Maybe" = MeloMance failed). The `album_artist` tag
still uses `albumArtist`, so album grouping is unaffected.
- **Album/artist download `album_artist`** comes from the Deezer tracks' `collectionArtistName`
(= the album-level `album.artist`, identical across the album), derived in `novasonic-server.js` —
**not** the request's `artist`, which can be the lead single's performer (a KPop soundtrack got
tagged `album_artist=TWICE` from track 1 "TAKEDOWN"). Falls back to the request artist only when
Deezer omits it.
- **Always write an `album` tag.** `ytdlp.js` defaults it to `Singles` when no album is known.
Previously no tag was written at all, so Navidrome showed its own "Singles" fallback while the
metadata editor showed "[Unknown Album]" — the two never matched and file resolution failed.
- **Duplicate detection** (`metadata.js findExistingTrack`): searches case-insensitively across
all album folders under the matching artist folder; compares normalized artist+title ignoring
track prefix and release/variant suffix. Handles DJ OKAWARI vs DJ Okawari and "- Single" drift.
- **Single-track metadata resolver = `backend/lib/trackmeta.js`** (Deezer → MusicBrainz → clean
fallback). Deezer's `pickTrackMatch` requires an **artist correspondence** (equal, or one name
contained in the other after stripping non-alphanumerics) — the old blind *title-only* fallback
grabbed a same-titled song by a different artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and
applied its wrong album/cover/year. On no match it returns `{}`; then `musicbrainz.js` (rate-limited
~1/s, `User-Agent` required, Cover Art Archive for covers, **same** artist discipline) is tried; if
that also misses, the download's built-in clean fallback runs (**album_artist = song artist, album =
"Singles", cover = source thumbnail, year/genre left untouched**). Only the three *single-track*
call sites use `trackmeta`; the album batch stays Deezer-only (avoids MB's rate limit). The clean
fallback always existed but was unreachable while the match was too greedy. Matching is unit-tested
(`deezer.test.js`, `musicbrainz.test.js`).
- **Known limitation:** yt-dlp's YTM/YouTube *search* can't surface very obscure Art Tracks by
ambiguously-named artists (e.g. "MINA") — the browser finds them, yt-dlp's API extraction
doesn't (confirmed across params/clients/deno). For those, the **direct link in the importer**
@@ -67,9 +124,47 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
or a stale stem. `metadata.js` rebases the path and falls back to recursive/stem matching
(tolerant of release-suffix mismatch). A ghost Navidrome entry needs a full library rescan.
- **Album splitting:** prevented by consistent `album_artist` tag. All tracks in a download
share the same `mainArtist` value (= iTunes `collectionArtistName` when available, else
share the same `mainArtist` value (= Deezer `collectionArtistName` when available, else
`albumArtist` param, else track artist). Never use `rawArtist` for `album_artist`.
## Listening stats / scrobble system (v2.0)
- **Why separate:** Navidrome stores only a play *count* + *last* play date (no timestamps), so
per-day/month/year stats and a "Wrapped" are impossible from it. NovaSonic *is* the player, so it
records every counted play into its **own** timestamped, multi-user log.
- **Own container `novasonic-scrobble`** (`scrobble/scrobble-server.js`) — deliberately isolated from
the download backend: the backend is heavy (yt-dlp/ffmpeg) and restarts often; an archive needs
uptime + data integrity. Runs on `node:22-slim` with the **built-in `node:sqlite`** (run with
`--experimental-sqlite`; no native build). DB `/data/novasonic-plays.db` in its own volume.
- **Routes:** `POST /scrobble/plays` (batch, **idempotent by uuid**), `GET /scrobble/history`,
`GET /scrobble/stats` (totals, top artists/albums/tracks, day/month/year series, 24h clock),
`DELETE /scrobble/plays?user=` (test cleanup), `GET /scrobble/health`.
- **Multi-user auth:** every request carries the same Subsonic token creds (`u`, `t`, `s`) the app
already uses; the service verifies them via Navidrome **`ping.view`** (cached 5 min) → the stored
`user` is real, not spoofable. `node:sqlite` **throws on extra named params**, so each query gets
exactly the params its SQL references. `from` defaults to all-time (do **not** route `0` through
`_toEpochSec`, which maps `0`→now).
- **Denormalized on purpose:** title/artist/album are stored per play so the archive survives even if
the song is later deleted from Navidrome. Deleting a song does **not** touch the play-log.
- **Capture point (Web + Android):** `logPlay()` fires at the **existing** counted-scrobble moment
(respects the user's `scrobbleMode`, consistent with Last.fm) — **not** a separate 50%/4-min rule.
It runs **in addition to** the Navidrome/Last.fm scrobble, never replacing it. Web:
`src/player/scrobbleLog.js` (localStorage queue) + `subsonicAuthParams()` in `api.js`. Android:
`data/PlayLogClient.kt` (JSON-file queue, **one reused Ktor client**) + `repo.logPlay/flushPlayLog`
+ hook in `PlayerController.scheduleScrobble`. Both queue offline and flush as an **idempotent**
batch (server dedups by uuid) on start / when connectivity returns.
- **Stats view:** Web `src/ui/stats.js` (overlay from Settings), Android `ui/screen/StatsScreen.kt` +
`StatsViewModel.kt` (from a Settings button). **Cover art is resolved client-side** from Navidrome
(`coverUrl`/`coverArtUrl` via `artistId`/`albumId`/`songId`) — the scrobble service stays
cover-agnostic; the stats endpoint just returns the IDs.
- **Config:** `SCROBBLE_URL` env → `window.NS_SCROBBLE_URL` (entrypoint) → `S.cfg.scrobbleUrl` (web) /
`scrobbleUrl` pref (Android), mirroring `backendUrl`. Port **3710**.
- **Deploy gotcha:** the scrobble subdomain **must be excluded from any SSO / forward-auth gateway**
(like the download backend) — it self-authenticates via `ping.view`, and a gateway in front
302-redirects the browser's API calls to the login portal (breaks `fetch` → "nicht erreichbar").
- **Planned:** `/scrobble/wrapped` (yearly story) + optional ListenBrainz relay. See
`docs/scrobble-plan.md`.
## Android offline system
- **Storage:** `context.filesDir/offline/audio/{songId}.{ext}` and `.../covers/{coverArtId}.jpg` — private internal storage, not accessible via file manager.
@@ -79,6 +174,39 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
- **Playback:** `MusicRepository.streamUrl()` checks `offlineRepo.localAudioPath(id)` first; if the file exists it returns a `file://` URI. This applies to every play path — album, playlist, Offline tab — no special offline mode needed.
- **Offline tab:** Library → tab 2 (OFFLINE). Driven by `OfflineViewModel`. Play button on a completed group calls `offlineVm.songsForGroup()` (JOIN query → `DownloadedTrackEntity.toSong()`) then `player.playSongs()`.
## Android navigation, state & insets (hard-won)
- **A `BackHandler` inside a NavHost destination loses back priority to the NavHost.** The Erkunden
(Apple) artist/album are in-screen *overlays* on the `erkunden` destination, not routes — their
`BackHandler` never fired, so one back press popped the whole tab to Home. Fix: the overlay reports
its close action upward via `onOverlayBackChange`, and `AppNavigation` registers a **root-level**
`OnBackPressedCallback` for it (the same mechanism the NowPlaying overlay always used successfully).
Real nav routes (`album/{id}`, `artist/{id}`) are unaffected — `popBackStack()` is correct there.
- **Re-assert root-level back callbacks on `ON_RESUME`, not just on navigation.** Returning from the
background re-registers the NavController's own `OnBackPressedCallback` on **top** of the dispatcher,
which steals back-priority from the NowPlaying/ABS/Erkunden overlays — back then pops the NavHost
(invisibly, to Home) before dismissing the visible overlay. Fix in `AppNavigation`: a `resumeTick`
(`LifecycleEventEffect(ON_RESUME)`) plus `currentBackEntry` are keys on every overlay's
`DisposableEffect`, so each re-adds (bumps itself above the NavController) after a resume or a nav.
- **Overlay state belongs in the ViewModel, not `remember`.** Anything an overlay shows (e.g. the
artist's "all albums" section) is destroyed when the user drills into an album, because the overlay
leaves composition. `rememberSaveable` does **not** save you — it only survives for real nav
destinations. Hoist it into the ViewModel (see `ExploreViewModel.artistAllAlbums`).
- **`bottomBar` insets:** the tab `NavigationBar` absorbs the system-navigation inset. Detail screens
have no tab bar, so the MiniPlayer needs `navigationBarsPadding()` itself — otherwise it sits behind
the phone's gesture/navigation bar.
- **`ModalBottomSheet`:** use `rememberModalBottomSheetState(skipPartiallyExpanded = true)` *and*
`navigationBarsPadding()` on the content, or the bottom-most action (e.g. "Herunterladen") opens
hidden behind the navigation bar and needs an extra drag-up.
- **Reuse the Ktor client.** `MusicRepository.coverArtUrl()/streamUrl()` must go through
`ClientHolder.get(creds)`. Constructing a `SubsonicClient` per call spins up (and never closes) an
`HttpClient` for *every cover art request* — this was the cause of visible scroll/tab-switch lag.
- **Star/favorite is optimistic and global** via `data/StarState.kt`. Toggle through it so every icon
(song rows, NowPlaying, context sheet) flips instantly and rolls back if the API call fails.
- **Deezer artists carry a real photo** (`picture_*`), so artist search cards show it directly — no
album-cover borrowing needed. (The old iTunes artist entities had no `artworkUrl100`, which is why
the `entity=album&limit=1` album-cover fallback exists; it now only runs if a picture is missing.)
## UI / references
When referencing files in chat, use clickable markdown links, not backticks:
+27 -10
View File
@@ -10,11 +10,12 @@ Built with Vite + vanilla ES modules (web) and Jetpack Compose + Media3/ExoPlaye
- **Library** — Stream from Navidrome / Subsonic; browse artists, albums, playlists, genres, starred, and random; search; star + context menu on every row.
- **Playback** — Queue with shuffle, repeat, and radio mode (endless similar songs); gapless-style preload; lock-screen / notification controls; Navidrome scrobbling.
- **Lyrics** — Synced and plain lyrics via lrclib.net, auto-fetched and cached per session.
- **Artist pages** — Hero card, Last.fm bio / similar / top songs, Ticketmaster events, and iTunes cross-reference showing owned vs. missing tracks.
- **Artist pages** — Hero card, bio / similar artists / top songs, Ticketmaster events, and a Deezer cross-reference showing owned vs. missing tracks. Same-name artists can be **pinned** (Deezer search or a pasted Deezer link/id) to fix the wrong-discography and wrong-photo problem; a pinned artist's similar list comes from Deezer.
- **Playlists** — Create, rename, delete, add/remove tracks, and upload a cover (Web + Android).
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with iTunes metadata enrichment and duplicate detection.
- **Importer & downloads** — Paste a Spotify / YouTube / YouTube Music / SoundCloud link to download via yt-dlp, with Deezer metadata enrichment and duplicate detection.
- **Metadata editor** (web) — Edit tags + cover art; files are renamed/moved and Navidrome is rescanned on save.
- **Offline** (Android) — Download albums/playlists to private storage; an Offline tab to play and manage them; downloaded tracks play from disk on every screen.
- **Offline** (Android) — Download albums, playlists, or single songs to private storage; an Offline tab to play and manage them (works without a server connection); downloaded tracks play from disk on every screen.
- **Listening stats** — A separate, self-hosted scrobble service records **timestamped plays** (multi-user; Navidrome only keeps a play *count*). A statistics view (Web + Android, from Settings) shows totals, top artists/tracks/albums with cover art, a day/month/year play series, and a 24h listening clock, plus a **Musik-Match** that compares two users' taste. Runs *in addition to* Navidrome/Last.fm scrobbling.
- **Audiobookshelf** — Browse and stream audiobooks with chapter navigation, speed control, sleep timer, and progress sync.
Web is an installable PWA (themes, German UI); Android is native Jetpack Compose + Media3/ExoPlayer with a mini player, media/ABS notifications, and Android Auto. See [CHANGELOG.md](CHANGELOG.md) for the full web/Android parity matrix.
@@ -28,7 +29,8 @@ NovaSonic/
├── src/
│ ├── player/
│ │ ├── player.js # Playback, queue, transport, media session, preload
│ │ └── nowplaying.js # Now Playing overlay + synced lyrics
│ │ ├── nowplaying.js # Now Playing overlay + synced lyrics
│ │ └── scrobbleLog.js # Play-log capture + offline queue → scrobble service
│ ├── views/
│ │ ├── detail.js # Artist / album detail pages
│ │ ├── views.js # Navigation, page loaders, search
@@ -48,24 +50,28 @@ NovaSonic/
│ │ ├── metadata.js # Metadata editor
│ │ ├── modals.js # Song info, events modals
│ │ ├── notify.js # Toast notifications
│ │ ├── stats.js # Listening statistics view (overlay)
│ │ └── settings.js # Settings page
│ ├── state.js # Shared mutable state (S object)
│ ├── utils.js # Helpers (md5, esc, formatDur, toast)
│ ├── api.js # Subsonic API, iTunes API, cover art
│ ├── api.js # Subsonic API, Deezer API, cover art
│ └── main.js # App init, audio wiring, window globals
├── backend/
│ ├── novasonic-server.js # Express API: downloads, metadata, events proxy, ABS proxy
│ └── lib/
│ ├── config.js # Env var → config object
│ ├── jobs.js # In-memory download job store
│ ├── metadata.js # iTunes lookup, artwork, filename helpers
│ ├── deezer.js # Deezer metadata: track/album/artist lookups
│ ├── metadata.js # Artwork, filename helpers, duplicate detection
│ ├── ytdlp.js # yt-dlp download + ffmpeg metadata embedding
│ ├── downloader.js # Unified downloadTrack / downloadAlbumTracks
│ └── romaji.js # Japanese → romaji search index
├── scrobble/ # Isolated listening-stats service (own container + DB)
│ └── scrobble-server.js # node:sqlite play-log: ingest / history / stats / health
├── android/ # Native Android app (Jetpack Compose + Media3)
│ └── app/src/main/kotlin/com/novasonic/app/
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient
│ ├── data/ # PrefsStore (DataStore), MusicRepository
│ ├── api/ # SubsonicClient, AbsClient, LrcLibClient, ItunesClient (Deezer-backed)
│ ├── data/ # PrefsStore (DataStore), MusicRepository, StarState
│ ├── offline/ # Room DB, DAO, OfflineRepository, DownloadWorker (offline downloads)
│ ├── player/ # PlayerController, MusicService (Media3)
│ ├── ui/
@@ -83,7 +89,7 @@ NovaSonic/
├── Dockerfile
├── docker-compose.yml
├── nginx.conf
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL at runtime
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
```
---
@@ -103,6 +109,7 @@ Edit `.env`:
```env
NAVIDROME_URL=https://music.yourdomain.com
BACKEND_URL=https://novasonic-api.yourdomain.com
SCROBBLE_URL=https://novasonic-scrobble.yourdomain.com # listening-stats service
MUSIC_PATH=/srv/navidrome/music
@@ -119,7 +126,7 @@ TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
docker compose up -d
```
Frontend on port 8080, backend on port 3700. Route both through your reverse proxy.
Frontend on port 8080, backend on port 3700, scrobble service on port 3710. Route each through your reverse proxy. **Important:** the scrobble subdomain must *not* sit behind an SSO / forward-auth gateway — it authenticates itself via Navidrome (`ping.view`), and a gateway in front redirects the app's API calls to a login page.
### 3. First launch
@@ -127,6 +134,7 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
2. Optionally add a Last.fm API key in Settings for bios and recommendations
3. Optionally connect Audiobookshelf in Settings
4. Optionally add the NovaSonic backend URL in Settings to enable downloads
5. Optionally add the NovaSonic scrobble URL in Settings to record listening stats
---
@@ -138,6 +146,7 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
|---|---|
| `NAVIDROME_URL` | Pre-fills the Navidrome server URL on the login screen |
| `BACKEND_URL` | Pre-fills the download backend URL on the login screen |
| `SCROBBLE_URL` | Pre-fills the listening-stats (scrobble) service URL on the login screen |
### Backend container
@@ -150,6 +159,14 @@ Frontend on port 8080, backend on port 3700. Route both through your reverse pro
| `MAX_PARALLEL` | `2` | Max concurrent download jobs |
| `TICKETMASTER_API_KEY` | — | Free key from [developer.ticketmaster.com](https://developer.ticketmaster.com) |
### Scrobble container
| Variable | Default | Description |
|---|---|---|
| `PORT` | `3710` | Port the scrobble service listens on |
| `DATA_PATH` | `/data` | Where the play-log SQLite DB is stored (own volume) |
| `NAVIDROME_URL` | — | Used to verify user credentials via `ping.view` (multi-user auth) |
---
## Development
+13
View File
@@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- In-app updater: install a downloaded APK (user still confirms the install) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:name=".NovaSonicApp"
@@ -44,6 +46,17 @@
</service>
<!-- FileProvider — hands the downloaded update APK to the system installer -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
<!-- Android Auto -->
<meta-data
android:name="com.google.android.gms.car.application"
@@ -5,6 +5,11 @@ import com.novasonic.app.data.MusicRepository
import com.novasonic.app.offline.OfflineRepository
import com.novasonic.app.player.AbsPlayerController
import com.novasonic.app.player.PlayerController
import com.novasonic.app.player.PreviewController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class NovaSonicApp : Application() {
@@ -16,6 +21,8 @@ class NovaSonicApp : Application() {
private set
lateinit var offlineRepository: OfflineRepository
private set
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
val previewController: PreviewController = PreviewController()
override fun onCreate() {
super.onCreate()
@@ -24,6 +31,10 @@ class NovaSonicApp : Application() {
playerController = PlayerController(applicationContext, repository)
playerController.connect()
absPlayerController = AbsPlayerController(applicationContext)
// Flush any plays queued while offline to the scrobble service.
CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {
runCatching { repository.flushPlayLog() }
}
}
}
@@ -0,0 +1,59 @@
package com.novasonic.app.api
import com.novasonic.app.api.model.ArtistOverride
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
/**
* Reads/writes the shared artist-pin overrides on the download backend
* (GET/POST /api/artist-overrides, DELETE /api/artist-overrides/:id).
* The pins fix same-name artist collisions; see [ArtistOverride].
*/
class ArtistOverrideClient(private val backendUrl: String) {
private val http = HttpClient(Android) {
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; coerceInputValues = true }) }
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
}
private fun base() = backendUrl.trimEnd('/')
@Serializable
private data class SetReq(
val ndArtistId: String,
val deezerArtistId: String,
val name: String,
val image: String,
val fanCount: Long?,
)
/** Full map { ndArtistId -> override }. */
suspend fun loadAll(): Map<String, ArtistOverride> =
http.get("${base()}/api/artist-overrides").body()
/** Upsert a pin. Returns true on 2xx. */
suspend fun set(ndArtistId: String, ov: ArtistOverride): Boolean {
val resp: HttpResponse = http.post("${base()}/api/artist-overrides") {
contentType(ContentType.Application.Json)
setBody(SetReq(ndArtistId, ov.deezerArtistId, ov.name, ov.image, ov.fanCount))
}
return resp.status.isSuccess()
}
/** Remove a pin. Returns true on 2xx. */
suspend fun remove(ndArtistId: String): Boolean {
val resp: HttpResponse = http.delete("${base()}/api/artist-overrides/${ndArtistId.encodeURLPath()}")
return resp.status.isSuccess()
}
fun close() = http.close()
}
@@ -4,6 +4,7 @@ import com.novasonic.app.api.model.ConcertEvent
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
@@ -12,6 +13,10 @@ import kotlinx.serialization.json.Json
object EventsClient {
private val http = HttpClient(Android) {
// Surface non-2xx as a real exception (e.g. an SSO redirect / HTML login page) instead of
// letting .body() fail obscurely — the ViewModel turns it into a visible "load failed".
expectSuccess = true
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 10_000 }
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
}
@@ -8,64 +8,107 @@ import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.ContentType
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.serialization.json.Json
/**
* Metadata/search client. Backed by **Deezer** (api.deezer.com) — Apple's iTunes /search
* returns 403 from datacenter IPs, and Deezer keeps the phone + web PWA on the *same* source
* (identical naming + explicit versions). Deezer responses are mapped to the legacy ItunesItem
* shape (see DeezerModels.kt) so the screens/view models did not change. The public method names
* are kept for the same reason.
*/
object ItunesClient {
private const val BASE = "https://api.deezer.com"
private val http = HttpClient(Android) {
install(ContentNegotiation) {
// iTunes returns text/javascript instead of application/json — accept any content type
json(Json { ignoreUnknownKeys = true; coerceInputValues = true }, contentType = ContentType.Any)
}
}
/**
* Combined search. Deezer splits by type, so `entity` "album" hits /search/album; anything else
* runs tracks + albums + artists in parallel and merges them (the view model filters by wrapperType).
*/
suspend fun search(
query: String,
entity: String = "musicTrack,album,musicArtist",
limit: Int = 30,
country: String = "us",
): ItunesSearchResponse = http.get("https://itunes.apple.com/search") {
parameter("term", query)
parameter("media", "music")
parameter("entity", entity)
parameter("limit", limit)
parameter("country", country)
): ItunesSearchResponse {
if (entity == "album") {
val albums: DzList<DzAlbum> = http.get("$BASE/search/album") {
parameter("q", query); parameter("limit", limit)
}.body()
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", collectionId)
parameter("entity", "song")
}.body()
// First result is the album itself; rest are tracks
return resp.results.filter { it.kind == "song" }
return ItunesSearchResponse(results = albums.data.map { it.toItunesItem() })
}
return coroutineScope {
val tracksD = async { runCatching { http.get("$BASE/search") { parameter("q", query); parameter("limit", limit) }.body<DzList<DzTrack>>().data }.getOrDefault(emptyList()) }
val albumsD = async { runCatching { http.get("$BASE/search/album") { parameter("q", query); parameter("limit", 12) }.body<DzList<DzAlbum>>().data }.getOrDefault(emptyList()) }
val artistsD = async { runCatching { http.get("$BASE/search/artist") { parameter("q", query); parameter("limit", 8) }.body<DzList<DzArtist>>().data }.getOrDefault(emptyList()) }
// Deezer doesn't rank artists by popularity — sort by nb_fan so a name collision
// (5× "Joji") resolves to the artist the user means (real Joji has 559k fans, not 62).
val results = artistsD.await().sortedByDescending { it.nb_fan ?: 0 }.map { it.toItunesItem() } +
albumsD.await().map { it.toItunesItem() } +
tracksD.await().map { it.toItunesItem() }
ItunesSearchResponse(results = results)
}
}
/** All albums for an iTunes artist */
/** All tracks of a Deezer album, in the ItunesItem song shape. */
suspend fun lookupAlbum(collectionId: Long): List<ItunesItem> {
val album: DzAlbum = http.get("$BASE/album/$collectionId").body()
return album.tracks?.data.orEmpty().mapIndexed { i, t -> t.toAlbumTrackItem(album, i) }
}
/** A Deezer artist's full discography — albums, EPs AND singles, matching what Deezer's own
* artist page shows. Ordered albums → EPs → singles (Deezer returns newest-first within each). */
suspend fun lookupArtistAlbums(artistId: Long): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId)
parameter("entity", "album")
val albums: DzList<DzAlbum> = http.get("$BASE/artist/$artistId/albums") {
parameter("limit", 200)
}.body()
return resp.results.filter { it.wrapperType == "collection" }
val rank = { rt: String? -> when (rt) { "album" -> 0; "ep" -> 1; "single" -> 2; else -> 3 } }
return albums.data.sortedBy { rank(it.record_type) }.map { it.toItunesItem() }
}
/** Worldwide top tracks for an iTunes artist */
/**
* A single Deezer artist by id (name / picture / fan count), as an ItunesItem. The reliable
* route when /search/artist can't surface a low-profile same-name artist — used by the
* artist-pin "paste Deezer link/id" flow. Null on a bad id / error.
*/
suspend fun lookupArtist(artistId: Long): ItunesItem? {
val a = runCatching { http.get("$BASE/artist/$artistId").body<DzArtist>() }.getOrNull()
return if (a?.id != null) a.toItunesItem() else null
}
/** Top tracks for a Deezer artist. */
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
val resp: ItunesSearchResponse = http.get("https://itunes.apple.com/lookup") {
parameter("id", artistId)
parameter("entity", "song") // "musicTrack" only works on /search — lookup needs "song"
parameter("limit", limit + 1)
parameter("explicit", "Yes")
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
parameter("limit", limit)
}.body()
return resp.results.filter { it.wrapperType == "track" && it.kind == "song" }.take(limit)
return tracks.data.map { it.toItunesItem() }
}
/** Top albums RSS feed for a given country */
/**
* Related Deezer artists (real pictures + fan counts). Used for the "Similar Artists" row when
* a pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome
* artist, so it goes "weird"; Deezer's related are correct for the pinned artist.
*/
suspend fun lookupRelatedArtists(artistId: Long, limit: Int = 12): List<ItunesItem> {
val artists: DzList<DzArtist> = http.get("$BASE/artist/$artistId/related") {
parameter("limit", limit)
}.body()
return artists.data.map { it.toItunesItem() }
}
/** "Neu & Beliebt" — Deezer's global album chart, mapped to the RSS-entry shape. */
suspend fun topAlbums(country: String = "us", limit: Int = 25): List<ItunesRssEntry> {
val resp: ItunesRssResponse =
http.get("https://itunes.apple.com/$country/rss/topalbums/limit=$limit/json").body()
return resp.feed?.entry ?: emptyList()
val albums: DzList<DzAlbum> = http.get("$BASE/chart/0/albums") {
parameter("limit", limit)
}.body()
return albums.data.map { it.toRssEntry() }
}
}
@@ -10,6 +10,8 @@ import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -55,9 +57,13 @@ object LrcLibClient {
connectTimeoutMillis = 5_000
socketTimeoutMillis = 8_000
}
// lrclib asks clients to identify themselves; a proper UA also avoids being throttled.
install(UserAgent) { agent = "NovaSonic/1.0 (+https://gitea.honeyy.duckdns.org/Docker/NovaSonic)" }
}
private val cache = HashMap<String, LrcLibResponse?>()
// Per-song lock so a prefetch + an on-open load don't both fire the whole cascade (→ throttling).
private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
suspend fun getLyrics(
artist: String,
@@ -67,6 +73,18 @@ object LrcLibClient {
): LrcLibResponse? {
val cacheKey = "$artist|$title"
if (cache.containsKey(cacheKey)) return cache[cacheKey]
val mutex = locks.getOrPut(cacheKey) { Mutex() }
return mutex.withLock { getLyricsInner(artist, title, album, durationSecs, cacheKey) }
}
private suspend fun getLyricsInner(
artist: String,
title: String,
album: String?,
durationSecs: Int?,
cacheKey: String,
): LrcLibResponse? {
if (cache.containsKey(cacheKey)) return cache[cacheKey] // populated while we waited on the lock
// 1a. lrclib exact match with duration
val exact = runCatching {
@@ -0,0 +1,17 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
/**
* A manual "pin" mapping a local Navidrome artist → a specific Deezer artist, fixing same-name
* collisions where NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
* Stored server-side (shared across users) on the download backend at /api/artist-overrides, keyed
* by the Navidrome artist id. Mirrors the web PWA's override shape exactly.
*/
@Serializable
data class ArtistOverride(
val deezerArtistId: String = "",
val name: String = "",
val image: String = "",
val fanCount: Long? = null,
)
@@ -0,0 +1,131 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
// ─── DEEZER API MODELS ────────────────────────────────────────────────────────
// Apple's iTunes /search returns 403 from server IPs, so the Explore/search source is
// Deezer (free, no key, returns explicit flags + artist images + durations). Deezer JSON is
// mapped to the existing ItunesItem/ItunesRssEntry shapes so the screens + view models are
// unchanged. See ItunesClient for the endpoint calls.
@Serializable
data class DzList<T>(val data: List<T> = emptyList())
@Serializable
data class DzArtist(
val id: Long? = null,
val name: String? = null,
val picture_medium: String? = null,
val picture_big: String? = null,
val picture_xl: String? = null,
val nb_fan: Long? = null, // popularity — disambiguates same-name artists (5× "Joji")
)
@Serializable
data class DzGenre(val name: String? = null)
@Serializable
data class DzGenres(val data: List<DzGenre> = emptyList())
@Serializable
data class DzAlbum(
val id: Long? = null,
val title: String? = null,
val cover_medium: String? = null,
val cover_big: String? = null,
val cover_xl: String? = null,
val release_date: String? = null,
val nb_tracks: Int? = null,
val record_type: String? = null,
val genres: DzGenres? = null,
val artist: DzArtist? = null,
val tracks: DzList<DzTrack>? = null,
val position: Int? = null,
)
@Serializable
data class DzTrack(
val id: Long? = null,
val title: String? = null,
val duration: Long? = null,
val track_position: Int? = null,
val disk_number: Int? = null,
val explicit_lyrics: Boolean = false,
val preview: String? = null,
val artist: DzArtist? = null,
val album: DzAlbum? = null,
)
// ─── MAPPERS to the legacy ItunesItem shape ───────────────────────────────────
private fun DzAlbum.coverXl(): String? = cover_xl ?: cover_big ?: cover_medium
private fun DzArtist.pictureXl(): String? = picture_xl ?: picture_big ?: picture_medium
/** A Deezer search-result track → ItunesItem (album cover for artwork). */
fun DzTrack.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "track",
kind = "song",
trackId = id,
artistId = artist?.id,
collectionId = album?.id,
artistName = artist?.name,
collectionName = album?.title,
trackName = title,
artworkUrl100 = album?.coverXl() ?: artist?.pictureXl(),
previewUrl = preview,
trackTimeMillis = duration?.let { it * 1000 },
trackNumber = track_position,
)
/** A Deezer album → ItunesItem collection. */
fun DzAlbum.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "collection",
kind = "album",
collectionId = id,
artistId = artist?.id,
artistName = artist?.name,
collectionName = title,
artworkUrl100 = coverXl(),
trackCount = nb_tracks,
releaseDate = release_date,
primaryGenreName = genres?.data?.firstOrNull()?.name,
)
/** A Deezer artist → ItunesItem artist (carries a real picture, unlike iTunes). */
fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
wrapperType = "artist",
artistId = id,
artistName = name,
artworkUrl100 = pictureXl(),
fanCount = nb_fan,
)
/**
* A Deezer album track (from /album/{id}) → ItunesItem song. The embedded tracks carry no
* track_position, so the album ordering supplies the number; genre/release come from the album.
*/
fun DzTrack.toAlbumTrackItem(album: DzAlbum, index: Int): ItunesItem = ItunesItem(
wrapperType = "track",
kind = "song",
trackId = id,
artistId = artist?.id ?: album.artist?.id,
collectionId = album.id,
artistName = artist?.name ?: album.artist?.name,
collectionName = album.title,
trackName = title,
artworkUrl100 = album.coverXl(),
previewUrl = preview,
trackTimeMillis = duration?.let { it * 1000 },
trackNumber = track_position ?: (index + 1),
trackCount = album.nb_tracks,
releaseDate = album.release_date,
primaryGenreName = album.genres?.data?.firstOrNull()?.name,
)
/** A Deezer chart album → ItunesRssEntry (keeps the charts UI unchanged). */
fun DzAlbum.toRssEntry(): ItunesRssEntry = ItunesRssEntry(
name = ItunesLabel(title),
artist = ItunesLabel(artist?.name),
image = listOfNotNull(coverXl()?.let { ItunesLabel(it) }),
id = ItunesId(attributes = ItunesIdAttributes(imId = id?.toString())),
)
@@ -27,8 +27,18 @@ data class ItunesItem(
val trackCount: Int? = null,
val trackNumber: Int? = null,
val collectionPrice: Double? = null,
val fanCount: Long? = null, // Deezer nb_fan — disambiguates same-name artists + shown in UI
)
// German fan-count format: 559542 → "559.542", 1200000 → "1,2 Mio." (matches the web PWA).
fun fmtFanCount(n: Long): String {
if (n >= 1_000_000) {
val s = String.format(java.util.Locale.GERMAN, "%.1f", n / 1_000_000.0).removeSuffix(",0")
return "$s Mio."
}
return String.format(java.util.Locale.GERMAN, "%,d", n)
}
// iTunes RSS feed response
@Serializable
data class ItunesRssResponse(
@@ -0,0 +1,128 @@
package com.novasonic.app.api.model
import kotlinx.serialization.Serializable
// Mirrors the JSON from the scrobble service GET /scrobble/stats.
@Serializable
data class ScrobbleStats(
val firstPlayedAt: Long? = null,
val totalPlays: Int = 0,
val totalSeconds: Long = 0,
val topArtists: List<StatArtist> = emptyList(),
val topAlbums: List<StatAlbum> = emptyList(),
val topTracks: List<StatTrack> = emptyList(),
val topGenres: List<StatGenre> = emptyList(),
val series: List<StatBucket> = emptyList(),
val byHour: List<StatHour> = emptyList(),
)
@Serializable
data class StatGenre(
val name: String? = null,
val plays: Int = 0,
)
@Serializable
data class StatArtist(
val name: String? = null,
val artistId: String? = null,
val plays: Int = 0,
val seconds: Long = 0,
)
@Serializable
data class StatAlbum(
val name: String? = null,
val albumId: String? = null,
val artist: String? = null,
val coverArt: String? = null,
val plays: Int = 0,
)
@Serializable
data class StatTrack(
val title: String? = null,
val artist: String? = null,
val album: String? = null,
val albumId: String? = null,
val artistId: String? = null,
val coverArt: String? = null,
val songId: String? = null,
val plays: Int = 0,
)
@Serializable
data class StatBucket(
val bucket: String = "",
val plays: Int = 0,
val seconds: Long = 0,
)
@Serializable
data class StatHour(
val hour: Int = 0,
val plays: Int = 0,
)
// ─── Head-to-Head "Musik-Match" ─────────────────────────────────────────────
// GET /scrobble/users — distinct users in the log, for the partner picker.
@Serializable
data class ScrobbleUsersResponse(
val me: String = "",
val users: List<ScrobbleUser> = emptyList(),
)
@Serializable
data class ScrobbleUser(
val user: String = "",
val plays: Int = 0,
val lastPlayedAt: Long? = null,
)
// GET /scrobble/compare?with= — taste comparison of the authed user (A) vs another (B).
@Serializable
data class CompareResult(
val userA: String = "",
val userB: String = "",
val matchScore: Int = 0,
val totalsA: CompareTotals = CompareTotals(),
val totalsB: CompareTotals = CompareTotals(),
val sharedArtists: List<SharedArtist> = emptyList(),
val sharedTracks: List<SharedTrack> = emptyList(),
val onlyA: List<UniqueArtist> = emptyList(),
val onlyB: List<UniqueArtist> = emptyList(),
)
@Serializable
data class CompareTotals(
val plays: Int = 0,
val seconds: Long = 0,
val artists: Int = 0,
)
@Serializable
data class SharedArtist(
val name: String? = null,
val artistId: String? = null,
val playsA: Int = 0,
val playsB: Int = 0,
)
@Serializable
data class SharedTrack(
val songId: String? = null,
val title: String? = null,
val artist: String? = null,
val artistId: String? = null,
val albumId: String? = null,
val coverArt: String? = null,
val playsA: Int = 0,
val playsB: Int = 0,
)
@Serializable
data class UniqueArtist(
val name: String? = null,
val artistId: String? = null,
val plays: Int = 0,
)
@@ -0,0 +1,96 @@
package com.novasonic.app.data
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.novasonic.app.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* In-app updater for the sideloaded APK. Talks ONLY to the backend the app already knows:
* GET {backend}/api/apk/info → is there a newer versionCode than this build?
* GET {backend}/api/apk → download it (the backend proxies the fixed Gitea release)
* The user still confirms the system install (Android requires that for sideloaded APKs).
*
* No APK hoarding: the download goes to a single file under cacheDir/updates that is wiped before
* each download. An update (same package + signature) is an in-place install, so app data / offline
* downloads survive — provided the signing key stays stable across builds.
*/
object AppUpdater {
@Serializable
data class ApkInfo(
val available: Boolean = false,
val version: String? = null,
val versionCode: Int? = null,
val size: Long? = null,
)
private val json = Json { ignoreUnknownKeys = true }
private val _available = MutableStateFlow<ApkInfo?>(null)
val available: StateFlow<ApkInfo?> = _available
private val _downloading = MutableStateFlow(false)
val downloading: StateFlow<Boolean> = _downloading
// Versions the user waved away this session — don't nag again until app restart.
@Volatile private var dismissedCode: Int = -1
suspend fun check(backendUrl: String) {
if (backendUrl.isBlank()) return
val info = withContext(Dispatchers.IO) {
runCatching {
val c = (URL("${backendUrl.trimEnd('/')}/api/apk/info").openConnection() as HttpURLConnection)
.apply { connectTimeout = 8000; readTimeout = 8000 }
c.inputStream.bufferedReader().use { it.readText() }
.let { json.decodeFromString<ApkInfo>(it) }
}.getOrNull()
} ?: return
val vc = info.versionCode ?: return // no reliable integer → don't prompt
if (info.available && vc > BuildConfig.VERSION_CODE && vc != dismissedCode) {
_available.value = info
}
}
fun dismiss() {
dismissedCode = _available.value?.versionCode ?: dismissedCode
_available.value = null
}
suspend fun downloadAndInstall(context: Context, backendUrl: String, onError: (String) -> Unit) {
if (_downloading.value || backendUrl.isBlank()) return
_downloading.value = true
try {
val apk = withContext(Dispatchers.IO) {
val dir = File(context.cacheDir, "updates").apply { mkdirs(); listFiles()?.forEach { it.delete() } }
val out = File(dir, "NovaSonic-update.apk")
val c = (URL("${backendUrl.trimEnd('/')}/api/apk").openConnection() as HttpURLConnection)
.apply { connectTimeout = 15000; readTimeout = 30000; instanceFollowRedirects = true }
if (c.responseCode !in 200..299) throw RuntimeException("HTTP ${c.responseCode}")
c.inputStream.use { input -> out.outputStream().use { input.copyTo(it) } }
out
}
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk)
context.startActivity(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
)
_available.value = null // installer takes over
} catch (e: Exception) {
onError(e.message ?: "Download fehlgeschlagen")
} finally {
_downloading.value = false
}
}
}
@@ -32,6 +32,7 @@ object ClientHolder {
class MusicRepository(private val context: Context) {
private val prefs = PrefsStore(context)
private val playLog = PlayLogClient(context)
private suspend fun api(): SubsonicClient =
ClientHolder.get(prefs.credentials.first())
@@ -56,6 +57,49 @@ class MusicRepository(private val context: Context) {
suspend fun star(id: String) = api().star(id)
suspend fun unstar(id: String) = api().unstar(id)
suspend fun scrobble(id: String) = api().scrobble(id)
// Our own timestamped play-log (stats + Wrapped), in addition to the Navidrome scrobble above.
suspend fun logPlay(song: Song) = playLog.logPlay(song)
suspend fun flushPlayLog() = playLog.flush()
suspend fun fetchScrobbleStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int) =
playLog.fetchStats(from, to, tz, bucket, limit)
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
// ── Artist pins (shared, server-side same-name disambiguation) ───────────────
@Volatile private var _artistOverrides: Map<String, ArtistOverride>? = null
/** Full pin map, cached after first load. Empty if no backend / unreachable. */
suspend fun artistOverrides(): Map<String, ArtistOverride> {
_artistOverrides?.let { return it }
val url = getBackendUrl()
if (url.isBlank()) return emptyMap<String, ArtistOverride>().also { _artistOverrides = it }
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val m = runCatching { c.loadAll() }.getOrDefault(emptyMap())
c.close()
return m.also { _artistOverrides = it }
}
suspend fun getArtistOverride(ndArtistId: String): ArtistOverride? = artistOverrides()[ndArtistId]
suspend fun setArtistOverride(ndArtistId: String, ov: ArtistOverride): Boolean {
val url = getBackendUrl()
if (url.isBlank()) return false
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val ok = runCatching { c.set(ndArtistId, ov) }.getOrDefault(false)
c.close()
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) + (ndArtistId to ov)
return ok
}
suspend fun removeArtistOverride(ndArtistId: String): Boolean {
val url = getBackendUrl()
if (url.isBlank()) return false
val c = com.novasonic.app.api.ArtistOverrideClient(url)
val ok = runCatching { c.remove(ndArtistId) }.getOrDefault(false)
c.close()
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) - ndArtistId
return ok
}
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
suspend fun getGenres() = api().getGenres()
@@ -76,8 +120,9 @@ class MusicRepository(private val context: Context) {
}
}
val creds = lastCredentials ?: return null
return SubsonicClient(creds.serverUrl, creds.username, creds.password)
.coverArtUrl(id ?: return null, size)
// Reuse the cached client — building a fresh SubsonicClient here spins up a whole Ktor
// HttpClient per cover (and never closes it), which is heavy GC/alloc churn while scrolling.
return ClientHolder.get(creds).coverArtUrl(id ?: return null, size)
}
fun streamUrl(id: String): String? {
@@ -86,7 +131,7 @@ class MusicRepository(private val context: Context) {
if (f.exists()) return Uri.fromFile(f).toString()
}
val creds = lastCredentials ?: return null
return SubsonicClient(creds.serverUrl, creds.username, creds.password).streamUrl(id)
return ClientHolder.get(creds).streamUrl(id) // reuse cached client (no per-call HttpClient)
}
private fun offlineRepo(): OfflineRepository? = runCatching {
@@ -0,0 +1,186 @@
package com.novasonic.app.data
import android.content.Context
import com.novasonic.app.api.model.ScrobbleStats
import com.novasonic.app.api.model.Song
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.encodeURLParameter
import io.ktor.http.isSuccess
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.security.MessageDigest
import java.util.UUID
import kotlin.random.Random
/**
* Records timestamped plays into NovaSonic's own scrobble service (separate origin,
* settings.scrobbleUrl) — the basis for day/month/year stats + Wrapped.
*
* Runs IN ADDITION to the existing Navidrome scrobble (playCount + Last.fm); never
* replaces it. Called at the same "counted play" moment as the Navidrome scrobble
* (see PlayerController.scheduleScrobble).
*
* Offline-safe: entries persist to a JSON file and flush as an idempotent batch
* (the service dedups by uuid), so a play made offline is delivered later exactly once.
*
* One instance lives on MusicRepository → a single reused HttpClient (never per-call).
*/
class PlayLogClient(context: Context) {
private val prefs = PrefsStore(context)
private val queueFile = File(context.filesDir, "play-queue.json")
private val mutex = Mutex()
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val http = HttpClient(Android) {
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
}
@Serializable
data class PlayEntry(
val uuid: String,
val songId: String,
val title: String? = null,
val artist: String? = null,
val artistId: String? = null,
val album: String? = null,
val albumId: String? = null,
val coverArt: String? = null,
val genre: String? = null,
val duration: Int? = null,
val playedAt: Long,
val source: String = "android",
)
@Serializable
private data class Batch(val plays: List<PlayEntry>)
/** Queue one counted play and try to deliver it. */
suspend fun logPlay(song: Song) {
val s = prefs.appSettings.first()
if (s.scrobbleUrl.isBlank()) return
val entry = PlayEntry(
uuid = UUID.randomUUID().toString(),
songId = song.id,
title = song.title,
artist = song.artist,
artistId = song.artistId,
album = song.album,
albumId = song.albumId,
coverArt = song.coverArt,
genre = song.genre,
duration = song.duration,
playedAt = System.currentTimeMillis() / 1000,
)
mutex.withLock {
val q = readQueue().toMutableList()
q.add(entry)
writeQueue(q.takeLast(MAX_QUEUE))
}
flush(s)
}
/** Deliver everything queued as one idempotent batch. Safe to call repeatedly. */
suspend fun flush(settings: PrefsStore.AppSettings? = null) {
val s = settings ?: prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return
val batch = mutex.withLock { readQueue() }
if (batch.isEmpty()) return
val auth = authParams(s.username, s.password)
val ok = runCatching {
val resp: HttpResponse = http.post("$base/scrobble/plays?$auth") {
contentType(ContentType.Application.Json)
setBody(json.encodeToString(Batch(batch)))
}
resp.status.isSuccess()
}.getOrDefault(false)
if (ok) {
// Remove exactly what we sent (accepted + duplicates are durable), preserving
// any plays queued while the request was in flight.
val sent = batch.map { it.uuid }.toHashSet()
mutex.withLock { writeQueue(readQueue().filterNot { it.uuid in sent }) }
}
}
/** Read aggregated stats for the current user. Returns null on any failure (unconfigured,
* unauthorized, network). Decodes with the tolerant json instance (ignoreUnknownKeys). */
suspend fun fetchStats(from: Long, to: Long, tz: Int, bucket: String, limit: Int): ScrobbleStats? {
val s = prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
val auth = authParams(s.username, s.password)
val url = "$base/scrobble/stats?$auth&from=$from&to=$to&tz=$tz&bucket=$bucket&limit=$limit"
return runCatching {
val resp = http.get(url)
if (!resp.status.isSuccess()) return null
json.decodeFromString<ScrobbleStats>(resp.bodyAsText())
}.getOrNull()
}
/** Head-to-Head: list the users in the log (for the partner picker). Null on failure. */
suspend fun fetchUsers(): com.novasonic.app.api.model.ScrobbleUsersResponse? {
val s = prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
val auth = authParams(s.username, s.password)
return runCatching {
val resp = http.get("$base/scrobble/users?$auth")
if (!resp.status.isSuccess()) return null
json.decodeFromString<com.novasonic.app.api.model.ScrobbleUsersResponse>(resp.bodyAsText())
}.getOrNull()
}
/** Head-to-Head: compare the authed user against [with]. Null on failure. */
suspend fun fetchCompare(with: String): com.novasonic.app.api.model.CompareResult? {
val s = prefs.appSettings.first()
val base = s.scrobbleUrl.trimEnd('/')
if (base.isBlank() || s.username.isBlank() || s.password.isBlank()) return null
val auth = authParams(s.username, s.password)
val url = "$base/scrobble/compare?$auth&with=${with.encodeURLParameter()}"
return runCatching {
val resp = http.get(url)
if (!resp.status.isSuccess()) return null
json.decodeFromString<com.novasonic.app.api.model.CompareResult>(resp.bodyAsText())
}.getOrNull()
}
private fun readQueue(): List<PlayEntry> =
runCatching {
if (!queueFile.exists()) emptyList()
else json.decodeFromString<List<PlayEntry>>(queueFile.readText())
}.getOrDefault(emptyList())
private fun writeQueue(q: List<PlayEntry>) {
runCatching { queueFile.writeText(json.encodeToString(q)) }
}
private fun authParams(user: String, pass: String): String {
val salt = Random.nextInt(100_000, 999_999).toString()
val token = md5(pass + salt)
return "u=${user.encodeURLParameter()}&t=$token&s=$salt"
}
private fun md5(input: String): String =
MessageDigest.getInstance("MD5").digest(input.toByteArray())
.joinToString("") { "%02x".format(it) }
companion object { private const val MAX_QUEUE = 500 }
}
@@ -18,6 +18,7 @@ class PrefsStore(private val context: Context) {
val KEY_PASSWORD = stringPreferencesKey("password")
val KEY_LASTFM_KEY = stringPreferencesKey("lastfm_key")
val KEY_BACKEND_URL = stringPreferencesKey("backend_url")
val KEY_SCROBBLE_URL = stringPreferencesKey("scrobble_url")
val KEY_SCROBBLE_MODE = stringPreferencesKey("scrobble_mode")
val KEY_SHOW_COUNT = booleanPreferencesKey("show_play_count")
val KEY_THEME = stringPreferencesKey("theme")
@@ -40,6 +41,7 @@ class PrefsStore(private val context: Context) {
val password: String = "",
val lastFmKey: String = "",
val backendUrl: String = "",
val scrobbleUrl: String = "",
val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false,
val theme: String = "dark-ocean",
@@ -70,6 +72,7 @@ class PrefsStore(private val context: Context) {
password = prefs[KEY_PASSWORD] ?: "",
lastFmKey = prefs[KEY_LASTFM_KEY] ?: "",
backendUrl = prefs[KEY_BACKEND_URL] ?: "",
scrobbleUrl = prefs[KEY_SCROBBLE_URL] ?: "",
scrobbleMode = prefs[KEY_SCROBBLE_MODE] ?: "30s",
showPlayCount = prefs[KEY_SHOW_COUNT] ?: false,
theme = prefs[KEY_THEME] ?: "dark-ocean",
@@ -104,6 +107,7 @@ class PrefsStore(private val context: Context) {
prefs[KEY_PASSWORD] = settings.password
prefs[KEY_LASTFM_KEY] = settings.lastFmKey
prefs[KEY_BACKEND_URL] = settings.backendUrl
prefs[KEY_SCROBBLE_URL] = settings.scrobbleUrl
prefs[KEY_SCROBBLE_MODE] = settings.scrobbleMode
prefs[KEY_SHOW_COUNT] = settings.showPlayCount
prefs[KEY_THEME] = settings.theme
@@ -0,0 +1,33 @@
package com.novasonic.app.data
import com.novasonic.app.api.model.Song
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Global, reactive star/favorite overrides. Tapping a star flips the state here *immediately*
* (optimistic), so every screen showing that song updates at once — regardless of when each
* screen last fetched `song.starred`. The API call runs in the background and reverts on failure.
*/
object StarState {
private val _overrides = MutableStateFlow<Map<String, Boolean>>(emptyMap())
val overrides: StateFlow<Map<String, Boolean>> = _overrides.asStateFlow()
/** Current starred state for a song: the live override if present, else the song's own flag. */
fun isStarred(song: Song): Boolean = _overrides.value[song.id] ?: (song.starred != null)
fun isStarred(songId: String, fallback: Boolean): Boolean = _overrides.value[songId] ?: fallback
/** Optimistically toggle, then hit the API; revert the override if the call fails. */
fun toggle(repo: MusicRepository, scope: CoroutineScope, songId: String, fallbackStarred: Boolean) {
val current = _overrides.value[songId] ?: fallbackStarred
val next = !current
_overrides.value = _overrides.value + (songId to next)
scope.launch {
val ok = runCatching { if (next) repo.star(songId) else repo.unstar(songId) }.isSuccess
if (!ok) _overrides.value = _overrides.value + (songId to current) // revert on failure
}
}
}
@@ -36,6 +36,9 @@ class MusicService : MediaLibraryService() {
// Pseudo-items for play-all / shuffle at top of each list
const val PLAY_ALL_PREFIX = "playall:"
const val SHUFFLE_PREFIX = "shuffle:"
// Encodes a song's browse context so a tap plays the whole list from that song:
// "songctx:<contextId>|<songId>" (contextId may itself be "album:…"/"playlist:…").
const val SONG_CTX_PREFIX = "songctx:"
// ABS session commands (handled via onCustomCommand)
const val ACTION_ABS_PREV_CH = "novasonic.abs.PREV_CHAPTER"
const val ACTION_ABS_SKIP_BACK = "novasonic.abs.SKIP_BACK"
@@ -349,6 +352,10 @@ class MusicService : MediaLibraryService() {
.build()
).build()
}
mediaId.startsWith(SONG_CTX_PREFIX) -> {
val songId = mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')
repo.getSong(songId).toMediaItem(repo).buildUpon().setMediaId(mediaId).build()
}
else -> null
}
if (item != null) settable.set(LibraryResult.ofItem(item, null))
@@ -360,42 +367,78 @@ class MusicService : MediaLibraryService() {
return settable
}
// When Android Auto selects a browsable item (album/playlist), load its songs.
// Android Auto "add to queue" path — resolve browsable items to their songs.
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
): ListenableFuture<MutableList<MediaItem>> {
val settable = SettableFuture.create<MutableList<MediaItem>>()
scope.launch {
try { settable.set(expandForPlayback(mediaItems).first.toMutableList()) }
catch (e: Exception) { settable.set(mediaItems) }
}
return settable
}
// Android Auto "tap to play" path — return the resolved list AND the index to start at, so
// tapping a song plays its whole list from that song (not just the one song).
override fun onSetMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: MutableList<MediaItem>,
startIndex: Int,
startPositionMs: Long,
): ListenableFuture<MediaSession.MediaItemsWithStartPosition> {
val settable = SettableFuture.create<MediaSession.MediaItemsWithStartPosition>()
scope.launch {
try {
val resolved = mediaItems.flatMap { item ->
when {
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> {
resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), shuffle = false)
}
item.mediaId.startsWith(SHUFFLE_PREFIX) -> {
resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), shuffle = true)
}
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM))
album.song.map { song -> song.toMediaItem(repo) }
}
item.mediaId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL))
pl.entry.map { song -> song.toMediaItem(repo) }
}
else -> listOf(item)
}
}
settable.set(resolved.toMutableList())
val (list, idx) = expandForPlayback(mediaItems)
// A single browse tap expands into a full list → start at the tapped index.
// Otherwise honour the caller's startIndex.
val expanded = list.size != mediaItems.size
val start = if (expanded) idx else startIndex.coerceIn(0, (list.size - 1).coerceAtLeast(0))
settable.set(MediaSession.MediaItemsWithStartPosition(list, start, startPositionMs))
} catch (e: Exception) {
settable.set(mediaItems)
settable.set(MediaSession.MediaItemsWithStartPosition(mediaItems, startIndex, startPositionMs))
}
}
return settable
}
// Resolve browse items (play-all / shuffle / album / playlist / context-song / plain song)
// into a flat, playable song list plus the index to start at (the tapped song, else 0).
private suspend fun expandForPlayback(items: List<MediaItem>): Pair<List<MediaItem>, Int> {
if (items.size == 1 && items[0].mediaId.startsWith(SONG_CTX_PREFIX)) {
val rest = items[0].mediaId.removePrefix(SONG_CTX_PREFIX)
val ctx = rest.substringBefore('|')
val songId = rest.substringAfter('|')
val full = resolveSongs(ctx, shuffle = false)
if (full.isNotEmpty()) {
val idx = full.indexOfFirst { it.mediaId == songId }.coerceAtLeast(0)
return full to idx
}
return listOf(reattachUri(items[0], songId)) to 0
}
val resolved = items.flatMap { item ->
when {
item.mediaId.startsWith(PLAY_ALL_PREFIX) -> resolveSongs(item.mediaId.removePrefix(PLAY_ALL_PREFIX), false)
item.mediaId.startsWith(SHUFFLE_PREFIX) -> resolveSongs(item.mediaId.removePrefix(SHUFFLE_PREFIX), true)
item.mediaId.startsWith(ITEM_PREFIX_ALBUM) -> repo.getAlbum(item.mediaId.removePrefix(ITEM_PREFIX_ALBUM)).song.map { it.toMediaItem(repo) }
item.mediaId.startsWith(ITEM_PREFIX_PL) -> repo.getPlaylist(item.mediaId.removePrefix(ITEM_PREFIX_PL)).entry.map { it.toMediaItem(repo) }
item.mediaId.startsWith(SONG_CTX_PREFIX) -> listOf(reattachUri(item, item.mediaId.removePrefix(SONG_CTX_PREFIX).substringAfter('|')))
else -> listOf(reattachUri(item))
}
}
return resolved to 0
}
// Android Auto strips the URI when a browse item crosses the process boundary — re-resolve it.
private fun reattachUri(item: MediaItem, songId: String = item.mediaId): MediaItem {
val uri = repo.streamUrl(songId)?.let { android.net.Uri.parse(it) }
return item.buildUpon().setMediaId(songId).setUri(uri).build()
}
private suspend fun resolveSongs(contextId: String, shuffle: Boolean): List<MediaItem> {
val songs: List<com.novasonic.app.api.model.Song> = when {
contextId.startsWith(ITEM_PREFIX_ALBUM) ->
@@ -432,16 +475,16 @@ class MusicService : MediaLibraryService() {
PLAYLISTS_ID -> repo.getPlaylists().map { it.toMediaItem(repo) }
FAVORITES_ID -> {
val songs = repo.getStarred().song
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { it.toMediaItem(repo) }
listOf(playAllItem(FAVORITES_ID), shuffleItem(FAVORITES_ID)) + songs.map { browseSong(it, FAVORITES_ID) }
}
else -> when {
parentId.startsWith(ITEM_PREFIX_ALBUM) -> {
val album = repo.getAlbum(parentId.removePrefix(ITEM_PREFIX_ALBUM))
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { it.toMediaItem(repo) }
listOf(playAllItem(parentId), shuffleItem(parentId)) + album.song.map { browseSong(it, parentId) }
}
parentId.startsWith(ITEM_PREFIX_PL) -> {
val pl = repo.getPlaylist(parentId.removePrefix(ITEM_PREFIX_PL))
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { it.toMediaItem(repo) }
listOf(playAllItem(parentId), shuffleItem(parentId)) + pl.entry.map { browseSong(it, parentId) }
}
else -> emptyList()
}
@@ -449,6 +492,13 @@ class MusicService : MediaLibraryService() {
return ImmutableList.copyOf(items)
}
// A song shown in a browse list, with its context encoded in the mediaId so a tap can play the
// whole list from that song (Android Auto strips the URI, so it's re-resolved on playback).
private fun browseSong(song: com.novasonic.app.api.model.Song, contextId: String): MediaItem =
song.toMediaItem(repo).buildUpon()
.setMediaId("$SONG_CTX_PREFIX$contextId|${song.id}")
.build()
private fun browseFolder(id: String, title: String, mediaType: Int) =
MediaItem.Builder()
.setMediaId(id)
@@ -227,11 +227,25 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
while (isActive) {
delay(500)
val ctrl = controller ?: break
_state.update { it.copy(positionMs = ctrl.currentPosition) }
// Refresh durationMs too — after a SEAMLESS track transition (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 durationMs stuck at 0. Seek then computes fraction*0 = 0 and
// snaps to the start every time. Re-reading here lets it self-heal within 500ms.
_state.update { it.copy(positionMs = ctrl.currentPosition, durationMs = resolveDurationMs(ctrl)) }
}
}
}
/** Player duration, falling back to the current song's known duration while ctrl.duration is
* still TIME_UNSET (right after a media transition). Prevents a 0-duration seek → snap-to-0. */
private fun resolveDurationMs(ctrl: MediaController): Long {
val d = ctrl.duration
if (d > 0) return d
val songDurSecs = queue.getOrNull(ctrl.currentMediaItemIndex)?.duration?.toLong() ?: 0L
return songDurSecs * 1000L
}
private fun stopTicker() {
tickerJob?.cancel()
tickerJob = null
@@ -245,6 +259,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val delayMs = runCatching { repo.getScrobbleDelayMs(durationMs) }.getOrDefault(30_000L)
delay(delayMs)
runCatching { repo.scrobble(song.id) }
runCatching { repo.logPlay(song) } // our own play-log, same counted moment
}
}
@@ -270,7 +285,7 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
currentSong = queue.getOrNull(idx),
currentIndex = idx,
positionMs = ctrl.currentPosition,
durationMs = ctrl.duration.coerceAtLeast(0),
durationMs = resolveDurationMs(ctrl),
queue = queue,
repeatMode = ctrl.repeatMode,
shuffled = isManuallyShuffled,
@@ -280,9 +295,13 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
)
}
/** Warm the LrcLib cache as soon as a track starts, so opening Lyrics feels instant. */
private var _lyricsPrefetchJob: Job? = null
/** Warm the LrcLib cache shortly after a track settles. Debounced so rapid skips don't
* hammer lrclib (which throttles the IP and then no lyrics load at all). */
private fun prefetchLyrics(song: Song) {
scope.launch {
_lyricsPrefetchJob?.cancel()
_lyricsPrefetchJob = scope.launch {
delay(1500) // skip quickly without firing a request per track
runCatching {
LrcLibClient.getLyrics(
artist = song.artist ?: "",
@@ -310,7 +329,11 @@ class PlayerController(private val context: Context, private val repo: MusicRepo
val mediaId = ctrl.currentMediaItem?.mediaId ?: return
if (mediaId.startsWith("abs:")) return
val song = queue.getOrNull(ctrl.currentMediaItemIndex) ?: return
val dur = ctrl.duration.coerceAtLeast(0)
// Use the song's KNOWN duration — at a media transition ctrl.duration is still
// TIME_UNSET (→0), which collapsed the 1/3 / 2/3 modes to the 15s floor and scrobbled
// far too early. Fall back to the player only if the song has no duration.
val songDurMs = (song.duration?.toLong() ?: 0L) * 1000L
val dur = if (songDurMs > 0) songDurMs else ctrl.duration.coerceAtLeast(0)
scheduleScrobble(song, dur)
scope.launch { runCatching { repo.saveLastSong(song) } }
prefetchLyrics(song)
@@ -0,0 +1,79 @@
package com.novasonic.app.player
import android.media.MediaPlayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
/**
* App-level 30s preview player (iTunes/Deezer preview clips). Kept on the Application — NOT in a
* screen ViewModel — so a preview keeps a **global control bar** (see AppNavigation) even after the
* user leaves the album/artist/Explore screen it started on. Previously the preview lived in
* ExploreViewModel and became uncontrollable once you navigated away.
*/
data class PreviewState(
val trackId: Long? = null,
val title: String = "",
val artist: String = "",
val artworkUrl: String? = null,
val isPlaying: Boolean = false, // true = actively playing, false = paused
)
class PreviewController {
private val _state = MutableStateFlow(PreviewState())
val state: StateFlow<PreviewState> = _state
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
private var mp: MediaPlayer? = null
/** Tap on a track: same track → toggle play/pause; different track → start it. */
fun toggle(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
val cur = _state.value
when {
cur.trackId == trackId && cur.isPlaying -> pause()
cur.trackId == trackId && mp != null -> resume()
else -> start(trackId, url, title, artist, artworkUrl)
}
}
private fun start(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) {
stop()
_state.value = PreviewState(trackId, title, artist, artworkUrl, isPlaying = true)
scope.launch(Dispatchers.IO) {
try {
val player = MediaPlayer().apply {
setDataSource(url)
prepare()
start()
setOnCompletionListener { stop() }
}
mp = player
} catch (e: Exception) {
stop()
}
}
}
fun pause() {
mp?.runCatching { pause() }
_state.update { it.copy(isPlaying = false) }
}
fun resume() {
val player = mp
if (player == null) return
player.runCatching { start() }
_state.update { it.copy(isPlaying = true) }
}
fun stop() {
mp?.runCatching { stop(); release() }
mp = null
_state.value = PreviewState()
}
}
@@ -23,6 +23,8 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.*
import androidx.navigation.compose.*
@@ -34,6 +36,7 @@ import com.novasonic.app.app
import com.novasonic.app.data.AppNotification
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.component.MiniPlayer
import com.novasonic.app.ui.component.PreviewBar
import com.novasonic.app.ui.component.NotificationSheet
import com.novasonic.app.ui.component.PlaylistPickerSheet
import com.novasonic.app.ui.component.SongContextSheet
@@ -53,6 +56,10 @@ private enum class TopLevel(val route: String, val icon: ImageVector, val label:
private val NavBg = Color(0xF7080814)
// Position of a route among the top-level tabs (in bottom-bar order), or -1 if it isn't one.
// Used to slide tab switches toward the side the target tab sits on, instead of a push/pop feel.
private fun topLevelIndex(route: String?): Int = TopLevel.entries.indexOfFirst { it.route == route }
@Composable
fun AppNavigation() {
val context = LocalContext.current
@@ -73,6 +80,34 @@ fun AppNavigation() {
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
// ── In-app updater — offer the newer APK the backend advertises ─────────────
val updateInfo by com.novasonic.app.data.AppUpdater.available.collectAsStateWithLifecycle()
val updating by com.novasonic.app.data.AppUpdater.downloading.collectAsStateWithLifecycle()
val updateScope = rememberCoroutineScope()
LaunchedEffect(appSettings.backendUrl) { com.novasonic.app.data.AppUpdater.check(appSettings.backendUrl) }
updateInfo?.let { info ->
AlertDialog(
onDismissRequest = { com.novasonic.app.data.AppUpdater.dismiss() },
title = { Text("Update verfügbar") },
text = { Text("NovaSonic ${info.version ?: ""} ist verfügbar. Jetzt laden und installieren? Deine Downloads und Einstellungen bleiben erhalten.") },
confirmButton = {
TextButton(enabled = !updating, onClick = {
updateScope.launch {
com.novasonic.app.data.AppUpdater.downloadAndInstall(context, appSettings.backendUrl) { msg ->
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_LONG).show()
}
}
}) { Text(if (updating) "Lädt…" else "Laden & installieren") }
},
dismissButton = { TextButton(onClick = { com.novasonic.app.data.AppUpdater.dismiss() }) { Text("Später") } },
)
}
// 30s preview (app-level, so its control bar survives navigation). Starting real playback
// cancels any active preview so the two don't overlap.
val previewState by app.previewController.state.collectAsStateWithLifecycle()
LaunchedEffect(playerState.isPlaying) { if (playerState.isPlaying) app.previewController.stop() }
val absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank()
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/'))
@@ -96,9 +131,21 @@ fun AppNavigation() {
var showNotifications by remember { mutableStateOf(false) }
var selectedAbsItem by remember { mutableStateOf<AbsLibraryItem?>(null) }
var showAbsPlayer by remember { mutableStateOf(false) }
// Close action for the Erkunden (Apple) in-screen overlay (iTunes artist / album). Handled at the
// app root because a BackHandler inside the NavHost "erkunden" destination loses back priority to
// the NavHost, which pops Erkunden → Home instead of dismissing the overlay.
var exploreOverlayBack by remember { mutableStateOf<(() -> Unit)?>(null) }
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
DisposableEffect(showNowPlaying, backDispatcher) {
val currentBackEntry by navController.currentBackStackEntryAsState()
// Returning from the background re-registers the NavController's OWN back callback on top of the
// dispatcher, which stole back-priority from the NowPlaying/ABS overlays: pressing back then popped
// the NavHost (invisibly, all the way to Home) before dismissing the overlay. Bump this tick on
// ON_RESUME (and re-key on navigation) so the overlay callbacks below re-add above it again.
var resumeTick by remember { mutableIntStateOf(0) }
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { resumeTick++ }
DisposableEffect(showNowPlaying, backDispatcher, currentBackEntry, resumeTick) {
if (!showNowPlaying || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { showNowPlaying = false }
@@ -106,7 +153,7 @@ fun AppNavigation() {
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
DisposableEffect(showAbsPlayer, backDispatcher) {
DisposableEffect(showAbsPlayer, backDispatcher, currentBackEntry, resumeTick) {
if (!showAbsPlayer || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { showAbsPlayer = false }
@@ -114,7 +161,7 @@ fun AppNavigation() {
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
DisposableEffect(selectedAbsItem, backDispatcher) {
DisposableEffect(selectedAbsItem, backDispatcher, currentBackEntry, resumeTick) {
if (selectedAbsItem == null || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { selectedAbsItem = null }
@@ -122,22 +169,35 @@ fun AppNavigation() {
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
fun coverUrl(coverId: String?) = repo.coverArtUrl(coverId, 600)
fun heroCoverUrl(coverId: String?) = repo.coverArtUrl(coverId, 1000)
fun starToggle(songId: String, currentlyStarred: Boolean) {
scope.launch { runCatching { if (currentlyStarred) repo.unstar(songId) else repo.star(songId) } }
// Optimistic + reactive: flips StarState instantly so every star icon updates, then hits the API.
com.novasonic.app.data.StarState.toggle(repo, scope, songId, currentlyStarred)
}
fun showContextMenu(song: Song) { contextMenuSong = song }
var homeRefreshKey by remember { mutableIntStateOf(0) }
val currentBackEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentBackEntry?.destination?.route
val isTopLevel = TopLevel.entries.any { it.route == currentRoute }
// Root-level back for the Erkunden (Apple) overlay. Keyed on currentBackEntry as well, so the
// callback is re-added (bumped to the top of the OnBackPressedDispatcher) after every navigation
// — otherwise, once the NavHost re-registers its own callback during an album drill-down or tab
// switch, it could regain priority and pop Erkunden → Home on the next back ("sometimes" bug).
DisposableEffect(exploreOverlayBack, backDispatcher, currentBackEntry, resumeTick) {
val action = exploreOverlayBack
if (action == null || backDispatcher == null) return@DisposableEffect onDispose {}
val cb = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { action() }
}
backDispatcher.addCallback(cb)
onDispose { cb.remove() }
}
val bgGlow = Brush.radialGradient(
colors = listOf(Color(0x0AE8A020), Color(0x00000000)),
center = Offset.Unspecified, radius = 900f,
@@ -154,8 +214,24 @@ fun AppNavigation() {
containerColor = NsBackground,
modifier = Modifier.background(NsBackground).background(bgGlow),
bottomBar = {
if (isTopLevel) {
Column {
// MiniPlayer shows on EVERY screen (incl. album/artist/playlist detail) so playback
// is always reachable; the tab bar stays top-level-only. On detail screens there is no
// NavigationBar to absorb the system-navigation inset, so pad the bar itself — otherwise
// the MiniPlayer sits behind the phone's gesture/nav bar.
Column(modifier = if (isTopLevel) Modifier else Modifier.navigationBarsPadding()) {
// 30s preview control bar — above the MiniPlayer, on every screen, so a preview
// started on an album/artist/Explore screen can be paused/stopped from anywhere.
AnimatedVisibility(
visible = previewState.trackId != null,
enter = slideInVertically { it } + fadeIn(tween(220)),
exit = slideOutVertically { it } + fadeOut(tween(180)),
) {
PreviewBar(
state = previewState,
onToggle = { if (previewState.isPlaying) app.previewController.pause() else app.previewController.resume() },
onClose = { app.previewController.stop() },
)
}
// ABS audio routes through MusicService — one notification, one mini player.
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
// instead of the music NowPlaying screen.
@@ -189,6 +265,7 @@ fun AppNavigation() {
},
)
}
if (isTopLevel) {
NavigationBar(containerColor = NavBg, tonalElevation = 0.dp) {
navItems.forEach { dest ->
NavigationBarItem(
@@ -223,10 +300,36 @@ fun AppNavigation() {
navController = navController,
startDestination = TopLevel.HOME.route,
modifier = Modifier.padding(innerPadding),
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) },
exitTransition = { slideOutHorizontally(targetOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) },
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it / 3 }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeOut(tween(180)) },
// Direction of a tab switch: +1 if the target tab sits to the right of the current
// one, -1 if to the left, 0 if this isn't a tab-to-tab move (detail push/pop).
enterTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeIn(tween(200))
},
exitTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeOut(tween(180))
},
popEnterTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { dir * it } + fadeIn(tween(200))
else slideInHorizontally(tween(280, easing = FastOutSlowInEasing)) { -it / 3 } + fadeIn(tween(200))
},
popExitTransition = {
val f = topLevelIndex(initialState.destination.route)
val t = topLevelIndex(targetState.destination.route)
val dir = if (f >= 0 && t >= 0 && f != t) (if (t > f) 1 else -1) else 0
if (dir != 0) slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { -dir * it } + fadeOut(tween(180))
else slideOutHorizontally(tween(280, easing = FastOutSlowInEasing)) { it } + fadeOut(tween(180))
},
) {
composable(TopLevel.HOME.route) {
HomeScreen(
@@ -271,6 +374,8 @@ fun AppNavigation() {
onSongPlay = { songs, idx -> player.playSongs(songs, idx) },
onAlbumClick = { album -> navController.navigate("album/${album.id}") },
onArtistClick = { artist -> navController.navigate("artist/${artist.id}") },
onShowContextMenu = ::showContextMenu,
onOverlayBackChange = { exploreOverlayBack = it },
onOpenImporter = { navController.navigate("importer") },
)
}
@@ -305,7 +410,28 @@ fun AppNavigation() {
}
}
}
composable(TopLevel.SETTINGS.route) { SettingsScreen() }
composable(TopLevel.SETTINGS.route) {
SettingsScreen(
onOpenStats = { navController.navigate("stats") },
onOpenCompare = { navController.navigate("compare") },
)
}
composable("stats") {
StatsScreen(
coverUrl = { id -> coverUrl(id) },
onBack = { navController.popBackStack() },
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
)
}
composable("compare") {
CompareScreen(
coverUrl = { id -> coverUrl(id) },
onBack = { navController.popBackStack() },
onArtistClick = { id -> navController.navigate("artist/$id") },
onAlbumClick = { id -> navController.navigate("album/$id") },
)
}
composable(
"album/{albumId}",
@@ -432,12 +558,12 @@ fun AppNavigation() {
contextMenuSong?.let { song ->
SongContextSheet(
song = song,
isStarred = song.starred != null,
isStarred = com.novasonic.app.data.StarState.isStarred(song),
onDismiss = { contextMenuSong = null },
onPlay = { player.playSongs(listOf(song), 0) },
onInsertNext = { player.insertNext(song) },
onAddToQueue = { player.addToQueueEnd(song) },
onStarToggle = { starToggle(song.id, song.starred != null) },
onStarToggle = { starToggle(song.id, com.novasonic.app.data.StarState.isStarred(song)) },
onAddToPlaylist = { playlistPickerSong = song; contextMenuSong = null },
onGoToAlbum = song.albumId?.let { albumId -> { navController.navigate("album/$albumId") } },
onShowInfo = { infoSheetSong = song; contextMenuSong = null },
@@ -463,6 +589,7 @@ fun AppNavigation() {
PlaylistPickerSheet(
song = song,
loadPlaylists = { repo.getPlaylists() },
coverUrl = { id -> coverUrl(id) },
onAdd = { pl ->
playlistPickerSong = null
scope.launch {
@@ -502,13 +629,19 @@ fun AppNavigation() {
val res = repo.search(n.navAlbum)
val albums = res.album
val norm = { s: String -> s.lowercase().trim() }
val na = norm(n.navArtist)
// REQUIRE the artist match when we know it — several artists share an
// album title ("I LIKE IT!"); the old name-only / firstOrNull fallback
// opened a completely different artist's album (wrong song).
val match = albums.firstOrNull {
norm(it.name) == norm(n.navAlbum) &&
(n.navArtist.isBlank() || norm(it.artist ?: "") == norm(n.navArtist))
} ?: albums.firstOrNull { norm(it.name) == norm(n.navAlbum) }
?: albums.firstOrNull()
val aa = norm(it.artist ?: "")
norm(it.name) == norm(n.navAlbum) && na.isNotBlank() &&
(aa == na || aa.contains(na) || na.contains(aa))
} ?: if (na.isBlank()) albums.firstOrNull { norm(it.name) == norm(n.navAlbum) } else null
if (match != null) {
navController.navigate("album/${match.id}")
} else {
Toast.makeText(context, "Album noch nicht gefunden — evtl. wird die Library gerade gescannt", Toast.LENGTH_SHORT).show()
}
}
}
@@ -21,6 +21,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.ui.theme.*
import java.time.LocalDateTime
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
@@ -31,6 +32,7 @@ fun ConcertsSheet(
artistName: String,
events: List<ConcertEvent>,
onDismiss: () -> Unit,
eventsError: String? = null,
) {
val context = LocalContext.current
@@ -47,17 +49,25 @@ fun ConcertsSheet(
)
if (events.isEmpty()) {
Box(
contentAlignment = Alignment.Center,
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
.padding(horizontal = 20.dp, vertical = 40.dp),
) {
Text(
"Keine Konzerte angekündigt",
if (eventsError != null) "Konzerte konnten nicht geladen werden" else "Keine Konzerte angekündigt",
fontFamily = Outfit, fontSize = 14.sp,
color = NsOnSurface2,
)
if (eventsError != null) {
Spacer(Modifier.height(6.dp))
Text(
eventsError,
fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2.copy(alpha = 0.6f),
)
}
}
} else {
LazyColumn(
@@ -78,7 +88,7 @@ fun ConcertsSheet(
@Composable
private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
val (dateStr, timeStr) = formatEventDateTime(event.datetime)
val w = formatEventWhen(event.datetime)
val venue = event.venue?.name.orEmpty()
val city = listOfNotNull(event.venue?.city, event.venue?.country).joinToString(", ")
val offer = event.offers.firstOrNull { !it.url.isNullOrBlank() }
@@ -92,26 +102,33 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
.background(NsSurface2)
.padding(horizontal = 14.dp, vertical = 12.dp),
) {
// Date block
// Date block — fixed width, calendar style (day big, month/year + time small) so every
// row is the same height and the divider/venue line up.
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(52.dp),
modifier = Modifier.width(56.dp),
) {
Text(
dateStr,
w.day,
fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 13.sp, color = NsAccent,
fontSize = 18.sp, color = NsAccent, maxLines = 1,
)
if (timeStr.isNotBlank()) {
if (w.monthYear.isNotBlank()) {
Text(
timeStr,
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
w.monthYear,
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
)
}
if (w.time.isNotBlank()) {
Text(
w.time,
fontFamily = Outfit, fontSize = 10.sp, color = NsOnSurface2, maxLines = 1,
)
}
}
Spacer(Modifier.width(14.dp))
Box(Modifier.width(1.dp).height(36.dp).background(NsBorder))
Box(Modifier.width(1.dp).height(40.dp).background(NsBorder))
Spacer(Modifier.width(14.dp))
// Venue
@@ -156,14 +173,21 @@ private fun EventRow(event: ConcertEvent, onTicketClick: (String) -> Unit) {
}
}
private fun formatEventDateTime(datetime: String): Pair<String, String> {
private data class EventWhen(val day: String, val monthYear: String, val time: String)
private val DAY_FMT = DateTimeFormatter.ofPattern("dd", Locale.GERMAN)
private val MONTH_FMT = DateTimeFormatter.ofPattern("MMM yyyy", Locale.GERMAN)
private val TIME_FMT = DateTimeFormatter.ofPattern("HH:mm")
private fun formatEventWhen(datetime: String): EventWhen {
return try {
val zdt = ZonedDateTime.parse(datetime)
val date = zdt.format(DateTimeFormatter.ofPattern("dd. MMM yyyy", Locale.GERMAN))
val time = if (zdt.hour == 0 && zdt.minute == 0) ""
else zdt.format(DateTimeFormatter.ofPattern("HH:mm"))
date to time
// Backend now sends a naive venue-local datetime ("2026-09-16T20:00:00"); older/zoned
// values ("…Z") still parse via the ZonedDateTime fallback.
val ldt = runCatching { LocalDateTime.parse(datetime) }
.getOrElse { ZonedDateTime.parse(datetime).toLocalDateTime() }
val time = if (ldt.hour == 0 && ldt.minute == 0) "" else ldt.format(TIME_FMT)
EventWhen(ldt.format(DAY_FMT), ldt.format(MONTH_FMT), time)
} catch (_: Exception) {
datetime.take(10) to ""
EventWhen(datetime.take(10), "", "")
}
}
@@ -40,6 +40,9 @@ fun ItunesDownloadSheet(
ModalBottomSheet(
onDismissRequest = onDismiss,
// Open fully so the "Herunterladen" button is visible right away (it sits above the system
// nav bar via navigationBarsPadding below) — no drag-up needed.
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2.copy(0.3f)) },
) {
@@ -1,19 +1,25 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.automirrored.filled.QueueMusic
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Playlist
import com.novasonic.app.api.model.Song
import com.novasonic.app.ui.theme.*
@@ -23,6 +29,7 @@ import com.novasonic.app.ui.theme.*
fun PlaylistPickerSheet(
song: Song,
loadPlaylists: suspend () -> List<Playlist>,
coverUrl: (String?) -> String?,
onAdd: (Playlist) -> Unit,
onCreate: (String) -> Unit,
onDismiss: () -> Unit,
@@ -65,15 +72,19 @@ fun PlaylistPickerSheet(
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
placeholder = { Text("Neue Playlist…", fontSize = 14.sp) },
placeholder = { Text("Neue Playlist…", color = NsOnSurface2, fontSize = 14.sp) },
leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, null, tint = NsOnSurface2) },
singleLine = true,
shape = RoundedCornerShape(12.dp),
modifier = Modifier.weight(1f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NsAccent,
unfocusedBorderColor = NsBorder,
unfocusedBorderColor = NsOnSurface3,
cursorColor = NsAccent,
focusedTextColor = NsOnSurface,
unfocusedTextColor = NsOnSurface,
focusedContainerColor = NsSurface2,
unfocusedContainerColor = NsSurface2,
),
textStyle = androidx.compose.ui.text.TextStyle(
fontFamily = Outfit, fontSize = 14.sp,
@@ -117,8 +128,22 @@ fun PlaylistPickerSheet(
.padding(horizontal = 20.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val cv = coverUrl(pl.coverArt)
Box(
Modifier.size(40.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2),
contentAlignment = Alignment.Center,
) {
if (cv != null) {
AsyncImage(
model = cv, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(Icons.AutoMirrored.Filled.QueueMusic, null,
tint = NsOnSurface2, modifier = Modifier.size(20.dp))
}
}
Spacer(Modifier.width(14.dp))
Text(
pl.name,
@@ -0,0 +1,92 @@
package com.novasonic.app.ui.component
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.novasonic.app.player.PreviewState
import com.novasonic.app.ui.theme.*
/**
* Global 30s-preview control bar. Rendered above the MiniPlayer on every screen while a preview
* clip is active, so the user can pause/stop it after navigating away from the album/artist/Explore
* screen it was started on.
*/
@Composable
fun PreviewBar(
state: PreviewState,
onToggle: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(top = 6.dp, bottom = 2.dp)
.shadow(10.dp, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(NsSurface2)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
AsyncImage(
model = state.artworkUrl, contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(38.dp).clip(RoundedCornerShape(7.dp)).background(NsSurface),
)
Spacer(Modifier.width(11.dp))
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "30s Preview", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 9.sp, color = NsAccent, letterSpacing = 0.5.sp,
)
}
Text(
text = state.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
if (state.artist.isNotBlank()) {
Text(
text = state.artist, fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
Box(
Modifier.size(36.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onToggle),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (state.isPlaying) "Pause" else "Abspielen",
tint = Color.White, modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(2.dp))
IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) {
Icon(Icons.Default.Close, contentDescription = "Preview beenden", tint = NsOnSurface2, modifier = Modifier.size(20.dp))
}
}
}
@@ -36,6 +36,9 @@ fun SongContextSheet(
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
// Open fully expanded so all options (incl. "Herunterladen" at the bottom) are reachable
// right away — no extra drag-up needed.
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
containerColor = NsSurface,
dragHandle = { BottomSheetDefaults.DragHandle(color = NsOnSurface2) },
) {
@@ -10,6 +10,8 @@ import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -34,6 +36,9 @@ fun SongRow(
onStarToggle: (() -> Unit)? = null,
onMore: (() -> Unit)? = null,
) {
// Reactive star: reflects an optimistic StarState override the instant it's toggled anywhere.
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
val effectiveStarred = starOverrides[song.id] ?: starred
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -113,9 +118,9 @@ fun SongRow(
if (onStarToggle != null) {
IconButton(onClick = onStarToggle, modifier = Modifier.size(38.dp)) {
Icon(
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
if (effectiveStarred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = null,
tint = if (starred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
tint = if (effectiveStarred) NsAccent else NsOnSurface2.copy(alpha = 0.6f),
modifier = Modifier.size(17.dp),
)
}
@@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -42,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.Song
import com.novasonic.app.api.model.fmtFanCount
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.component.AlbumCard
import com.novasonic.app.ui.component.ConcertsSheet
@@ -49,6 +51,7 @@ import com.novasonic.app.ui.component.ItunesDownloadSheet
import com.novasonic.app.ui.component.SongRow
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ArtistViewModel
import kotlinx.coroutines.launch
@Composable
fun ArtistDetailScreen(
@@ -64,15 +67,17 @@ fun ArtistDetailScreen(
val vm: ArtistViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
BackHandler(enabled = state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.subAlbumItem != null && state.partialAlbum == null) { vm.dismissSubAlbum() }
var showAllAlbums by remember { mutableStateOf(false) }
var allAlbumsTitle by remember { mutableStateOf("") }
var allAlbumEntries by remember { mutableStateOf<List<AllAlbumEntry>>(emptyList()) }
// Which "all albums" section is expanded (by title), or null. rememberSaveable so it survives
// navigating into an album and back — otherwise back would land on the collapsed artist page.
var openAllAlbums by rememberSaveable { mutableStateOf<String?>(null) }
val showAllAlbums = openAllAlbums != null
BackHandler(enabled = showAllAlbums && state.partialAlbum == null && state.subAlbumItem == null) {
showAllAlbums = false
openAllAlbums = null
}
LaunchedEffect(state.pendingShufflePlay) {
@@ -90,6 +95,7 @@ fun ArtistDetailScreen(
artistName = artist.name,
events = state.events,
onDismiss = vm::dismissEventsSheet,
eventsError = state.eventsError,
)
}
}
@@ -108,6 +114,22 @@ fun ArtistDetailScreen(
)
}
// Artist-pin picker
if (state.showPinPicker) {
ArtistPinSheet(
artistName = state.artist?.name ?: "",
pinned = state.pin,
searching = state.pinSearching,
candidates = state.pinCandidates,
onPick = vm::pinCandidate,
onPinLink = { link -> vm.pinByLink(link) { ok ->
if (!ok) Toast.makeText(context, "Deezer-Künstler nicht gefunden", Toast.LENGTH_SHORT).show()
} },
onClear = vm::clearPin,
onDismiss = vm::dismissPinPicker,
)
}
if (state.isLoading) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
@@ -134,6 +156,7 @@ fun ArtistDetailScreen(
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissPartialAlbum,
onShowContextMenu = onShowContextMenu,
)
return
}
@@ -163,11 +186,35 @@ fun ArtistDetailScreen(
// Album categories (computed once iTunes data arrives)
val cats = if (state.iAlbums.isNotEmpty()) categorizeAlbums(artist.album, state.iAlbums) else null
// Rebuild the overlay entries from the (surviving) categorized albums for whichever section is
// open. Derived — not stored — so it's correct after returning from an album.
val allAlbumEntries: List<AllAlbumEntry> = when (openAllAlbums) {
"In Library" -> cats?.downloaded?.map { (nd, _) ->
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
}.orEmpty()
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
AllAlbumEntry(
name = nd.name, year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { vm.loadPartialAlbum(nd, ia) }, onDownload = { vm.showDownloadSheet(ia) },
)
}.orEmpty()
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
AllAlbumEntry(
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
onClick = { vm.loadSubAlbum(ia) }, onDownload = { vm.showDownloadSheet(ia) },
)
}.orEmpty()
else -> emptyList()
}
if (showAllAlbums) {
AllAlbumsOverlay(
title = allAlbumsTitle,
title = openAllAlbums ?: "",
entries = allAlbumEntries,
onBack = { showAllAlbums = false },
onBack = { openAllAlbums = null },
)
return
}
@@ -191,7 +238,10 @@ fun ArtistDetailScreen(
// Hero card
item {
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
// Navidrome/Last.fm image is the wrong person).
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
?: coverUrl(artist.coverArt)
Box(
Modifier
@@ -207,6 +257,20 @@ fun ArtistDetailScreen(
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
// "Wrong artist?" pin — minimal icon-only chip, top-right over the hero.
Surface(
onClick = { vm.openPinPicker() },
shape = RoundedCornerShape(999.dp),
color = Color(0x59000000),
modifier = Modifier.align(Alignment.TopEnd).padding(10.dp),
) {
Text(
"⚙",
fontSize = 14.sp,
color = if (state.pin != null) Color(0xFF7EB8FF) else Color.White.copy(0.72f),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
Box(
Modifier.fillMaxSize().background(
Brush.verticalGradient(
@@ -215,13 +279,21 @@ fun ArtistDetailScreen(
)
)
)
Column(modifier = Modifier.align(Alignment.BottomStart).padding(20.dp)) {
Text(
artist.name,
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
letterSpacing = (-0.5).sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis,
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp),
)
state.fanCount?.takeIf { it > 0 }?.let {
Text(
"${fmtFanCount(it)} Fans",
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
color = Color.White.copy(0.58f), modifier = Modifier.padding(top = 4.dp),
)
}
}
}
Spacer(Modifier.height(16.dp))
}
@@ -255,11 +327,52 @@ fun ArtistDetailScreen(
}
}
// Similar artists (exclude the current artist)
// Similar artists. When pinned, use Deezer "related" for the pinned artist — Last.fm's list
// is keyed to the (possibly merged/wrong) Navidrome artist, so it goes "weird".
val usePinnedRelated = state.pin != null && state.pinnedRelated.isNotEmpty()
val similarArtists = state.info?.similarArtist
?.filter { !it.name.equals(artist.name, ignoreCase = true) }
?: emptyList()
if (similarArtists.isNotEmpty()) {
if (usePinnedRelated) {
item {
SectionLabel("Similar Artists")
LazyRow(
contentPadding = PaddingValues(horizontal = 20.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.padding(bottom = 20.dp),
) {
items(state.pinnedRelated, key = { it.artistId ?: 0L }) { rel ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(70.dp)
// Deezer-related has no Navidrome id → resolve by name on tap; navigate
// if the artist is in the library, else say so.
.clickable {
scope.launch {
val id = vm.libraryArtistId(rel.artistName)
if (id != null && onArtistClick != null) onArtistClick(id)
else Toast.makeText(context, "Nicht in deiner Bibliothek", Toast.LENGTH_SHORT).show()
}
},
) {
AsyncImage(
model = rel.artworkUrl100,
contentDescription = rel.artistName,
contentScale = ContentScale.Crop,
modifier = Modifier.size(60.dp).clip(CircleShape).background(NsSurface2),
)
Spacer(Modifier.height(6.dp))
Text(
rel.artistName ?: "", fontFamily = Outfit, fontSize = 11.sp,
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
}
} else if (similarArtists.isNotEmpty()) {
item {
SectionLabel("Similar Artists")
LazyRow(
@@ -338,7 +451,7 @@ fun ArtistDetailScreen(
color = NsAccent, modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
)
Spacer(Modifier.width(10.dp))
Text("iTunes Daten werden geladen…", fontFamily = Outfit,
Text("Deezer Daten werden geladen…", fontFamily = Outfit,
fontSize = 12.sp, color = NsOnSurface2)
}
}
@@ -349,13 +462,7 @@ fun ArtistDetailScreen(
if (cats.downloaded.isNotEmpty()) {
item {
ArtistSectionHeader("In Library", cats.downloaded.size,
onShowAll = if (cats.downloaded.size > 4) ({
allAlbumsTitle = "In Library"
allAlbumEntries = cats.downloaded.map { (nd, _) ->
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
}
showAllAlbums = true
}) else null
onShowAll = if (cats.downloaded.size > 4) ({ openAllAlbums = "In Library" }) else null
)
}
item {
@@ -379,20 +486,7 @@ fun ArtistDetailScreen(
if (cats.partial.isNotEmpty()) {
item {
ArtistSectionHeader("Unvollständig", cats.partial.size,
onShowAll = if (cats.partial.size > 4) ({
allAlbumsTitle = "Unvollständig"
allAlbumEntries = cats.partial.map { (nd, ia, counts) ->
AllAlbumEntry(
name = nd.name,
year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { vm.loadPartialAlbum(nd, ia) },
onDownload = { vm.showDownloadSheet(ia) },
)
}
showAllAlbums = true
}) else null
onShowAll = if (cats.partial.size > 4) ({ openAllAlbums = "Unvollständig" }) else null
)
}
item {
@@ -419,20 +513,7 @@ fun ArtistDetailScreen(
if (cats.notDownloaded.isNotEmpty()) {
item {
ArtistSectionHeader("Nicht heruntergeladen", cats.notDownloaded.size,
onShowAll = if (cats.notDownloaded.size > 4) ({
allAlbumsTitle = "Nicht heruntergeladen"
allAlbumEntries = cats.notDownloaded.map { ia ->
AllAlbumEntry(
name = ia.collectionName ?: "—",
year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
dimmed = true,
onClick = { vm.loadSubAlbum(ia) },
onDownload = { vm.showDownloadSheet(ia) },
)
}
showAllAlbums = true
}) else null
onShowAll = if (cats.notDownloaded.size > 4) ({ openAllAlbums = "Nicht heruntergeladen" }) else null
)
}
item {
@@ -626,3 +707,93 @@ internal fun AllAlbumsOverlay(
}
}
}
// ─── Artist-pin picker ────────────────────────────────────────────────────────
// Pin a local (Navidrome) artist to a specific Deezer artist, fixing same-name collisions.
// A pasted Deezer link/id is the reliable route when /search/artist can't surface the artist.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ArtistPinSheet(
artistName: String,
pinned: com.novasonic.app.api.model.ArtistOverride?,
searching: Boolean,
candidates: List<com.novasonic.app.api.model.ItunesItem>,
onPick: (com.novasonic.app.api.model.ItunesItem) -> Unit,
onPinLink: (String) -> Unit,
onClear: () -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var link by remember { mutableStateOf("") }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = NsSurface) {
Column(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 20.dp)
.padding(bottom = 16.dp),
) {
Text("Richtigen Künstler wählen", fontFamily = Outfit, fontWeight = FontWeight.Bold,
fontSize = 18.sp, color = NsOnSurface)
Text("Für „$artistName\"", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 2.dp, bottom = 14.dp))
if (pinned != null) {
OutlinedButton(
onClick = onClear,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
) {
Text("Pin entfernen (aktuell: ${pinned.name.ifBlank { "gepinnt" }})",
color = NsError, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
}
// Paste a Deezer link/id → pin directly (reliable when search misses the artist).
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = link, onValueChange = { link = it },
placeholder = { Text("Deezer-Link oder ID", fontFamily = Outfit, fontSize = 13.sp) },
singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(
onClick = { if (link.isNotBlank()) onPinLink(link) },
shape = RoundedCornerShape(10.dp),
) { Text("Pinnen", fontFamily = Outfit, fontWeight = FontWeight.Bold) }
}
Text("Nicht in der Liste? Künstler auf deezer.com öffnen und Link einfügen.",
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp))
when {
searching -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(28.dp))
}
candidates.isEmpty() -> Text("Keine Deezer-Künstler gefunden.", fontFamily = Outfit,
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 12.dp))
else -> Column(Modifier.fillMaxWidth()) {
candidates.forEach { c ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable { onPick(c) }.padding(vertical = 7.dp),
) {
Box(Modifier.size(48.dp).clip(CircleShape).background(NsSurface2)) {
AsyncImage(model = c.artworkUrl100, contentDescription = null,
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(c.artistName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(c.fanCount?.let { "${fmtFanCount(it)} Fans" } ?: "Deezer",
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
}
}
}
}
}
}
@@ -0,0 +1,345 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.CompareResult
import com.novasonic.app.api.model.SharedArtist
import com.novasonic.app.api.model.SharedTrack
import com.novasonic.app.api.model.UniqueArtist
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.CompareViewModel
@Composable
fun CompareScreen(
coverUrl: (String?) -> String?,
onBack: () -> Unit,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
vm: CompareViewModel = viewModel(),
) {
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Top bar ──
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
"Musik-Match",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
color = NsOnSurface, modifier = Modifier.weight(1f),
)
// Show "wechseln" once a partner is picked so you can go back to the list.
if (state.partner != null) {
Text(
"↺ Wechseln",
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = NsOnSurface2,
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { vm.clearPartner() }
.padding(horizontal = 8.dp, vertical = 6.dp),
)
}
}
when {
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
!state.configured -> CenterMsg(
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
)
state.error != null -> CenterMsg(state.error!!, NsError)
state.partner == null -> PartnerPicker(state.me, state.users, vm::choosePartner)
state.result != null -> CompareContent(state.result!!, coverUrl, onArtistClick, onAlbumClick)
else -> CenterBox { CircularProgressIndicator(color = NsAccent) }
}
}
}
@Composable
private fun PartnerPicker(
me: String,
users: List<com.novasonic.app.api.model.ScrobbleUser>,
onPick: (String) -> Unit,
) {
if (users.isEmpty()) {
CenterMsg("Noch niemand sonst hat Wiedergaben aufgezeichnet.\nSobald eine andere Person NovaSonic nutzt, könnt ihr euch hier vergleichen.")
return
}
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
Text(
"Mit wem willst du dich vergleichen?",
fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2,
modifier = Modifier.padding(vertical = 6.dp),
)
users.forEach { u ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.padding(vertical = 5.dp)
.clip(RoundedCornerShape(12.dp))
.background(NsSurface2)
.clickable { onPick(u.user) }
.padding(12.dp),
) {
Box(
Modifier.size(40.dp).clip(CircleShape).background(NsAccent),
contentAlignment = Alignment.Center,
) {
Text(
(u.user.firstOrNull()?.uppercase() ?: "?"),
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 18.sp, color = Color.Black,
)
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(u.user, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text("${u.plays} Plays", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
Text("›", fontFamily = Outfit, fontSize = 20.sp, color = NsOnSurface2)
}
}
}
}
@Composable
private fun CompareContent(
d: CompareResult,
coverUrl: (String?) -> String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
ScoreRing(d)
Spacer(Modifier.height(20.dp))
Totals(d)
Spacer(Modifier.height(22.dp))
SectionHeader("Gemeinsame Künstler")
if (d.sharedArtists.isEmpty()) {
EmptyLine("Keine gemeinsamen Künstler in diesem Zeitraum.")
} else {
val max = (d.sharedArtists.maxOfOrNull { maxOf(it.playsA, it.playsB) } ?: 1).coerceAtLeast(1)
d.sharedArtists.take(15).forEach { a ->
SharedArtistRow(a, max, a.artistId?.let { id -> { onArtistClick(id) } })
}
}
Spacer(Modifier.height(22.dp))
if (d.sharedTracks.isNotEmpty()) {
SectionHeader("Gemeinsame Songs")
d.sharedTracks.take(12).forEach { t ->
SharedTrackRow(t, coverUrl(t.coverArt ?: t.albumId ?: t.songId),
t.albumId?.let { id -> { onAlbumClick(id) } })
}
Spacer(Modifier.height(22.dp))
}
SectionHeader("Nur bei…")
Row(horizontalArrangement = Arrangement.spacedBy(18.dp)) {
UniqueColumn("Nur ${d.userA}", d.onlyA, onArtistClick, Modifier.weight(1f))
UniqueColumn("Nur ${d.userB}", d.onlyB, onArtistClick, Modifier.weight(1f))
}
}
}
@Composable
private fun ScoreRing(d: CompareResult) {
val score = d.matchScore.coerceIn(0, 100)
val verdict = when {
score >= 80 -> "Seelenverwandte 🎯"
score >= 60 -> "Starkes Match 🔥"
score >= 40 -> "Solide Überschneidung 👍"
score >= 20 -> "Ein paar Gemeinsamkeiten 🙂"
else -> "Ziemlich unterschiedlich 🌗"
}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Box(Modifier.size(132.dp), contentAlignment = Alignment.Center) {
val accent = NsAccent
val track = NsSurface2
Canvas(Modifier.fillMaxSize()) {
val stroke = 14.dp.toPx()
val inset = stroke / 2
val arcSize = Size(size.width - stroke, size.height - stroke)
drawArc(
color = track, startAngle = 0f, sweepAngle = 360f, useCenter = false,
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
style = Stroke(width = stroke),
)
drawArc(
color = accent, startAngle = -90f, sweepAngle = score * 3.6f, useCenter = false,
topLeft = androidx.compose.ui.geometry.Offset(inset, inset), size = arcSize,
style = Stroke(width = stroke),
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("$score%", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 30.sp, color = NsOnSurface)
Text("Match", fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2)
}
}
Spacer(Modifier.height(10.dp))
Text(verdict, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsOnSurface)
Spacer(Modifier.height(4.dp))
Text("${d.userA} ⟷ ${d.userB}", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable
private fun Totals(d: CompareResult) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TotalCard(d.userA, d.totalsA.plays, d.totalsA.artists, Modifier.weight(1f))
TotalCard(d.userB, d.totalsB.plays, d.totalsB.artists, Modifier.weight(1f))
}
}
@Composable
private fun TotalCard(name: String, plays: Int, artists: Int, modifier: Modifier) {
Column(modifier.clip(RoundedCornerShape(12.dp)).background(NsSurface2).padding(14.dp)) {
Text(name, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(6.dp))
Text("$plays Plays · $artists Künstler", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
}
}
@Composable
private fun SharedArtistRow(a: SharedArtist, max: Int, onClick: (() -> Unit)?) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 7.dp),
) {
Text("${a.playsA}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
color = NsOnSurface2, textAlign = TextAlign.End, modifier = Modifier.width(30.dp))
// left (A) bar grows toward the centre
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Box(Modifier.fillMaxWidth(a.playsA.toFloat() / max).height(6.dp)
.clip(RoundedCornerShape(3.dp)).background(NsAccent))
}
Text(
a.name ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = TextAlign.Center,
modifier = Modifier.weight(1.6f).padding(horizontal = 6.dp),
)
// right (B) bar grows from the centre
Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
Box(Modifier.fillMaxWidth(a.playsB.toFloat() / max).height(6.dp)
.clip(RoundedCornerShape(3.dp)).background(NsAccent.copy(alpha = 0.55f)))
}
Text("${a.playsB}", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
color = NsOnSurface2, textAlign = TextAlign.Start, modifier = Modifier.width(30.dp))
}
}
@Composable
private fun SharedTrackRow(t: SharedTrack, cover: String?, onClick: (() -> Unit)?) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 5.dp),
) {
Box(Modifier.size(44.dp).clip(RoundedCornerShape(6.dp)).background(NsSurface2)) {
AsyncImage(model = cover, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize())
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(t.title ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!t.artist.isNullOrBlank()) {
Text(t.artist, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Text("${t.playsA} · ${t.playsB}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable
private fun UniqueColumn(title: String, items: List<UniqueArtist>, onArtistClick: (String) -> Unit, modifier: Modifier) {
Column(modifier) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 6.dp))
if (items.isEmpty()) {
Text("—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 4.dp))
} else {
items.take(6).forEach { a ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.then(if (a.artistId != null) Modifier.clickable { onArtistClick(a.artistId) } else Modifier)
.padding(vertical = 4.dp),
) {
Text(a.name ?: "—", fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Text("${a.plays}", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 12.sp,
color = NsOnSurface2, modifier = Modifier.padding(start = 6.dp))
}
}
}
}
}
@Composable
private fun SectionHeader(t: String) {
Text(t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp))
}
@Composable
private fun EmptyLine(msg: String) {
Text(msg, fontFamily = Outfit, fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 8.dp))
}
@Composable
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
}
@Composable
private fun CenterMsg(msg: String, color: Color = NsOnSurface2) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color, textAlign = TextAlign.Center)
}
}
@@ -1,9 +1,10 @@
package com.novasonic.app.ui.screen
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -49,6 +50,8 @@ fun ExploreScreen(
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
onOverlayBackChange: ((() -> Unit)?) -> Unit = {},
onOpenImporter: () -> Unit = {},
) {
val vm: ExploreViewModel = viewModel()
@@ -56,10 +59,31 @@ fun ExploreScreen(
val focusManager = LocalFocusManager.current
val context = LocalContext.current
// BackHandlers for state-based navigation (must be unconditional)
BackHandler(enabled = state.artistDetail != null && state.partialAlbum != null) { vm.dismissPartialAlbum() }
BackHandler(enabled = state.artistDetail != null && state.albumTracks != null && state.partialAlbum == null) { vm.dismissAlbum() }
BackHandler(enabled = state.artistDetail != null && state.albumTracks == null && state.partialAlbum == null) { vm.closeArtistDetail() }
// Overlay back is handled at the app root (see AppNavigation.exploreOverlayBack): a BackHandler
// inside this NavHost destination loses back priority to the NavHost, which pops Erkunden → Home.
// Report the current overlay's close action up; clear it when this screen leaves composition
// (e.g. drilling into a real album route) so that screen's own back works normally.
val overlayKind = when {
state.artistDetail != null && state.partialAlbum != null -> 5 // artist → partial album
state.artistDetail != null && state.albumTracks != null -> 4 // artist → iTunes album
state.artistDetail != null && state.artistAllAlbums != null -> 3 // artist → all-albums grid
state.artistDetail != null -> 2 // iTunes artist
state.albumTracks != null -> 1 // album straight from search
else -> 0
}
DisposableEffect(overlayKind) {
onOverlayBackChange(
when (overlayKind) {
5 -> vm::dismissPartialAlbum
4 -> vm::dismissAlbum
3 -> vm::closeArtistAllAlbums
2 -> vm::closeArtistDetail
1 -> vm::dismissAlbum
else -> null
}
)
onDispose { onOverlayBackChange(null) }
}
// Download sheet
state.downloadSheetItem?.let { item ->
@@ -91,6 +115,7 @@ fun ExploreScreen(
onPreview = vm::togglePreview,
onDownload = vm::showDownloadSheet,
onBack = vm::dismissPartialAlbum,
onShowContextMenu = onShowContextMenu,
)
}
albumTracks != null && albumItem != null -> {
@@ -117,28 +142,44 @@ fun ExploreScreen(
onItunesAlbumOpen = vm::loadAlbum,
onItunesTrackPreview = vm::togglePreview,
previewingId = state.previewingId,
allAlbumsOpen = state.artistAllAlbums,
onOpenAllAlbums = vm::openArtistAllAlbums,
onCloseAllAlbums = vm::closeArtistAllAlbums,
)
}
}
return
}
// Swipe left/right to switch between the Bibliothek and Erkunden sub-tabs, kept in sync with the
// TabRow: a chip tap animates the pager; a settled swipe updates the selected tab.
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { ExploreTab.values().size }
LaunchedEffect(state.tab) {
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
}
LaunchedEffect(pagerState.settledPage) {
val t = ExploreTab.values()[pagerState.settledPage]
if (t != state.tab) { vm.selectTab(t); vm.stopPreview() }
}
Column(Modifier.fillMaxSize()) {
// ── Tabs ─────────────────────────────────────────────────────────────
// Drive the indicator off the pager's current page so it moves WITH the swipe (at the
// half-way point), not only once the swipe settles — that lag read as "not smooth".
TabRow(
selectedTabIndex = state.tab.ordinal,
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.background,
contentColor = NsAccent,
) {
Tab(
selected = state.tab == ExploreTab.LIBRARY,
selected = pagerState.currentPage == ExploreTab.LIBRARY.ordinal,
onClick = { vm.selectTab(ExploreTab.LIBRARY); vm.stopPreview() },
text = { Text("Bibliothek", maxLines = 1) },
selectedContentColor = NsAccent,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
Tab(
selected = state.tab == ExploreTab.GLOBAL,
selected = pagerState.currentPage == ExploreTab.GLOBAL.ordinal,
onClick = { vm.selectTab(ExploreTab.GLOBAL); vm.stopPreview() },
text = { Text("Erkunden", maxLines = 1) },
selectedContentColor = NsAccent,
@@ -205,17 +246,28 @@ fun ExploreScreen(
}
}
// ── Content ──────────────────────────────────────────────────────────
when (state.tab) {
ExploreTab.LIBRARY -> LibrarySearchContent(
// ── Content (swipeable) ────────────────────────────────────────────────
HorizontalPager(
state = pagerState,
// Pre-compose the neighbour page so the swipe doesn't hitch while its (heavy) LazyColumn
// builds on first drag.
beyondViewportPageCount = 1,
modifier = Modifier.weight(1f),
) { page ->
// fillMaxSize so short content (e.g. the Top-Alben charts row) sits at the top, not
// vertically centered in the page.
Box(Modifier.fillMaxSize()) {
when (page) {
ExploreTab.LIBRARY.ordinal -> LibrarySearchContent(
query = state.query,
coverUrl = coverUrl,
playerState = playerState,
onSongPlay = onSongPlay,
onAlbumClick = onAlbumClick,
onArtistClick = onArtistClick,
onShowContextMenu = onShowContextMenu,
)
ExploreTab.GLOBAL -> GlobalExploreContent(
else -> GlobalExploreContent(
state = state,
vm = vm,
coverUrl = coverUrl,
@@ -227,6 +279,8 @@ fun ExploreScreen(
}
}
}
}
}
// ── Library (local Navidrome) search ─────────────────────────────────────────
@@ -238,6 +292,7 @@ private fun LibrarySearchContent(
onSongPlay: (List<com.novasonic.app.api.model.Song>, Int) -> Unit,
onAlbumClick: (com.novasonic.app.api.model.Album) -> Unit,
onArtistClick: (com.novasonic.app.api.model.Artist) -> Unit,
onShowContextMenu: ((com.novasonic.app.api.model.Song) -> Unit)? = null,
) {
// Reuse SearchScreen's ViewModel
val vm: com.novasonic.app.viewmodel.SearchViewModel = viewModel()
@@ -262,7 +317,7 @@ private fun LibrarySearchContent(
return
}
LazyColumn {
LazyColumn(state = vm.listState) {
val artists = state.searchResult?.artist ?: emptyList()
val albums = state.searchResult?.album ?: emptyList()
val songs = state.searchResult?.song ?: emptyList()
@@ -283,7 +338,11 @@ private fun LibrarySearchContent(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(artists, key = { it.id }) { a ->
ArtistCard(name = a.name, imageUrl = coverUrl(a.coverArt)) { onArtistClick(a) }
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image
// is the wrong person). Otherwise: search3 usually omits coverArt → fall back
// to the artist id, which Navidrome's getCoverArt resolves to the artist image.
val pinImg = state.overrides[a.id]?.image?.takeIf { it.isNotBlank() }
ArtistCard(name = a.name, imageUrl = pinImg ?: coverUrl(a.coverArt ?: a.id)) { onArtistClick(a) }
}
}
Spacer(Modifier.height(8.dp))
@@ -313,7 +372,10 @@ private fun LibrarySearchContent(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
onPlay = { onSongPlay(songs, idx) },
starred = song.starred != null,
// Play only the tapped song; Radio mode (if on) fills the rest.
onPlay = { onSongPlay(listOf(song), 0) },
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
}
@@ -399,7 +461,7 @@ private fun GlobalExploreContent(
return
}
LazyColumn {
LazyColumn(state = vm.searchListState) {
if (state.error != null) {
item { Text(state.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)) }
@@ -415,6 +477,7 @@ private fun GlobalExploreContent(
ArtistCard(
name = a.artistName ?: "",
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
subtitle = a.fanCount?.takeIf { it > 0 }?.let { "${fmtFanCount(it)} Fans" },
) { vm.openArtistDetail(a) }
}
}
@@ -590,28 +653,47 @@ private fun ItunesTrackRow(
}
@Composable
private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) {
private fun ArtistCard(name: String, imageUrl: String?, subtitle: String? = null, onClick: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.width(80.dp)
.clickable(onClick = onClick),
) {
AsyncImage(
model = imageUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(72.dp)
.clip(androidx.compose.foundation.shape.CircleShape)
.background(NsSurface2),
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
Icons.Default.Person, contentDescription = null,
tint = NsOnSurface2, modifier = Modifier.size(32.dp),
)
}
}
Spacer(Modifier.height(6.dp))
Text(
name, fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.Medium,
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
if (subtitle != null) {
Text(
subtitle, fontFamily = Outfit, fontSize = 10.sp, fontWeight = FontWeight.Medium,
color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
}
@@ -12,6 +12,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PlayCircle
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -205,7 +207,12 @@ fun ImporterScreen(onBack: () -> Unit) {
}
itemsIndexed(r.tracks) { idx, track ->
ImportTrackRow(idx + 1, track)
ImportTrackRow(
num = idx + 1,
track = track,
isPreviewing = state.previewingId != null && state.previewingId == vm.previewId(track),
onPreview = { vm.togglePreview(track) },
)
}
}
}
@@ -258,7 +265,12 @@ private fun JobProgress(
}
@Composable
private fun ImportTrackRow(num: Int, track: ImportTrack) {
private fun ImportTrackRow(
num: Int,
track: ImportTrack,
isPreviewing: Boolean,
onPreview: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
@@ -272,11 +284,22 @@ private fun ImportTrackRow(num: Int, track: ImportTrack) {
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(track.trackName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.Medium,
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
fontSize = 14.sp, color = if (isPreviewing) NsAccent else NsOnSurface,
maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!track.artistName.isNullOrBlank()) {
Text(track.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
// 30s preview — only when the source provided a clip
if (track.previewUrl != null) {
IconButton(onClick = onPreview, modifier = Modifier.size(36.dp)) {
Icon(
if (isPreviewing) Icons.Default.Stop else Icons.Default.PlayCircle,
if (isPreviewing) "Stop" else "Preview",
tint = if (isPreviewing) NsAccent else NsOnSurface2,
)
}
}
}
}
@@ -34,12 +34,15 @@ import coil.compose.AsyncImage
import com.novasonic.app.api.model.Album
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.Song
import com.novasonic.app.api.model.fmtFanCount
import com.novasonic.app.player.PlayerState
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.ItunesArtistState
private fun normAlbum(s: String?): String =
(s ?: "").lowercase().replace(Regex("[^a-z0-9\\s]"), " ").replace(Regex("\\s+"), " ").trim()
// Keep Unicode letters/numbers (\p{L}\p{N}) — stripping to a-z0-9 emptied every Japanese/CJK
// title, so all of an artist's JP singles collapsed into one and "matched" each other.
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}\\s]"), " ").replace(Regex("\\s+"), " ").trim()
data class ArtistAlbumCategories(
val downloaded: List<Pair<Album, ItunesItem?>>,
@@ -57,6 +60,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)
@@ -64,11 +70,15 @@ 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)
// Deezer returns the same album as several variants (single/EP/album/regional); distinctBy
// above only catches identical ids, so also skip an owned album we've already classified —
// otherwise it shows 2-3x ("PIXELATED KISSES").
match.id in usedNdIds -> Unit
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)
}
}
}
@@ -96,21 +106,44 @@ fun ItunesArtistScreen(
onItunesAlbumOpen: (ItunesItem) -> Unit,
onItunesTrackPreview: (ItunesItem) -> Unit,
previewingId: Long?,
allAlbumsOpen: String?,
onOpenAllAlbums: (String) -> Unit,
onCloseAllAlbums: () -> Unit,
) {
val artist = artistState.artist
val cats = if (!artistState.isLoading)
categorizeAlbums(artistState.ndAlbums, artistState.iAlbums)
else null
var showAllAlbums by remember { mutableStateOf(false) }
var allAlbumsTitle by remember { mutableStateOf("") }
var allAlbumEntries by remember { mutableStateOf<List<AllAlbumEntry>>(emptyList()) }
// "All albums" state is hoisted (ExploreViewModel.artistAllAlbums) so it survives an album
// drill-down and back; entries are re-derived from cats for whichever section is open.
val allAlbumEntries: List<AllAlbumEntry> = when (allAlbumsOpen) {
"In Library" -> cats?.downloaded?.map { (nd, _) ->
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
}.orEmpty()
"Unvollständig" -> cats?.partial?.map { (nd, ia, counts) ->
AllAlbumEntry(
name = nd.name, year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { onPartialAlbumOpen(nd, ia) }, onDownload = { onDownload(ia) },
)
}.orEmpty()
"Nicht heruntergeladen" -> cats?.notDownloaded?.map { ia ->
AllAlbumEntry(
name = ia.collectionName ?: "—", year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"), dimmed = true,
onClick = { onItunesAlbumOpen(ia) }, onDownload = { onDownload(ia) },
)
}.orEmpty()
else -> emptyList()
}
if (showAllAlbums) {
if (allAlbumsOpen != null) {
AllAlbumsOverlay(
title = allAlbumsTitle,
title = allAlbumsOpen,
entries = allAlbumEntries,
onBack = { showAllAlbums = false },
onBack = onCloseAllAlbums,
)
return
}
@@ -162,6 +195,10 @@ fun ItunesArtistScreen(
Text(artist.primaryGenreName!!, fontFamily = Outfit,
fontSize = 13.sp, color = Color.White.copy(0.6f))
}
artist.fanCount?.takeIf { it > 0 }?.let {
Text("${fmtFanCount(it)} Fans", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 12.sp, color = Color.White.copy(0.55f))
}
}
}
@@ -208,7 +245,7 @@ fun ItunesArtistScreen(
// iTunes top tracks
if (artistState.iTracks.isNotEmpty()) {
item { ArtistSectionHeader("Weltweit populär (iTunes)") }
item { ArtistSectionHeader("Weltweit populär (Deezer)") }
itemsIndexed(artistState.iTracks, key = { _, t -> t.trackId ?: 0L }) { _, track ->
ItunesArtistTrackRow(
track = track,
@@ -224,13 +261,7 @@ fun ItunesArtistScreen(
if (c.downloaded.isNotEmpty()) {
item {
ArtistSectionHeader("In Library", c.downloaded.size,
onShowAll = if (c.downloaded.size > 4) ({
allAlbumsTitle = "In Library"
allAlbumEntries = c.downloaded.map { (nd, _) ->
AllAlbumEntry(name = nd.name, year = nd.year?.toString(), artUrl = coverUrl(nd.coverArt), onClick = { onAlbumClick(nd) })
}
showAllAlbums = true
}) else null
onShowAll = if (c.downloaded.size > 4) ({ onOpenAllAlbums("In Library") }) else null
)
}
item {
@@ -255,20 +286,7 @@ fun ItunesArtistScreen(
if (c.partial.isNotEmpty()) {
item {
ArtistSectionHeader("Unvollständig", c.partial.size,
onShowAll = if (c.partial.size > 4) ({
allAlbumsTitle = "Unvollständig"
allAlbumEntries = c.partial.map { (nd, ia, counts) ->
AllAlbumEntry(
name = nd.name,
year = nd.year?.toString(),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb") ?: coverUrl(nd.coverArt),
badge = "${counts.first}/${counts.second}",
onClick = { onPartialAlbumOpen(nd, ia) },
onDownload = { onDownload(ia) },
)
}
showAllAlbums = true
}) else null
onShowAll = if (c.partial.size > 4) ({ onOpenAllAlbums("Unvollständig") }) else null
)
}
item {
@@ -295,20 +313,7 @@ fun ItunesArtistScreen(
if (c.notDownloaded.isNotEmpty()) {
item {
ArtistSectionHeader("Nicht heruntergeladen", c.notDownloaded.size,
onShowAll = if (c.notDownloaded.size > 4) ({
allAlbumsTitle = "Nicht heruntergeladen"
allAlbumEntries = c.notDownloaded.map { ia ->
AllAlbumEntry(
name = ia.collectionName ?: "—",
year = ia.releaseDate?.take(4),
artUrl = ia.artworkUrl100?.replace("100x100bb", "300x300bb"),
dimmed = true,
onClick = { onItunesAlbumOpen(ia) },
onDownload = { onDownload(ia) },
)
}
showAllAlbums = true
}) else null
onShowAll = if (c.notDownloaded.size > 4) ({ onOpenAllAlbums("Nicht heruntergeladen") }) else null
)
}
item {
@@ -10,6 +10,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
@@ -79,16 +81,28 @@ fun LibraryScreen(
var showCreateDialog by remember { mutableStateOf(false) }
var groupToDelete by remember { mutableStateOf<DownloadGroupEntity?>(null) }
// Swipe left/right to switch library tabs, kept in sync with the TabRow (like Erkunden): a chip
// tap animates the pager; a settled swipe updates the selected tab. Indicator is driven off the
// pager's current page so it moves WITH the swipe.
val pagerState = rememberPagerState(initialPage = state.tab.ordinal) { LibraryTab.entries.size }
LaunchedEffect(state.tab) {
if (pagerState.currentPage != state.tab.ordinal) pagerState.animateScrollToPage(state.tab.ordinal)
}
LaunchedEffect(pagerState.settledPage) {
val t = LibraryTab.entries[pagerState.settledPage]
if (t != state.tab) vm.selectTab(t)
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
TabRow(
selectedTabIndex = state.tab.ordinal,
selectedTabIndex = pagerState.currentPage,
containerColor = MaterialTheme.colorScheme.background,
contentColor = NsAccent,
) {
LibraryTab.entries.forEach { tab ->
Tab(
selected = state.tab == tab,
selected = pagerState.currentPage == tab.ordinal,
onClick = { vm.selectTab(tab) },
text = {
Text(
@@ -108,14 +122,15 @@ fun LibraryScreen(
}
}
if (state.isLoading) {
HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page ->
val pageTab = LibraryTab.entries[page]
// The Offline tab is fed by the local Room DB (offlineGroups) and must stay usable even while
// the online library is still loading or unreachable — only gate the network-backed tabs.
if (state.isLoading && pageTab != LibraryTab.OFFLINE) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NsAccent)
}
return@Column
}
when (state.tab) {
} else when (pageTab) {
LibraryTab.ALBUMS -> Column(Modifier.fillMaxSize()) {
// Sort chips
androidx.compose.foundation.lazy.LazyRow(
@@ -151,7 +166,7 @@ fun LibraryScreen(
)
}
}
val albumGridState = rememberLazyGridState()
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
LazyVerticalGrid(
state = albumGridState,
@@ -174,7 +189,7 @@ fun LibraryScreen(
}
LibraryTab.ARTISTS -> {
val listState = rememberLazyListState()
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
val scope = rememberCoroutineScope()
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
// Map letter → flat LazyColumn index of its header item
@@ -386,9 +401,10 @@ fun LibraryScreen(
}
}
}
} // end HorizontalPager
} // end Column
if (state.tab == LibraryTab.PLAYLISTS) {
if (pagerState.currentPage == LibraryTab.PLAYLISTS.ordinal) {
FloatingActionButton(
onClick = { showCreateDialog = true },
modifier = Modifier
@@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -76,6 +78,18 @@ fun NowPlayingScreen(
val song = state.currentSong
var tab by remember { mutableStateOf(NpTab.PLAYER) }
// Swipe left/right between Player / Lyrics / Queue, kept in sync with the tab pills (like
// Erkunden). A horizontal drag that starts on the cover still skips tracks — that gesture is a
// child pointerInput and consumes the drag before the pager sees it.
val pagerState = rememberPagerState(initialPage = tab.ordinal) { NpTab.entries.size }
LaunchedEffect(tab) {
if (pagerState.currentPage != tab.ordinal) pagerState.animateScrollToPage(tab.ordinal)
}
LaunchedEffect(pagerState.settledPage) {
val t = NpTab.entries[pagerState.settledPage]
if (t != tab) tab = t
}
// Preload lyrics as soon as NowPlaying opens — so the Lyrics tab shows instantly
val lyricsVm: LyricsViewModel = viewModel()
LaunchedEffect(song?.id) {
@@ -178,7 +192,7 @@ fun NowPlayingScreen(
.padding(3.dp),
) {
NpTab.entries.forEach { t ->
val sel = tab == t
val sel = pagerState.currentPage == t.ordinal
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
@@ -210,8 +224,18 @@ fun NowPlayingScreen(
}
}
// ── Swipeable tab content (Player / Lyrics / Queue) ────────────
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f).fillMaxWidth(),
) { page ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize(),
) {
when (NpTab.entries[page]) {
// ── PLAYER TAB ─────────────────────────────────────────────────
if (tab == NpTab.PLAYER) {
NpTab.PLAYER -> {
var dragX by remember { mutableStateOf(0f) }
Spacer(Modifier.height(28.dp))
@@ -258,6 +282,16 @@ fun NowPlayingScreen(
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(3.dp))
// Order matches the web player: title → album → artists.
if (!song?.album.isNullOrBlank()) {
Text(
song!!.album!!,
fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(3.dp))
}
if (song != null && !song.artist.isNullOrBlank()) {
// Prefer OpenSubsonic artists array (has IDs per contributor).
// Fall back to splitting artist string by ';' (same as web app).
@@ -303,17 +337,10 @@ fun NowPlayingScreen(
}
}
}
if (!song?.album.isNullOrBlank()) {
Text(
song!!.album!!,
fontFamily = Outfit, fontSize = 12.sp,
color = Color.White.copy(alpha = 0.45f),
maxLines = 1, overflow = TextOverflow.Ellipsis,
)
}
}
if (onStarToggle != null && song != null) {
val starred = song.starred != null
val starOverrides by com.novasonic.app.data.StarState.overrides.collectAsState()
val starred = starOverrides[song.id] ?: (song.starred != null)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
@@ -321,7 +348,7 @@ fun NowPlayingScreen(
.clickable(onClick = onStarToggle),
) {
Icon(
if (starred) Icons.Default.Star else Icons.Default.StarBorder,
if (starred) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
null,
tint = if (starred) NsAccent else Color.White.copy(0.5f),
modifier = Modifier.size(22.dp),
@@ -398,7 +425,7 @@ fun NowPlayingScreen(
}
// ── LYRICS TAB ─────────────────────────────────────────────────
if (tab == NpTab.LYRICS && song != null) {
NpTab.LYRICS -> if (song != null) {
val lyricsState by lyricsVm.state.collectAsStateWithLifecycle()
val lyricsListState = rememberLazyListState()
val density = LocalDensity.current
@@ -492,7 +519,7 @@ fun NowPlayingScreen(
}
// ── QUEUE TAB ──────────────────────────────────────────────────
if (tab == NpTab.QUEUE) {
NpTab.QUEUE -> {
// Radio mode toggle
if (onToggleRadio != null) {
Row(
@@ -590,6 +617,9 @@ fun NowPlayingScreen(
}
}
}
} // end when
} // end inner Column
} // end HorizontalPager
}
}
}
@@ -5,9 +5,12 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
@@ -27,7 +30,9 @@ import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.PartialAlbumState
private fun normTrack(s: String?) =
(s ?: "").lowercase().replace(Regex("[^a-z0-9]"), "").trim()
// Keep Unicode letters/numbers so Japanese track titles aren't all emptied to "" (which made
// every downloaded JP track "match" every missing one in the partial-album split).
(s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "").trim()
@Composable
fun PartialAlbumScreen(
@@ -39,6 +44,7 @@ fun PartialAlbumScreen(
onPreview: (ItunesItem) -> Unit,
onDownload: (ItunesItem) -> Unit,
onBack: () -> Unit,
onShowContextMenu: ((Song) -> Unit)? = null,
) {
val ia = partialAlbum.itunesAlbum
val nd = partialAlbum.ndAlbum
@@ -94,6 +100,50 @@ fun PartialAlbumScreen(
.sortedBy { it.trackNumber ?: 999 }
LazyColumn(Modifier.fillMaxSize()) {
// ── Play / Shuffle the downloaded tracks (you can still enjoy the album even when not
// every track is downloaded yet) ─────────────────────────────────────────────────
if (downloadedSongs.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 12.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(NsAccent)
.clickable { onPlay(downloadedSongs, 0) }
.padding(vertical = 11.dp),
) {
Icon(Icons.Default.PlayArrow, null, tint = NsBackground, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Abspielen", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsBackground)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(10.dp))
.background(NsSurface2)
.clickable { onPlay(downloadedSongs.shuffled(), 0) }
.padding(vertical = 11.dp),
) {
Icon(Icons.Default.Shuffle, null, tint = NsOnSurface, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text("Zufall", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, color = NsOnSurface)
}
}
}
}
// ── Downloaded tracks ─────────────────────────────────────────────
if (downloadedSongs.isNotEmpty()) {
item {
@@ -111,6 +161,7 @@ fun PartialAlbumScreen(
starred = song.starred != null,
onPlay = { onPlay(downloadedSongs, idx) },
onStarToggle = {},
onMore = onShowContextMenu?.let { cb -> { cb(song) } },
)
}
}
@@ -196,7 +196,10 @@ fun PlaylistDetailScreen(
}
}
// ── Play / Shuffle / Edit-mode row ────────────────────────────────
// ── Play / Shuffle / Edit / Download ──────────────────────────────
// Four buttons in one row left no room for labels (the Play/Shuffle text was being
// clipped away), so all four are equal-width and icon-only. Play stays filled as the
// primary action; the rest are outlined and tint accent when active/done.
Row(
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
@@ -206,35 +209,33 @@ fun PlaylistDetailScreen(
modifier = Modifier.weight(1f).height(46.dp),
colors = ButtonDefaults.buttonColors(containerColor = NsAccent, contentColor = Color.White),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.PlayArrow, null, modifier = Modifier.size(20.dp))
Spacer(Modifier.width(6.dp))
Text("Play", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp)
Icon(Icons.Default.PlayArrow, "Abspielen", modifier = Modifier.size(22.dp))
}
OutlinedButton(
onClick = { if (pl.entry.isNotEmpty()) onPlay(pl.entry.shuffled(), 0) },
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(brush = SolidColor(NsAccent.copy(alpha = 0.55f))),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(Icons.Default.Shuffle, null, modifier = Modifier.size(20.dp), tint = NsAccent)
Spacer(Modifier.width(6.dp))
Text("Shuffle", fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 15.sp, color = NsAccent)
Icon(Icons.Default.Shuffle, "Zufällig abspielen", modifier = Modifier.size(20.dp), tint = NsAccent)
}
// Toggle edit mode for removing songs
OutlinedButton(
onClick = { editMode = !editMode },
modifier = Modifier.height(46.dp),
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (editMode) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
contentPadding = PaddingValues(0.dp),
) {
Icon(
if (editMode) Icons.Default.Done else Icons.Default.Edit,
null,
modifier = Modifier.size(18.dp),
if (editMode) "Bearbeiten beenden" else "Titel entfernen",
modifier = Modifier.size(20.dp),
tint = if (editMode) NsAccent else NsOnSurface2,
)
}
@@ -242,17 +243,17 @@ fun PlaylistDetailScreen(
val dlDone = downloadGroup?.let { it.downloadedTracks >= it.totalTracks && it.totalTracks > 0 } ?: false
OutlinedButton(
onClick = { if (!dlDone) vm.downloadPlaylist() },
modifier = Modifier.height(46.dp),
modifier = Modifier.weight(1f).height(46.dp),
border = ButtonDefaults.outlinedButtonBorder(enabled = true).copy(
brush = SolidColor(if (dlDone) NsAccent else NsOnSurface3)
),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(horizontal = 14.dp),
contentPadding = PaddingValues(0.dp),
) {
when {
dlDone -> Icon(Icons.Default.CheckCircle, null, modifier = Modifier.size(18.dp), tint = NsAccent)
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = NsAccent)
else -> Icon(Icons.Default.Download, null, modifier = Modifier.size(18.dp), tint = NsOnSurface2)
dlDone -> Icon(Icons.Default.CheckCircle, "Offline verfügbar", modifier = Modifier.size(20.dp), tint = NsAccent)
isDownloading -> CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = NsAccent)
else -> Icon(Icons.Default.Download, "Herunterladen", modifier = Modifier.size(20.dp), tint = NsOnSurface2)
}
}
}
@@ -57,12 +57,12 @@ fun SearchScreen(
if (r.song.isNotEmpty()) {
item { ResultHeader("Songs") }
items(r.song, key = { it.id }) { song ->
val idx = r.song.indexOf(song)
SongRow(
song = song,
coverUrl = coverUrl(song.coverArt),
isPlaying = playerState.currentSong?.id == song.id,
onPlay = { onSongPlay(r.song, idx) },
// Play only the tapped song; Radio mode (if on) fills the rest.
onPlay = { onSongPlay(listOf(song), 0) },
)
}
}
@@ -41,7 +41,7 @@ private val SCROBBLE_OPTIONS = listOf(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
fun SettingsScreen(onOpenStats: () -> Unit = {}, onOpenCompare: () -> Unit = {}) {
val vm: SettingsViewModel = viewModel()
val state by vm.state.collectAsStateWithLifecycle()
@@ -80,7 +80,23 @@ fun SettingsScreen() {
color = NsOnSurface,
)
// ── 📊 Statistics (TOP) ──────────────────────────────────────────────
OutlinedButton(
onClick = onOpenStats,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Text("📊 Meine Statistiken", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
OutlinedButton(
onClick = onOpenCompare,
modifier = Modifier.fillMaxWidth().height(48.dp),
) {
Text("🎧 Musik-Match", color = NsOnSurface, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
}
// ── Save button (TOP) ────────────────────────────────────────────────
Spacer(Modifier.height(4.dp))
SaveButton(vm, state.isSaving, state.savedOk, state.error)
// ── 🎨 Theme ─────────────────────────────────────────────────────────
@@ -186,6 +202,14 @@ fun SettingsScreen() {
)
ConnStatusRow(status = state.backendStatus, label = "Nicht konfiguriert", onTest = vm::testBackend)
NsTextField(
value = state.scrobbleUrl, onValueChange = vm::onScrobbleUrl,
label = "NovaSonic Scrobble URL",
hint = "Play-Log für Statistiken (Port 3710)",
keyboardType = KeyboardType.Uri,
)
ConnStatusRow(status = state.scrobbleStatus, label = "Nicht konfiguriert", onTest = vm::testScrobble)
Spacer(Modifier.height(4.dp))
// ── 🌐 Navidrome Server ──────────────────────────────────────────────
@@ -0,0 +1,347 @@
package com.novasonic.app.ui.screen
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.novasonic.app.api.model.ScrobbleStats
import com.novasonic.app.ui.theme.*
import com.novasonic.app.viewmodel.StatsView
import com.novasonic.app.viewmodel.StatsViewModel
private val MONTHS = listOf(
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
)
@Composable
fun StatsScreen(
coverUrl: (String?) -> String?,
onBack: () -> Unit,
onArtistClick: (String) -> Unit = {},
onAlbumClick: (String) -> Unit = {},
vm: StatsViewModel = viewModel(),
) {
val state by vm.state.collectAsStateWithLifecycle()
Column(Modifier.fillMaxSize().background(NsBackground)) {
// ── Top bar ──
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, top = 8.dp, end = 12.dp),
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Zurück", tint = NsOnSurface)
}
Text(
"Meine Statistiken",
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp,
color = NsOnSurface,
)
}
// ── Navigator: scope chips + year/month stepper ──
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
) {
listOf(StatsView.ALL to "Alles", StatsView.YEAR to "Jahr", StatsView.MONTH to "Monat").forEach { (v, l) ->
val active = v == state.view
Text(
l,
fontFamily = Outfit, fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = if (active) Color.White else NsOnSurface2,
modifier = Modifier
.padding(end = 8.dp)
.clip(RoundedCornerShape(999.dp))
.background(if (active) NsAccent else Color.Transparent)
.clickable { vm.setView(v) }
.padding(horizontal = 14.dp, vertical = 7.dp),
)
}
if (state.view != StatsView.ALL) {
Spacer(Modifier.weight(1f))
StepArrow("‹", enabled = !vm.atStart()) { vm.step(-1) }
Text(
if (state.view == StatsView.YEAR) state.year.toString() else "${MONTHS[state.month]} ${state.year}",
fontFamily = Outfit, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = NsOnSurface,
modifier = Modifier.padding(horizontal = 8.dp),
)
StepArrow("›", enabled = !vm.atCurrent()) { vm.step(1) }
}
}
when {
state.loading -> CenterBox { CircularProgressIndicator(color = NsAccent) }
!state.configured -> CenterMessage(
"Kein Scrobble-Dienst konfiguriert.\nTrage die Scrobble-URL in den Einstellungen ein.",
)
state.error != null -> CenterMessage(state.error!!, NsError)
(state.stats?.totalPlays ?: 0) == 0 -> CenterMessage(
"Noch keine Wiedergaben in diesem Zeitraum.\nSpiel ein paar Songs — sie erscheinen hier automatisch.",
)
else -> StatsContent(state.stats!!, coverUrl, onArtistClick, onAlbumClick)
}
}
}
@Composable
private fun StatsContent(
d: ScrobbleStats,
coverUrl: (String?) -> String?,
onArtistClick: (String) -> Unit,
onAlbumClick: (String) -> Unit,
) {
Column(
Modifier.fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp).padding(bottom = 24.dp),
) {
// Tiles
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(bottom = 18.dp)) {
Tile(d.totalPlays.toString(), "Plays", Modifier.weight(1f))
Tile(fmtTime(d.totalSeconds), "Hörzeit", Modifier.weight(1f))
Tile(if (d.topArtists.size >= 10) "10+" else d.topArtists.size.toString(), "Künstler", Modifier.weight(1f))
}
if (d.series.isNotEmpty()) {
SectionHeader("Verlauf")
SeriesChart(d.series)
Spacer(Modifier.height(20.dp))
}
if (d.topArtists.isNotEmpty()) {
SectionHeader("Top-Künstler")
d.topArtists.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.artistId), a.name ?: "—", null, a.plays, circular = true,
onClick = a.artistId?.let { id -> { onArtistClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
if (d.topTracks.isNotEmpty()) {
SectionHeader("Top-Songs")
d.topTracks.forEachIndexed { i, t ->
TopRow(i + 1, coverUrl(t.coverArt ?: t.albumId ?: t.songId), t.title ?: "—", t.artist, t.plays,
onClick = t.albumId?.let { id -> { onAlbumClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
if (d.topAlbums.isNotEmpty()) {
SectionHeader("Top-Alben")
d.topAlbums.forEachIndexed { i, a ->
TopRow(i + 1, coverUrl(a.coverArt ?: a.albumId), a.name ?: "—", a.artist, a.plays,
onClick = a.albumId?.let { id -> { onAlbumClick(id) } })
}
Spacer(Modifier.height(20.dp))
}
if (d.topGenres.isNotEmpty()) {
SectionHeader("Top-Genres")
d.topGenres.forEachIndexed { i, g -> GenreRow(i + 1, g.name ?: "—", g.plays) }
Spacer(Modifier.height(20.dp))
}
if (d.byHour.any { it.plays > 0 }) {
SectionHeader("Hör-Uhr (Stunde)")
HourClock(d.byHour.map { it.plays })
}
}
}
@Composable
private fun GenreRow(rank: Int, name: String, plays: Int) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
) {
Text(
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = NsOnSurface2, modifier = Modifier.width(22.dp),
)
Text(
name, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 12.dp),
)
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable
private fun StepArrow(glyph: String, enabled: Boolean, onClick: () -> Unit) {
Text(
glyph,
fontFamily = Outfit, fontSize = 20.sp, fontWeight = FontWeight.Bold,
color = if (enabled) NsOnSurface else NsOnSurface2.copy(alpha = 0.35f),
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
@Composable
private fun Tile(value: String, label: String, modifier: Modifier = Modifier) {
Column(
modifier
.clip(RoundedCornerShape(12.dp))
.background(NsSurface2)
.padding(14.dp),
) {
Text(value, fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 22.sp, color = NsOnSurface, maxLines = 1)
Text(label, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, modifier = Modifier.padding(top = 2.dp))
}
}
@Composable
private fun SectionHeader(t: String) {
Text(
t.uppercase(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 12.sp,
letterSpacing = 0.6.sp, color = NsOnSurface2, modifier = Modifier.padding(bottom = 8.dp),
)
}
@Composable
private fun TopRow(
rank: Int, cover: String?, title: String, subtitle: String?, plays: Int, circular: Boolean = false,
onClick: (() -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
.padding(vertical = 5.dp),
) {
Text(
rank.toString(), fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 13.sp,
color = NsOnSurface2, modifier = Modifier.width(22.dp),
)
Box(
Modifier
.size(44.dp)
.clip(if (circular) CircleShape else RoundedCornerShape(6.dp))
.background(NsSurface2),
) {
AsyncImage(
model = cover, contentDescription = null, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!subtitle.isNullOrBlank()) {
Text(subtitle, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Text("$plays", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, color = NsOnSurface2)
}
}
@Composable
private fun SeriesChart(series: List<com.novasonic.app.api.model.StatBucket>) {
val max = (series.maxOfOrNull { it.plays } ?: 1).coerceAtLeast(1)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().height(110.dp).horizontalScroll(rememberScrollState()),
) {
series.forEach { s ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.width(30.dp)) {
Box(
Modifier
.width(20.dp)
.height((6 + (84 * s.plays / max)).dp)
.clip(RoundedCornerShape(topStart = 4.dp, topEnd = 4.dp))
.background(NsAccent),
)
Text(
seriesLabel(s.bucket), fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
maxLines = 1, modifier = Modifier.padding(top = 4.dp),
)
}
}
}
}
@Composable
private fun HourClock(hours: List<Int>) {
val max = (hours.maxOrNull() ?: 1).coerceAtLeast(1)
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.Bottom,
modifier = Modifier.fillMaxWidth().height(72.dp),
) {
hours.forEachIndexed { h, plays ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f)) {
Box(
Modifier
.fillMaxWidth()
.height((2 + (48 * plays / max)).dp)
.clip(RoundedCornerShape(topStart = 3.dp, topEnd = 3.dp))
.background(if (plays > 0) NsAccent else NsSurface2),
)
Text(
if (h % 6 == 0) h.toString() else "",
fontFamily = Outfit, fontSize = 9.sp, color = NsOnSurface2,
modifier = Modifier.padding(top = 3.dp),
)
}
}
}
}
@Composable
private fun CenterBox(content: @Composable BoxScope.() -> Unit) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center, content = content)
}
@Composable
private fun CenterMessage(msg: String, color: Color = NsOnSurface2) {
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
Text(msg, fontFamily = Outfit, fontSize = 14.sp, color = color)
}
}
// "3h 12m" / "12m" / "45s"
private fun fmtTime(sec: Long): String {
val h = sec / 3600
val m = (sec % 3600) / 60
return when {
h > 0 -> "${h}h ${m}m"
m > 0 -> "${m}m"
else -> "${sec}s"
}
}
// "2025-08" -> "08.25", "2025-08-03" -> "03.08", "2025" -> "2025"
private fun seriesLabel(b: String): String = when {
b.length == 7 -> b.substring(5) + "." + b.substring(2, 4)
b.length == 10 -> b.substring(8) + "." + b.substring(5, 7)
else -> b
}
@@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.Color
// ── Dynamic theme state — Compose tracks reads automatically ─────────────────
object NsColors {
var themeId by mutableStateOf("dark-ocean") // currently applied theme id (readable)
var accent by mutableStateOf(Color(0xFF4F8EF7))
var accentDim by mutableStateOf(Color(0xFF2A6CD6))
var bg by mutableStateOf(Color(0xFF070D14))
@@ -88,6 +89,7 @@ val NS_THEMES = listOf(
fun applyNsTheme(themeId: String) {
val t = NS_THEMES.find { it.id == themeId } ?: NS_THEMES.first()
NsColors.themeId = t.id
NsColors.accent = t.accent
NsColors.accentDim = t.accentDim
NsColors.bg = t.bg
@@ -1,7 +1,6 @@
package com.novasonic.app.viewmodel
import android.app.Application
import android.media.MediaPlayer
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
@@ -9,6 +8,7 @@ import com.novasonic.app.api.EventsClient
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.ItunesClient
import com.novasonic.app.api.model.Artist
import com.novasonic.app.api.model.ArtistOverride
import com.novasonic.app.api.model.ConcertEvent
import com.novasonic.app.api.model.ArtistInfo
import com.novasonic.app.api.model.DownloadRequest
@@ -41,32 +41,45 @@ data class ArtistUiState(
val partialAlbum: PartialAlbumState? = null,
// Concerts
val events: List<ConcertEvent> = emptyList(),
val eventsError: String? = null, // non-null → the fetch failed (vs. genuinely no concerts)
val showEventsSheet: Boolean = false,
// Deezer fan count of the matched artist (shown under the name), null if unknown
val fanCount: Long? = null,
// Preview + download
val previewingId: Long? = null,
val downloadSheetItem: ItunesItem? = null,
// Artist pin (same-name disambiguation): the active pin, the image it overrides the hero with,
// and the picker state (link/id paste + Deezer search candidates).
val pin: ArtistOverride? = null,
val pinnedImage: String? = null,
// When pinned, the Similar-Artists row uses Deezer "related" (correct for the pinned artist)
// instead of Last.fm's list (keyed to the wrong/merged Navidrome artist).
val pinnedRelated: List<ItunesItem> = emptyList(),
val showPinPicker: Boolean = false,
val pinSearching: Boolean = false,
val pinCandidates: List<ItunesItem> = emptyList(),
)
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
private val repo = app.app.repository
private val player = app.app.playerController
private val preview = app.app.previewController
private val artistId: String? = savedState["artistId"]
private val _state = MutableStateFlow(ArtistUiState())
val state: StateFlow<ArtistUiState> = _state
private var mediaPlayer: MediaPlayer? = null
init {
if (artistId.isNullOrBlank()) {
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
} else {
load()
}
// Mirror the app-level preview so track rows show which clip plays (bar lives in AppNavigation).
viewModelScope.launch {
player.state.collect { ps ->
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
preview.state.collect { ps ->
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
}
}
}
@@ -83,19 +96,30 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
val info = infoDef.await()
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
val top = topDef.await()
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
val pin = pinDef.await()
_state.value = ArtistUiState(
artist = artist, info = info, topSongs = top,
isLoading = false, itunesLoading = true,
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
)
// Fetch iTunes + events in parallel without blocking the main screen
launch { loadItunesData(artist.name) }
launch { loadItunesData(artist.name, pin) }
launch {
val backendUrl = repo.getBackendUrl()
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
_state.value = _state.value.copy(events = ev)
runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }
.onSuccess { _state.value = _state.value.copy(events = it, eventsError = null) }
.onFailure { e ->
android.util.Log.w("NovaSonic", "Konzert-Fetch fehlgeschlagen für ${artist.name}", e)
_state.value = _state.value.copy(events = emptyList(), eventsError = e.message ?: e.toString())
}
}
} catch (e: Exception) {
_state.value = ArtistUiState(isLoading = false, itunesLoading = false, error = e.message ?: e.toString())
@@ -103,20 +127,49 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
}
}
private suspend fun loadItunesData(artistName: String) {
private suspend fun loadItunesData(artistName: String, pin: ArtistOverride? = null) {
// Pinned: skip the name-guess entirely and enrich straight from the pinned Deezer artist id.
val pinnedId = pin?.deezerArtistId?.toLongOrNull()
if (pinnedId != null) {
_state.value = _state.value.copy(
fanCount = pin.fanCount ?: _state.value.fanCount,
pinnedImage = pin.image.takeIf { it.isNotBlank() } ?: _state.value.pinnedImage,
)
coroutineScope {
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(pinnedId) }.getOrDefault(emptyList()) }
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(pinnedId, 10) }.getOrDefault(emptyList()) }
val relatedD = async { runCatching { ItunesClient.lookupRelatedArtists(pinnedId, 12) }.getOrDefault(emptyList()) }
_state.value = _state.value.copy(
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(),
pinnedRelated = relatedD.await(), itunesLoading = false,
)
}
enrichPartialCounts()
return
}
// Not pinned → clear any stale pinned-related from a previous pin.
_state.value = _state.value.copy(pinnedRelated = emptyList())
val searchResp = runCatching {
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
}.getOrNull()
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
val iArtistId = (
artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
?: artists.find {
val n = it.artistName ?: return@find false
// artists are fan-sorted (ItunesClient), so firstOrNull gives the most popular match.
// Prefer an EXACT normalized-name match: "Flay!" must not resolve to the far more popular
// "K.FLAY" (its name merely contains "flay"). Only fall back to a loose contains-match.
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
val q = norm(artistName)
val iArtist = (
artists.firstOrNull { norm(it.artistName) == q }
?: artists.firstOrNull {
val n = it.artistName ?: return@firstOrNull false
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
}
?: artists.firstOrNull() // iTunes sorts by relevance — first hit is almost always right
)?.artistId
?: artists.firstOrNull()
)
val iArtistId = iArtist?.artistId
if (iArtist?.fanCount != null) _state.value = _state.value.copy(fanCount = iArtist.fanCount)
if (iArtistId != null) {
coroutineScope {
@@ -128,11 +181,41 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
itunesLoading = false,
)
}
enrichPartialCounts()
} else {
_state.value = _state.value.copy(itunesLoading = false)
}
}
/**
* Deezer's /artist/{id}/albums omits nb_tracks, so an owned album's real total is unknown and it
* never got flagged "Unvollständig" (iCount stayed 0). Mirror the web: for MATCHED (owned) albums
* whose Deezer trackCount is unknown, fetch the album's track list once to learn the real total,
* then update state so categorizeAlbums can detect partials. Only matched albums are enriched —
* a couple of calls, not the whole discography.
*/
private suspend fun enrichPartialCounts() {
val ndAlbums = _state.value.artist?.album ?: return
val iAlbums = _state.value.iAlbums
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
val enriched = coroutineScope {
iAlbums.map { ia ->
val cid = ia.collectionId
val known = (ia.trackCount ?: 0) > 0
val matched = ndAlbums.any { a ->
val n = norm(a.name); val i = norm(ia.collectionName)
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
}
if (!known && matched && cid != null) async {
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
if (n > 0) ia.copy(trackCount = n) else ia
} else async { ia }
}.map { it.await() }
}
_state.value = _state.value.copy(iAlbums = enriched)
}
// ── Sub-album (not-downloaded iTunes album) ──────────────────────────────
fun loadSubAlbum(album: ItunesItem) {
@@ -254,41 +337,102 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
}
}
// ── iTunes 30s preview ───────────────────────────────────────────────────
// ── 30s preview (app-level PreviewController → global control bar, survives navigation) ──
fun togglePreview(track: ItunesItem) {
val url = track.previewUrl ?: return
val id = track.trackId ?: return
if (_state.value.previewingId == id) stopPreview() else startPreview(url, id)
}
private fun startPreview(url: String, id: Long) {
stopPreview()
player.pause()
_state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) {
try {
val mp = MediaPlayer().apply {
setDataSource(url)
prepare()
start()
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
}
mediaPlayer = mp
} catch (e: Exception) {
_state.value = _state.value.copy(previewingId = null)
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
}
fun stopPreview() = preview.stop()
// ── Artist pin (same-name disambiguation) ────────────────────────────────
fun openPinPicker() {
_state.value = _state.value.copy(showPinPicker = true, pinCandidates = emptyList(), pinSearching = true)
val name = _state.value.artist?.name ?: return
viewModelScope.launch {
val res = runCatching { ItunesClient.search(name, entity = "musicArtist", limit = 8) }.getOrNull()
val cands = res?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
_state.value = _state.value.copy(pinCandidates = cands, pinSearching = false)
}
}
fun stopPreview() {
mediaPlayer?.runCatching { stop(); release() }
mediaPlayer = null
_state.value = _state.value.copy(previewingId = null)
fun dismissPinPicker() { _state.value = _state.value.copy(showPinPicker = false) }
/** Pin one of the searched candidates. */
fun pinCandidate(item: ItunesItem) {
val dzId = item.artistId ?: return
applyPin(ArtistOverride(
deezerArtistId = dzId.toString(),
name = item.artistName ?: _state.value.artist?.name ?: "",
image = item.artworkUrl100 ?: "",
fanCount = item.fanCount,
))
}
override fun onCleared() {
super.onCleared()
stopPreview()
/** Pin by a pasted Deezer link or bare id (the reliable route when search can't surface it). */
fun pinByLink(raw: String, onResult: (Boolean) -> Unit) {
val id = parseDeezerArtistId(raw)?.toLongOrNull()
if (id == null) { onResult(false); return }
viewModelScope.launch {
val a = runCatching { ItunesClient.lookupArtist(id) }.getOrNull()
if (a?.artistId == null) { onResult(false); return@launch }
applyPin(ArtistOverride(
deezerArtistId = a.artistId.toString(),
name = a.artistName ?: _state.value.artist?.name ?: "",
image = a.artworkUrl100 ?: "",
fanCount = a.fanCount,
))
onResult(true)
}
}
/**
* Resolve a (Deezer-related) artist name to a Navidrome library artist id, so a pinned artist's
* "Similar" chips are tappable when that artist is actually in the library. Exact name match only
* (case-insensitive) to avoid navigating to the wrong artist; null if not in the library.
*/
suspend fun libraryArtistId(name: String?): String? {
val n = name?.trim()?.lowercase() ?: return null
if (n.isEmpty()) return null
val res = runCatching { repo.search(n) }.getOrNull() ?: return null
return res.artist.firstOrNull { it.name.trim().lowercase() == n }?.id
}
fun clearPin() {
val id = artistId ?: return
viewModelScope.launch {
runCatching { repo.removeArtistOverride(id) }
_state.value = _state.value.copy(pin = null, pinnedImage = null, showPinPicker = false, itunesLoading = true)
loadItunesData(_state.value.artist?.name ?: "", null)
}
}
private fun applyPin(ov: ArtistOverride) {
val id = artistId ?: return
viewModelScope.launch {
runCatching { repo.setArtistOverride(id, ov) }
_state.value = _state.value.copy(
pin = ov,
pinnedImage = ov.image.takeIf { it.isNotBlank() },
showPinPicker = false,
itunesLoading = true,
)
loadItunesData(_state.value.artist?.name ?: "", ov)
}
}
// Pull a Deezer artist id out of a pasted URL (deezer.com/.../artist/12345) or a bare id.
private fun parseDeezerArtistId(raw: String): String? {
val s = raw.trim()
if (s.isEmpty()) return null
Regex("artist/(\\d+)").find(s)?.let { return it.groupValues[1] }
return if (s.matches(Regex("\\d+"))) s else null
}
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
// global bar) after leaving this screen.
}
@@ -0,0 +1,68 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.CompareResult
import com.novasonic.app.api.model.ScrobbleUser
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
data class CompareUiState(
val loading: Boolean = true,
val configured: Boolean = true,
val error: String? = null,
val me: String = "",
val users: List<ScrobbleUser> = emptyList(), // candidates (excludes me)
val partner: String? = null, // chosen userB; null → show picker
val result: CompareResult? = null,
)
/** Head-to-Head "Musik-Match": pick a partner from the play-log, then compare. */
class CompareViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(CompareUiState())
val state: StateFlow<CompareUiState> = _state
init { loadUsers() }
fun loadUsers() {
viewModelScope.launch {
_state.value = CompareUiState(loading = true)
val settings = repo.appSettingsFlow().first()
if (settings.scrobbleUrl.isBlank()) {
_state.value = _state.value.copy(loading = false, configured = false)
return@launch
}
val resp = repo.fetchScrobbleUsers()
_state.value = if (resp == null)
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
else
_state.value.copy(
loading = false, configured = true, error = null,
me = resp.me,
users = resp.users.filter { it.user != resp.me },
)
}
}
/** Back to the partner picker. */
fun clearPartner() {
_state.value = _state.value.copy(partner = null, result = null, error = null)
}
fun choosePartner(name: String) {
_state.value = _state.value.copy(partner = name, loading = true, error = null, result = null)
viewModelScope.launch {
val res = repo.fetchScrobbleCompare(name)
_state.value = if (res == null)
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
else
_state.value.copy(loading = false, result = res, error = null)
}
}
}
@@ -1,7 +1,6 @@
package com.novasonic.app.viewmodel
import android.app.Application
import android.media.MediaPlayer
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.ImporterClient
@@ -11,8 +10,10 @@ import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ItunesItem
import com.novasonic.app.api.model.ItunesRssEntry
import com.novasonic.app.api.model.Song
import androidx.compose.foundation.lazy.LazyListState
import com.novasonic.app.api.model.artworkHd
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -50,6 +51,9 @@ data class ExploreUiState(
val albumItem: ItunesItem? = null,
// Artist detail (iTunes)
val artistDetail: ItunesArtistState? = null,
// Which "all albums" section is expanded inside the iTunes artist overlay (by title), or null.
// Hoisted here so it survives drilling into an album (nav route OR overlay) and coming back.
val artistAllAlbums: String? = null,
// Partial album detail (opened from artist's "Unvollständig" section)
val partialAlbum: PartialAlbumState? = null,
// Download sheet
@@ -68,18 +72,22 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val player = app.app.playerController
private val preview = app.app.previewController
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
val state: StateFlow<ExploreUiState> = _state
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
val searchListState = LazyListState()
private var searchJob: Job? = null
private var mediaPlayer: MediaPlayer? = null
init {
loadCharts()
// Mirror the app-level preview state so the track rows still show which clip is playing.
viewModelScope.launch {
player.state.collect { ps ->
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
preview.state.collect { ps ->
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
}
}
}
@@ -102,23 +110,54 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
}
private fun search(q: String) {
// Strip leading aesthetic punctuation (".diedlonely", "†…") so iTunes actually surfaces the
// artist instead of nothing. Falls back to the raw query if it was only symbols.
val term = q.trimStart { !it.isLetterOrDigit() }.trim().ifBlank { q }
viewModelScope.launch {
_state.value = _state.value.copy(isSearching = true, error = null)
try {
val resp = ItunesClient.search(q)
val resp = ItunesClient.search(term)
val results = resp.results
val artists = results.filter { it.wrapperType == "artist" }
_state.value = _state.value.copy(
artists = results.filter { it.wrapperType == "artist" },
artists = artists,
albums = results.filter { it.wrapperType == "collection" },
tracks = results.filter { it.wrapperType == "track" && it.kind == "song" },
isSearching = false,
)
enrichArtistArt(artists)
} catch (e: Exception) {
_state.value = _state.value.copy(isSearching = false, error = e.message)
}
}
}
/**
* iTunes artist entities carry no artwork, so search results render as grey circles. Mirror the
* web PWA: fetch one album per artist and borrow its cover. Runs after the main results are shown
* (list appears instantly) and only applies if the user is still viewing the same artist set.
*/
private fun enrichArtistArt(artists: List<ItunesItem>) {
if (artists.none { it.artworkUrl100.isNullOrBlank() && it.artistName != null }) return
viewModelScope.launch {
val enriched = artists.map { a ->
async {
if (!a.artworkUrl100.isNullOrBlank()) return@async a
val name = a.artistName ?: return@async a
val art = runCatching {
ItunesClient.search(name, entity = "album", limit = 1).results
.firstOrNull { it.wrapperType == "collection" }?.artworkUrl100
}.getOrNull()
if (art != null) a.copy(artworkUrl100 = art) else a
}
}.awaitAll()
val cur = _state.value.artists
if (cur.map { it.artistId } == artists.map { it.artistId }) {
_state.value = _state.value.copy(artists = enriched)
}
}
}
fun loadAlbum(album: ItunesItem) {
val id = album.collectionId ?: return
viewModelScope.launch {
@@ -137,9 +176,13 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(albumTracks = null, albumItem = null)
}
fun openArtistAllAlbums(title: String) { _state.value = _state.value.copy(artistAllAlbums = title) }
fun closeArtistAllAlbums() { _state.value = _state.value.copy(artistAllAlbums = null) }
fun openArtistDetail(artist: ItunesItem) {
_state.value = _state.value.copy(
artistDetail = ItunesArtistState(artist = artist, isLoading = true),
artistAllAlbums = null,
)
viewModelScope.launch {
val artistId = artist.artistId
@@ -150,21 +193,58 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
val iAlbumsD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistAlbums(artistId) }.getOrDefault(emptyList()) else emptyList() }
val iTracksD: Deferred<List<ItunesItem>> = async { if (artistId != null) runCatching { ItunesClient.lookupArtistTopTracks(artistId, 10) }.getOrDefault(emptyList()) else emptyList() }
val ndAlbums = ndAlbumsD.await()
val iAlbums = iAlbumsD.await()
_state.value = _state.value.copy(
artistDetail = ItunesArtistState(
artist = artist,
isLoading = false,
ndAlbums = ndAlbumsD.await(),
iAlbums = iAlbumsD.await(),
ndAlbums = ndAlbums,
iAlbums = iAlbums,
iTracks = iTracksD.await(),
topSongs = topSongsD.await(),
),
)
// Deezer's artist-albums list omits nb_tracks, so owned albums never show "Unvollständig".
// Resolve the real total for matched albums (like web) so partials are detected.
val enriched = enrichPartialCounts(ndAlbums, iAlbums)
if (enriched !== iAlbums) {
_state.value.artistDetail?.let { d ->
if (d.artist.artistId == artist.artistId) {
_state.value = _state.value.copy(artistDetail = d.copy(iAlbums = enriched))
}
}
}
}
}
/** For MATCHED (owned) albums whose Deezer trackCount is unknown, fetch the album's track list
* once to learn the real total. Only owned albums are enriched (a couple of calls). */
private suspend fun enrichPartialCounts(
ndAlbums: List<Album>, iAlbums: List<ItunesItem>,
): List<ItunesItem> {
if (ndAlbums.isEmpty() || iAlbums.isEmpty()) return iAlbums
fun norm(s: String?) = (s ?: "").lowercase().replace(Regex("[^\\p{L}\\p{N}]"), "")
var changed = false
val out = coroutineScope {
iAlbums.map { ia ->
val cid = ia.collectionId
val known = (ia.trackCount ?: 0) > 0
val matched = ndAlbums.any { a ->
val n = norm(a.name); val i = norm(ia.collectionName)
n == i || (n.length > 3 && i.startsWith(n)) || (i.length > 3 && n.startsWith(i))
}
if (!known && matched && cid != null) async {
val n = runCatching { ItunesClient.lookupAlbum(cid).size }.getOrDefault(0)
if (n > 0) { changed = true; ia.copy(trackCount = n) } else ia
} else async { ia }
}.map { it.await() }
}
return if (changed) out else iAlbums
}
fun closeArtistDetail() {
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null)
_state.value = _state.value.copy(artistDetail = null, partialAlbum = null, artistAllAlbums = null)
}
fun loadPartialAlbum(nd: Album, ia: ItunesItem) {
@@ -240,7 +320,15 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
}
val client = ImporterClient(backendUrl)
runCatching { client.download(request) }
.onSuccess { onDone(true, "✓ Download gestartet") }
.onSuccess {
// App-lifetime poll → success/failure notification even after leaving the screen
// (previously only the artist page + importer notified; search/Bibliothek was silent).
NotificationStore.startPoll(
backendUrl, request.jobId, request.title, request.artist ?: "",
navType = type, navAlbum = item.collectionName ?: "", navArtist = item.artistName ?: "",
)
onDone(true, "✓ Download gestartet")
}
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
client.close()
}
@@ -262,41 +350,12 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
fun togglePreview(track: ItunesItem) {
val url = track.previewUrl ?: return
val id = track.trackId ?: return
val cur = _state.value.previewingId
if (cur == id) {
stopPreview()
} else {
startPreview(url, id)
}
player.pause() // don't overlap the 30s clip with library playback
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
}
private fun startPreview(url: String, id: Long) {
stopPreview()
player.pause()
_state.value = _state.value.copy(previewingId = id)
viewModelScope.launch(Dispatchers.IO) {
try {
val mp = MediaPlayer().apply {
setDataSource(url)
prepare()
start()
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
}
mediaPlayer = mp
} catch (e: Exception) {
_state.value = _state.value.copy(previewingId = null)
}
}
}
fun stopPreview() = preview.stop()
fun stopPreview() {
mediaPlayer?.runCatching { stop(); release() }
mediaPlayer = null
_state.value = _state.value.copy(previewingId = null)
}
override fun onCleared() {
super.onCleared()
stopPreview()
}
// NOTE: no onCleared stopPreview — the preview is app-level on purpose, so leaving the Explore
// tab keeps it playing with the global control bar (see AppNavigation).
}
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.ImporterClient
import com.novasonic.app.api.model.DownloadRequest
import com.novasonic.app.api.model.ImportResolveResponse
import com.novasonic.app.api.model.ImportTrack
import com.novasonic.app.app
import com.novasonic.app.data.NotificationStore
import kotlinx.coroutines.delay
@@ -26,11 +27,15 @@ data class ImporterUiState(
val jobMessage: String? = null,
val jobCompleted: Int = 0,
val jobTotal: Int = 0,
// 30s preview — synthetic id (previewUrl hash) of the clip currently playing, or null
val previewingId: Long? = null,
)
class ImporterViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val player = app.app.playerController
private val preview = app.app.previewController
private val _state = MutableStateFlow(ImporterUiState())
val state: StateFlow<ImporterUiState> = _state
@@ -41,7 +46,28 @@ class ImporterViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(backendUrl = s.backendUrl)
}
}
// Mirror the app-level preview so the track rows show which clip is playing.
viewModelScope.launch {
preview.state.collect { ps ->
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
}
}
}
// ── 30s preview ─────────────────────────────────────────────────────────
// Import tracks have no numeric id, so key the app-level preview by the previewUrl's hash.
fun previewId(track: ImportTrack): Long = track.previewUrl?.hashCode()?.toLong() ?: 0L
fun togglePreview(track: ImportTrack) {
val url = track.previewUrl ?: return
player.pause() // don't overlap the 30s clip with library playback
preview.toggle(
previewId(track), url,
track.trackName ?: "", track.artistName ?: "", track.artworkUrl100,
)
}
fun stopPreview() = preview.stop()
fun onLink(v: String) {
_state.value = _state.value.copy(
@@ -1,6 +1,8 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.Album
@@ -34,6 +36,12 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(LibraryUiState(isLoading = true))
val state: StateFlow<LibraryUiState> = _state
// Scroll positions live in the VM so they survive drilling into an album/artist and back (the
// Library back-stack entry, hence this VM, is retained). A full teardown of the tab → fresh VM
// → back at the top, as expected.
val albumsGridState = LazyGridState()
val artistsListState = LazyListState()
init { loadPlaylists() }
fun setAlbumSort(sort: AlbumSort) {
@@ -1,8 +1,10 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.ArtistOverride
import com.novasonic.app.api.model.SearchResult3
import com.novasonic.app.app
import kotlinx.coroutines.Job
@@ -16,6 +18,8 @@ data class SearchUiState(
val searchResult: SearchResult3? = null,
val isLoading: Boolean = false,
val error: String? = null,
// Artist pins (by Navidrome artist id) so a pinned artist shows the correct picture in results.
val overrides: Map<String, ArtistOverride> = emptyMap(),
)
class SearchViewModel(app: Application) : AndroidViewModel(app) {
@@ -25,8 +29,18 @@ class SearchViewModel(app: Application) : AndroidViewModel(app) {
private val _state = MutableStateFlow(SearchUiState())
val state: StateFlow<SearchUiState> = _state
// Results scroll — hoisted so it survives a drill-in/back within the search.
val listState = LazyListState()
private var searchJob: Job? = null
init {
viewModelScope.launch {
val ov = runCatching { repo.artistOverrides() }.getOrDefault(emptyMap())
if (ov.isNotEmpty()) _state.value = _state.value.copy(overrides = ov)
}
}
fun onQueryChange(q: String) = onSearch(q)
fun onSearch(q: String) {
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.AbsClient
import com.novasonic.app.app
import com.novasonic.app.data.PrefsStore
import com.novasonic.app.ui.theme.NsColors
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpTimeout
@@ -28,6 +29,7 @@ data class SettingsUiState(
val password: String = "",
val lastFmKey: String = "",
val backendUrl: String = "",
val scrobbleUrl: String = "",
val scrobbleMode: String = "30s",
val showPlayCount: Boolean = false,
val theme: String = "dark-ocean",
@@ -39,13 +41,15 @@ data class SettingsUiState(
val error: String? = null,
val absStatus: ConnStatus = ConnStatus.Idle,
val backendStatus: ConnStatus = ConnStatus.Idle,
val scrobbleStatus: ConnStatus = ConnStatus.Idle,
)
class SettingsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(SettingsUiState())
// Seed the theme with the already-applied one so the picker doesn't jump before DataStore emits.
private val _state = MutableStateFlow(SettingsUiState(theme = NsColors.themeId))
val state: StateFlow<SettingsUiState> = _state
init {
@@ -57,6 +61,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
password = s.password,
lastFmKey = s.lastFmKey,
backendUrl = s.backendUrl,
scrobbleUrl = s.scrobbleUrl,
scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount,
theme = s.theme,
@@ -73,6 +78,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
fun onPassword(v: String) = update { it.copy(password = v, savedOk = false) }
fun onLastFmKey(v: String) = update { it.copy(lastFmKey = v, savedOk = false) }
fun onBackendUrl(v: String) = update { it.copy(backendUrl = v, savedOk = false, backendStatus = ConnStatus.Idle) }
fun onScrobbleUrl(v: String) = update { it.copy(scrobbleUrl = v, savedOk = false, scrobbleStatus = ConnStatus.Idle) }
fun onScrobbleMode(v: String) = update { it.copy(scrobbleMode = v, savedOk = false) }
fun onShowPlayCount(v: Boolean) = update { it.copy(showPlayCount = v, savedOk = false) }
fun onAbsUrl(v: String) = update { it.copy(absUrl = v, savedOk = false, absStatus = ConnStatus.Idle) }
@@ -124,6 +130,31 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
}
}
fun testScrobble() {
val url = _state.value.scrobbleUrl.trim().trimEnd('/')
if (url.isBlank()) {
update { it.copy(scrobbleStatus = ConnStatus.Err("Scrobble URL eingeben")) }
return
}
update { it.copy(scrobbleStatus = ConnStatus.Checking) }
viewModelScope.launch {
try {
val http = HttpClient(Android) {
install(HttpTimeout) { requestTimeoutMillis = 5_000; connectTimeoutMillis = 4_000 }
}
val resp = http.get("$url/scrobble/health")
http.close()
if (resp.status == HttpStatusCode.OK) {
update { it.copy(scrobbleStatus = ConnStatus.Ok("Verbunden · $url")) }
} else {
update { it.copy(scrobbleStatus = ConnStatus.Err("HTTP ${resp.status.value}")) }
}
} catch (e: Exception) {
update { it.copy(scrobbleStatus = ConnStatus.Err("Nicht erreichbar")) }
}
}
}
fun save() {
val s = _state.value
if (s.serverUrl.isBlank() || s.username.isBlank() || s.password.isBlank()) {
@@ -139,6 +170,7 @@ class SettingsViewModel(app: Application) : AndroidViewModel(app) {
password = s.password,
lastFmKey = s.lastFmKey.trim(),
backendUrl = s.backendUrl.trimEnd('/'),
scrobbleUrl = s.scrobbleUrl.trimEnd('/'),
scrobbleMode = s.scrobbleMode,
showPlayCount = s.showPlayCount,
theme = s.theme,
@@ -0,0 +1,124 @@
package com.novasonic.app.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.novasonic.app.api.model.ScrobbleStats
import com.novasonic.app.app
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Calendar
import java.util.TimeZone
enum class StatsView { ALL, YEAR, MONTH }
data class StatsUiState(
val loading: Boolean = true,
val configured: Boolean = true,
val error: String? = null,
val view: StatsView = StatsView.ALL,
val year: Int = Calendar.getInstance().get(Calendar.YEAR),
val month: Int = Calendar.getInstance().get(Calendar.MONTH), // 0-11
val firstPlayedAt: Long? = null, // earliest logged play → navigator lower bound
val stats: ScrobbleStats? = null,
)
class StatsViewModel(app: Application) : AndroidViewModel(app) {
private val repo = app.app.repository
private val _state = MutableStateFlow(StatsUiState())
val state: StateFlow<StatsUiState> = _state
init { load() }
fun setView(v: StatsView) {
if (v == _state.value.view) return
_state.value = _state.value.copy(view = v)
load()
}
/** Step the current year/month window by ±1 (no-op in ALL). */
fun step(dir: Int) {
val s = _state.value
when (s.view) {
StatsView.YEAR -> _state.value = s.copy(year = s.year + dir)
StatsView.MONTH -> {
var m = s.month + dir
var y = s.year
if (m < 0) { m = 11; y-- }
if (m > 11) { m = 0; y++ }
_state.value = s.copy(year = y, month = m)
}
else -> return
}
load()
}
/** True when the shown window is the current one — used to disable stepping into the future. */
fun atCurrent(): Boolean {
val s = _state.value
val now = Calendar.getInstance()
return when (s.view) {
StatsView.YEAR -> s.year >= now.get(Calendar.YEAR)
StatsView.MONTH -> s.year > now.get(Calendar.YEAR) ||
(s.year == now.get(Calendar.YEAR) && s.month >= now.get(Calendar.MONTH))
else -> true
}
}
/** True when the shown window is at/before the first logged play — caps stepping backwards. */
fun atStart(): Boolean {
val s = _state.value
val first = s.firstPlayedAt ?: return false
val c = Calendar.getInstance().apply { timeInMillis = first * 1000 }
val fy = c.get(Calendar.YEAR)
val fm = c.get(Calendar.MONTH)
return when (s.view) {
StatsView.YEAR -> s.year <= fy
StatsView.MONTH -> s.year < fy || (s.year == fy && s.month <= fm)
else -> false
}
}
private fun startOf(year: Int, month: Int, day: Int): Long {
val c = Calendar.getInstance().apply {
clear()
set(year, month, day, 0, 0, 0)
}
return c.timeInMillis / 1000
}
private fun range(): Triple<Long, Long, String> {
val s = _state.value
val nowSec = System.currentTimeMillis() / 1000
return when (s.view) {
StatsView.ALL -> Triple(0L, nowSec + 1, "month")
StatsView.YEAR -> Triple(startOf(s.year, 0, 1), startOf(s.year + 1, 0, 1), "month")
StatsView.MONTH -> Triple(startOf(s.year, s.month, 1), startOf(s.year, s.month + 1, 1), "day")
}
}
private fun load() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
val settings = repo.appSettingsFlow().first()
if (settings.scrobbleUrl.isBlank()) {
_state.value = _state.value.copy(loading = false, configured = false)
return@launch
}
val (from, to, bucket) = range()
val tz = TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 1000
val stats = repo.fetchScrobbleStats(from, to, tz, bucket, 10)
_state.value = if (stats == null)
_state.value.copy(loading = false, error = "Scrobble-Dienst nicht erreichbar")
else
_state.value.copy(
loading = false, configured = true, stats = stats,
// Keep the earliest-play bound once known (a windowed query may return null for it).
firstPlayedAt = stats.firstPlayedAt ?: _state.value.firstPlayedAt,
)
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- The downloaded update APK lives in cacheDir/updates and is shared with the system installer. -->
<cache-path name="updates" path="updates/"/>
</paths>
+175
View File
@@ -0,0 +1,175 @@
const https = require('https');
// ─── DEEZER METADATA SOURCE ───────────────────────────────────────────────────
// Apple's iTunes /search returns 403 from server/datacenter IPs, so all server-side
// metadata (single-track enrichment, album track lists, artist discography, the
// metadata-editor autofill) comes from Deezer. Free, no key, ~50 req/5s, returns
// explicit flags + artist images + durations. Results are mapped to the same
// "ItunesItem" shape the download pipeline (downloadAlbumTracks / downloadTrack)
// already consumes, so nothing downstream had to change.
const DZ = 'https://api.deezer.com';
function dzGet(subpath) {
return new Promise((resolve, reject) => {
https.get(`${DZ}/${subpath}`, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d) || {}); } catch { reject(new Error('Deezer parse error')); } });
}).on('error', reject);
});
}
// ─── matching helpers (local copies to avoid a circular require with metadata.js) ─
function normalizeTitle(s) {
return (s || '')
.toLowerCase().trim()
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
.replace(/[\[((][^\]))]*[\]))]/g, '')
.replace(/[,.'!?]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
const firstWord = s => stripLeadSym((s || '').toLowerCase()).split(/[\s,;/]/)[0];
const yearOf = d => { const y = d ? new Date(d).getFullYear() : NaN; return Number.isFinite(y) ? y : undefined; };
// Deezer covers come in fixed sizes; cover_xl is 1000x1000 (matches the iTunes hi-res artwork).
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || a?.cover || '';
// Map one album track (from /album/{id} .tracks.data — these carry no track_position,
// so the album's own ordering supplies the number) to the iTunes track shape.
function mapAlbumTrack(t, al, index) {
return {
wrapperType: 'track',
trackId: t.id,
trackName: t.title,
artistName: t.artist?.name || al.artist?.name || '', // per-track artist (mixed albums)
collectionName: al.title,
collectionArtistName: al.artist?.name || '', // album-level artist (stable)
collectionType: al.record_type === 'compilation' ? 'Compilation' : 'Album',
artworkUrl100: coverXl(al),
releaseDate: al.release_date || undefined,
primaryGenreName: al.genres?.data?.[0]?.name || undefined,
trackNumber: index + 1,
trackCount: al.nb_tracks || undefined,
trackTimeMillis: t.duration ? t.duration * 1000 : undefined,
trackExplicitness: t.explicit_lyrics ? 'explicit' : 'notExplicit',
previewUrl: t.preview || '',
};
}
// Pick the best track from a Deezer /search result list for (artist, title). Pure + exported
// so the matching rules are unit-testable. Strict = same title AND same first artist word;
// falls back to a title-only match, but never for Japanese titles (same title, different artist
// is common for anime/OST). Returns the matched Deezer track or null.
function pickTrackMatch(results, artist, title) {
if (!Array.isArray(results) || !results.length) return null;
const normT = normalizeTitle(title);
const normA = stripLeadSym((artist || '').split(/[;,&]/)[0].trim()).toLowerCase().trim();
const strict = results.find(t => normalizeTitle(t.title) === normT && firstWord(t.artist?.name) === firstWord(normA));
const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/;
const isJp = JP_RE.test(normT) || JP_RE.test(normA);
// Loose artist match: same title AND the artist names correspond (equal, or one contains the
// other after stripping all non-alphanumerics — covers "Okawari" vs "DJ Okawari"). This REPLACES
// the old blind title-only fallback, which grabbed a same-titled song by a completely different
// artist (e.g. "Memories" by Rab Noakes for "☆NoAki☆") and applied its wrong album/cover/year.
const _n = s => stripLeadSym((s || '').toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, '');
const wantA = _n(normA);
const artistOk = candA => {
if (!wantA || !candA) return false;
if (candA === wantA) return true;
const short = Math.min(candA.length, wantA.length);
return short >= 3 && (candA.includes(wantA) || wantA.includes(candA));
};
const loose = results.find(t => normalizeTitle(t.title) === normT && artistOk(_n(t.artist?.name)));
return strict || (isJp ? null : loose) || null;
}
// Single-track metadata — the Deezer equivalent of the old iTunes lookupTrackMeta.
// Fills album/year/trackNum/genre/duration/artwork when the caller only has artist+title.
// Returns {} when nothing convincing matches (callers treat that as "no enrichment").
async function lookupTrackMeta(artist, title) {
try {
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
const searchTitle = baseTitle !== title ? baseTitle : title;
const searchArtist = stripLeadSym((artist || '').split(/[;,&]/)[0].trim());
const q = encodeURIComponent(`${searchArtist} ${searchTitle}`);
const results = (await dzGet(`search?q=${q}&limit=10`)).data || [];
if (!results.length) return {};
const match = pickTrackMatch(results, artist, title);
if (!match) return {};
// The search result lacks genre / track number / release date — the album endpoint carries them.
const al = match.album?.id ? await dzGet(`album/${match.album.id}`).catch(() => ({})) : {};
// track_position + disk_number live on the full /track record, not on the search hit.
const tr = match.id ? await dzGet(`track/${match.id}`).catch(() => ({})) : {};
return {
album: match.album?.title || al.title || '',
albumArtist: al.artist?.name || undefined,
trackName: match.title || undefined,
year: yearOf(tr.release_date || al.release_date),
trackNum: tr.track_position || undefined,
totalTracks: al.nb_tracks || undefined,
discNum: tr.disk_number || undefined,
discTotal: undefined,
genre: al.genres?.data?.[0]?.name || undefined,
artworkUrl: coverXl(al) || coverXl(match.album) || '',
durationMs: (tr.duration || match.duration) ? (tr.duration || match.duration) * 1000 : undefined,
metaArtistName: match.artist?.name || undefined,
};
} catch { return {}; }
}
// All tracks of a Deezer album id, in the legacy ItunesItem track shape.
async function fetchAlbumTracks(albumId) {
try {
const al = await dzGet(`album/${albumId}`);
return (al.tracks?.data || []).map((t, i) => mapAlbumTrack(t, al, i));
} catch { return []; }
}
// Album fallback when no album id is known: search Deezer for the album, then its tracks.
async function searchAlbumTracks(artist, album) {
try {
const q = encodeURIComponent(`${artist} ${album}`);
const results = (await dzGet(`search/album?q=${q}&limit=5`)).data || [];
const lc = s => (s || '').toLowerCase();
const match = results.find(r =>
lc(r.title).includes(lc(album)) || lc(r.artist?.name).includes(lc(artist))
) || results[0];
return match ? await fetchAlbumTracks(match.id) : [];
} catch { return []; }
}
// Artist discography for the artist-download: search the artist, take real albums
// (record_type "album", not singles/EPs), return {collectionId, collectionName, artworkUrl100}.
async function fetchArtistAlbums(artistName, limit = 10) {
try {
const artists = (await dzGet(`search/artist?q=${encodeURIComponent(artistName)}&limit=1`)).data || [];
if (!artists.length) return [];
const albums = (await dzGet(`artist/${artists[0].id}/albums?limit=100`)).data || [];
return albums
.filter(a => a.record_type === 'album')
.slice(0, limit)
.map(a => ({
collectionId: a.id,
collectionName: a.title,
artistName: artists[0].name,
artworkUrl100: coverXl(a),
}));
} catch { return []; }
}
module.exports = {
dzGet,
lookupTrackMeta,
fetchAlbumTracks,
searchAlbumTracks,
fetchArtistAlbums,
// exported for unit tests
mapAlbumTrack,
pickTrackMatch,
normalizeTitle,
};
+5
View File
@@ -18,6 +18,11 @@ async function downloadAlbumTracks({ jobId, artist, albumTitle, tracks, artworkU
const result = await downloadTrack({
jobId,
artist: track.artistName || artist,
// Drive the YTM/YouTube search + channel filter with THIS track's own artist, not the album
// artist. On mixed-artist soundtracks/compilations (e.g. "Strategy" is TWICE, "Love, Maybe"
// is MeloMance) the album-artist blob + its channel filter matches nothing. album_artist tag
// still uses `albumArtist` below, so album grouping is unaffected.
rawArtist: track.artistName || artist,
albumArtist: artist,
title: track.trackName || track.title,
album: albumTitle,
+27 -130
View File
@@ -17,7 +17,7 @@ function navidromeArtist(name) {
.replace(/,\s*/g, ';');
}
// Download hi-res iTunes artwork (1000x1000) to cover.jpg, return path or null
// Download hi-res cover artwork (1000x1000) to cover.jpg, return path or null
async function downloadArtwork(url, dir) {
if (!url) return null;
const dest = path.join(dir, 'cover.jpg');
@@ -51,14 +51,24 @@ const AUDIO_EXTS = ['mp3', 'flac', 'opus', 'm4a', 'ogg', 'wav'];
// Strips release-type suffixes like "- Single", "- EP", "2024" from end of title/stem.
const _RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
// Version markers that make a track a DISTINCT recording — brackets containing these must NOT be
// stripped during duplicate detection, or a remix/live/acoustic/instrumental collapses onto the
// base track and gets falsely skipped (e.g. "Therapy (Remix)" was skipped because "Therapy" existed).
const _VERSION_KW = /\b(?:remix|live|acoustic|instrumental|version|vip|flip|bootleg|re-?work|reprise|unplugged|demo|mashup|cover|sped|slowed|nightcore|extended|edit|mix|dub)\b/i;
// Robust duplicate detection: search case-insensitively across all album folders
// under the artist folder matching firstArtist, compare normalized title ignoring
// release suffixes and track prefixes. Handles DJ OKAWARI vs DJ Okawari, and
// "recognize - Single.mp3" vs "recognize.mp3" (drift between runs).
function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
const norm = s => (s || '').toLowerCase()
.replace(/[\[((][^\]))]*[\]))]/g, '')
.replace(/[,.'!?]/g, '')
// Strip bracketed noise (feat. X, Official Video, Prod. by …) but KEEP version markers
// (Remix/Live/Acoustic/…) so distinct recordings aren't collapsed onto the base track.
.replace(/[\[((]([^\]))]*)[\]))]/g, (m, inner) => _VERSION_KW.test(inner) ? m : '')
// Filesystem-sanitized chars (/ \ : * ? " < > |) become "_" on disk — map those AND "_"
// to a space so a title tag "Kwik Flip | YTPMV" matches the file "Kwik Flip _ YTPMV".
.replace(/[/\\:*?"<>|_]/g, ' ')
.replace(/[,.'!]/g, '')
.replace(/\s+/g, ' ')
.trim();
const normArtist = norm(firstArtist);
@@ -80,11 +90,18 @@ function findExistingTrack(musicRoot, firstArtist, cleanTitle) {
if (!AUDIO_EXTS.includes(ext)) continue;
// Strip extension + leading track prefix ("01 - ", "01-15 – " etc.)
const stem = f.slice(0, -(ext.length + 1)).replace(/^\d[\d-]*\s*[-–—]\s*/, '');
// Stem may be "Artist - Title" or just "Title"
// Candidate titles from the filename. The old code only used the part AFTER the first
// " - " (assuming "Artist - Title"), which mis-parses titles that themselves contain
// " - " (e.g. "Kingdom Hearts 2 - Dearly Beloved - Main Theme") or filenames with a
// garbage/absent artist prefix ("01-01 - …") → the duplicate went undetected and got
// re-downloaded. Now match if ANY reading equals the query title:
// 1) the FULL stem (title with internal dashes, or no clean artist prefix)
// 2) the part after the first " - " (normal "Artist - Title")
const di = stem.indexOf(' - ');
const rawTitle = di >= 0 ? stem.slice(di + 3) : stem;
const normStemTitle = norm(rawTitle.replace(_RELEASE_SUFFIX_RE, ''));
if (titleVariants.has(normStemTitle)) return path.join(albumPath, f);
const candidates = di >= 0 ? [stem, stem.slice(di + 3)] : [stem];
for (const cand of candidates) {
if (titleVariants.has(norm(cand.replace(_RELEASE_SUFFIX_RE, '')))) return path.join(albumPath, f);
}
}
} catch {}
}
@@ -109,131 +126,11 @@ async function fetchLyrics(artist, title, album) {
} catch { return null; }
}
// Normalize a track title for comparison: unify variant suffixes so that
// "don't rush - slowed" and "don't rush (slowed)" are treated as equal.
function normalizeTitle(s) {
return (s || '')
.toLowerCase()
.trim()
// convert "title - variant" → "title (variant)" so both forms compare equal
.replace(/\s+-\s+([^-]+)$/, ' ($1)')
// strip all remaining parens/brackets content for the base comparison
.replace(/[\[((][^\]))]*[\]))]/g, '')
// strip punctuation so "Mr. Lawrence" == "Mr Lawrence" and comma variants match
.replace(/[,.'!?]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
// iTunes JP store returns localized genre names — map them to English equivalents
const JP_GENRE_MAP = {
'アニメ': 'Anime', 'イージーリスニング': 'Easy Listening', 'ポップ': 'Pop',
'J-Pop': 'J-Pop', 'ロック': 'Rock', 'クラシック': 'Classical', 'ジャズ': 'Jazz',
'エレクトロニック': 'Electronic', 'R&B/ソウル': 'R&B/Soul', 'ヒップホップ/ラップ': 'Hip-Hop/Rap',
'オルタナティブ': 'Alternative', 'サウンドトラック': 'Soundtrack', 'ボーカル': 'Vocal',
'インストゥルメンタル': 'Instrumental', 'ワールド': 'World', 'フォーク': 'Folk',
'カントリー': 'Country', 'レゲエ': 'Reggae', 'ソウル': 'Soul', 'ブルース': 'Blues',
'メタル': 'Metal', 'パンク': 'Punk', 'ダンス': 'Dance', 'コメディ': 'Comedy',
'スピーチ': 'Speech', 'キッズ/ファミリー': 'Kids/Family',
};
function toEnglishGenre(g) { return JP_GENRE_MAP[g] || g; }
// iTunes track lookup — fills in album, year, trackNum, totalTracks, genre when missing
async function lookupItunesTrackMeta(artist, title) {
try {
// Search with base title (strip variant suffix) for better iTunes hit rate —
// e.g. "don't rush - slowed" → search "don't rush" so iTunes finds the single
const baseTitle = title.replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
const searchTitle = baseTitle !== title ? baseTitle : title;
const searchArtist = artist.split(/[;,&]/)[0].trim();
const fetchItunes = (country) => new Promise((resolve, reject) => {
const url = `https://itunes.apple.com/search?term=${encodeURIComponent(searchArtist + ' ' + searchTitle)}&media=music&entity=musicTrack&limit=10&country=${country}&explicit=Yes`;
https.get(url, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
}).on('error', reject);
});
const normT = normalizeTitle(title);
const normA = (searchArtist || '').toLowerCase().trim();
// Split by space OR comma/semicolon/slash so "VXLLAIN, iGRES" → "vxllain" not "vxllain,"
const firstWord = s => (s || '').toLowerCase().split(/[\s,;/]/)[0];
const strictMatch = results => results.find(t =>
normalizeTitle(t.trackName) === normT && firstWord(t.artistName) === firstWord(normA)
);
const titleOnlyMatch = results => results.find(t => normalizeTitle(t.trackName) === normT);
// Fetch both in parallel — JP for correct Japanese titles/artwork, US for English genre names
const [jpResults, usResults] = await Promise.all([fetchItunes('jp'), fetchItunes('us')]);
let match = strictMatch(jpResults);
// titleOnlyMatch (no artist check) is too permissive for Japanese titles/artists —
// common anime/OST titles exist on both JP and US stores under different artists.
// Only use it as a fallback when neither title nor artist contains Japanese characters.
const JP_RE = /[぀-ヿ㐀-䶿一-鿿]/;
const isJpSearch = JP_RE.test(normT) || JP_RE.test(normA);
let usMatch = strictMatch(usResults) || (!isJpSearch && titleOnlyMatch(usResults));
if (!match) match = usMatch;
if (!match) return {};
// JP genre names are localized (e.g. イージーリスニング); prefer US genre, map JP→EN as fallback
const rawGenre = (usMatch || match).primaryGenreName;
const genre = rawGenre ? toEnglishGenre(rawGenre) : undefined;
return {
album: match.collectionName || '',
albumArtist: match.collectionArtistName || undefined,
trackName: match.trackName || undefined,
year: match.releaseDate ? new Date(match.releaseDate).getFullYear() : undefined,
trackNum: match.trackNumber || undefined,
totalTracks: match.trackCount || undefined,
discNum: match.discNumber || undefined,
discTotal: match.discCount || undefined,
genre: genre,
artworkUrl: (match.artworkUrl100 || '').replace('100x100bb', '1000x1000bb'),
durationMs: match.trackTimeMillis || undefined,
itunesArtistName: match.artistName || undefined,
};
} catch { return {}; }
}
// Fetch individual tracks for an iTunes collectionId
async function fetchItunesTracks(collectionId) {
return new Promise((resolve, reject) => {
const url = `https://itunes.apple.com/lookup?id=${collectionId}&entity=song&explicit=Yes`;
https.get(url, res => {
let data = '';
res.on('data', d => data += d);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
resolve((parsed.results || []).filter(r => r.wrapperType === 'track'));
} catch { reject(new Error('iTunes API parse error')); }
});
}).on('error', reject);
});
}
// Search iTunes for album tracks by artist + album name (fallback when no collectionId)
async function searchItunesAlbumTracks(artist, album) {
return new Promise(resolve => {
const q = encodeURIComponent(`${artist} ${album}`);
https.get(`https://itunes.apple.com/search?term=${q}&media=music&entity=album&limit=5&country=jp&explicit=Yes`, res => {
let d = '';
res.on('data', c => d += c);
res.on('end', () => {
try {
const results = JSON.parse(d).results || [];
const match = results.find(r =>
r.collectionName?.toLowerCase().includes(album.toLowerCase()) ||
r.artistName?.toLowerCase().includes(artist.toLowerCase())
);
if (match) fetchItunesTracks(match.collectionId).then(resolve).catch(() => resolve([]));
else resolve([]);
} catch { resolve([]); }
});
}).on('error', () => resolve([]));
});
}
// Track/album/artist metadata now comes from Deezer (backend/lib/deezer.js) — Apple's
// iTunes /search returns 403 from server IPs. See that module for lookupTrackMeta,
// fetchAlbumTracks, searchAlbumTracks and fetchArtistAlbums.
module.exports = {
sanitize, navidromeArtist,
downloadArtwork, findExistingTrack,
lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks,
};
+97
View File
@@ -0,0 +1,97 @@
// MusicBrainz fallback for single-track metadata — used only when Deezer finds no artist-verified
// match (see trackmeta.js). Returns the SAME shape as deezer.lookupTrackMeta so the caller is
// agnostic. Same matching discipline as Deezer: the artist must correspond, or we return {} and let
// the download use its clean fallback (album_artist = song artist, album = "Singles", source cover).
//
// MusicBrainz rules: a descriptive User-Agent is REQUIRED and requests are rate-limited to ~1/sec.
// We do a single recording search per lookup and throttle to stay within that budget. Cover art
// comes from the Cover Art Archive (coverartarchive.org) by release MBID; a 404 there is tolerated
// downstream (the download falls back to the source thumbnail).
const MB_BASE = 'https://musicbrainz.org/ws/2';
const USER_AGENT = 'NovaSonic/2.0 ( https://github.com/novasonic )';
// ── normalization + artist matching (self-contained; mirrors deezer.js intent) ──
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
const _normTitle = s => (s || '').toLowerCase().replace(/[\[((].*?[\]))]/g, '')
.replace(/\s*[-–]\s*(feat|ft|featuring)\.?.*$/i, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
const _normArtist = s => stripLeadSym((s || '').split(/[;,&]/)[0].toLowerCase()).replace(/[^\p{L}\p{N}]+/gu, '');
function _artistOk(candA, wantA) {
if (!wantA || !candA) return false;
if (candA === wantA) return true;
const short = Math.min(candA.length, wantA.length);
return short >= 3 && (candA.includes(wantA) || wantA.includes(candA));
}
// Pure: choose the best artist-verified recording for (artist, title). Prefers a recording that
// carries a release (so we get an album). Exported for unit tests.
function mbPickMatch(recordings, artist, title) {
if (!Array.isArray(recordings) || !recordings.length) return null;
const normT = _normTitle(title);
const wantA = _normArtist(artist);
const candArtist = rec => _normArtist(rec['artist-credit']?.[0]?.name || rec['artist-credit']?.[0]?.artist?.name);
const matches = recordings.filter(r => _normTitle(r.title) === normT && _artistOk(candArtist(r), wantA));
if (!matches.length) return null;
// Prefer one with a release (album); otherwise the first artist+title match.
return matches.find(r => Array.isArray(r.releases) && r.releases.length) || matches[0];
}
const _yearOf = d => { const m = /^(\d{4})/.exec(d || ''); return m ? Number(m[1]) : undefined; };
// Pick the most "album-like" release: an official Album release-group, else one with a date, else first.
function _pickRelease(rec) {
const rels = Array.isArray(rec.releases) ? rec.releases : [];
if (!rels.length) return null;
const isAlbum = r => (r['release-group']?.['primary-type'] || '').toLowerCase() === 'album';
return rels.find(r => isAlbum(r) && r.date) || rels.find(isAlbum) || rels.find(r => r.date) || rels[0];
}
// Pure: map a chosen recording to the lookupTrackMeta shape. Exported for unit tests.
function mapMbRecording(rec) {
if (!rec) return {};
const rel = _pickRelease(rec);
const artistName = rec['artist-credit']?.[0]?.name || rec['artist-credit']?.[0]?.artist?.name || undefined;
return {
album: rel?.title || undefined,
albumArtist: artistName, // recording search rarely carries the release artist-credit
trackName: rec.title || undefined,
year: _yearOf(rel?.date),
genre: undefined, // omitted (extra call); "leave the rest alone" on fallback
artworkUrl: rel?.id ? `https://coverartarchive.org/release/${rel.id}/front-500` : '',
durationMs: Number.isFinite(rec.length) ? rec.length : undefined,
metaArtistName: artistName,
_source: 'musicbrainz',
};
}
// ── rate limiting (~1 request/sec, global to the process) ──
let _chain = Promise.resolve();
let _last = 0;
function _throttle() {
_chain = _chain.then(async () => {
const wait = Math.max(0, 1000 - (Date.now() - _last));
if (wait) await new Promise(r => setTimeout(r, wait));
_last = Date.now();
});
return _chain;
}
async function lookupTrackMeta(artist, title) {
try {
const a = stripLeadSym((artist || '').split(/[;,&]/)[0].trim());
const t = (title || '').replace(/\s*[-–]\s*\S[^-]*$/, '').trim() || title;
if (!a || !t) return {};
const q = encodeURIComponent(`recording:"${t}" AND artist:"${a}"`);
await _throttle();
const r = await fetch(`${MB_BASE}/recording?query=${q}&fmt=json&limit=10&inc=releases`, {
headers: { 'User-Agent': USER_AGENT, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000),
});
if (!r.ok) return {};
const recordings = (await r.json()).recordings || [];
return mapMbRecording(mbPickMatch(recordings, artist, title));
} catch { return {}; }
}
module.exports = { lookupTrackMeta, mbPickMatch, mapMbRecording };
+22
View File
@@ -0,0 +1,22 @@
// Single-track metadata resolver — the ONE place callers use to enrich a downloaded single.
// Order: Deezer (artist-verified) → MusicBrainz (artist-verified) → nothing.
// Returning {} is intentional and safe: the download then uses its clean fallback
// (album_artist = song artist, album = "Singles", cover = source thumbnail, year/genre untouched).
//
// Both sources apply the SAME discipline — the artist must correspond — so a same-titled song by a
// different artist can never contaminate album/cover/year again.
const dz = require('./deezer');
const mb = require('./musicbrainz');
async function lookupTrackMeta(artist, title) {
const d = await dz.lookupTrackMeta(artist, title).catch(() => ({}));
if (d && d.album) return d; // Deezer matched (artist-verified)
const m = await mb.lookupTrackMeta(artist, title).catch(() => ({}));
if (m && m.album) return m; // MusicBrainz matched (artist-verified)
return d || {}; // nothing convincing → clean fallback
}
module.exports = { lookupTrackMeta };
+23 -4
View File
@@ -122,6 +122,12 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0];
const artistChannelPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
? ` & channel ~= (?i)${escRe(artistFirstWord)}` : '';
// yt-dlp fills `artist` (music metadata) on YouTube "Art Tracks" — the label-provided official
// audio — even when the channel doesn't contain the artist name (BANGTANTV/HYBE/…). Verified with
// "BTS Pied Piper": ONLY the official upload had artist=BTS; every fan/live/lyric reupload had
// none. Used as an OR alternative to the channel check so those official uploads still pass strict.
const artistMetaPart = (artistFirstWord.length >= 3 && !JP_RE.test(artistFirstWord))
? ` & artist ~= (?i)${escRe(artistFirstWord)}` : '';
// Use a space (not " - ") between artist and title: this matches how users type
// in the YTM search box and ranks the exact official track first. A dash skews
@@ -185,7 +191,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
log(jobId, `🔗 Direkter Download: ${sourceUrl}`);
await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]);
const audioFile = hasAudio() ? expectedAudio : null;
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: true, dir }; }
// Direct-link download failed (e.g. YouTube 403 / bot check) — must report FAILURE, not success.
// Previously returned { success: true }, so the job was marked "done/Fertig!" and the UI wrongly
// said the track was added.
if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: false, failed: true, dir }; }
return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title: searchTitle });
}
@@ -240,8 +249,15 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
} else {
durPart = '';
}
return ['--match-filter',
`${durPart}!is_live${titleKwPart}${strict ? artistChannelPart : ''}${variant ? variantPart : ''}`];
const base = `${durPart}!is_live${titleKwPart}${variant ? variantPart : ''}`;
// Non-strict, or no artist constraint (short / Japanese name): single filter.
if (!strict || !artistChannelPart) return ['--match-filter', base];
// Strict: pass if the channel name matches the artist OR yt-dlp's music `artist` metadata does
// (catches official label-channel uploads like BANGTANTV). Two --match-filter args = OR in yt-dlp.
return [
'--match-filter', `${base}${artistChannelPart}`,
'--match-filter', `${base}${artistMetaPart}`,
];
};
const PI_FILTERED = ['--playlist-items', '1-5', '--max-downloads', '1'];
@@ -359,7 +375,10 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
'-metadata', `artist=${tagArtist}`,
'-metadata', `album_artist=${tagAlbumArtist}`,
);
if (album) ffArgs.push('-metadata', `album=${album}`);
// Always write an album tag, defaulting to "Singles" so it matches the on-disk folder
// (album || 'Singles'). Without this, no-album tracks land in a "Singles" folder but show
// as "[Unknown Album]" in Navidrome — a tag/folder mismatch that breaks the metadata editor.
ffArgs.push('-metadata', `album=${album || 'Singles'}`);
// Sort tags — only write when they differ from main (no-op for non-Japanese tracks)
if (sortTitle !== title) ffArgs.push('-metadata', `TITLESORT=${sortTitle}`);
if (sortArtist !== tagArtist) ffArgs.push('-metadata', `ARTISTSORT=${sortArtist}`);
+274 -78
View File
@@ -13,11 +13,14 @@ const { execSync, spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const https = require('https');
const http = require('http');
const crypto = require('crypto');
const CONFIG = require('./lib/config');
const { jobs, updateJob, log } = require('./lib/jobs');
const { sanitize, lookupItunesTrackMeta, fetchItunesTracks, searchItunesAlbumTracks } = require('./lib/metadata');
const { sanitize, findExistingTrack } = require('./lib/metadata');
const dz = require('./lib/deezer');
const tm = require('./lib/trackmeta'); // Deezer → MusicBrainz → clean fallback (single tracks)
const { downloadTrack, downloadAlbumTracks } = require('./lib/downloader');
const { buildRomajiIndex, clearRomajiCache, isTokenizerReady, fetchNdSongs, romajiForStorage } = require('./lib/romaji');
@@ -231,6 +234,47 @@ app.delete('/api/romaji-overrides/:kanji', (req, res) => {
res.json({ ok: true });
});
// ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ──
// Fixes same-name collisions where NovaSonic would otherwise guess the wrong Deezer artist
// (fans / top songs / albums / image). Keyed by the Navidrome artist id.
const _artistOverridesFile = path.join(CONFIG.dataPath, 'deezer-artist-overrides.json');
function _loadArtistOverrides() {
try { return JSON.parse(fs.readFileSync(_artistOverridesFile, 'utf8')); } catch { return {}; }
}
function _saveArtistOverrides(map) {
try {
fs.mkdirSync(CONFIG.dataPath, { recursive: true });
fs.writeFileSync(_artistOverridesFile, JSON.stringify(map, null, 2), 'utf8');
} catch (e) { console.error('Failed to save artist overrides:', e.message); }
}
// GET — full map { ndArtistId: { deezerArtistId, name, image, fanCount } }
app.get('/api/artist-overrides', (_req, res) => res.json(_loadArtistOverrides()));
// POST — upsert a pin { ndArtistId, deezerArtistId, name, image, fanCount }
app.post('/api/artist-overrides', (req, res) => {
const { ndArtistId, deezerArtistId, name, image, fanCount } = req.body || {};
if (!ndArtistId || !deezerArtistId) return res.status(400).json({ error: 'ndArtistId and deezerArtistId required' });
const map = _loadArtistOverrides();
map[String(ndArtistId)] = {
deezerArtistId: String(deezerArtistId),
name: name || '',
image: image || '',
fanCount: (fanCount ?? null),
};
_saveArtistOverrides(map);
res.json({ ok: true });
});
// DELETE /api/artist-overrides/:ndArtistId — remove a pin
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
const id = decodeURIComponent(req.params.ndArtistId);
const map = _loadArtistOverrides();
delete map[id];
_saveArtistOverrides(map);
res.json({ ok: true });
});
// POST /api/romaji-convert — convert an array of strings to romaji
app.post('/api/romaji-convert', (req, res) => {
const { texts } = req.body;
@@ -274,7 +318,7 @@ app.post('/api/spotify/track-artwork', async (req, res) => {
const batch = tracks.slice(i, i + CONC);
const batchRes = await Promise.all(batch.map(async t => {
const searchArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim();
const meta = await lookupItunesTrackMeta(searchArtist, t.trackName || '');
const meta = await dz.lookupTrackMeta(searchArtist, t.trackName || '');
return { idx: t.idx, artworkUrl: meta.artworkUrl || t.existingArt || '' };
}));
results.push(...batchRes);
@@ -282,18 +326,20 @@ app.post('/api/spotify/track-artwork', async (req, res) => {
res.json({ results });
});
// ─── ITUNES METADATA LOOKUP (must be before the proxy route) ─────────────────
app.get('/api/itunes/lookup', async (req, res, next) => {
// ─── METADATA-EDITOR AUTOFILL ────────────────────────────────────────────────
// Fills the editor from Deezer given artist + title (replaces the old iTunes autofill).
app.get('/api/meta/lookup', async (req, res) => {
const { artist, title } = req.query;
// ID-based lookups (?id=...) are not for track meta — let them fall through to the proxy.
if (!artist || !title) return next();
if (!artist || !title) return res.status(400).json({ error: 'artist and title required' });
try {
const meta = await lookupItunesTrackMeta(artist, title);
// Editor "Deezer" button = Deezer only (there is a separate "MusicBrainz" button). The combined
// Deezer→MusicBrainz→clean resolver (tm) is used by the DOWNLOAD paths, not this manual autofill.
const meta = await dz.lookupTrackMeta(artist, title);
if (!meta.album && !meta.genre && !meta.year) return res.json({ found: false });
res.json({
found: true,
title: meta.trackName || undefined,
artist: meta.itunesArtistName || undefined,
artist: meta.metaArtistName || undefined,
albumArtist: meta.albumArtist || undefined,
album: meta.album || undefined,
year: meta.year || undefined,
@@ -304,29 +350,59 @@ app.get('/api/itunes/lookup', async (req, res, next) => {
coverUrl: meta.artworkUrl || undefined,
});
} catch (e) {
console.error('[itunes/lookup]', e.message);
console.error('[meta/lookup]', e.message);
res.status(500).json({ error: e.message });
}
});
// ─── ITUNES PROXY ─────────────────────────────────────────────────────────────
// Browsers can't call itunes.apple.com directly (no CORS headers). Proxy it here.
// Note: /api/itunes/lookup is handled above — proxy only covers /search and id-based /lookup.
app.get('/api/itunes/:endpoint', (req, res) => {
const allowed = new Set(['search', 'lookup']);
if (!allowed.has(req.params.endpoint)) return res.status(404).json({ error: 'not found' });
// Deezer API proxy — the metadata source for search, charts, album/artist lookups (Apple's /search
// returns 403 from server IPs). Free, no key, returns explicit versions + artist images + durations.
// Covers /search, /search/album, /search/artist, /album/{id}, /artist/{id}/albums, /chart/…, etc.
// Regex route (not '/api/deezer/*') — a bare "*" is an invalid path pattern in Express 5 and
// crashes the server at startup, taking every endpoint down with it.
app.get(/^\/api\/deezer\/(.+)/, (req, res) => {
const sub = String(req.params[0] || '').replace(/[^a-zA-Z0-9/_-]/g, '');
const qs = new URLSearchParams(req.query).toString();
const target = `https://itunes.apple.com/${req.params.endpoint}${qs ? '?' + qs : ''}`;
const target = `https://api.deezer.com/${sub}${qs ? '?' + qs : ''}`;
https.get(target, { headers: { 'User-Agent': 'NovaSonic/1.0' } }, upstream => {
res.setHeader('Content-Type', 'application/json');
res.status(upstream.statusCode || 200);
upstream.pipe(res);
}).on('error', e => res.status(502).json({ error: e.message }));
});
// Server-side events cache. Ticketmaster's free `keyword` search is flaky — for the same artist
// it intermittently returns a partial subset (or []), so back-to-back calls disagree (web showed 7,
// a later live call got only 2). Caching on the BACKEND (not per-client) keeps every client
// consistent, and we PREFER THE LARGER recent result: a transient partial/empty response never
// shrinks a good list. TTL bounds staleness (a genuinely-dropped show self-heals within an hour).
const _eventsCache = new Map(); // artistLower → { ts, data }
const _EVENTS_TTL = 60 * 60 * 1000; // 1h
app.get('/api/events/:artist', (req, res) => {
const key = CONFIG.ticketmasterKey;
if (!key) { console.warn('[events] TICKETMASTER_API_KEY not set'); return res.json([]); }
const artist = req.params.artist;
const cacheKey = artist.toLowerCase();
const cached = _eventsCache.get(cacheKey);
const fresh = cached && (Date.now() - cached.ts < _EVENTS_TTL);
// Serve the cached list, but only when it wouldn't hide a better live result. When we fall back
// to cache on a TM error/partial, we DON'T bump ts — so the cache still expires on schedule and
// self-heals instead of pinning stale events forever.
const respondWithCacheFallback = (liveEvents) => {
if (fresh && cached.data.length > liveEvents.length) {
console.log(`[events] ${artist}: live=${liveEvents.length} < cache=${cached.data.length} → serving cache`);
return res.json(cached.data);
}
if (liveEvents.length > 0 || !fresh) {
_eventsCache.set(cacheKey, { ts: Date.now(), data: liveEvents });
return res.json(liveEvents);
}
// live empty but cache still fresh → serve cache
return res.json(cached.data);
};
const now = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); // 2024-06-08T12:00:00Z
const url = `https://app.ticketmaster.com/discovery/v2/events.json` +
`?keyword=${encodeURIComponent(artist)}&classificationName=music` +
@@ -340,15 +416,18 @@ app.get('/api/events/:artist', (req, res) => {
const data = JSON.parse(body);
if (upstream.statusCode !== 200 || data.fault || data.errors) {
console.error('[events] Ticketmaster error:', upstream.statusCode, JSON.stringify(data).slice(0, 200));
return res.json([]);
return respondWithCacheFallback([]);
}
const raw = data?._embedded?.events || [];
const events = raw.map(e => {
const venue = e._embedded?.venues?.[0] || {};
const start = e.dates?.start || {};
const dt = start.dateTime || (start.localDate
// Prefer the venue-LOCAL date/time (localDate + localTime) over the UTC `dateTime` —
// otherwise a 20:00 local show is shown as its 08:00Z UTC instant. Sent as a naive local
// datetime string (no zone); the clients render it as-is.
const dt = (start.localDate
? `${start.localDate}T${start.localTime || '00:00:00'}`
: null);
: start.dateTime) || null;
const soldOut = e.dates?.status?.code === 'offsale' ||
e.dates?.status?.code === 'cancelled';
return {
@@ -361,11 +440,11 @@ app.get('/api/events/:artist', (req, res) => {
offers: e.url ? [{ url: e.url, status: soldOut ? 'unavailable' : 'available' }] : [],
};
}).filter(e => e.datetime);
console.log(`[events] ${artist}: ${events.length} events`);
res.json(events);
} catch(err) { console.error('[events] parse error:', err); res.json([]); }
console.log(`[events] ${artist}: ${events.length} events (live)`);
respondWithCacheFallback(events);
} catch(err) { console.error('[events] parse error:', err); respondWithCacheFallback([]); }
});
}).on('error', e => { console.error('[events] request error:', e.message); res.json([]); });
}).on('error', e => { console.error('[events] request error:', e.message); respondWithCacheFallback([]); });
});
// Debug-only: returns raw Ticketmaster response so you can see exactly what the API sends back.
@@ -388,6 +467,109 @@ app.get('/api/events-debug/:artist', (req, res) => {
}).on('error', e => res.json({ error: e.message }));
});
// ── NovaSonic Android APK distribution ──────────────────────────────────────
// The web app offers a "download the Android app" button that points here, so co-users always get
// the latest APK without the maintainer hand-delivering it. Two sources, tried in order:
// 1) A FIXED Gitea release (default tag "apk-latest") whose .apk asset you overwrite each build —
// constant ~30 MB, uploadable from anywhere. The BACKEND fetches it server-side (same LAN as
// Gitea), so Gitea can stay internal-only and the token never reaches the browser.
// env: GITEA_URL (internal base, e.g. http://gitea:3000), GITEA_REPO (Docker/NovaSonic),
// GITEA_TOKEN (read token; only needed for a private repo), GITEA_APK_TAG (default apk-latest)
// 2) Fallback: a local file in the writable data volume:
// docker cp app-release.apk novasonic-backend:/data/novasonic.apk
// (optional /data/novasonic.version.txt with a version string)
const APK_PATH = process.env.APK_PATH || path.join(CONFIG.dataPath, 'novasonic.apk');
const APK_VER_PATH = path.join(CONFIG.dataPath, 'novasonic.version.txt');
const GITEA_URL = (process.env.GITEA_URL || '').replace(/\/$/, '');
const GITEA_REPO = process.env.GITEA_REPO || '';
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
const GITEA_APK_TAG = process.env.GITEA_APK_TAG || 'apk-latest';
function _giteaApiJson(pathAndQuery) {
return new Promise((resolve, reject) => {
if (!GITEA_URL || !GITEA_REPO) return reject(new Error('gitea not configured'));
const u = new URL(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}${pathAndQuery}`);
const lib = u.protocol === 'https:' ? https : http;
const headers = { 'Accept': 'application/json', 'User-Agent': 'NovaSonic' };
if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`;
lib.get(u, { headers }, r => {
let body = '';
r.on('data', d => body += d);
r.on('end', () => {
if (r.statusCode < 200 || r.statusCode >= 300) return reject(new Error(`gitea ${r.statusCode}`));
try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
});
}).on('error', reject);
});
}
// The .apk asset of the fixed release, or null if unavailable.
async function _giteaApkAsset() {
const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`);
const asset = (rel.assets || []).find(a => /\.apk$/i.test(a.name || ''));
if (!asset) return null;
// The publish script writes "versionCode=NN" into the release body so the in-app updater has a
// reliable integer to compare against BuildConfig.VERSION_CODE.
const vcMatch = /versionCode\s*[=:]\s*(\d+)/i.exec(rel.body || '');
return {
size: asset.size,
url: asset.browser_download_url,
version: rel.name || rel.tag_name || null,
versionCode: vcMatch ? parseInt(vcMatch[1], 10) : null,
updatedAt: new Date(asset.created_at || rel.published_at || Date.now()).getTime(),
};
}
function _serveLocalApk(res) {
if (res.headersSent) return;
fs.stat(APK_PATH, (err) => {
if (err) return res.status(404).json({ error: 'Keine APK hinterlegt' });
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
res.sendFile(APK_PATH);
});
}
// Stream a Gitea attachment to the client, following redirects, falling back to the local file.
function _streamApkFromUrl(urlStr, res, redirectsLeft = 3) {
const u = new URL(urlStr);
const lib = u.protocol === 'https:' ? https : http;
const headers = { 'User-Agent': 'NovaSonic' };
if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`;
lib.get(u, { headers }, up => {
if ([301, 302, 303, 307, 308].includes(up.statusCode) && up.headers.location && redirectsLeft > 0) {
up.resume();
return _streamApkFromUrl(new URL(up.headers.location, u).toString(), res, redirectsLeft - 1);
}
if (up.statusCode !== 200) { up.resume(); return _serveLocalApk(res); }
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
if (up.headers['content-length']) res.setHeader('Content-Length', up.headers['content-length']);
up.pipe(res);
}).on('error', () => _serveLocalApk(res));
}
app.get('/api/apk/info', async (req, res) => {
try {
const a = await _giteaApkAsset();
if (a) return res.json({ available: true, size: a.size, updatedAt: a.updatedAt, version: a.version, versionCode: a.versionCode, source: 'gitea' });
} catch { /* fall through to local */ }
fs.stat(APK_PATH, (err, st) => {
if (err) return res.json({ available: false });
let version = null, versionCode = null;
try { version = fs.readFileSync(APK_VER_PATH, 'utf8').trim() || null; } catch {}
try { versionCode = parseInt(fs.readFileSync(path.join(CONFIG.dataPath, 'novasonic.versioncode.txt'), 'utf8').trim(), 10) || null; } catch {}
res.json({ available: true, size: st.size, updatedAt: st.mtimeMs, version, versionCode, source: 'local' });
});
});
app.get('/api/apk', async (req, res) => {
let asset = null;
try { asset = await _giteaApkAsset(); } catch { /* fall through to local */ }
if (asset?.url) return _streamApkFromUrl(asset.url, res);
_serveLocalApk(res);
});
app.get('/api/health', (req, res) => {
res.json({
status: 'ok', ytdlpVersion,
@@ -515,27 +697,27 @@ app.post('/api/youtube/resolve', async (req, res) => {
artworkUrl: entries[0]?.thumbnail || '', tracks });
}
// Single video — enrich with iTunes
// Single video — enrich with Deezer
const e = entries[0];
const rawArtist = _cleanYtArtist(e.uploader || e.channel || e.creator || '');
const title = e.title || '';
const sourceUrl = e.webpage_url || url;
let itunes = {};
try { itunes = await lookupItunesTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
let meta = {};
try { meta = await tm.lookupTrackMeta(rawArtist.split(',')[0].trim(), title); } catch {}
const artist = itunes.artist || rawArtist;
const artworkUrl = itunes.artworkUrl || e.thumbnail || '';
const artist = meta.artist || rawArtist;
const artworkUrl = meta.artworkUrl || e.thumbnail || '';
return res.json({
type: 'track', name: title, artist, artworkUrl,
tracks: [{
trackName: title,
artistName: artist,
artworkUrl: artworkUrl,
collectionName: itunes.album || '',
releaseDate: itunes.year ? String(itunes.year) : '',
trackNumber: itunes.trackNum || undefined,
durationMs: itunes.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
genre: itunes.genre || '',
collectionName: meta.album || '',
releaseDate: meta.year ? String(meta.year) : '',
trackNumber: meta.trackNum || undefined,
durationMs: meta.durationMs || (e.duration ? Math.round(e.duration * 1000) : undefined),
genre: meta.genre || '',
sourceUrl,
}],
});
@@ -579,20 +761,21 @@ app.post('/api/download', async (req, res) => {
};
const searchArtist = artist.split(',')[0].trim();
if (!meta.album) {
log(jobId, '📋 Suche Album-Metadaten von iTunes…');
const extra = await lookupItunesTrackMeta(searchArtist, title);
log(jobId, '📋 Suche Album-Metadaten von Deezer…');
const extra = await tm.lookupTrackMeta(searchArtist, title);
if (extra.album) log(jobId, `📋 Album: "${extra.album}" (${extra.year || '?'})`);
meta = { ...meta, ...extra, artworkUrl: spArt || extra.artworkUrl || '', genre: meta.genre || extra.genre || undefined };
} else {
log(jobId, `📋 Album: "${meta.album}"`);
// Always fetch iTunes when any field is missing — durationMs is required for the yt-dlp
// Always fetch Deezer when any field is missing — durationMs is required for the yt-dlp
// duration filter; without it any unrelated video that matches the title text gets downloaded.
if (!meta.genre || !meta.totalTracks || !meta.durationMs || !meta.trackNum) {
// Strip release-type suffixes before iTunes lookup so "recognize - Single" → "recognize"
if (!meta.genre || !meta.totalTracks || !meta.durationMs || !meta.trackNum || !meta.year) {
// Strip release-type suffixes before the Deezer lookup so "recognize - Single" → "recognize"
const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
const itunesTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
const genreMeta = await lookupItunesTrackMeta(searchArtist, itunesTitle).catch(() => ({}));
const lookupTitle = title.replace(RELEASE_SUFFIX_RE, '').trim() || title;
const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({}));
if (!meta.genre && genreMeta.genre) { meta.genre = genreMeta.genre; log(jobId, `📋 Genre: "${genreMeta.genre}"`); }
if (!meta.year && genreMeta.year) { meta.year = genreMeta.year; log(jobId, `📋 Jahr: ${genreMeta.year}`); }
if (!meta.totalTracks && genreMeta.totalTracks) { meta.totalTracks = genreMeta.totalTracks; }
if (!meta.discNum && genreMeta.discNum) { meta.discNum = genreMeta.discNum; }
if (!meta.discTotal && genreMeta.discTotal) { meta.discTotal = genreMeta.discTotal; }
@@ -600,30 +783,41 @@ app.post('/api/download', async (req, res) => {
if (!meta.trackNum && genreMeta.trackNum) { meta.trackNum = genreMeta.trackNum; }
}
}
const finalArtist = meta.itunesArtistName || artist;
const finalArtist = meta.metaArtistName || artist;
const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined });
if (result?.skipped) {
updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true });
} else if (result?.notFound) {
updateJob(jobId, { status: 'error', message: 'Nicht eindeutig gefunden — bitte Direktlink im Importer nutzen' });
} else if (result?.failed) {
updateJob(jobId, { status: 'error', message: 'Download fehlgeschlagen — YouTube hat den Abruf blockiert (403).' });
} else {
updateJob(jobId, { status: 'done', progress: 100, message: 'Fertig!' });
}
} else if (type === 'album') {
let tracks = [];
if (collectionId) {
log(jobId, '📋 Lade Track-Liste von iTunes…');
tracks = await fetchItunesTracks(collectionId);
// Prefer a track list supplied in the request body (Deezer albums send their tracks directly,
// pre-mapped to the ItunesItem shape). Fall back to a Deezer lookup for legacy callers.
let tracks = Array.isArray(req.body.tracks) ? req.body.tracks : [];
if (tracks.length) log(jobId, `📋 ${tracks.length} Tracks aus Anfrage (Deezer)`);
if (!tracks.length && collectionId) {
log(jobId, '📋 Lade Track-Liste von Deezer…');
tracks = await dz.fetchAlbumTracks(collectionId);
}
if (!tracks.length) {
log(jobId, '🔍 Suche Album auf iTunes…');
tracks = await searchItunesAlbumTracks(artist, album || title);
log(jobId, '🔍 Suche Album auf Deezer…');
tracks = await dz.searchAlbumTracks(artist, album || title);
}
if (!tracks.length) {
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über iTunes Global-Suche herunterladen.`);
throw new Error(`Keine einzelnen Tracks für "${album || title}" gefunden. Bitte über die Global-Suche herunterladen.`);
}
const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist, albumTitle: album || title, tracks, artworkUrl });
// Album artist: prefer the album-level collectionArtistName (identical across every track)
// over the request's `artist`, which can be a single track's performer — e.g. the lead single
// "TAKEDOWN" is by TWICE, so `artist` arrives as "TWICE" and would tag the whole soundtrack's
// album_artist as TWICE. Keeps album_artist consistent so Navidrome doesn't split the album.
const albumArtistName = tracks.map(t => t.collectionArtistName).find(Boolean) || artist;
if (albumArtistName !== artist) log(jobId, `👥 Album-Künstler: "${albumArtistName}" (Deezer)`);
const { done: dlDone, skipped: dlSkipped } = await downloadAlbumTracks({ jobId, artist: albumArtistName, albumTitle: album || title, tracks, artworkUrl });
const allSkipped = dlSkipped === tracks.length;
const someSkipped = dlSkipped > 0 && !allSkipped;
const msg = allSkipped
@@ -635,19 +829,17 @@ app.post('/api/download', async (req, res) => {
} else if (type === 'artist') {
log(jobId, `🎤 Künstler-Download: ${artist}`);
const artistData = await new Promise((resolve, reject) => {
https.get(`https://itunes.apple.com/search?term=${encodeURIComponent(artist)}&media=music&entity=album&limit=10&country=de`, res => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { try { resolve(JSON.parse(d).results || []); } catch { reject(new Error('parse')); } });
}).on('error', reject);
});
const artistAlbums = artistData.filter(a => a.artistName?.toLowerCase() === artist.toLowerCase()).slice(0, 10);
// Deezer discography: search the artist, take their real albums (not singles/EPs).
const artistAlbums = await dz.fetchArtistAlbums(artist, 10).catch(() => []);
log(jobId, `📀 ${artistAlbums.length} Alben gefunden`);
let done = 0;
for (const alb of artistAlbums) {
const tracks = await fetchItunesTracks(alb.collectionId).catch(() => []);
const tracks = await dz.fetchAlbumTracks(alb.collectionId).catch(() => []);
if (tracks.length) {
await downloadAlbumTracks({ jobId, artist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
// Same as the album path: album_artist from the album-level artist, so a collaboration
// or soundtrack in the artist's discography isn't tagged with the searched artist.
const albArtist = tracks.map(t => t.collectionArtistName).find(Boolean) || alb.artistName || artist;
await downloadAlbumTracks({ jobId, artist: albArtist, albumTitle: alb.collectionName, tracks, artworkUrl: alb.artworkUrl100 });
}
done++;
updateJob(jobId, { progress: Math.round((done / artistAlbums.length) * 95) });
@@ -665,46 +857,46 @@ app.post('/api/download', async (req, res) => {
const batchResults = await Promise.allSettled(batch.map(async track => {
const trackTitle = track.trackName || '';
const trackArtist = track.artistName || artist || '';
// Strip release-type suffixes before iTunes lookup ("Song - Single" → "Song")
// Strip release-type suffixes before the Deezer lookup ("Song - Single" → "Song")
const RELEASE_SUFFIX_RE = /\s*[-–]\s*(?:(?:official\s+)?(?:music\s+)?(?:single|ep|lp|album|ost|soundtrack|remaster(?:ed)?|deluxe(?:\s+edition)?|bonus\s+tracks?|b-?sides?|version|edit)\b.*|(?:20|19)\d{2})\s*$/i;
const itunesTitle = trackTitle.replace(RELEASE_SUFFIX_RE, '').trim() || trackTitle;
// If the frontend already enriched this track with iTunes data (collectionName +
const lookupTitle = trackTitle.replace(RELEASE_SUFFIX_RE, '').trim() || trackTitle;
// If the frontend already enriched this track with Deezer data (collectionName +
// trackNumber + durationMs all present), skip the re-lookup to avoid a different
// iTunes result overriding the already-correct enriched metadata.
// Deezer result overriding the already-correct enriched metadata.
const alreadyEnriched = !!(track.collectionName && track.trackNumber && track.durationMs);
const searchArtist = trackArtist.split(',')[0].trim();
let itunes = {};
let meta = {};
if (alreadyEnriched) {
log(jobId, `📋 Frontend-Metadaten: "${trackArtist} – ${trackTitle}" (${track.collectionName})`);
// Spotify enrichment copies album/trackNum/duration but not genre/totalTracks/disc — fetch those
const genreMeta = await lookupItunesTrackMeta(searchArtist, itunesTitle).catch(() => ({}));
itunes = {
const genreMeta = await dz.lookupTrackMeta(searchArtist, lookupTitle).catch(() => ({}));
meta = {
genre: genreMeta.genre,
totalTracks: genreMeta.totalTracks,
discNum: genreMeta.discNum,
discTotal: genreMeta.discTotal,
};
} else {
log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`);
itunes = await lookupItunesTrackMeta(searchArtist, itunesTitle);
log(jobId, `📋 Deezer: "${trackArtist} – ${trackTitle}"`);
meta = await dz.lookupTrackMeta(searchArtist, lookupTitle);
}
// Prefer iTunes-resolved name (from server lookup or frontend enrichment) for sort tags / folders.
// Prefer the resolved name (from server lookup or frontend enrichment) for sort tags / folders.
// trackArtist stays as rawArtist for main tags and YTM search.
const resolvedArtist = itunes.itunesArtistName || track.itunesArtistName || trackArtist;
const resolvedArtist = meta.metaArtistName || track.metaArtistName || trackArtist;
return downloadTrack({
jobId,
artist: resolvedArtist, // iTunes English name → used for sort tags + folder names
artist: resolvedArtist, // resolved (Deezer) name → used for sort tags + folder names
rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search
albumArtist: resolvedArtist,
title: trackTitle,
album: itunes.album || track.collectionName || track.album || '',
year: itunes.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year,
trackNum: itunes.trackNum || track.trackNumber || undefined,
totalTracks: itunes.totalTracks || undefined,
genre: itunes.genre || track.primaryGenreName || track.genre || undefined,
artworkUrl: itunes.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '',
album: meta.album || track.collectionName || track.album || '',
year: meta.year || (track.releaseDate ? new Date(track.releaseDate).getFullYear() : undefined) || track.year,
trackNum: meta.trackNum || track.trackNumber || undefined,
totalTracks: meta.totalTracks || undefined,
genre: meta.genre || track.primaryGenreName || track.genre || undefined,
artworkUrl: meta.artworkUrl || track.artworkUrl100 || track.artworkUrl || artworkUrl || '',
explicit: track.trackExplicitness === 'explicit',
durationMs: itunes.durationMs || track.durationMs || undefined,
durationMs: meta.durationMs || track.durationMs || undefined,
sourceUrl: track.sourceUrl || undefined,
});
}));
@@ -1178,7 +1370,11 @@ app.post('/api/edit-metadata', async (req, res) => {
const found = rebased
|| _findFileRecursive(CONFIG.musicPath, fileName)
|| _findFileInDir(rebasedDir || originalDir, fileName)
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem);
|| _findFileByStemOrRomaji(CONFIG.musicPath, titleStem)
// Last resort: Subsonic getSong returns a *virtual* (tag-built) path, so the folder
// can be "[Unknown Album]" while the file actually sits in "Singles" with an artist
// prefix. findExistingTrack matches by normalized artist+title across all album folders.
|| findExistingTrack(CONFIG.musicPath, ndSong.albumArtist || ndSong.artist || '', ndSong.title || '');
if (found) { filePath = found; }
else return res.status(404).json({ error: `File not found: ${filePath} (music root: ${CONFIG.musicPath})` });
}
+3
View File
@@ -1,6 +1,9 @@
{
"name": "novasonic-backend",
"private": true,
"scripts": {
"test": "node --test"
},
"dependencies": {
"express": "^4.18.0",
"kuromoji": "^0.1.2"
+120
View File
@@ -0,0 +1,120 @@
'use strict';
// Unit tests for the Deezer metadata mapping/matching (backend/lib/deezer.js).
// Pure logic only — no network. Run: `node --test` from backend/ (or `npm test`).
// A live smoke test against api.deezer.com is included but only runs with RUN_LIVE=1.
const test = require('node:test');
const assert = require('node:assert/strict');
const dz = require('../lib/deezer');
// ── Fixtures: the exact Deezer shapes the mappers consume ─────────────────────
const ALBUM = {
id: 302127,
title: 'Discovery',
cover_xl: 'https://img/cover_xl.jpg',
cover_big: 'https://img/cover_big.jpg',
release_date: '2001-03-07',
nb_tracks: 14,
record_type: 'album',
genres: { data: [{ id: 113, name: 'Dance' }] },
artist: { id: 27, name: 'Daft Punk' },
};
const TRACK = {
id: 3135556,
title: 'Harder, Better, Faster, Stronger',
duration: 224,
explicit_lyrics: false,
preview: 'https://img/preview.mp3',
artist: { id: 27, name: 'Daft Punk' },
};
test('mapAlbumTrack: numbers by album order and inherits album-level fields', () => {
const item = dz.mapAlbumTrack(TRACK, ALBUM, 3); // 4th track
assert.equal(item.wrapperType, 'track');
assert.equal(item.kind, undefined); // shape has no kind here; downloadAlbumTracks doesn't read it
assert.equal(item.trackNumber, 4); // index + 1 (Deezer album tracks carry no track_position)
assert.equal(item.trackName, 'Harder, Better, Faster, Stronger');
assert.equal(item.collectionName, 'Discovery');
assert.equal(item.collectionArtistName, 'Daft Punk');
assert.equal(item.primaryGenreName, 'Dance');
assert.equal(item.releaseDate, '2001-03-07');
assert.equal(item.trackCount, 14);
assert.equal(item.trackTimeMillis, 224000); // seconds → ms
assert.equal(item.trackExplicitness, 'notExplicit');
assert.equal(item.artworkUrl100, 'https://img/cover_xl.jpg');
});
test('mapAlbumTrack: per-track artist wins over the album artist (mixed-artist albums)', () => {
const feat = { ...TRACK, artist: { id: 99, name: 'MeloMance' } };
const item = dz.mapAlbumTrack(feat, ALBUM, 0);
assert.equal(item.artistName, 'MeloMance'); // drives the per-track YTM search
assert.equal(item.collectionArtistName, 'Daft Punk'); // album_artist stays stable → no album split
});
test('mapAlbumTrack: explicit flag maps through', () => {
const item = dz.mapAlbumTrack({ ...TRACK, explicit_lyrics: true }, ALBUM, 0);
assert.equal(item.trackExplicitness, 'explicit');
});
test('pickTrackMatch: strict match on artist + title', () => {
const results = [
{ title: 'Some Other Song', artist: { name: 'Daft Punk' } },
{ title: 'Harder, Better, Faster, Stronger', artist: { name: 'Daft Punk' } },
];
const m = dz.pickTrackMatch(results, 'Daft Punk', 'Harder, Better, Faster, Stronger');
assert.equal(m, results[1]);
});
test('pickTrackMatch: NO match when the artist is completely different (no wrong album)', () => {
// Regression: "☆NoAki☆ – Memories" used to grab a same-titled song by an unrelated artist and
// apply its album/cover/year. A wholly different artist must now return null → clean fallback.
const results = [
{ title: 'Memories', artist: { name: 'Rab Noakes' } },
{ title: 'Memories', artist: { name: 'Maroon 5' } },
];
assert.equal(dz.pickTrackMatch(results, '☆NoAki☆', 'Memories'), null);
assert.equal(dz.pickTrackMatch(results, 'Some Cover Act', 'Levitating'), null);
});
test('pickTrackMatch: loose artist match — one name contained in the other', () => {
const results = [{ title: 'Flower Dance', artist: { name: 'DJ Okawari' } }];
const m = dz.pickTrackMatch(results, 'Okawari', 'Flower Dance');
assert.equal(m, results[0]); // "okawari" ⊂ "djokawari" → accepted
});
test('pickTrackMatch: NO title-only fallback for Japanese titles', () => {
const results = [{ title: '残酷な天使のテーゼ', artist: { name: 'Someone Else' } }];
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
assert.equal(m, null); // JP title + wrong artist must not match a same-titled cover
});
test('pickTrackMatch: Japanese title DOES match when the artist matches', () => {
const results = [{ title: '残酷な天使のテーゼ', artist: { name: '高橋洋子' } }];
const m = dz.pickTrackMatch(results, '高橋洋子', '残酷な天使のテーゼ');
assert.equal(m, results[0]);
});
test('pickTrackMatch: leading aesthetic punctuation is ignored on the artist', () => {
const results = [{ title: 'nostalgia', artist: { name: '.diedlonely' } }];
const m = dz.pickTrackMatch(results, '.diedlonely', 'nostalgia');
assert.equal(m, results[0]);
});
test('pickTrackMatch: empty / no match returns null', () => {
assert.equal(dz.pickTrackMatch([], 'x', 'y'), null);
assert.equal(dz.pickTrackMatch([{ title: 'A', artist: { name: 'B' } }], 'C', 'D'), null);
});
test('normalizeTitle: unifies variant suffixes and punctuation', () => {
assert.equal(dz.normalizeTitle("don't rush - slowed"), dz.normalizeTitle("don't rush (slowed)"));
assert.equal(dz.normalizeTitle('Mr. Lawrence'), dz.normalizeTitle('Mr Lawrence'));
});
// ── Live smoke test (opt-in): confirms the real Deezer shape still maps ────────
test('LIVE: lookupTrackMeta returns enriched fields', { skip: process.env.RUN_LIVE !== '1' }, async () => {
const meta = await dz.lookupTrackMeta('Daft Punk', 'Harder, Better, Faster, Stronger');
assert.ok(meta.album, 'album present');
assert.ok(meta.genre, 'genre present');
assert.ok(meta.year, 'year present');
assert.ok(meta.durationMs > 0, 'duration present');
});
+50
View File
@@ -0,0 +1,50 @@
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { findExistingTrack } = require('../lib/metadata');
let ROOT;
const touch = (artist, album, file) => {
const dir = path.join(ROOT, artist, album);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), '');
};
before(() => {
ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'ns-dedup-'));
// KH single: normal "01 - " prefix, title contains multiple " - "
touch('Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme - Single',
'01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3');
// KH album variant: garbage "01-01" artist prefix, no track number
touch('Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme',
'01-01 - Kingdom Hearts 2 - Dearly Beloved - Main Theme.mp3');
// Normal "Artist - Title" file
touch('Some Artist', 'Therapy - Single', '01 - Some Artist - Therapy.mp3');
});
after(() => { try { fs.rmSync(ROOT, { recursive: true, force: true }); } catch {} });
test('detects a re-download when the title itself contains " - " (KH bug)', () => {
assert.ok(findExistingTrack(ROOT, 'Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme'));
});
test('detects it even with a garbage "01-01 - " artist prefix', () => {
// Both KH files should resolve; at least one must be found → dedup skips the re-download.
const hit = findExistingTrack(ROOT, 'Geek Music', 'Kingdom Hearts 2 - Dearly Beloved - Main Theme');
assert.ok(hit && hit.includes('Kingdom Hearts 2 - Dearly Beloved - Main Theme'));
});
test('normal "Artist - Title" filename still matches on title alone', () => {
assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy'));
});
test('no false positive: a different title is not matched', () => {
assert.equal(findExistingTrack(ROOT, 'Geek Music', 'Some Unrelated Song'), null);
assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Kingdom Hearts 2'), null);
});
test('remix stays distinct from the base track (no regression from the version-marker fix)', () => {
assert.equal(findExistingTrack(ROOT, 'Some Artist', 'Therapy (Remix)'), null);
assert.ok(findExistingTrack(ROOT, 'Some Artist', 'Therapy')); // base still found
});
+48
View File
@@ -0,0 +1,48 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const mb = require('../lib/musicbrainz');
const rec = (title, artist, releases = []) => ({
id: 'rec-' + title, title, length: 200000,
'artist-credit': [{ name: artist, artist: { id: 'a', name: artist } }],
releases,
});
const albumRel = { id: 'rel-1', title: 'Real Album', date: '2021-05-01', 'release-group': { 'primary-type': 'Album' } };
const singleRel = { id: 'rel-2', title: 'Some Single', date: '2020-01-01', 'release-group': { 'primary-type': 'Single' } };
test('mbPickMatch: artist-verified match prefers a recording with a release', () => {
const recs = [rec('Memories', 'NoAki'), rec('Memories', 'NoAki', [albumRel])];
assert.equal(mb.mbPickMatch(recs, 'NoAki', 'Memories'), recs[1]);
});
test('mbPickMatch: completely different artist → null (no wrong album)', () => {
const recs = [rec('Memories', 'Rab Noakes', [albumRel]), rec('Memories', 'Maroon 5', [albumRel])];
assert.equal(mb.mbPickMatch(recs, '☆NoAki☆', 'Memories'), null);
});
test('mbPickMatch: loose artist match (one contained in the other)', () => {
const recs = [rec('Flower Dance', 'DJ Okawari', [albumRel])];
assert.equal(mb.mbPickMatch(recs, 'Okawari', 'Flower Dance'), recs[0]);
});
test('mapMbRecording: maps album/year/cover/duration and prefers the Album release', () => {
const m = mb.mapMbRecording(rec('Memories', 'NoAki', [singleRel, albumRel]));
assert.equal(m.album, 'Real Album');
assert.equal(m.albumArtist, 'NoAki');
assert.equal(m.year, 2021);
assert.equal(m.trackName, 'Memories');
assert.equal(m.durationMs, 200000);
assert.equal(m.artworkUrl, 'https://coverartarchive.org/release/rel-1/front-500');
assert.equal(m._source, 'musicbrainz');
});
test('mapMbRecording: no release → no album, no artwork (clean fallback territory)', () => {
const m = mb.mapMbRecording(rec('Orphan Song', 'NoAki', []));
assert.equal(m.album, undefined);
assert.equal(m.artworkUrl, '');
assert.equal(m.metaArtistName, 'NoAki');
});
test('mapMbRecording: null in → {} out', () => {
assert.deepEqual(mb.mapMbRecording(null), {});
});
+34 -4
View File
@@ -10,10 +10,11 @@ services:
environment:
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
- BACKEND_URL=${BACKEND_URL} # pre-filled on the setup screen
- SCROBBLE_URL=${SCROBBLE_URL} # play-log / stats service (setup screen)
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
novasonic-backend:
image: node:20-slim
image: node:22-slim
container_name: novasonic-backend
restart: unless-stopped
working_dir: /app
@@ -32,14 +33,43 @@ services:
- MAX_PARALLEL=${MAX_PARALLEL:-2}
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
# Android APK download button — backend proxies a fixed Gitea release ("apk-latest"), same LAN.
# Optional: leave unset to use the local /data/novasonic.apk fallback instead.
- GITEA_URL=${GITEA_URL:-} # internal Gitea base, e.g. http://gitea:3000
- GITEA_REPO=${GITEA_REPO:-} # owner/repo, e.g. Docker/NovaSonic
- GITEA_TOKEN=${GITEA_TOKEN:-} # read token (only for a private repo)
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
command: >
sh -c "
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git && rm -rf /var/lib/apt/lists/* &&
curl -sL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp && chmod a+rx /usr/local/bin/yt-dlp &&
apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git unzip && rm -rf /var/lib/apt/lists/* &&
pip install --break-system-packages -U --pre 'yt-dlp[default]' &&
curl -fsSL https://deno.land/install.sh | sh &&
pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader' &&
mkdir -p /deps && cd /deps && npm install express kuromoji &&
cd /app && NODE_PATH=/deps/node_modules node novasonic-server.js
cd /app && PATH=/root/.deno/bin:$$PATH NODE_PATH=/deps/node_modules node novasonic-server.js
"
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
novasonic-scrobble:
image: node:22-slim
container_name: novasonic-scrobble
restart: unless-stopped
working_dir: /app
volumes:
- ./scrobble:/app:ro # server code (read-only)
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
ports:
- "3710:3710" # route this port through your reverse proxy
environment:
- PORT=3710
- DATA_PATH=/data
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
command: >
sh -c "
mkdir -p /deps && cd /deps && npm install express &&
cd /app && NODE_PATH=/deps/node_modules node --experimental-sqlite scrobble-server.js
"
volumes:
novasonic-data: # persistent storage for romaji overrides and app state
novasonic-scrobble-data: # persistent play-log DB (novasonic-plays.db)
+1
View File
@@ -3,5 +3,6 @@
cat > /usr/share/nginx/html/config.js <<EOF
window.NS_SERVER_URL = "${NAVIDROME_URL:-}";
window.NS_BACKEND_URL = "${BACKEND_URL:-}";
window.NS_SCROBBLE_URL = "${SCROBBLE_URL:-}";
EOF
exec nginx -g "daemon off;"
+234
View File
@@ -0,0 +1,234 @@
# NovaSonic Scrobble & Listening-Archive — Plan
> Status: **Planung** (kein Code). Eigenes, zeitgestempeltes Play-Log als Grundlage für
> Tages-/Monats-/Jahres-Statistiken und ein „Wrapped", **multi-user**, Web + Android.
> Ersetzt Koito/Spindle-Ideen (beide effektiv single-user).
## Warum überhaupt eigenes Log
- Navidrome speichert nur `playCount` + `playDate` (letztes Datum) — **keine Historie mit
Zeitstempeln**. Damit sind „Plays pro Tag/Monat/Jahr" und ein Jahres-Wrapped **nicht** möglich.
- **Koito** und **Spindle** sind praktisch **single-user** (ein Admin/DEFAULT_USER, eine Historie).
Für mehrere Navidrome-Nutzer im Haushalt passt keins.
- NovaSonic **ist der Player** → kennt bei jedem Abspielen: **wer + Song + wann + wie lange**.
Das ist der natürliche Erfassungspunkt, pro Nutzer.
---
## Architektur-Entscheidung: eigener Container statt im Download-Backend
**Empfehlung: separater, schlanker Service `novasonic-scrobble`.**
Die zwei Workloads haben **gegensätzliche Profile**:
| | Download-Backend | Scrobble-Service |
|---|---|---|
| Last | schwer, bursty (yt-dlp/ffmpeg/spotdl-Subprozesse) | leicht, konstant (wenige Writes/Song) |
| Uptime-Bedarf | egal, darf abstürzen/neustarten | **hoch** — darf keine Plays verlieren |
| Neustart-Kadenz | **regelmäßig** (zieht yt-dlp bei jedem Start neu) | selten |
| Kritikalität der Daten | reproduzierbar (erneut downloaden) | **Archiv, nicht reproduzierbar** |
| Deps | python3, ffmpeg, git, kuromoji … | nur `node:sqlite` (0 native Deps) |
Ein `yt-dlp`/`ffmpeg`-OOM oder der Routine-Neustart „yt-dlp neu ziehen" darf **nie** in-flight
Scrobbles verlieren oder die DB gefährden. Isolation = Datensicherheit. Der Scrobble-Service
startet in Sekunden (winziges Image), läuft stabil durch, besitzt sein eigenes DB-Volume.
**Kosten:** eine zusätzliche Compose-Service + ein Upstream in der nginx-Config. Gering, weil der
Service trivial ist (Express + SQLite + ~4 Routen).
### Alternative (verworfen): alles im Download-Backend
Weniger Dateien, aber koppelt das **Archiv** an den instabilsten/am häufigsten neugestarteten
Prozess. Für einen reinen MVP machbar, aber die Datenintegrität ist es nicht wert.
### Node-/SQLite-Wahl für den neuen Service
Neues Image auf **`node:22-slim`** → eingebautes **`node:sqlite`** (stabil ab Node 22), **keine
native Build-Kette**, kein `better-sqlite3`-Kompilat. Das bestehende Download-Backend bleibt auf
`node:20-slim` unangetastet.
---
## Datenmodell (SQLite, im eigenen Volume)
DB-Datei: `/data/novasonic-plays.db` (eigenes `novasonic-scrobble-data`-Volume).
**Denormalisiert** — Titel/Artist/Album werden mitgespeichert, damit das Archiv überlebt, wenn ein
Song später aus Navidrome verschwindet (Kern eines Archivs).
```sql
CREATE TABLE plays (
id INTEGER PRIMARY KEY,
user TEXT NOT NULL, -- Navidrome-Username → Mandanten-Trennung
song_id TEXT NOT NULL, -- Subsonic id (kann später ungültig werden)
title TEXT,
artist TEXT,
artist_id TEXT,
album TEXT,
album_id TEXT,
duration INTEGER, -- Track-Länge in Sekunden
played_at INTEGER NOT NULL, -- Unix-Epoch, UTC
source TEXT, -- 'web' | 'android'
uuid TEXT UNIQUE -- Idempotenz (Offline-Retry doppelt-sicher)
);
CREATE INDEX ix_plays_user_time ON plays(user, played_at);
CREATE INDEX ix_plays_user_artist ON plays(user, artist_id);
CREATE INDEX ix_plays_user_song ON plays(user, song_id);
```
- **`uuid UNIQUE`**: Web/Android queuen Plays offline und flushen später → ohne Idempotenz zählt ein
Retry doppelt. Client erzeugt die UUID; Ingest ignoriert Duplikate (`INSERT OR IGNORE`).
- **Zeitzone**: immer **UTC** speichern; beim Aggregieren die lokale TZ des Users anwenden
(Client schickt Offset/IANA-TZ), sonst rutscht ein Play um Mitternacht in den falschen Tag.
- **Rollups**: bewusst **keine** materialisierten Tabellen zu Beginn — On-the-fly `GROUP BY` reicht
für persönliche Datenmengen locker (100k Zeilen sind für SQLite nichts). Nachrüstbar.
---
## Was zählt als „Play"
**Umgesetzt (besser als der ursprüngliche 50%/4-min-Plan):** Wir docken am **bestehenden
`scrobbleMode`-Moment** an — genau dann, wenn NovaSonic ohnehin den echten Navidrome-Scrobble
absendet (nicht das `submission=false`-„läuft gerade"), feuert **zusätzlich** `logPlay()`.
Vorteile: (1) **konsistent mit Last.fm** — beide zählen denselben Play zur selben Zeit;
(2) respektiert die **vom User gewählte** Scrobble-Schwelle (15s / 30s / 1/3 / 2/3 / end) statt
einer fest verdrahteten Regel; (3) eine einzige Wahrheit, weniger Code. Der Play-Log ersetzt den
Navidrome/Last.fm-Scrobble **nie** — er läuft parallel.
Web: [src/player/player.js](../src/player/player.js) `_scheduleScrobble`/`_scrobbleNow`.
Android: [PlayerController.kt](../android/app/src/main/kotlin/com/novasonic/app/player/PlayerController.kt) `scheduleScrobble`.
---
## API (Service `novasonic-scrobble`)
| Endpoint | Zweck | Status |
|---|---|---|
| `POST /scrobble/plays` | Batch-Ingest `{plays:[…]}`, idempotent per `uuid` | ✅ Phase 1 |
| `GET /scrobble/history?limit=&before=` | Roh-Verlauf chronologisch = **das Archiv** | ✅ Phase 1 |
| `DELETE /scrobble/plays?user=NAME` | Zeilen eines Users löschen (Test-Cleanup) | ✅ Phase 1 |
| `GET /scrobble/health` | Liveness + Zeilen-Zahl | ✅ Phase 1 |
| `GET /scrobble/stats?from=&to=&tz=&bucket=day\|month\|year&limit=` | Aggregate: totals, Top-Artists/Alben/Tracks, Zeit-Serie (day/month/year), Hör-Uhr (byHour) | ✅ Phase 3 (Endpoint) |
| `GET /scrobble/wrapped?year=YYYY&tz=` | Fertiger Wrapped-Payload | Phase 4 |
### Routing: eigene Origin, KEIN nginx-Upstream
Der Frontend-nginx serviert nur Static und **proxyt nichts** — der Client spricht das Download-Backend
direkt über `S.cfg.backendUrl` (`window.NS_BACKEND_URL`) an. Der Scrobble-Service folgt exakt diesem
Muster: eigene Origin `S.cfg.scrobbleUrl` (`window.NS_SCROBBLE_URL`, aus `SCROBBLE_URL` in der
`.env` → `docker-entrypoint.sh`). CORS `*` wie im Backend. Der User routet Port **3710** über seinen
Reverse-Proxy (wie 8080/3700).
### Auth / Multi-User (Trust-Modell)
Jeder Call trägt die Subsonic-Token-Creds, die NovaSonic ohnehin nutzt (`u`, `t=md5(pass+salt)`,
`s=salt`). Der Service verifiziert den User einmal per günstigem Subsonic **`ping.view`** gegen
Navidrome (5 min gecacht) → der `user`-Wert im Play ist damit echt, nicht spoofbar. Kein neues
Login, kein neues Passwort.
### Aggregation Tag / Monat / Jahr (das Archiv-Herzstück)
```sql
-- Plays pro Tag im Zeitraum (User + lokale TZ via Offset-Sekunden)
SELECT strftime('%Y-%m-%d', played_at + :tzOffset, 'unixepoch') AS bucket,
COUNT(*) AS plays, SUM(duration) AS seconds
FROM plays
WHERE user = :user AND played_at BETWEEN :from AND :to
GROUP BY bucket ORDER BY bucket;
```
Gleiche Query mit `%Y-%m` → Monat, `%Y` → Jahr. Top-Listen analog via `GROUP BY artist_id` etc.
### Wrapped-Payload (Beispiel-Felder)
`year`, `totalPlays`, `totalMinutes`, `topArtists[]`, `topAlbums[]`, `topTracks[]`,
`mostActiveDay`, `listeningClock` (Plays je Stunde 0–23), `monthlyBreakdown[12]`,
`newArtistsDiscovered` (erste je Artist im Jahr), `streakLongest`.
---
## Erfassung im Client
- **Web** ([src/api.js](../src/api.js) / Player): im `timeupdate`/`ended`-Handler die 50 %/4-min-
Schwelle prüfen → lokale Queue (localStorage) → Flush an `/scrobble/plays`. Läuft **parallel** zum
bestehenden Navidrome-Scrobble (Play-Count bleibt unangetastet).
- **Android** (Media3-Listener): gleiche Schwelle über Position/State → Room-Queue → Flush.
Nutzt dieselbe Offline-Queue-Denke wie das bestehende Offline-System.
Beide: Queue + Retry, damit ein Play auch offline erfasst und später idempotent nachgereicht wird.
---
## UI
- **Web:** neue View „Statistiken" — Kalender-Heatmap (Tage), Monats-Balken, Top-Listen; **Wrapped**
als Story-Seite zum Jahresende. Vanilla SVG/Canvas, **keine Chart-Lib** (Bundle schlank halten).
- **Android:** Stats-Screen (Compose), spiegelbildlich zur Web-View.
---
## Optional später: ListenBrainz-Relay
Jeden Play zusätzlich an einen **ListenBrainz-kompatiblen** Endpoint schieben (falls konfiguriert)
→ NovaSonic wird nebenbei ein Standard-Scrobbler, Koito/LB als externes Ziel andockbar. Nicht
Teil des Kerns.
---
## docker-compose (Skizze)
```yaml
novasonic-scrobble:
image: node:22-slim
container_name: novasonic-scrobble
restart: unless-stopped
working_dir: /app
volumes:
- ./scrobble:/app:ro
- novasonic-scrobble-data:/data
ports:
- "3710:3710"
environment:
- PORT=3710
- DATA_PATH=/data
- NAVIDROME_URL=${NAVIDROME_URL} # für ping.view-Verifikation
command: >
sh -c "mkdir -p /deps && cd /deps && npm install express &&
cd /app && NODE_PATH=/deps/node_modules node scrobble-server.js"
volumes:
novasonic-scrobble-data:
```
`.env`: `SCROBBLE_URL=https://scrobble.deine-domain` ergänzen; der Frontend-Container injiziert das als
`window.NS_SCROBBLE_URL` (via `docker-entrypoint.sh`). Kein nginx-Upstream nötig — eigene Origin, CORS `*`.
---
## Phasen
1. ✅ **Service-Fundament** *(erledigt)*: `scrobble/scrobble-server.js` (node:22 + `node:sqlite`),
`POST /scrobble/plays` (idempotent) + `GET /scrobble/history` + `DELETE /scrobble/plays` +
`GET /scrobble/health`, `ping.view`-Auth (5 min gecacht), eigenes Volume, Compose-Service,
`SCROBBLE_URL`-Plumbing (entrypoint + `S.cfg.scrobbleUrl`), Backend auf node:22 hochgezogen.
Lokal end-to-end getestet (Ingest/Idempotenz/History/Purge). *Ab hier wird bereits archiviert.*
2. ✅ **Erfassung Web + Android** *(erledigt)*: Play-Log am `scrobbleMode`-Moment (s.o.),
Offline-Queue (Web: localStorage `ns_play_queue`; Android: `filesDir/play-queue.json`),
idempotenter Batch-Flush bei Start + `online`-Event. `SCROBBLE_URL`/`scrobbleUrl` in beiden
Settings. Web-Build + Android-`compileDebugKotlin` grün. *Ab jetzt wird real erfasst.*
3. ✅ **Erledigt**: `/scrobble/stats` (Endpoint: totals, Top-Listen, day/month/year-Serie, Hör-Uhr;
on-the-fly `GROUP BY strftime`, tz-Offset, unit-getestet) **+ Web-Statistik-View**
([src/ui/stats.js](../src/ui/stats.js)) — Overlay aus den Einstellungen („📊 Meine Statistiken"),
Zeitraum-Tabs (Alles / Dieses Jahr / Letzte 30 Tage), Kacheln, Verlaufs-Balken, Top-Listen,
Hör-Uhr. Vanilla/inline, keine Chart-Lib. **Braucht Runtime-Test am Gerät.**
4. 🔨 **In Arbeit**: **Android-Stats-Screen** ✅ (konsumiert `/scrobble/stats`; Kacheln, Verlaufs-
Balken, Top-Listen, Hör-Uhr; Zeitraum-Tabs; aus den Android-Einstellungen). **Cover + Artist-
Bilder** ✅ in Web **und** Android (client-seitig aus Navidrome via `coverUrl`/`coverArtUrl` über
`artistId`/`albumId`/`songId`; Endpoint liefert jetzt auch `albumId` bei Top-Tracks).
**Noch offen: Wrapped** (`/scrobble/wrapped` + Web-Story-Seite). Android + Web build grün;
**braucht Runtime-Test am Gerät.**
5. *(optional)* ListenBrainz-Relay.
---
## Offene Entscheidungen
- **Container-Netzwerk/Proxy:** eigener Port (3710) hinter nginx-Upstream `/scrobble/` — bestätigen.
- **TZ-Übergabe:** fester UTC-Offset pro Request vs. IANA-Name (Sommerzeit-genau). Start: Offset,
später IANA falls DST-Grenzfälle stören.
- **Schwelle:** 50 %/4 min (Last.fm) übernehmen oder anpassen?
- **Aufbewahrung:** unbegrenzt (Archiv). Backup = das SQLite-File im Volume sichern.
```
+100 -97
View File
@@ -85,9 +85,6 @@
<div class="nav-item" onclick="navigate('random')" data-page="random">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>Zufällig
</div>
<div class="nav-item" onclick="navigate('spotify-importer')" data-page="spotify-importer">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>Song Importer
</div>
</div>
<div class="nav-section" id="nav-abs-section" style="display:none">
<div class="nav-section-title">Hörbücher</div>
@@ -121,6 +118,8 @@
</button>
</div>
</div>
<!-- Results panel: spans the full main-content width (anchored to the header, which already
sits right of the sidebar) so it never covers the sidebar and leaves no dead scroll strip -->
<div id="header-search-overlay" style="display:none">
<div id="header-search-results"></div>
</div>
@@ -151,7 +150,7 @@
<!-- ARTIST DETAIL -->
<div class="page" id="page-artist-detail">
<button class="back-btn" id="artist-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Alle Künstler</button>
<div class="artist-hero" id="artist-detail-hero"><div class="artist-hero-bg" id="artist-hero-bg"></div><div class="artist-hero-overlay"><div class="artist-hero-pfp-wrap" id="artist-hero-pfp-wrap"><span class="artist-hero-pfp-initial" id="artist-hero-pfp-initial"></span></div><div class="artist-hero-info"><div class="artist-hero-name" id="artist-detail-name"></div></div></div></div>
<div class="artist-hero" id="artist-detail-hero"><div class="artist-hero-bg" id="artist-hero-bg"></div><div class="artist-hero-overlay"><div class="artist-hero-pfp-wrap" id="artist-hero-pfp-wrap"><span class="artist-hero-pfp-initial" id="artist-hero-pfp-initial"></span></div><div class="artist-hero-info"><div class="artist-hero-name" id="artist-detail-name"></div><div class="artist-hero-fans" id="artist-detail-fans"></div></div></div></div>
<div id="artist-action-bar" class="artist-action-bar"></div>
<div id="artist-bio-section"></div>
<div id="artist-similar-section"></div>
@@ -166,22 +165,22 @@
<div id="artist-songs-list"></div>
</div>
<!-- ITUNES ALBUM DETAIL -->
<div class="page" id="page-itunes-album-detail">
<button class="back-btn" id="itunes-album-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<!-- DEEZER ALBUM DETAIL -->
<div class="page" id="page-deezer-album-detail">
<button class="back-btn" id="deezer-album-back-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div class="album-header">
<div class="album-cover" id="itunes-album-cover-wrap"><div class="no-art">💿</div></div>
<div class="album-cover" id="deezer-album-cover-wrap"><div class="no-art">💿</div></div>
<div class="album-meta">
<div class="album-type">iTunes</div>
<div class="album-name" id="itunes-album-detail-name">—</div>
<div class="album-artist-name" id="itunes-album-detail-artist"></div>
<div class="album-stats" id="itunes-album-detail-stats"></div>
<div class="album-type">Deezer</div>
<div class="album-name" id="deezer-album-detail-name">—</div>
<div class="album-artist-name" id="deezer-album-detail-artist"></div>
<div class="album-stats" id="deezer-album-detail-stats"></div>
<div class="album-actions">
<button class="btn-outline" id="itunes-album-dl-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Album laden</button>
<button class="btn-outline" id="deezer-album-dl-btn"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Album laden</button>
</div>
</div>
</div>
<div id="itunes-album-songs"></div>
<div id="deezer-album-songs"></div>
</div>
<!-- PARTIAL ALBUM DETAIL (incomplete album: downloaded + missing tracks) -->
@@ -329,20 +328,76 @@
<div id="search-results"></div>
</div>
<!-- DISCOVER (Global) -->
<!-- DISCOVER (Global) — Deezer search + link importer unified in ONE field -->
<div class="page" id="page-discover">
<div class="page-header">
<div class="page-title"><span class="blue">Global</span> Suche</div>
<span class="badge badge-itunes" style="font-size:11px;padding:4px 10px">iTunes Store</span>
<span class="badge badge-deezer" style="font-size:11px;padding:4px 10px">Deezer</span>
</div>
<div class="search-wrap" style="padding:0 0 20px">
<div class="search-box" style="background:var(--surface);border-color:var(--border2)">
<div class="search-wrap" style="padding:0 0 12px">
<div style="display:flex;gap:8px">
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input id="global-search-input" type="text" placeholder="Künstler, Titel, Album suchen…" oninput="onGlobalSearch(this.value)" style="font-size:15px">
<div id="importer-badge" style="display:none;font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:var(--surface2);color:var(--text3);white-space:nowrap;flex-shrink:0">URL</div>
<input id="global-search-input" type="text" placeholder="Künstler, Titel, Album — oder Link zum Importieren einfügen…" oninput="onGlobalInput(this.value)" onkeydown="if(event.key==='Enter'){event.preventDefault();tryImporterResolve()}" style="font-size:15px">
</div>
<button id="importer-btn" class="btn-outline" style="display:none;padding:0 16px;font-size:13px;font-weight:700;white-space:nowrap;flex-shrink:0" onclick="resolveImporterLink()">Importieren</button>
</div>
</div>
<!-- Link importer — auto-detects Spotify / SoundCloud / YouTube; shown only when a link is in the field -->
<div id="importer-area" style="display:none">
<div id="importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:8px">
<div style="display:flex;gap:16px;flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:6px"><span style="color:#1DB954;font-weight:700">Spotify</span> — Track, Album oder Playlist</div>
<div style="display:flex;align-items:center;gap:6px"><span style="color:#ff5500;font-weight:700">SoundCloud</span> — Track oder Playlist</div>
<div style="display:flex;align-items:center;gap:6px"><span style="color:#FF0000;font-weight:700">YouTube</span> — Video oder Playlist</div>
</div>
<div style="margin-top:6px;color:var(--text3);font-size:11px">Link einfügen → Metadaten werden geladen → via yt-dlp in die Navidrome Library herunterladen.</div>
</div>
<!-- hidden inputs kept for compat with spotify.js / soundcloud.js resolvers -->
<input id="importer-link-input" type="hidden">
<input id="spotify-link-input" type="hidden">
<input id="sc-link-input" type="hidden">
<!-- SPOTIFY RESULTS -->
<div id="spotify-preview" style="display:none;margin-bottom:8px">
<div id="spotify-preview-inner"></div>
<div id="spotify-enrich-status" style="display:none"></div>
<div id="spotify-tracks-section" style="display:none">
<div class="section-header" style="margin-top:8px">
<span class="section-title" id="spotify-tracks-title">Tracks</span>
</div>
<div id="spotify-tracks-list" class="song-list"></div>
</div>
</div>
<!-- SOUNDCLOUD RESULTS -->
<div id="sc-preview" style="display:none;margin-bottom:8px">
<div id="sc-preview-inner"></div>
<div id="sc-tracks-section" style="display:none">
<div class="section-header" style="margin-top:8px">
<span class="section-title" id="sc-tracks-title">Tracks</span>
</div>
<div id="sc-tracks-list" class="song-list"></div>
</div>
</div>
<!-- YOUTUBE RESULTS -->
<div id="yt-preview" style="display:none;margin-bottom:8px">
<div id="yt-preview-inner"></div>
<div id="yt-tracks-section" style="display:none">
<div class="section-header" style="margin-top:8px">
<span class="section-title" id="yt-tracks-title">Tracks</span>
</div>
<div id="yt-tracks-list" class="song-list"></div>
</div>
</div>
</div>
<div id="global-search-results">
<div class="empty-state"><div class="icon">🔍</div><p>Gib einen Suchbegriff ein um loszulegen</p></div>
<div class="empty-state"><div class="icon">🔍</div><p>Gib einen Suchbegriff ein — oder füge einen Spotify-/SoundCloud-/YouTube-Link ein zum Importieren</p></div>
</div>
</div>
@@ -350,7 +405,7 @@
<div class="page" id="page-new-releases">
<div class="page-header">
<div class="page-title">Neue <em>Releases</em></div>
<span class="badge badge-itunes" style="font-size:11px;padding:4px 10px">iTunes Charts</span>
<span class="badge badge-deezer" style="font-size:11px;padding:4px 10px">Deezer Charts</span>
</div>
<div class="section-header" style="margin-top:0"><span class="section-title">Top Alben weltweit</span></div>
<div id="nr-albums" class="grid grid-4"></div>
@@ -390,69 +445,6 @@
<div id="abs-book-detail-content"></div>
</div>
<!-- SPOTIFY IMPORTER -->
<div class="page" id="page-spotify-importer">
<div class="page-header">
<div class="page-title">Song <em>Importer</em></div>
</div>
<!-- UNIFIED INPUT — auto-detects Spotify vs SoundCloud -->
<div class="search-wrap" style="padding:0 0 8px">
<div style="display:flex;gap:8px">
<div class="search-box" style="background:var(--surface);border-color:var(--border2);flex:1">
<div id="importer-badge" style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:var(--surface2);color:var(--text3);white-space:nowrap;flex-shrink:0">URL</div>
<input id="importer-link-input" type="url" placeholder="Spotify, SoundCloud oder YouTube Link einfügen…" style="font-size:14px" oninput="onImporterInput(this.value)">
</div>
<button id="importer-btn" class="btn-outline" style="padding:0 16px;font-size:13px;font-weight:700;white-space:nowrap;flex-shrink:0" onclick="resolveImporterLink()">Importieren</button>
</div>
</div>
<div id="importer-hint" style="background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px;font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:4px">
<div style="display:flex;gap:16px;flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:6px"><span style="color:#1DB954;font-weight:700">Spotify</span> — Track, Album oder Playlist</div>
<div style="display:flex;align-items:center;gap:6px"><span style="color:#ff5500;font-weight:700">SoundCloud</span> — Track oder Playlist</div>
<div style="display:flex;align-items:center;gap:6px"><span style="color:#FF0000;font-weight:700">YouTube</span> — Video oder Playlist</div>
</div>
<div style="margin-top:6px;color:var(--text3);font-size:11px">Link einfügen → Metadaten werden geladen → via yt-dlp in die Navidrome Library herunterladen.</div>
</div>
<!-- hidden inputs kept for compat with spotify.js / soundcloud.js -->
<input id="spotify-link-input" type="hidden">
<input id="sc-link-input" type="hidden">
<!-- SPOTIFY RESULTS -->
<div id="spotify-preview" style="display:none;margin-bottom:8px">
<div id="spotify-preview-inner"></div>
<div id="spotify-enrich-status" style="display:none"></div>
<div id="spotify-tracks-section" style="display:none">
<div class="section-header" style="margin-top:8px">
<span class="section-title" id="spotify-tracks-title">Tracks</span>
</div>
<div id="spotify-tracks-list" class="song-list"></div>
</div>
</div>
<!-- SOUNDCLOUD RESULTS -->
<div id="sc-preview" style="display:none;margin-bottom:8px">
<div id="sc-preview-inner"></div>
<div id="sc-tracks-section" style="display:none">
<div class="section-header" style="margin-top:8px">
<span class="section-title" id="sc-tracks-title">Tracks</span>
</div>
<div id="sc-tracks-list" class="song-list"></div>
</div>
</div>
<!-- YOUTUBE RESULTS -->
<div id="yt-preview" style="display:none;margin-bottom:8px">
<div id="yt-preview-inner"></div>
<div id="yt-tracks-section" style="display:none">
<div class="section-header" style="margin-top:8px">
<span class="section-title" id="yt-tracks-title">Tracks</span>
</div>
<div id="yt-tracks-list" class="song-list"></div>
</div>
</div>
</div>
</main>
</div>
@@ -545,12 +537,12 @@
</button>
</div>
<!-- PREVIEW BAR (iTunes 30s) -->
<!-- PREVIEW BAR (Deezer 30s) -->
<div class="preview-bar" id="preview-bar">
<div class="player-art" id="preview-art" style="width:42px;height:42px;border-radius:6px"><div class="no-art" style="font-size:16px">♪</div></div>
<div class="preview-info">
<div class="preview-title" id="preview-title">—<span class="preview-badge">30s Preview</span></div>
<div class="preview-sub" id="preview-sub">iTunes Store</div>
<div class="preview-sub" id="preview-sub">Deezer</div>
</div>
<div class="preview-prog"><div class="preview-fill" id="preview-fill"></div></div>
<button class="preview-close" onclick="stopPreview()">✕</button>
@@ -613,6 +605,18 @@
<div id="s-cover-color-bg" class="toggle" onclick="this.classList.toggle('on')"></div>
</div>
</div>
<div class="settings-section" id="s-apk-section" style="display:none">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">🤖</span>
Android App
</h3>
<div style="font-size:12px;color:var(--text2);margin-bottom:10px;line-height:1.6">Installiere die NovaSonic Android-App direkt aufs Handy. <span id="s-apk-meta" style="color:var(--text3)"></span></div>
<a id="s-apk-btn" href="#" download style="display:inline-flex;align-items:center;gap:8px;padding:10px 16px;border-radius:10px;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#fff;font-weight:700;font-size:13px;text-decoration:none;box-shadow:0 4px 12px var(--accent-glow)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
APK herunterladen
</a>
<div style="font-size:11px;color:var(--text3);margin-top:8px">Danach im Handy „Installation aus unbekannten Quellen" für den Browser erlauben.</div>
</div>
<div class="settings-section">
<h3 style="display:flex;align-items:center;gap:8px;margin-bottom:12px">
<span style="width:24px;height:24px;border-radius:6px;background:var(--surface2);display:inline-flex;align-items:center;justify-content:center;font-size:12px">📊</span>
@@ -712,6 +716,8 @@
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:var(--surface2);color:var(--text2)" onclick="closeSettings()">Abbrechen</button>
<button style="flex:1;padding:12px;border-radius:9px;font-family:var(--font);font-size:14px;font-weight:600;cursor:pointer;border:none;background:linear-gradient(135deg,var(--accent),var(--accent-dark));color:#000" onclick="saveSettings()">Speichern</button>
</div>
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openStats()">📊 Meine Statistiken</button>
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid var(--border);color:var(--text2);transition:background .15s" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'" onclick="closeSettings(); openCompare()">🎧 Musik-Match</button>
<button style="width:100%;padding:10px;border-radius:9px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;background:none;border:1px solid rgba(224,85,85,0.3);color:var(--danger);transition:background .15s" onmouseover="this.style.background='rgba(224,85,85,0.1)'" onmouseout="this.style.background='none'" onclick="confirmDisconnect()">Abmelden</button>
</div>
</div>
@@ -742,6 +748,7 @@
<div class="toast-container" id="toast-container"></div>
<audio id="audio-element" preload="auto" playsinline crossorigin="anonymous"></audio>
<audio id="audio-element-b" preload="auto" playsinline crossorigin="anonymous"></audio>
<audio id="preview-audio"></audio>
<audio id="abs-audio"></audio>
@@ -995,15 +1002,15 @@
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
MusicBrainz
</button>
<button class="btn-outline blue" style="font-size:11px;padding:6px 10px;height:fit-content" onclick="autofillItunes()">
<button class="btn-outline blue" style="font-size:11px;padding:6px 10px;height:fit-content" onclick="autofillDeezer()">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right:4px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Apple Music
Deezer
</button>
</div>
<div class="info-edit-fields">
<label class="info-edit-label">Titel<input class="info-edit-input" id="ie-title" type="text" placeholder="Titel"></label>
<label class="info-edit-label">Künstler<span class="info-edit-hint">Mehrere Künstler mit <code>;</code> trennen — z.B. <code>Künstler A; Künstler B</code></span><input class="info-edit-input" id="ie-artist" type="text" placeholder="Künstler"></label>
<label class="info-edit-label">Album-Künstler<span class="info-edit-hint">Mehrere mit <code>,</code> trennen — z.B. <code>Künstler A, Künstler B</code></span><input class="info-edit-input" id="ie-albumartist" type="text" placeholder="Album-Künstler"></label>
<label class="info-edit-label">Künstler<span class="info-edit-hint">Mehrere mit <code>;</code> oder <code>,</code> trennen — wird in einzelne Künstler aufgeteilt</span><input class="info-edit-input" id="ie-artist" type="text" placeholder="Künstler"></label>
<label class="info-edit-label">Album-Künstler<span class="info-edit-hint">Mehrere mit <code>;</code> oder <code>,</code> trennen — bleibt als ein Album-Credit zusammen</span><input class="info-edit-input" id="ie-albumartist" type="text" placeholder="Album-Künstler"></label>
<label class="info-edit-label">Album<input class="info-edit-input" id="ie-album" type="text" placeholder="Album"></label>
<label class="info-edit-label">Genre<input class="info-edit-input" id="ie-genre" type="text" placeholder="Genre"></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
@@ -1066,9 +1073,6 @@
<div class="np-text">
<div class="np-title-row">
<div class="np-title" id="np-title">Kein Titel</div>
<button class="np-star" id="np-star-btn" onclick="npToggleStar()">
<svg id="np-star-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2" style="transition:fill .2s,stroke .2s"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
</button>
</div>
<div class="np-artist">
<span id="np-album-name" onclick="npGoAlbum()" style="cursor:pointer;opacity:.8">—</span>
@@ -1077,6 +1081,9 @@
<span id="np-artist-name">—</span>
</div>
</div>
<button class="np-star" id="np-star-btn" onclick="npToggleStar()">
<svg id="np-star-icon" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,.5)" stroke-width="2" style="transition:fill .2s,stroke .2s"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
</button>
</div>
<div class="np-progress">
@@ -1189,10 +1196,6 @@
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
Zufällig
</button>
<button class="bnav-item" onclick="navigate('spotify-importer')" data-page="spotify-importer">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 11.973c2.5-1.473 5.5-.973 7.5.527M9.5 14.473c1.5-.973 3.5-.973 5 .027M7 9c3-1.5 6.5-1 9 1"/></svg>
Importer
</button>
</div>
</div>
</nav>
+2 -1
View File
@@ -5,7 +5,8 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"icons": "node scripts/gen-icons.mjs"
"icons": "node scripts/gen-icons.mjs",
"publish-apk": "node scripts/publish-apk.mjs"
},
"devDependencies": {
"sharp": "^0.34.5",
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v41';
const CACHE = 'ns-v78';
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.
+13
View File
@@ -0,0 +1,13 @@
# Copy this file to scripts/.env.release (gitignored) and fill in your values.
# Used ONLY by `npm run publish-apk` on your DEV machine to upload the built APK to Gitea.
#
# cp scripts/.env.release.example scripts/.env.release
#
# GITEA_URL: a Gitea base URL reachable FROM THIS (dev) machine. On your LAN that's the internal
# address, e.g. http://192.168.1.10:3000 — NOT the (blocked) external duckdns URL.
GITEA_URL=http://192.168.1.10:3000
GITEA_REPO=Docker/NovaSonic
# A Gitea token with WRITE access (Settings → Applications → scope write:repository).
GITEA_TOKEN=paste-your-write-token-here
# Fixed release tag the APK lives in (leave as-is unless you changed it on the backend too).
GITEA_APK_TAG=apk-latest
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env node
// Build the NovaSonic Android APK and upload it to the fixed Gitea release ("apk-latest"), replacing
// the previous .apk asset — so the web "Android App" download button always serves the latest build.
//
// One command after your changes: npm run publish-apk
//
// Config (env, or a gitignored scripts/.env.release file with KEY=VALUE lines):
// GITEA_URL internal/LAN Gitea base reachable from THIS machine, e.g. http://192.168.1.10:3000
// GITEA_REPO owner/repo, e.g. Docker/NovaSonic
// GITEA_TOKEN Gitea token with write access to the repo (repo/releases)
// GITEA_APK_TAG fixed release tag (default: apk-latest)
// APK_GRADLE_TASK gradle task (default: assembleDebug — installable; release is unsigned here)
// APK_FILE override the built APK path if you use a custom task
//
// The debug build is used by default because the release build type has no signingConfig yet, so a
// release APK would be unsigned and refuse to install. Same dev machine ⇒ stable debug key ⇒ updates
// install over each other. Add a keystore + signingConfig later to switch APK_GRADLE_TASK=assembleRelease.
import { spawnSync } from 'node:child_process';
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// ── tiny .env.release loader (never committed) ───────────────────────────────
const envFile = join(root, 'scripts', '.env.release');
if (existsSync(envFile)) {
for (const line of readFileSync(envFile, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/i);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
const GITEA_URL = (process.env.GITEA_URL || '').replace(/\/$/, '');
const GITEA_REPO = process.env.GITEA_REPO || '';
const GITEA_TOKEN = process.env.GITEA_TOKEN || '';
const TAG = process.env.GITEA_APK_TAG || 'apk-latest';
const TASK = process.env.APK_GRADLE_TASK || 'assembleDebug';
function die(msg) { console.error(`\n✖ ${msg}`); process.exit(1); }
if (!GITEA_URL || !GITEA_REPO || !GITEA_TOKEN)
die('GITEA_URL, GITEA_REPO und GITEA_TOKEN müssen gesetzt sein (env oder scripts/.env.release).');
const API = `${GITEA_URL}/api/v1/repos/${GITEA_REPO}`;
const authHeaders = { Authorization: `token ${GITEA_TOKEN}` };
// ── bump versionCode + read versionName from the Gradle config ───────────────
// The in-app updater compares versionCode, so every published build MUST increment it — we do that
// here automatically (pass --version X.Y to also set the versionName). Commit the changed
// build.gradle.kts afterwards so the repo reflects what's live.
const gradlePath = join(root, 'android', 'app', 'build.gradle.kts');
let gradle = readFileSync(gradlePath, 'utf8');
const curCode = parseInt(gradle.match(/versionCode\s*=\s*(\d+)/)?.[1] || '0', 10);
const versionCode = curCode + 1;
const versionArg = process.argv.slice(2).find(a => a.startsWith('--version='))?.split('=')[1]
|| (process.argv.includes('--version') ? process.argv[process.argv.indexOf('--version') + 1] : null);
let version = versionArg || gradle.match(/versionName\s*=\s*"([^"]+)"/)?.[1] || `0.${versionCode}`;
gradle = gradle
.replace(/versionCode\s*=\s*\d+/, `versionCode = ${versionCode}`)
.replace(/versionName\s*=\s*"[^"]*"/, `versionName = "${version}"`);
writeFileSync(gradlePath, gradle);
console.log(`▶ versionCode → ${versionCode}, versionName → ${version} (build.gradle.kts danach committen!)`);
const relBody = `versionCode=${versionCode}\nversionName=${version}`;
// ── 1) build ─────────────────────────────────────────────────────────────────
const isWin = process.platform === 'win32';
const gradlew = join(root, 'android', isWin ? 'gradlew.bat' : 'gradlew');
console.log(`▶ Baue APK: ${TASK} …`);
const build = spawnSync(gradlew, [`:app:${TASK}`], {
cwd: join(root, 'android'), stdio: 'inherit', shell: isWin,
});
if (build.status !== 0) die('Gradle-Build fehlgeschlagen.');
// derive the APK path from the task (assembleDebug → debug/app-debug.apk)
const variant = TASK.replace(/^assemble/, '').toLowerCase() || 'debug';
const apkPath = process.env.APK_FILE
|| join(root, 'android', 'app', 'build', 'outputs', 'apk', variant, `app-${variant}.apk`);
if (!existsSync(apkPath)) die(`APK nicht gefunden: ${apkPath} (APK_FILE setzen, falls Task abweicht).`);
const apkBytes = readFileSync(apkPath);
console.log(`✓ Gebaut: ${apkPath} (${(apkBytes.length / 1048576).toFixed(1)} MB)`);
// ── 2) get-or-create the fixed release ───────────────────────────────────────
async function api(path, opts = {}) {
const r = await fetch(`${API}${path}`, { ...opts, headers: { ...authHeaders, ...(opts.headers || {}) } });
return r;
}
let rel;
{
const r = await api(`/releases/tags/${encodeURIComponent(TAG)}`);
if (r.status === 200) {
rel = await r.json();
} else if (r.status === 404) {
console.log(`▶ Release "${TAG}" existiert nicht — lege sie an …`);
const cr = await api('/releases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_name: TAG, target: 'main', name: `NovaSonic ${version}`, body: relBody }),
});
if (!cr.ok) die(`Release anlegen fehlgeschlagen: ${cr.status} ${await cr.text()}`);
rel = await cr.json();
} else {
die(`Release-Abfrage fehlgeschlagen: ${r.status} ${await r.text()}`);
}
}
// ── 3) delete old .apk assets, upload the new one ────────────────────────────
for (const a of rel.assets || []) {
if (/\.apk$/i.test(a.name)) {
const d = await api(`/releases/${rel.id}/assets/${a.id}`, { method: 'DELETE' });
console.log(d.ok ? `✓ Altes Asset gelöscht: ${a.name}` : `⚠ Löschen von ${a.name} fehlgeschlagen (${d.status})`);
}
}
const form = new FormData();
form.append('attachment', new Blob([apkBytes], { type: 'application/vnd.android.package-archive' }), 'NovaSonic.apk');
const up = await api(`/releases/${rel.id}/assets?name=NovaSonic.apk`, { method: 'POST', body: form });
if (!up.ok) die(`Upload fehlgeschlagen: ${up.status} ${await up.text()}`);
// keep the release title + versionCode body in sync
await api(`/releases/${rel.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: `NovaSonic ${version}`, body: relBody }),
}).catch(() => {});
console.log(`\n✔ NovaSonic ${version} hochgeladen → Release "${TAG}". Der Web-Button serviert jetzt diese APK.`);
+12
View File
@@ -0,0 +1,12 @@
{
"name": "novasonic-scrobble",
"private": true,
"description": "Isolated timestamped play-log service for NovaSonic (stats + Wrapped).",
"engines": { "node": ">=22" },
"scripts": {
"start": "node --experimental-sqlite scrobble-server.js"
},
"dependencies": {
"express": "^4.18.0"
}
}
+359
View File
@@ -0,0 +1,359 @@
/**
* NovaSonic Scrobble Service
* ---------------------------
* A tiny, isolated service that records a timestamped, per-user play-log — the
* foundation for day/month/year listening stats and a yearly "Wrapped".
*
* Deliberately SEPARATE from the download backend: the download server is heavy
* (yt-dlp/ffmpeg subprocesses), may crash, and restarts regularly (to re-pull
* yt-dlp). This archive needs high uptime + data integrity, so it runs on its own.
*
* Storage: node:sqlite (built into Node 22; run with --experimental-sqlite).
* DB file: {DATA_PATH}/novasonic-plays.db (own persistent volume)
*
* Auth / multi-user: every request carries the same Subsonic token creds the PWA
* already uses (u, t=md5(pass+salt), s=salt). We verify them against Navidrome's
* ping.view once (cached briefly) → the `user` we store is real, not spoofable.
* No new login, no new password.
*
* Phase 1 routes:
* POST /scrobble/plays ingest a batch, idempotent by uuid
* GET /scrobble/history raw chronological log (= the archive)
* DELETE /scrobble/plays?user=NAME purge one user's rows (test cleanup)
* GET /scrobble/health liveness + total row count
*/
const { DatabaseSync } = require('node:sqlite');
const express = require('express');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const PORT = process.env.PORT || 3710;
const DATA_PATH = process.env.DATA_PATH || '/data';
const NAVIDROME_URL = (process.env.NAVIDROME_URL || '').replace(/\/$/, '');
if (!NAVIDROME_URL) console.warn('⚠ NAVIDROME_URL not set — auth verification will reject everything.');
// ─── DB ─────────────────────────────────────────────────────────────────────
fs.mkdirSync(DATA_PATH, { recursive: true });
const db = new DatabaseSync(path.join(DATA_PATH, 'novasonic-plays.db'));
db.exec('PRAGMA journal_mode = WAL;'); // survive restarts / concurrent reads
db.exec(`
CREATE TABLE IF NOT EXISTS plays (
id INTEGER PRIMARY KEY,
user TEXT NOT NULL,
song_id TEXT NOT NULL,
title TEXT,
artist TEXT,
artist_id TEXT,
album TEXT,
album_id TEXT,
duration INTEGER,
played_at INTEGER NOT NULL, -- Unix epoch, UTC
source TEXT, -- 'web' | 'android'
uuid TEXT UNIQUE, -- idempotency for offline-retry
cover_art TEXT, -- Subsonic coverArt token (NOT the entity id) for real covers
genre TEXT -- per-play genre (from the song's tags) for genre stats
);
`);
// Migrations for DBs created before a column existed (ALTER throws if it's already there).
try { db.exec('ALTER TABLE plays ADD COLUMN cover_art TEXT'); } catch {}
try { db.exec('ALTER TABLE plays ADD COLUMN genre TEXT'); } catch {}
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_time ON plays(user, played_at);');
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_artist ON plays(user, artist_id);');
db.exec('CREATE INDEX IF NOT EXISTS ix_plays_user_song ON plays(user, song_id);');
const _insert = db.prepare(`
INSERT OR IGNORE INTO plays
(user, song_id, title, artist, artist_id, album, album_id, duration, played_at, source, uuid, cover_art, genre)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const _history = db.prepare(`
SELECT id, song_id, title, artist, artist_id, album, album_id, duration, played_at, source
FROM plays WHERE user = ? AND played_at < ? ORDER BY played_at DESC LIMIT ?
`);
const _purge = db.prepare('DELETE FROM plays WHERE user = ?');
const _count = db.prepare('SELECT COUNT(*) AS n FROM plays');
// ─── Auth (Navidrome ping.view, cached) ─────────────────────────────────────
const _authCache = new Map(); // "u|t|s" -> { ok, exp }
const _AUTH_TTL = 5 * 60 * 1000;
async function verifyUser(req) {
const u = req.query.u || req.body?.u;
const t = req.query.t || req.body?.t;
const s = req.query.s || req.body?.s;
if (!u || !t || !s || !NAVIDROME_URL) return null;
const key = `${u}|${t}|${s}`;
const now = Date.now();
const cached = _authCache.get(key);
if (cached && cached.exp > now) return cached.ok ? u : null;
try {
const url = `${NAVIDROME_URL}/rest/ping.view?u=${encodeURIComponent(u)}&t=${encodeURIComponent(t)}&s=${encodeURIComponent(s)}&v=1.16.1&c=NovaSonic&f=json`;
const r = await fetch(url, { signal: AbortSignal.timeout(8000) });
const j = await r.json();
const ok = j?.['subsonic-response']?.status === 'ok';
_authCache.set(key, { ok, exp: now + _AUTH_TTL });
return ok ? u : null;
} catch {
return null;
}
}
// ─── App ────────────────────────────────────────────────────────────────────
const app = express();
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
res.setHeader('Access-Control-Allow-Private-Network', 'true');
if (req.method === 'OPTIONS') { res.sendStatus(204); return; }
next();
});
app.use(express.json({ limit: '2mb' }));
// Normalize a client timestamp to Unix seconds (accepts s or ms; defaults to now)
function _toEpochSec(v) {
const n = Number(v);
if (!Number.isFinite(n) || n <= 0) return Math.floor(Date.now() / 1000);
return n > 1e12 ? Math.floor(n / 1000) : Math.floor(n);
}
// POST /scrobble/plays — ingest a batch { plays: [ {...} ] }, idempotent by uuid
app.post('/scrobble/plays', async (req, res) => {
const user = await verifyUser(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });
const plays = Array.isArray(req.body?.plays) ? req.body.plays : [];
if (!plays.length) return res.json({ accepted: 0, duplicates: 0 });
let accepted = 0, duplicates = 0, invalid = 0;
for (const p of plays) {
if (!p || !p.songId) { invalid++; continue; }
const uuid = String(p.uuid || crypto.randomUUID());
try {
const r = _insert.run(
user,
String(p.songId),
p.title ?? null,
p.artist ?? null,
p.artistId ?? null,
p.album ?? null,
p.albumId ?? null,
Number.isFinite(Number(p.duration)) ? Math.floor(Number(p.duration)) : null,
_toEpochSec(p.playedAt),
p.source === 'android' ? 'android' : 'web',
uuid,
p.coverArt ?? null,
p.genre ?? null
);
if (r.changes > 0) accepted++; else duplicates++;
} catch { invalid++; }
}
res.json({ accepted, duplicates, invalid });
});
// GET /scrobble/history?limit=&before= — raw chronological log (the archive)
app.get('/scrobble/history', async (req, res) => {
const user = await verifyUser(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
const before = _toEpochSec(req.query.before || Date.now());
const rows = _history.all(user, before, limit);
res.json({ user, count: rows.length, plays: rows });
});
// GET /scrobble/stats — aggregated listening stats for the authed user.
// ?from=&to= Unix seconds window (default: all time)
// ?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)
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
app.get('/scrobble/stats', async (req, res) => {
const user = await verifyUser(req);
if (!user) return res.status(401).json({ error: 'unauthorized' });
const tz = Math.trunc(Number(req.query.tz) || 0);
// Default window = all time. Parse `from` directly — do NOT route through _toEpochSec, which maps
// 0 → now (so the client's explicit from=0 for "all time" would wrongly become an empty window).
const fromN = Number(req.query.from);
const from = Number.isFinite(fromN) && fromN > 0 ? (fromN > 1e12 ? Math.floor(fromN / 1000) : Math.floor(fromN)) : 0;
const to = req.query.to ? _toEpochSec(req.query.to) : Math.floor(Date.now() / 1000) + 1;
const bucket = _STATS_FMT[req.query.bucket] ? req.query.bucket : 'day';
const fmt = _STATS_FMT[bucket];
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 100);
const win = { user, from, to }; // node:sqlite throws on extra named params —
const winL = { user, from, to, limit }; // so each query gets exactly the params it uses.
const winT = { user, from, to, tz };
const WHERE = 'user = :user AND played_at >= :from AND played_at < :to';
const totals = db.prepare(
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration), 0) AS seconds FROM plays WHERE ${WHERE}`
).get(win);
// Earliest play across ALL of the user's history (unfiltered by window) — lets the client cap the
// 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;
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
`).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
`).all(winL);
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
FROM plays WHERE ${WHERE}
GROUP BY song_id ORDER BY 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
`).all(winL);
const series = db.prepare(`
SELECT strftime('${fmt}', played_at + :tz, 'unixepoch') AS bucket, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} GROUP BY bucket ORDER BY bucket
`).all(winT);
const hourRows = db.prepare(`
SELECT CAST(strftime('%H', played_at + :tz, 'unixepoch') AS INTEGER) AS hr, COUNT(*) AS plays
FROM plays WHERE ${WHERE} GROUP BY hr
`).all(winT);
const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, plays: 0 }));
for (const r of hourRows) if (r.hr >= 0 && r.hr < 24) byHour[r.hr].plays = r.plays;
res.json({
user,
range: { from, to, tz, bucket },
firstPlayedAt,
totalPlays: totals.plays,
totalSeconds: totals.seconds,
topArtists, topAlbums, topTracks, topGenres, series, byHour,
});
});
// GET /scrobble/users — distinct users in the log, for the Head-to-Head partner picker.
// Any valid Navidrome login may list them (so you can pick who to compare against).
app.get('/scrobble/users', async (req, res) => {
const me = await verifyUser(req);
if (!me) return res.status(401).json({ error: 'unauthorized' });
const rows = db.prepare(`
SELECT user, COUNT(*) AS plays, MAX(played_at) AS lastPlayedAt
FROM plays GROUP BY user ORDER BY plays DESC
`).all();
res.json({ me, users: rows });
});
// GET /scrobble/compare?with=NAME&from= — Head-to-Head "Musik-Match" of the authed user (A) vs
// another user (B). Taste similarity = cosine of the two artist-play vectors (robust, 0..100).
// Also returns shared artists/tracks and each side's unique top artists.
app.get('/scrobble/compare', async (req, res) => {
const userA = await verifyUser(req);
if (!userA) return res.status(401).json({ error: 'unauthorized' });
const userB = String(req.query.with || '').trim();
if (!userB) return res.status(400).json({ error: 'missing ?with=' });
if (userB === userA) return res.status(400).json({ error: 'same user' });
// Window (default all-time). Parse `from` directly — _toEpochSec maps 0 → now (see /stats).
const fromN = Number(req.query.from);
const from = Number.isFinite(fromN) && fromN > 0 ? (fromN > 1e12 ? Math.floor(fromN / 1000) : Math.floor(fromN)) : 0;
const to = req.query.to ? _toEpochSec(req.query.to) : Math.floor(Date.now() / 1000) + 1;
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
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> ''
GROUP BY lower(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,
MAX(album_id) AS albumId, MAX(cover_art) AS coverArt, COUNT(*) AS plays
FROM plays WHERE ${WHERE} GROUP BY song_id
`).all({ user: u, from, to });
const totalsOf = u => db.prepare(
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
).get({ user: u, from, to });
const aArtists = artistsOf(userA), bArtists = artistsOf(userB);
const aMap = new Map(aArtists.map(r => [r.k, r]));
const bMap = new Map(bArtists.map(r => [r.k, r]));
// Cosine similarity over the artist-play vectors → a stable 0..100 "match".
let dot = 0, nA = 0, nB = 0;
for (const r of aArtists) nA += r.plays * r.plays;
for (const r of bArtists) nB += r.plays * r.plays;
for (const [k, ra] of aMap) { const rb = bMap.get(k); if (rb) dot += ra.plays * rb.plays; }
const matchScore = (nA && nB) ? Math.round((dot / (Math.sqrt(nA) * Math.sqrt(nB))) * 100) : 0;
const sharedArtists = [];
for (const [k, ra] of aMap) {
const rb = bMap.get(k);
if (rb) sharedArtists.push({ name: ra.name, artistId: ra.artistId || rb.artistId, playsA: ra.plays, playsB: rb.plays });
}
sharedArtists.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
const onlyTop = (mine, other) => mine
.filter(r => !other.has(r.k))
.sort((x, y) => y.plays - x.plays).slice(0, 8)
.map(r => ({ name: r.name, artistId: r.artistId, plays: r.plays }));
const aTracks = tracksOf(userA);
const bTrackMap = new Map(tracksOf(userB).map(r => [r.songId, r]));
const sharedTracks = [];
for (const ta of aTracks) {
const tb = bTrackMap.get(ta.songId);
if (tb) sharedTracks.push({
songId: ta.songId, title: ta.title, artist: ta.artist, artistId: ta.artistId,
albumId: ta.albumId, coverArt: ta.coverArt, playsA: ta.plays, playsB: tb.plays,
});
}
sharedTracks.sort((x, y) => (y.playsA + y.playsB) - (x.playsA + x.playsB));
res.json({
userA, userB, range: { from, to },
matchScore,
totalsA: { ...totalsOf(userA), artists: aArtists.length },
totalsB: { ...totalsOf(userB), artists: bArtists.length },
sharedArtists: sharedArtists.slice(0, 30),
sharedTracks: sharedTracks.slice(0, 30),
onlyA: onlyTop(aArtists, bMap),
onlyB: onlyTop(bArtists, aMap),
});
});
// DELETE /scrobble/plays?user=NAME — purge one user's rows (for test cleanup).
// Requires ANY valid Navidrome login (a deleted user can't authenticate as itself,
// so cleanup after deletion must be authorized by a currently-valid account).
app.delete('/scrobble/plays', async (req, res) => {
const caller = await verifyUser(req);
if (!caller) return res.status(401).json({ error: 'unauthorized' });
const target = req.query.user || caller;
const r = _purge.run(String(target));
res.json({ purged: r.changes, user: target });
});
// GET /scrobble/health — liveness + total rows (no auth)
app.get('/scrobble/health', (_req, res) => {
res.json({ ok: true, plays: _count.get().n, navidrome: !!NAVIDROME_URL });
});
app.listen(PORT, () => console.log(`✅ NovaSonic scrobble service on :${PORT} (data: ${DATA_PATH})`));
+196 -61
View File
@@ -7,6 +7,14 @@ export function apiUrl(method, extra='') {
const token = md5(S.cfg.pass + salt);
return `${S.cfg.url}/rest/${method}?u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}&v=1.16.1&c=NovaSonic&f=json${extra}`;
}
// Bare Subsonic token-auth params (u=..&t=..&s=..) for talking to non-Navidrome NovaSonic
// services (e.g. the scrobble play-log), which verify the same creds via ping.view.
export function subsonicAuthParams() {
if (!S.cfg.pass || !S.cfg.user) return '';
const salt = Math.random().toString(36).slice(2, 10);
const token = md5(S.cfg.pass + salt);
return `u=${encodeURIComponent(S.cfg.user)}&t=${token}&s=${salt}`;
}
export async function api(method, extra='') {
const res = await fetch(apiUrl(method, extra));
if(!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -186,10 +194,53 @@ export async function removeRomajiOverride(kanji) {
} catch (e) { console.warn('removeRomajiOverride failed:', e); }
}
// ─── ARTIST OVERRIDES (pin a local artist → a specific Deezer artist; shared) ───
// Fixes same-name collisions on the artist page (fans / top songs / albums / image).
let _artistOverridesCache = null;
export function getArtistOverrides() { return _artistOverridesCache || {}; }
export function getArtistOverride(ndArtistId) { return (_artistOverridesCache || {})[String(ndArtistId)] || null; }
export async function loadArtistOverrides() {
if (!S.cfg?.backendUrl) { _artistOverridesCache = {}; return; }
try {
const r = await fetch(`${S.cfg.backendUrl}/api/artist-overrides`);
_artistOverridesCache = r.ok ? await r.json() : {};
} catch { _artistOverridesCache = {}; }
}
export async function setArtistOverride(ndArtistId, deezer) {
if (!S.cfg?.backendUrl || !ndArtistId || !deezer?.deezerArtistId) return;
const entry = {
deezerArtistId: String(deezer.deezerArtistId),
name: deezer.name || '',
image: deezer.image || '',
fanCount: deezer.fanCount ?? null,
};
try {
await fetch(`${S.cfg.backendUrl}/api/artist-overrides`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ndArtistId: String(ndArtistId), ...entry }),
});
if (!_artistOverridesCache) _artistOverridesCache = {};
_artistOverridesCache[String(ndArtistId)] = entry;
} catch (e) { console.warn('setArtistOverride failed:', e); }
}
export async function removeArtistOverride(ndArtistId) {
if (!S.cfg?.backendUrl || !ndArtistId) return;
try {
await fetch(`${S.cfg.backendUrl}/api/artist-overrides/${encodeURIComponent(ndArtistId)}`, { method: 'DELETE' });
if (_artistOverridesCache) delete _artistOverridesCache[String(ndArtistId)];
} catch (e) { console.warn('removeArtistOverride failed:', e); }
}
export function dispArtistById(id, name) {
if (!S.cfg.showRomaji) return name || '';
const ov = getRomajiOverrides();
if (name && ov[name]) return ov[name];
// Only romanize Japanese names — for non-JP names the sort tag is a sort key (symbols/articles
// stripped, e.g. "☆NoAki☆" → "NoAki"), not a romaji, so it must not replace the display name.
if (!_isJp(name || '')) return name || '';
const sort = _artistSortMap?.get(String(id));
if (sort) return sort;
return _artistRomajiMap?.get(String(id)) || name || '';
@@ -217,6 +268,7 @@ export function dispTitle(s) {
if (!S.cfg.showRomaji) return s.title || '';
const ov = getRomajiOverrides();
if (s.title && ov[s.title]) return ov[s.title];
if (!_isJp(s.title || '')) return s.title || ''; // non-JP title → sort tag is not a romaji
const st = s.sortName || s.titleSort;
if (st && !_isJp(st)) return st;
return romajiLookup(s.id)?.titleRomaji || s.title || '';
@@ -228,6 +280,7 @@ export function dispArtist(s) {
const ov = getRomajiOverrides();
if (s.artist && ov[s.artist]) return ov[s.artist];
if (s.albumArtist && ov[s.albumArtist]) return ov[s.albumArtist];
if (!_isJp(s.artist || '')) return s.artist || ''; // non-JP artist → no romaji substitution
return romajiLookup(s.id)?.artistRomaji || s.artist || '';
}
// Songs don't carry album sort — Navidrome only puts sortName on album objects.
@@ -246,6 +299,7 @@ export function dispArtistParts(s, parseArtistsFn) {
const ov = getRomajiOverrides();
if (s.artist && ov[s.artist]) return [{ name: ov[s.artist], originalName: s.artist, id: parts[0]?.id || '' }];
if (s.albumArtist && ov[s.albumArtist]) return [{ name: ov[s.albumArtist], originalName: s.albumArtist, id: parts[0]?.id || '' }];
if (!_isJp(s.artist || parts[0]?.name || '')) return parts; // non-JP artist → keep original (e.g. ☆NoAki☆)
const st = s.sortArtist || s.artistSort;
if (st && !_isJp(st)) return [{ name: st, originalName: s.artist || parts[0]?.name || '', id: parts[0]?.id || '' }];
const info = romajiLookup(s.id);
@@ -267,81 +321,162 @@ export async function romajiConvert(texts) {
} catch { return texts; }
}
// ─── ITUNES ───
// Route through backend proxy to avoid CORS; fall back to direct if no backend configured.
function _itunesBase() {
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/itunes` : 'https://itunes.apple.com';
// ─── DEEZER (search + charts source) ───
// Apple's iTunes /search returns 403 from server IPs, so Deezer is the metadata backend for
// everything: search, album/artist lookups AND the "Neu & Beliebt" charts. Free, no key, returns
// explicit versions + artist images + track durations. Proxied through the backend to avoid any
// CORS surprises. All results are mapped to the legacy "DeezerItem" shape so the UI + download
// flow stay unchanged.
function _deezerBase() {
return S.cfg.backendUrl ? `${S.cfg.backendUrl}/api/deezer` : 'https://api.deezer.com';
}
function _dzTrack(t) {
return {
wrapperType: 'track', kind: 'song',
trackId: t.id, trackName: t.title,
artistId: t.artist?.id, artistName: t.artist?.name || '',
collectionId: t.album?.id, collectionName: t.album?.title || '',
artworkUrl100: t.album?.cover_big || t.album?.cover_medium || t.artist?.picture_medium || '',
previewUrl: t.preview || '',
trackTimeMillis: t.duration ? t.duration * 1000 : undefined,
trackNumber: t.track_position || undefined,
discNumber: t.disk_number || undefined,
trackExplicitness: t.explicit_lyrics ? 'explicit' : 'notExplicit',
};
}
function _dzAlbum(a) {
return {
wrapperType: 'collection', collectionType: 'Album',
collectionId: a.id, collectionName: a.title,
artistId: a.artist?.id, artistName: a.artist?.name || '',
artworkUrl100: a.cover_big || a.cover_medium || '',
trackCount: a.nb_tracks || undefined,
releaseDate: a.release_date || undefined,
};
}
function _dzArtist(a) {
return {
wrapperType: 'artist',
artistId: a.id, artistName: a.name,
artworkUrl100: a.picture_big || a.picture_medium || '',
// Deezer's artist search does NOT rank by popularity — several artists can share a name
// (5× "Joji"). fanCount lets us pick the one the user means (see deezerSearch + detail.js).
fanCount: a.nb_fan ?? undefined,
};
}
export async function itunesSearch(query, entity='musicTrack', limit=25) {
// Last search outcome: null = ok, else HTTP status or 'network'. Read by the search view to tell
// "no results" apart from "search backend down".
let _deezerLastError = null;
export function deezerLastError() { return _deezerLastError; }
// Keeps the old name so all callers stay unchanged. entity: musicTrack | album | musicArtist.
export async function deezerSearch(query, entity='musicTrack', limit=25) {
try {
const base = `${_itunesBase()}/search?term=${encodeURIComponent(query)}&media=music&entity=${entity}&limit=${limit}&explicit=Yes`;
const rJp = await fetch(`${base}&country=jp`);
const jpResults = JSON.parse(await rJp.text()).results || [];
if (jpResults.length) return jpResults;
const rUs = await fetch(`${base}&country=us`);
return JSON.parse(await rUs.text()).results || [];
} catch { return []; }
const path = entity === 'album' ? 'search/album' : entity === 'musicArtist' ? 'search/artist' : 'search';
const r = await fetch(`${_deezerBase()}/${path}?q=${encodeURIComponent(query)}&limit=${limit}`);
if (!r.ok) { _deezerLastError = r.status; return []; }
_deezerLastError = null;
const txt = await r.text();
const data = (txt ? JSON.parse(txt) : {}).data || [];
if (entity === 'album') return data.map(_dzAlbum);
// Sort artists by popularity so a name collision resolves to the artist the user means
// (Deezer returns them in an unhelpful order — the real "Joji" isn't first).
if (entity === 'musicArtist') return data.map(_dzArtist).sort((x, y) => (y.fanCount || 0) - (x.fanCount || 0));
return data.map(_dzTrack);
} catch { _deezerLastError = 'network'; return []; }
}
export async function itunesLookupAlbum(collectionId) {
const r = await fetch(`${_itunesBase()}/lookup?id=${collectionId}&entity=song&explicit=Yes`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'track');
}
// Returns actual explicit tracks for an artist via lookup (search endpoint only returns cleaned versions)
export async function itunesLookupArtistSongs(artistId, limit=15) {
const r = await fetch(`${_itunesBase()}/lookup?id=${artistId}&entity=song&limit=${limit}&country=us&explicit=Yes`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'track');
}
export async function itunesLookupArtistAlbums(artistId, limit=200) {
const r = await fetch(`${_itunesBase()}/lookup?id=${artistId}&entity=album&limit=${limit}&country=us`);
const d = await r.json();
return (d.results || []).filter(x => x.wrapperType === 'collection' && x.collectionType === 'Album');
}
export async function itunesTopCharts() {
const urls = [
'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/albums.json',
'https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/albums.json',
];
for(const url of urls) {
// Album tracks (for the album detail view + album download). Deezer's /album/{id} embeds tracks +
// album-level release/genre/cover, so one request gives everything.
export async function deezerLookupAlbum(collectionId) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
if(!r.ok) continue;
const d = await r.json();
if(d.feed?.results?.length) return d.feed.results;
} catch {}
}
try {
const r = await fetch(`${_itunesBase()}/search?term=new+music&media=music&entity=album&limit=50&country=de`);
const d = await r.json();
return (d.results||[]).map(a => ({
name: a.collectionName, artistName: a.artistName,
artworkUrl: a.artworkUrl100?.replace('100x100','300x300'), artworkUrl100: a.artworkUrl100,
const r = await fetch(`${_deezerBase()}/album/${collectionId}`);
const a = await r.json();
const cover = a.cover_big || a.cover_medium || '';
return (a.tracks?.data || []).map(t => ({
..._dzTrack({ ...t, album: { id: a.id, title: a.title, cover_big: a.cover_big, cover_medium: a.cover_medium } }),
collectionArtistName: a.artist?.name || '',
artworkUrl100: cover,
releaseDate: a.release_date || undefined,
primaryGenreName: a.genres?.data?.[0]?.name,
trackCount: a.nb_tracks || undefined,
}));
} catch { return []; }
}
export async function itunesTopSongs() {
const urls = [
'https://rss.applemarketingtools.com/api/v2/de/music/most-played/50/songs.json',
'https://rss.applemarketingtools.com/api/v2/us/music/most-played/50/songs.json',
];
for(const url of urls) {
export async function deezerLookupArtistSongs(artistId, limit=15) {
try {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) });
if(!r.ok) continue;
const r = await fetch(`${_deezerBase()}/artist/${artistId}/top?limit=${limit}`);
return ((await r.json()).data || []).map(_dzTrack);
} catch { return []; }
}
// Related Deezer artists (real pictures + fan counts). Used for the "Ähnliche Künstler" row when a
// pin is active — Last.fm's similar list is keyed to the (possibly merged/wrong) Navidrome artist.
export async function deezerRelatedArtists(artistId, limit=12) {
try {
const r = await fetch(`${_deezerBase()}/artist/${artistId}/related?limit=${limit}`);
return ((await r.json()).data || []).map(_dzArtist);
} catch { return []; }
}
// Deezer's /artist/{id}/albums list OMITS the artist object, so _dzAlbum yields artistName:''.
// We already know whose page this is → backfill artistName so downstream download/detail cards
// don't fall back to the literal "Unknown Artist" (which poisoned the yt-dlp search).
export async function deezerLookupArtistAlbums(artistId, limit=100, artistName='') {
try {
const r = await fetch(`${_deezerBase()}/artist/${artistId}/albums?limit=${limit}`);
return ((await r.json()).data || [])
.map(_dzAlbum)
.filter(a => a.collectionType === 'Album')
.map(a => (a.artistName ? a : { ...a, artistName }));
} catch { return []; }
}
// Direct artist lookup by Deezer id — the reliable route when /search/artist doesn't surface a
// low-profile same-name artist (Deezer search is a ranked, incomplete fuzzy match; e.g. the browser
// finds Nana id 80239872 but the search API never returns it). Used by the artist-pin "paste link"
// path. Returns the full _dzArtist shape (or null on miss / bad id).
export async function deezerLookupArtist(artistId) {
try {
const r = await fetch(`${_deezerBase()}/artist/${artistId}`);
if (!r.ok) return null;
const a = await r.json();
if (!a || a.error || !a.id) return null;
return _dzArtist(a);
} catch { return null; }
}
// Deezer artist photo (for the hero on non-local artist pages). iTunes artist entities had none.
export async function deezerArtistImage(artistId) {
try {
const a = await (await fetch(`${_deezerBase()}/artist/${artistId}`)).json();
return a.picture_xl || a.picture_big || a.picture_medium || null;
} catch { return null; }
}
// "Neu & Beliebt" — Deezer's global chart (Apple's charts RSS needed a proxy and the search
// fallback is 403 from server IPs). Mapped to { name, artistName, artworkUrl, artworkUrl100 }.
export async function deezerTopCharts() {
try {
const r = await fetch(`${_deezerBase()}/chart/0/albums?limit=50`);
const d = await r.json();
if(d.feed?.results?.length) return d.feed.results;
} catch {}
return (d.data || []).map(a => ({
name: a.title, artistName: a.artist?.name || '',
artworkUrl: a.cover_big || a.cover_medium || '', artworkUrl100: a.cover_medium || '',
}));
} catch { return []; }
}
export async function deezerTopSongs() {
try {
const r = await fetch(`${_itunesBase()}/search?term=top+hits&media=music&entity=musicTrack&limit=50&country=de`);
const r = await fetch(`${_deezerBase()}/chart/0/tracks?limit=50`);
const d = await r.json();
return (d.results||[]).map(t => ({ name: t.trackName, artistName: t.artistName, artworkUrl: t.artworkUrl100 }));
return (d.data || []).map(t => ({
name: t.title, artistName: t.artist?.name || '',
artworkUrl: t.album?.cover_medium || t.album?.cover_big || '',
previewUrl: t.preview || '',
}));
} catch { return []; }
}
+50 -26
View File
@@ -1,7 +1,11 @@
import { S } from './state.js';
import { toast } from './utils.js';
import { api, fetchRomajiIndex, loadRomajiOverrides } from './api.js';
import { api, fetchRomajiIndex, loadRomajiOverrides, loadArtistOverrides } from './api.js';
import { injectPWA } from './pwa.js';
import { initPlayLog } from './player/scrobbleLog.js';
import { openStats, closeStats } from './ui/stats.js';
import { openCompare, closeCompare } from './ui/compare.js';
import { closeArtistPin, clearArtistPin } from './ui/artistPin.js';
import { loadSavedTheme } from './themes.js';
import { initScrollRows } from './ui/components.js';
import {
@@ -13,14 +17,14 @@ import {
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState, initKeyboardShortcuts,
} from './player/player.js';
import {
navigate, _navGoBack, _navLoad, onHeaderSearch, closeHeaderSearch,
navigate, _navGoBack, _navOnPopState, _navLoad, onHeaderSearch, closeHeaderSearch,
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
searchSimilarArtist, searchAndPreview, loadItunesAlbum, closeEventsModal,
searchSimilarArtist, searchAndPreview, loadDeezerAlbum, closeEventsModal,
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
} from './views/views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
onImporterInput, resolveImporterLink } from './views/spotify.js';
onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve } from './views/spotify.js';
import { resolveYouTubeLink, executeYouTubeDownload } from './views/youtube.js';
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './views/soundcloud.js';
import {
@@ -46,7 +50,7 @@ import {
toggleMobileSearch, closeMobileSearch, switchSetupTab,
toggleNotifPanel, deleteNotif, clearNotifs, initNotifBell, _notifNavigate,
_pickTheme,
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong,
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillDeezer, saveMetadata, deleteSong,
addRomajiOverrideUI, removeRomajiOverrideUI,
openRomajiOverridesModal, closeRomajiOverridesModal,
} from './ui/ui.js';
@@ -59,17 +63,18 @@ async function doConnect() {
const _prev = (() => { try { return JSON.parse(localStorage.getItem('ns_cfg') || '{}'); } catch { return {}; } })();
const lfKey = document.getElementById('lastfm-key')?.value.trim() || _prev.lastfmKey || '';
const backUrl = (document.getElementById('backend-url')?.value.trim() || _prev.backendUrl || window.NS_BACKEND_URL?.trim() || '').replace(/\/$/,'');
const scrobUrl = (_prev.scrobbleUrl || window.NS_SCROBBLE_URL?.trim() || '').replace(/\/$/,'');
if(!url || !user || !pass) { _showSetupError('Bitte Server-URL, Benutzernamen und Passwort ausfüllen.'); return; }
const btn = document.getElementById('btn-connect');
btn.disabled=true; btn.textContent='Verbinde…';
document.getElementById('setup-error').style.display='none';
S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl};
S.cfg = {url, user, pass, lastfmKey:lfKey, backendUrl:backUrl, scrobbleUrl:scrobUrl};
try {
await api('ping');
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
startApp();
} catch(e) {
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''};
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''};
_showSetupError('Verbindung fehlgeschlagen: '+e.message);
btn.disabled=false; btn.textContent='Verbinden';
}
@@ -80,7 +85,7 @@ function disconnect() {
localStorage.removeItem('ns_cfg');
localStorage.removeItem('ns_last_played');
sessionStorage.removeItem('ns_nav');
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:''};
S.cfg = {url:'', user:'', pass:'', lastfmKey:'', backendUrl:'', scrobbleUrl:''};
S.queue=[]; S.qIdx=-1; S._navStack=[]; S._navCursor=-1;
S.audio.pause(); S.audio.src='';
document.getElementById('app').style.display='none';
@@ -107,6 +112,8 @@ async function startApp() {
navigate('home');
}
initNotifBell();
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared)
// Wait for overrides before restoring player bar so disp* functions have the cache ready
loadRomajiOverrides().catch(() => {}).then(() => { _restoreLastPlayed(); });
if(localStorage.getItem('ns_radio')==='1') { S.radioMode=true; _applyRadioBtn(); }
@@ -122,21 +129,31 @@ window.addEventListener('load', () => {
// Wire up audio elements from DOM
S.audio = document.getElementById('audio-element');
const _audioB = document.getElementById('audio-element-b');
// Two fixed audio elements for instant skips — S.audio always points at the active one,
// the other pre-buffers the next track so a skip can hand off to it without reloading.
S._audioEls = _audioB ? [S.audio, _audioB] : [S.audio];
S.previewAudio = document.getElementById('preview-audio');
const saved = localStorage.getItem('ns_cfg');
if(saved) {
S.cfg = JSON.parse(saved);
if(!S.cfg.backendUrl && window.NS_BACKEND_URL?.trim()) S.cfg.backendUrl = window.NS_BACKEND_URL.trim().replace(/\/$/,'');
if(!S.cfg.scrobbleUrl && window.NS_SCROBBLE_URL?.trim()) S.cfg.scrobbleUrl = window.NS_SCROBBLE_URL.trim().replace(/\/$/,'');
startApp();
updateNpColorMode();
}
if(window.NS_SERVER_URL?.trim()) { const el=document.getElementById('srv-url'); if(el&&!el.value) el.value=window.NS_SERVER_URL.trim(); }
document.getElementById('srv-pass').addEventListener('keydown', e => { if(e.key==='Enter') doConnect(); });
S.audio.addEventListener('timeupdate', updateProgress);
S.audio.addEventListener('ended', onEnded);
S.audio.addEventListener('play', () => {
// Listeners are bound to BOTH audio elements so a skip can hand off to the pre-buffered
// one with no rebinding. Handlers that only apply to the playing element guard with
// `el !== S.audio` (the idle standby element must not affect UI/recovery).
let _errRetries = 0, _errTimer = null;
function bindAudioEl(el) {
el.addEventListener('timeupdate', updateProgress);
el.addEventListener('ended', onEnded);
el.addEventListener('play', () => {
S._trackChanging = false;
absInterrupt();
if (S.previewCurrent) stopPreview(false);
@@ -144,20 +161,17 @@ window.addEventListener('load', () => {
document.getElementById('player-art').classList.add('spinning');
if(navigator.mediaSession) navigator.mediaSession.playbackState = 'playing';
});
S.audio.addEventListener('pause', () => {
el.addEventListener('pause', () => {
if (el !== S.audio) return; // ignore the idle standby element during a handoff
setPlayIcon(false);
document.getElementById('player-art').classList.remove('spinning');
// Suppress during track change — src swap fires pause before the new track starts
if(!S._trackChanging && navigator.mediaSession) navigator.mediaSession.playbackState = 'paused';
});
S.previewAudio.addEventListener('timeupdate', updatePreviewProgress);
S.previewAudio.addEventListener('ended', stopPreview);
// stalled = browser buffer ran out briefly; let the browser retry naturally.
// The watchdog below catches genuine long freezes.
let _errRetries = 0, _errTimer = null;
S.audio.addEventListener('play', () => { _errRetries = 0; clearTimeout(_errTimer); }, { capture: true });
S.audio.addEventListener('error', () => {
el.addEventListener('play', () => { _errRetries = 0; clearTimeout(_errTimer); }, { capture: true });
el.addEventListener('error', () => {
if (el !== S.audio) return; // standby/preload errors must not disrupt active playback
// Skip if playCurrent() is already handling recovery (_trackChanging = true means
// we're mid-transition or waiting for canplay — don't conflict with that)
if(!S.queue[S.qIdx] || S._trackChanging) return;
@@ -171,6 +185,11 @@ window.addEventListener('load', () => {
S.audio.play().catch(() => { S._trackChanging = false; });
}, delay);
});
}
S._audioEls.forEach(bindAudioEl);
S.previewAudio.addEventListener('timeupdate', updatePreviewProgress);
S.previewAudio.addEventListener('ended', stopPreview);
const _savedVol = parseInt(localStorage.getItem('ns_volume') ?? '', 10);
setVolume(isNaN(_savedVol) ? 55 : _savedVol);
@@ -186,10 +205,12 @@ window.addEventListener('load', () => {
// Skip when native is active: Media3 owns the system media session.
if(navigator.mediaSession && !S._trackChanging && !window.NSBridge)
navigator.mediaSession.playbackState = S.audio.paused ? 'paused' : 'playing';
// Only auto-resume if audio was actually playing when the page hid (OS-killed buffer).
// If the user explicitly paused, _wasPlayingBeforeHide is false — don't restart.
// Only auto-resume after an INVOLUNTARY background stop (OS-killed buffer / frozen tab).
// Never restart if the user deliberately paused — including via the media notification while
// backgrounded (that routes through togglePlay → S._userPaused=true), which used to wrongly
// auto-resume on foreground because _wasPlayingBeforeHide was still true from hide-time.
if(!S.audio.paused) return;
if(s && S.audio.src && _wasPlayingBeforeHide) S.audio.play().catch(() => { S._trackChanging = false; });
if(s && S.audio.src && _wasPlayingBeforeHide && !S._userPaused) S.audio.play().catch(() => { S._trackChanging = false; });
}
document.addEventListener('visibilitychange', () => {
@@ -288,7 +309,7 @@ window.addEventListener('load', () => {
}
});
window.addEventListener('popstate', () => _navGoBack());
window.addEventListener('popstate', (e) => _navOnPopState(e.state));
document.addEventListener('keydown', e => {
if(e.key !== 'Backspace') return;
const t = document.activeElement?.tagName;
@@ -303,7 +324,7 @@ Object.assign(window, {
// Navigation
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
loadItunesAlbum, closeEventsModal,
loadDeezerAlbum, closeEventsModal,
// Player
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
@@ -315,15 +336,18 @@ Object.assign(window, {
// UI / Modals
openSettings, closeSettings, saveSettings, confirmDisconnect, checkBackend,
openSidebar, closeSidebar, switchNavMode, showCtxMenu,
openStats, closeStats,
openCompare, closeCompare,
closeArtistPin, clearArtistPin,
// Views
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
onImporterInput, resolveImporterLink,
onImporterInput, resolveImporterLink, onGlobalInput, tryImporterResolve,
loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack,
executeSpotifyDownload, resolveYouTubeLink, executeYouTubeDownload,
openPlModal, closePlModal, addSongsToPlaylist, createAndAddPlaylist, createPlaylistPrompt,
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong,
showSongInfo, closeInfoModal, toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillDeezer, saveMetadata, deleteSong,
openDlModal, closeDlModal, selectDlType, executeDownload,
openEditPlModal, closeEditPlModal, saveEditPlaylist,
editPlUploadCover, editPlCoverSelected, editPlRemoveCover, deletePlaylist, _confirmOk, _confirmCancel,
+10 -1
View File
@@ -102,6 +102,7 @@ let _lyricsSongId = null;
let _lyricsLoadToken = 0;
let _prefetchTimer = null;
const _lyricsCache = new Map(); // "artist|title" → result object (null = not found)
const _lyricsInflight = new Map(); // "artist|title" → Promise — de-dupe concurrent fetches
const LYRICS_LOOKAHEAD_MS = 200;
@@ -176,8 +177,16 @@ async function _loadNpLyrics(songId) {
async function _fetchLyricsData(artist, title, album, dur) {
const cacheKey = `${artist}|${title}`;
if(_lyricsCache.has(cacheKey)) return _lyricsCache.get(cacheKey);
// De-dupe concurrent fetches for the same song (prefetch + tab-open) so we don't fire the
// whole lrclib cascade twice — that doubles the request rate and gets the IP throttled.
if(_lyricsInflight.has(cacheKey)) return _lyricsInflight.get(cacheKey);
const p = _doFetchLyrics(artist, title, album, dur, cacheKey);
_lyricsInflight.set(cacheKey, p);
try { return await p; } finally { _lyricsInflight.delete(cacheKey); }
}
const SIG = () => AbortSignal.timeout(6000);
async function _doFetchLyrics(artist, title, album, dur, cacheKey) {
const SIG = () => AbortSignal.timeout(8000);
const safeJson = async (promise) => { try { const r = await promise; return r.ok ? r.json() : null; } catch { return null; } };
const bestMatch = (candidates, durSecs) => {
if(!candidates?.length) return null;
+78 -23
View File
@@ -4,6 +4,7 @@ import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtis
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from '../views/views.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
import { logPlay } from './scrobbleLog.js';
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
// True when running inside the NovaSonic Android wrapper (NSBridge injected before loadUrl).
@@ -20,14 +21,15 @@ let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ign
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null;
let _scrobbleId = null;
function _scheduleScrobble(id, durSec) {
if (_scrobbleId === String(id)) return;
function _scheduleScrobble(s) {
const id = s?.id;
if (!id || _scrobbleId === String(id)) return;
_cancelScrobble();
_scrobbleId = String(id);
const mode = S.cfg.scrobbleMode || '15s';
if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded
let delay;
const dur = (durSec || 0) * 1000;
const dur = (s?.duration || 0) * 1000;
switch (mode) {
case '30s': delay = 30_000; break;
case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break;
@@ -35,7 +37,10 @@ function _scheduleScrobble(id, durSec) {
default: delay = 15_000; break;
}
_scrobbleTimer = setTimeout(() => {
if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
if (_scrobbleId) {
api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
}
_scrobbleId = null;
}, delay);
}
@@ -45,7 +50,16 @@ function _cancelScrobble() {
_scrobbleId = null;
}
function _scrobbleNow(s) {
if (s) api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
if (s) {
api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
logPlay(s, HAS_BRIDGE ? 'android' : 'web'); // our own play-log, same counted moment
}
}
// "Now Playing" ping (submission=false) — tells the server what's playing *right now* so
// getNowPlaying (and e.g. a Discord rich-presence plugin) updates instantly. This is distinct
// from the delayed "played" scrobble above, which counts the play for stats / Last.fm.
function _scrobbleNowPlaying(s) {
if (s) api('scrobble', `&id=${s.id}&submission=false`).catch(() => {});
}
// Unified duration / position / seek helpers — use native state when available,
@@ -208,7 +222,7 @@ window.NSPlayer = {
try {
const s = S.queue[S.qIdx];
if (!s) return;
_scheduleScrobble(s.id, s.duration);
_scheduleScrobble(s);
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
} catch (e) {}
},
@@ -276,10 +290,19 @@ function _setAudioSession() {
try { if(navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {}
}
// ─── PRELOAD — shadow Audio element actually buffers the next track ──────────
// ─── PRELOAD — the standby audio element buffers the next track ──────────────
// Buffering into the *standby* element (not a throwaway) lets a skip hand playback over to
// it instantly — no source reload, no ~1s wait. Covers are pre-warmed in parallel so the
// new artwork is ready too. Falls back to a source-swap when the next track isn't preloaded.
let _preUrl = '';
let _preId = '';
let _preAudio = null;
/** The audio element that is NOT currently active (or null if there's only one). */
function _standbyEl() {
const els = S._audioEls;
if (!els || els.length < 2) return null;
return els.find(e => e !== S.audio) || null;
}
function _nextIdx() {
if(S.repeat === 'one') return S.qIdx;
@@ -294,13 +317,12 @@ function _preloadNext() {
if(ni < 0) return;
const next = S.queue[ni];
if(!next || _preId === next.id) return;
const standby = _standbyEl();
if(!standby) return;
_preId = next.id;
_preUrl = streamUrl(next.id);
// Real Audio preload — actually buffers the stream so canplay fires instantly
if(_preAudio) { _preAudio.src = ''; _preAudio = null; }
_preAudio = new Audio();
_preAudio.preload = 'auto';
_preAudio.src = _preUrl;
standby.src = _preUrl; // preload="auto" → buffers the stream so a handoff plays instantly
try { standby.load(); } catch {}
if(next.coverArt) {
[256, 512, 1024].forEach(size => {
const img = new Image();
@@ -311,7 +333,8 @@ function _preloadNext() {
function _clearPreload() {
_preId = ''; _preUrl = '';
if(_preAudio) { _preAudio.src = ''; _preAudio = null; }
const standby = _standbyEl();
if(standby) { try { standby.pause(); standby.removeAttribute('src'); standby.load(); } catch {} }
}
// ─── PLAY HISTORY (for prev-in-shuffle) ──────────────────────────────────────
@@ -373,6 +396,7 @@ export function playQueue(songs, startIdx, opts = {}) {
export function playCurrent() {
if(S.qIdx < 0 || S.qIdx >= S.queue.length) return;
S._userPaused = false; // starting a track is intent to play — clear any stale pause intent
const s = S.queue[S.qIdx];
if (HAS_BRIDGE) {
@@ -395,11 +419,8 @@ export function playCurrent() {
updateMediaSession(s);
onNpSongChange(s.id);
renderQueue();
_scheduleScrobble(s.id, s.duration);
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
S.audio.src = src;
_scrobbleNowPlaying(s); // "läuft gerade" sofort an Navidrome (getNowPlaying)
_scheduleScrobble(s);
const _resetFlag = () => { if (_playGen === gen) S._trackChanging = false; };
@@ -409,6 +430,34 @@ export function playCurrent() {
setTimeout(_preloadNext, 2000);
};
// ── Instant skip: if the standby element already buffered THIS track, hand playback over
// to it (no source reload → no ~1s wait). The new title/cover were already set above. ──
const standby = _standbyEl();
if (standby && _preId === s.id && standby.readyState >= 2) {
const old = S.audio;
standby.currentTime = 0;
standby.volume = old.volume; standby.muted = old.muted; standby.playbackRate = old.playbackRate || 1;
S.audio = standby; // the active element is now the preloaded one
_preId = ''; _preUrl = '';
try { old.pause(); } catch {} // 'pause' is ignored for the now-inactive element
_setAudioSession();
S.audio.play()
.then(() => { S._trackChanging = false; _applySession(); })
.catch(() => {
// Handoff playback failed — recover with a fresh source load on this element.
if (_playGen !== gen) return;
S.audio.src = streamUrl(s.id);
try { S.audio.load(); } catch {}
S.audio.play().then(() => { S._trackChanging = false; _applySession(); }).catch(_resetFlag);
});
return;
}
// ── Fallback: classic source swap on the current element (unchanged) ──
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
S.audio.src = src;
let _guardTimer = null;
let _canplayFn = null;
@@ -494,9 +543,15 @@ export function updatePlayerUI(s, _skipMediaSession = false) {
} catch {}
_syncNpDirect(s);
_updatePlayingHighlights();
// Preload the hi-res NP cover now so opening the overlay is instant
// Keep the NP cover element pointing at the CURRENT song even while the overlay is closed,
// so opening it after rapid skips never shows a stale cover (it also preloads in the background).
const _npCv = coverUrl(s.coverArt, 1024);
if (_npCv && _npCv !== _npBgLastUrl) { const _pre = new Image(); _pre.src = _npCv; }
const _npCover = document.getElementById('np-cover');
const _npImg = _npCover?.querySelector('img');
if (_npCv) {
if (_npImg) { if (_npImg.src !== _npCv) _npImg.src = _npCv; }
else if (_npCover) _npCover.innerHTML = `<img src="${_npCv}" style="width:100%;height:100%;object-fit:cover">`;
}
// Pre-warm the next song's 1024 cover so auto-advance NP swap is instant from cache
const _nextForPreload = S.qIdx + 1 < S.queue.length ? S.queue[S.qIdx + 1] : null;
if (_nextForPreload?.coverArt) {
@@ -607,8 +662,8 @@ export function cancelMusicAutoplay() { _playGen++; S.audio.pause(); }
export function togglePlay() {
if (HAS_BRIDGE) { _nsBridge('togglePlay'); return; }
if(S.audio.src) {
if (S.audio.paused) { S.audio.play(); }
else { S.audio.pause(); _cancelScrobble(); }
if (S.audio.paused) { S._userPaused = false; S.audio.play(); }
else { S._userPaused = true; S.audio.pause(); _cancelScrobble(); }
}
}
+87
View File
@@ -0,0 +1,87 @@
// ─── NovaSonic play-log client ──────────────────────────────────────────────
// Records a timestamped play into our own scrobble service (separate origin,
// S.cfg.scrobbleUrl) — the foundation for day/month/year stats + Wrapped.
//
// This runs IN ADDITION to the existing Navidrome scrobble (which feeds playCount
// and, if configured, Last.fm). It never replaces it. We fire logPlay() at the
// exact moment NovaSonic decides a play "counts" (see player.js), so the play-log
// and Last.fm stay consistent and both honour the user's scrobbleMode setting.
//
// Offline-safe: entries are queued in localStorage and flushed as an idempotent
// batch (server dedups by uuid), so a play made offline is delivered later exactly
// once.
import { S } from '../state.js';
import { subsonicAuthParams } from '../api.js';
const _QUEUE_KEY = 'ns_play_queue';
const _MAX_QUEUE = 500; // guard against unbounded growth if the service stays down
let _flushing = false;
function _uuid() {
try { return crypto.randomUUID(); }
catch { return 'p-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10); }
}
function _load() {
try { return JSON.parse(localStorage.getItem(_QUEUE_KEY) || '[]'); } catch { return []; }
}
function _save(q) {
try { localStorage.setItem(_QUEUE_KEY, JSON.stringify(q.slice(-_MAX_QUEUE))); } catch {}
}
// Record one counted play. `song` is a Subsonic song object; source 'web' | 'android'.
export function logPlay(song, source = 'web') {
if (!S.cfg?.scrobbleUrl || !song?.id) return;
const entry = {
uuid: _uuid(),
songId: String(song.id),
title: song.title ?? null,
artist: song.artist ?? null,
artistId: song.artistId ?? null,
album: song.album ?? null,
albumId: song.albumId ?? null,
coverArt: song.coverArt ?? null, // Subsonic coverArt token — what getCoverArt actually needs
genre: song.genre ?? null,
duration: Number.isFinite(Number(song.duration)) ? Math.floor(Number(song.duration)) : null,
playedAt: Math.floor(Date.now() / 1000), // Unix seconds, UTC
source,
};
const q = _load();
q.push(entry);
_save(q);
flushPlayQueue();
}
// Deliver everything queued as one idempotent batch. Safe to call repeatedly.
export async function flushPlayQueue() {
if (_flushing || !S.cfg?.scrobbleUrl) return;
const batch = _load();
if (!batch.length) return;
const auth = subsonicAuthParams();
if (!auth) return;
_flushing = true;
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/plays?${auth}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plays: batch }),
});
if (!res.ok) return; // 401/5xx/network → keep the queue, retry later
// Success: the batch is handled (accepted + duplicates are both durable; invalid
// entries are malformed and will never succeed). Remove exactly what we sent by
// uuid, preserving any plays queued while the request was in flight.
const sent = new Set(batch.map(e => e.uuid));
_save(_load().filter(e => !sent.has(e.uuid)));
} catch {
// network error → keep queue for the next flush
} finally {
_flushing = false;
}
}
// Retry on app start and whenever connectivity returns.
export function initPlayLog() {
flushPlayQueue();
window.addEventListener('online', flushPlayQueue);
}
+2 -1
View File
@@ -1,6 +1,6 @@
// Shared mutable state — imported as { S } in every module
export const S = {
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false, showRomaji: false },
cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', scrobbleUrl: '', showPlayCount: false, showRomaji: false },
// Playback
queue: [],
@@ -15,6 +15,7 @@ export const S = {
_scrubbing: false,
_msPosLast: 0,
_trackChanging: false,
_userPaused: false, // true when the user deliberately paused → foreground must NOT auto-resume
queueOpen: false,
// Now Playing overlay
+136
View File
@@ -0,0 +1,136 @@
// ─── Artist pin picker ───────────────────────────────────────────────────────
// Opened from the artist page ("⚙ Falscher Künstler?"). Lets the user pin a local
// (Navidrome) artist to a specific Deezer artist, fixing same-name collisions where
// NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
// The mapping is stored server-side (shared across users) — see api.js.
import { deezerSearch, deezerLookupArtist, setArtistOverride, removeArtistOverride, getArtistOverride } from '../api.js';
import { esc, fmtCount, toast } from '../utils.js';
// Pull a Deezer artist id out of whatever the user pastes: a full URL
// (deezer.com/en/artist/80239872, /de/artist/…?…), or a bare id ("80239872").
function _parseDeezerArtistId(raw) {
const s = String(raw || '').trim();
if (!s) return null;
const m = s.match(/artist\/(\d+)/i);
if (m) return m[1];
if (/^\d+$/.test(s)) return s;
return null;
}
let _overlay = null;
let _ctx = null; // { ndArtistId, ndName }
export function openArtistPin(ndArtistId, ndName) {
_ctx = { ndArtistId: String(ndArtistId), ndName: ndName || '' };
if (!_overlay) _build();
_overlay.classList.add('open');
document.body.style.overflow = 'hidden';
_render();
}
export function closeArtistPin() {
if (_overlay) _overlay.classList.remove('open');
document.body.style.overflow = '';
}
function _build() {
_overlay = document.createElement('div');
_overlay.className = 'modal-overlay';
_overlay.id = 'artist-pin-overlay';
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeArtistPin(); });
_overlay.innerHTML = `
<div class="modal-card" style="max-width:520px;width:calc(100vw - 32px);max-height:86vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
<div style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
<span style="font-size:18px">⚙</span>
<div style="flex:1;min-width:0">
<div style="font-size:16px;font-weight:800">Richtigen Künstler wählen</div>
<div id="artist-pin-sub" style="font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>
</div>
<button onclick="closeArtistPin()" style="background:none;border:none;color:var(--text2);font-size:18px;cursor:pointer;padding:6px 10px">✕</button>
</div>
<div id="artist-pin-body" style="padding:12px 14px;overflow-y:auto"></div>
</div>`;
document.body.appendChild(_overlay);
}
async function _render() {
_overlay.querySelector('#artist-pin-sub').textContent = `Für „${_ctx.ndName}"`;
const body = _overlay.querySelector('#artist-pin-body');
const cur = getArtistOverride(_ctx.ndArtistId);
const clearBtn = cur
? `<button onclick="clearArtistPin()" style="width:100%;padding:10px;margin-bottom:10px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--danger);font-family:var(--font);font-weight:600;cursor:pointer">Pin entfernen (aktuell: ${esc(cur.name || 'gepinnt')})</button>`
: '';
// Deezer's /search/artist is a ranked, incomplete fuzzy match — low-profile same-name artists
// (e.g. Nana id 80239872) never surface. Paste the Deezer link/id to pin directly (reliable route).
const linkBox = `
<div style="display:flex;gap:8px;margin-bottom:12px">
<input id="artist-pin-link" type="text" placeholder="Deezer-Link oder ID einfügen…"
style="flex:1;min-width:0;padding:10px 12px;border-radius:9px;border:1px solid var(--border);background:var(--surface2);color:var(--text);font-family:var(--font);font-size:13px">
<button id="artist-pin-link-btn" style="padding:0 16px;border-radius:9px;border:none;background:var(--accent);color:#fff;font-family:var(--font);font-weight:700;cursor:pointer;white-space:nowrap">Pinnen</button>
</div>
<div style="font-size:11px;color:var(--text2);margin:-6px 2px 12px">Nicht in der Liste? Öffne den Künstler auf deezer.com und füge den Link ein.</div>`;
// Render the shell once (clear button + link box stay put); only the results list re-renders,
// so a value the user is typing into the link field survives the async search resolving.
body.innerHTML = clearBtn + linkBox + `<div id="artist-pin-results"><div style="text-align:center;color:var(--text2);padding:24px 0">Suche…</div></div>`;
_wireLinkBox();
const resultsEl = body.querySelector('#artist-pin-results');
let results = [];
try { results = (await deezerSearch(_ctx.ndName, 'musicArtist', 8)) || []; } catch {}
const rows = results.map(r => `
<div class="artist-pin-cand" data-id="${esc(r.artistId)}" data-name="${esc(r.artistName || '')}" data-img="${esc(r.artworkUrl100 || '')}" data-fans="${r.fanCount ?? ''}"
style="display:flex;align-items:center;gap:12px;padding:8px;border-radius:10px;cursor:pointer" onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'">
<div style="width:48px;height:48px;border-radius:50%;background:var(--surface2);overflow:hidden;flex-shrink:0">
${r.artworkUrl100 ? `<img src="${esc(r.artworkUrl100)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(r.artistName || '—')}</div>
<div style="font-size:12px;color:var(--text2)">${r.fanCount != null ? fmtCount(r.fanCount) + ' Fans' : 'Deezer'}</div>
</div>
</div>`).join('');
resultsEl.innerHTML = rows || `<div style="text-align:center;color:var(--text2);padding:24px 0">Keine Deezer-Künstler gefunden.</div>`;
resultsEl.querySelectorAll('.artist-pin-cand').forEach(el => el.addEventListener('click', () => _pick(el.dataset)));
}
// Wire the "paste a Deezer link/id" box → look the artist up directly and pin it.
function _wireLinkBox() {
const input = _overlay.querySelector('#artist-pin-link');
const btn = _overlay.querySelector('#artist-pin-link-btn');
if (!input || !btn) return;
const go = async () => {
const id = _parseDeezerArtistId(input.value);
if (!id) { toast('Kein gültiger Deezer-Link / keine ID'); return; }
btn.disabled = true; btn.textContent = '…';
const a = await deezerLookupArtist(id);
btn.disabled = false; btn.textContent = 'Pinnen';
if (!a) { toast('Deezer-Künstler nicht gefunden'); return; }
await _pick({ id: String(a.artistId), name: a.artistName || _ctx.ndName, img: a.artworkUrl100 || '', fans: a.fanCount != null ? String(a.fanCount) : '' });
};
btn.addEventListener('click', go);
input.addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
}
async function _pick(ds) {
await setArtistOverride(_ctx.ndArtistId, {
deezerArtistId: ds.id,
name: ds.name,
image: ds.img,
fanCount: ds.fans !== '' ? Number(ds.fans) : null,
});
closeArtistPin();
_reloadArtist();
}
export async function clearArtistPin() {
if (_ctx) await removeArtistOverride(_ctx.ndArtistId);
closeArtistPin();
_reloadArtist();
}
async function _reloadArtist() {
const { navigate } = await import('../views/views.js');
navigate('artist-detail', { id: _ctx.ndArtistId, name: _ctx.ndName });
}
+223
View File
@@ -0,0 +1,223 @@
// ─── Head-to-Head "Musik-Match" ─────────────────────────────────────────────
// Opened from Settings ("Musik-Match"). Compares the logged-in user's play-log
// against another user's (GET /scrobble/compare) and renders a taste-match score
// (cosine over artist-play vectors), shared artists/tracks, and each side's unique
// top artists. The partner is chosen from a picker fed by GET /scrobble/users.
// Self-contained overlay, mirroring stats.js.
import { S } from '../state.js';
import { esc } from '../utils.js';
import { subsonicAuthParams, coverUrl } from '../api.js';
let _overlay = null;
let _me = null; // authed username (userA)
let _partner = null; // chosen userB
let _users = []; // [{ user, plays, lastPlayedAt }]
export function openCompare() {
if (!_overlay) _buildOverlay();
_overlay.classList.add('open');
document.body.style.overflow = 'hidden';
_loadUsers();
}
export function closeCompare() {
if (_overlay) _overlay.classList.remove('open');
document.body.style.overflow = '';
}
// Called from the rendered partner list (delegated below) — kept module-local.
function _choosePartner(name) { _partner = name; _loadCompare(); }
function _buildOverlay() {
_overlay = document.createElement('div');
_overlay.className = 'modal-overlay';
_overlay.id = 'compare-overlay';
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeCompare(); });
_overlay.innerHTML = `
<div class="modal-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
<span style="font-size:18px">🎧</span>
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Musik-Match</div>
<button id="compare-back" title="Andere Person" style="display:none;padding:6px 10px;color:var(--text2);font-size:14px;font-weight:600;background:none;border:none;cursor:pointer">↺ Wechseln</button>
<button class="hbtn" onclick="closeCompare()" title="Schließen"
style="padding:6px 10px;color:var(--text2);font-size:18px;background:none;border:none;cursor:pointer">✕</button>
</div>
<div class="compare-body" id="compare-body" style="padding:16px 18px;overflow-y:auto;min-height:50vh"></div>
</div>`;
// Delegated: pick a partner, or navigate to an artist/album row.
_overlay.querySelector('#compare-body').addEventListener('click', async e => {
const pick = e.target.closest('[data-partner]');
if (pick) { _choosePartner(pick.dataset.partner); return; }
const row = e.target.closest('[data-nav-type]');
if (!row) return;
const { navType, navId, navName, navArtist } = row.dataset;
closeCompare();
const { navigate } = await import('../views/views.js');
if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName });
else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' });
});
_overlay.querySelector('#compare-back').addEventListener('click', () => { _partner = null; _renderPicker(); });
document.body.appendChild(_overlay);
}
function _errBox(msg) {
return `<div style="text-align:center;color:var(--danger);padding:40px 0">${esc(msg)}</div>`;
}
function _info(msg) {
return `<div style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">${msg}</div>`;
}
async function _loadUsers() {
const body = _overlay.querySelector('#compare-body');
_overlay.querySelector('#compare-back').style.display = 'none';
_partner = null;
if (!S.cfg.scrobbleUrl) {
body.innerHTML = _info('Kein Scrobble-Dienst konfiguriert.<br>Trage die <b>Scrobble-URL</b> in den Einstellungen ein.');
return;
}
const auth = subsonicAuthParams();
if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; }
body.innerHTML = _info('Lade…');
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/users?${auth}`);
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
const d = await res.json();
_me = d.me;
_users = (d.users || []).filter(u => u.user !== _me);
_renderPicker();
} catch {
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
}
}
function _renderPicker() {
const body = _overlay.querySelector('#compare-body');
_overlay.querySelector('#compare-back').style.display = 'none';
if (!_users.length) {
body.innerHTML = _info('Noch niemand sonst hat Wiedergaben aufgezeichnet.<br>Sobald eine andere Person NovaSonic nutzt, kannst du euch hier vergleichen.');
return;
}
const rows = _users.map(u => `
<div data-partner="${esc(u.user)}" style="display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:12px;cursor:pointer;border:1px solid var(--border);margin-bottom:8px"
onmouseover="this.style.background='var(--surface2)'" onmouseout="this.style.background='none'">
<div style="width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--accent),var(--accent-dark));display:flex;align-items:center;justify-content:center;font-weight:800;color:#000;flex-shrink:0">${esc((u.user[0] || '?').toUpperCase())}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(u.user)}</div>
<div style="font-size:12px;color:var(--text2)">${u.plays.toLocaleString('de-DE')} Plays</div>
</div>
<div style="color:var(--text2);font-size:18px">›</div>
</div>`).join('');
body.innerHTML = `<div style="font-size:13px;color:var(--text2);margin:2px 2px 14px">Mit wem willst du dich vergleichen, <b>${esc(_me || '')}</b>?</div>${rows}`;
}
async function _loadCompare() {
const body = _overlay.querySelector('#compare-body');
_overlay.querySelector('#compare-back').style.display = 'inline-block';
const auth = subsonicAuthParams();
body.innerHTML = _info('Vergleiche…');
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/compare?${auth}&with=${encodeURIComponent(_partner)}`);
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
_renderCompare(body, await res.json());
} catch {
body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
}
}
function _renderCompare(body, d) {
const score = Math.max(0, Math.min(100, d.matchScore || 0));
const verdict =
score >= 80 ? 'Seelenverwandte 🎯' :
score >= 60 ? 'Starkes Match 🔥' :
score >= 40 ? 'Solide Überschneidung 👍' :
score >= 20 ? 'Ein paar Gemeinsamkeiten 🙂' :
'Ziemlich unterschiedlich 🌗';
body.innerHTML = [
_scoreRing(d, score, verdict),
_totals(d),
_sharedArtists(d.sharedArtists),
_sharedTracks(d.sharedTracks),
_uniqueCols(d),
].join('');
}
function _scoreRing(d, score, verdict) {
const deg = Math.round(score * 3.6);
return `<div style="display:flex;flex-direction:column;align-items:center;gap:10px;margin:6px 0 22px">
<div style="width:132px;height:132px;border-radius:50%;background:conic-gradient(var(--accent) ${deg}deg, var(--surface2) ${deg}deg);display:flex;align-items:center;justify-content:center">
<div style="width:104px;height:104px;border-radius:50%;background:var(--surface);display:flex;flex-direction:column;align-items:center;justify-content:center">
<div style="font-size:32px;font-weight:800;letter-spacing:-1px;line-height:1">${score}<span style="font-size:16px">%</span></div>
<div style="font-size:11px;color:var(--text2);margin-top:2px">Match</div>
</div>
</div>
<div style="font-size:15px;font-weight:700">${esc(verdict)}</div>
<div style="font-size:13px;color:var(--text2)"><b>${esc(d.userA)}</b> &nbsp;⟷&nbsp; <b>${esc(d.userB)}</b></div>
</div>`;
}
function _totals(d) {
const col = (name, t) => `
<div style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:12px 14px;min-width:0">
<div style="font-weight:800;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px">${esc(name)}</div>
<div style="font-size:12px;color:var(--text2)">${(t.plays || 0).toLocaleString('de-DE')} Plays · ${(t.artists || 0).toLocaleString('de-DE')} Künstler</div>
</div>`;
return `<div style="display:flex;gap:10px;margin-bottom:22px">${col(d.userA, d.totalsA)}${col(d.userB, d.totalsB)}</div>`;
}
function _h(t) {
return `<div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);margin:0 0 10px">${esc(t)}</div>`;
}
// Shared artist row: a centered name with each side's play count on its flank + a
// mirrored bar so you can see who plays them more.
function _sharedArtists(items) {
if (!items?.length) return `${_h('Gemeinsame Künstler')}${_info('Keine gemeinsamen Künstler in diesem Zeitraum.')}`;
const max = Math.max(...items.map(a => Math.max(a.playsA, a.playsB)), 1);
const rows = items.slice(0, 15).map(a => {
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:7px 0">
<div style="width:34px;text-align:right;font-size:12px;color:var(--text2);font-weight:700">${a.playsA}</div>
<div style="flex:1;display:flex;justify-content:flex-end"><div style="height:6px;background:var(--accent);border-radius:3px;width:${Math.round((a.playsA / max) * 100)}%"></div></div>
<div style="flex:1.6;text-align:center;font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0">${esc(a.name)}</div>
<div style="flex:1;display:flex;justify-content:flex-start"><div style="height:6px;background:var(--accent-dark, var(--accent));opacity:.7;border-radius:3px;width:${Math.round((a.playsB / max) * 100)}%"></div></div>
<div style="width:34px;text-align:left;font-size:12px;color:var(--text2);font-weight:700">${a.playsB}</div>
</div>`;
}).join('');
return `${_h('Gemeinsame Künstler')}<div style="margin-bottom:22px">${rows}</div>`;
}
function _sharedTracks(items) {
if (!items?.length) return '';
const rows = items.slice(0, 12).map(t => {
const cover = coverUrl(t.coverArt || t.albumId || t.songId, 80);
const nav = t.albumId ? `data-nav-type="album" data-nav-id="${esc(t.albumId)}" data-nav-name="${esc(t.album || '')}" data-nav-artist="${esc(t.artist || '')}" style="cursor:pointer"` : '';
return `<div ${nav} style="display:flex;align-items:center;gap:10px;padding:6px 0">
<div style="width:40px;height:40px;border-radius:6px;background:var(--surface2);flex-shrink:0;overflow:hidden">
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(t.title || '—')}</div>
<div style="font-size:12px;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(t.artist || '')}</div>
</div>
<div style="font-size:12px;color:var(--text2);font-weight:600;white-space:nowrap">${t.playsA} · ${t.playsB}</div>
</div>`;
}).join('');
return `${_h('Gemeinsame Songs')}<div style="margin-bottom:22px">${rows}</div>`;
}
function _uniqueCols(d) {
const list = (title, arr) => {
const rows = (arr || []).slice(0, 6).map(a => {
const nav = a.artistId ? `data-nav-type="artist" data-nav-id="${esc(a.artistId)}" data-nav-name="${esc(a.name)}" style="cursor:pointer"` : '';
return `<div ${nav} style="display:flex;align-items:center;gap:6px;padding:4px 0;font-size:13px">
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(a.name)}</span>
<span style="color:var(--text2);font-size:12px;font-weight:600">${a.plays}</span>
</div>`;
}).join('') || `<div style="color:var(--text2);font-size:13px;padding:4px 0">—</div>`;
return `<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:800;color:var(--text2);margin-bottom:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(title)}</div>${rows}</div>`;
};
return `${_h('Nur bei…')}<div style="display:flex;gap:18px">${list(`Nur ${d.userA}`, d.onlyA)}${list(`Nur ${d.userB}`, d.onlyB)}</div>`;
}
+17 -9
View File
@@ -35,7 +35,7 @@ export function makeAlbumCard(a) {
}
// ─── SONG LIST ITEM (library) ───
export function makeSongItem(s, idx, list, { noArtistLink = false } = {}) {
export function makeSongItem(s, idx, list, { noArtistLink = false, singlePlay = false } = {}) {
const el = document.createElement('div');
if(s.starred && !s._starred) s._starred = true;
el.className = 'song-item' + (S.queue[S.qIdx]?.id === s.id ? ' playing' : '');
@@ -69,12 +69,13 @@ export function makeSongItem(s, idx, list, { noArtistLink = false } = {}) {
const btns = el.querySelectorAll('.icon-btn');
btns[0].onclick = (e) => { e.stopPropagation(); toggleStar(s, btns[0]); };
btns[1].onclick = (e) => { e.stopPropagation(); showCtxMenu(e, s, list, idx); };
el.onclick = () => playQueue(list, idx, { noShuffle: true });
// singlePlay (search results): play only the clicked song; Radio mode (if on) fills the rest.
el.onclick = () => singlePlay ? playQueue([s], 0, { noShuffle: true }) : playQueue(list, idx, { noShuffle: true });
el.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, list, idx); });
return el;
}
// ─── GLOBAL TRACK ITEM (iTunes / Spotify results) ───
// ─── GLOBAL TRACK ITEM (Deezer / Spotify results) ───
export function makeGlobalTrackItem(t, idx, list) {
const el = document.createElement('div');
el.className = 'gtrack-item';
@@ -131,24 +132,31 @@ export function enhanceScrollRow(row) {
const right = mkBtn('sa-right', _CHEV_R, 'Weiter');
const EPS = 4;
const update = () => {
// Cheap per-scroll work — just toggle which arrow is available; rAF-throttled to once per frame.
let _rafPending = false;
const updateArrows = () => {
_rafPending = false;
left.classList.toggle('can-scroll', row.scrollLeft > EPS);
right.classList.toggle('can-scroll', row.scrollLeft < row.scrollWidth - row.clientWidth - EPS);
// Vertically center the arrows on the cover art (fallback: CSS default 72px)
};
const onScroll = () => { if (!_rafPending) { _rafPending = true; requestAnimationFrame(updateArrows); } };
// Expensive (getBoundingClientRect forces layout) — only on size/content change, NOT every scroll.
const positionArrows = () => {
const art = row.querySelector('.card-art') || row.querySelector('.card');
if (art) {
const top = art.getBoundingClientRect().top - wrap.getBoundingClientRect().top + art.offsetHeight / 2;
if (top > 0) { left.style.top = right.style.top = top + 'px'; }
}
updateArrows();
};
const step = () => Math.max(160, row.clientWidth * 0.8);
left.addEventListener('click', () => row.scrollBy({ left: -step(), behavior: 'smooth' }));
right.addEventListener('click', () => row.scrollBy({ left: step(), behavior: 'smooth' }));
row.addEventListener('scroll', update, { passive: true });
new ResizeObserver(update).observe(row);
new MutationObserver(update).observe(row, { childList: true });
update();
row.addEventListener('scroll', onScroll, { passive: true });
new ResizeObserver(positionArrows).observe(row);
new MutationObserver(positionArrows).observe(row, { childList: true });
positionArrows();
}
/** Enhance every `.scroll-row` currently in the DOM (the static Home rows). */
+15 -4
View File
@@ -1,13 +1,16 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex } from '../api.js';
import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex, deezerLookupAlbum } from '../api.js';
import { _addNotif } from './notify.js';
// ─── DOWNLOAD MODAL ───
export function openDlModal(track) {
if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }
S.downloadTarget = track; S.dlType = 'track';
document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type==='track'));
// Album cards pass isAlbum → default the modal to an album download, not a single track named
// after the album (a one-click download of an album card used to grab it as one "track").
const _defType = track.isAlbum ? 'album' : 'track';
S.downloadTarget = track; S.dlType = _defType;
document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type===_defType));
const info = document.getElementById('dl-modal-info');
const art = track.artworkUrl100 || track.artworkUrl60 || '';
info.innerHTML = `${art ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
@@ -35,7 +38,7 @@ export async function executeDownload() {
title: _title,
album: _album,
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
deezerId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
collectionId: S.downloadTarget.collectionId || null,
explicit: S.downloadTarget.trackExplicitness === 'explicit',
trackNum: S.downloadTarget.trackNumber || undefined,
@@ -47,6 +50,14 @@ export async function executeDownload() {
...(ov[_title] && { titleSort: ov[_title] }),
...(ov[_album] && { albumSort: ov[_album] }),
};
// Album download: fetch the album's tracks from Deezer (id in collectionId) and send them in the
// request — the backend downloads that list directly (Apple's /search is 403, so no iTunes lookup).
if (S.dlType === 'album' && payload.collectionId) {
try {
const albumTracks = await deezerLookupAlbum(payload.collectionId);
if (albumTracks.length) payload.tracks = albumTracks;
} catch {}
}
addJob(jobId, payload.title, payload.artist, payload.type, payload.album);
try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
+22 -17
View File
@@ -2,13 +2,20 @@ import { S } from '../state.js';
import { esc, formatDur, toast } from '../utils.js';
import { api, coverUrl, fetchRomajiIndex, romajiLookup, getRomajiOverrides, invalidateRomajiIndex, invalidateApiCache } from '../api.js';
// Circular — safe (only used inside function bodies)
import { loadPlaylists, loadPlaylistDetail } from '../views/views.js';
import { loadPlaylists, loadPlaylistDetail, refreshCurrentView } from '../views/views.js';
import { showConfirm } from './modals.js';
// ─── SONG INFO MODAL ───
function _fmtArtists(str, sep) {
if (!str) return '';
return str.split(/\s*[•·;,]\s*/).map(s => s.trim()).filter(Boolean).join(sep);
let s = str.trim();
// Apple Music / MusicBrainz render multi-artist lists as "A, B, C & D" — the LAST artist is
// joined with " & " instead of the list separator, which would otherwise stay glued together as
// one artist. Only rewrite the " & " when the string is already a list (contains ; , • ·), so
// genuine band names like "Simon & Garfunkel" or "Earth, Wind & Fire" without a leading list are
// left intact. (Manual editor — the user can still fix the rare false positive before saving.)
if (/[;,•·]/.test(s)) s = s.replace(/\s+&\s+/g, '; ');
return s.split(/\s*[•·;,]\s*/).map(x => x.trim()).filter(Boolean).join(sep);
}
function _renderInfoModal(s) {
@@ -178,8 +185,8 @@ export async function autofillMusicBrainz() {
const d = await r.json();
if (!d.found) { status.textContent = '❌ Kein Treffer auf MusicBrainz'; return; }
if (d.title) document.getElementById('ie-title').value = d.title;
if (d.artist) document.getElementById('ie-artist').value = d.artist;
if (d.albumArtist) document.getElementById('ie-albumartist').value = d.albumArtist;
if (d.artist) document.getElementById('ie-artist').value = _fmtArtists(d.artist, '; ');
if (d.albumArtist) document.getElementById('ie-albumartist').value = _fmtArtists(d.albumArtist, ', ');
if (d.album) document.getElementById('ie-album').value = d.album;
if (d.genre) document.getElementById('ie-genre').value = d.genre;
if (d.year) document.getElementById('ie-year').value = d.year;
@@ -200,21 +207,21 @@ export async function autofillMusicBrainz() {
}
}
export async function autofillItunes() {
export async function autofillDeezer() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong;
if (!s) return;
const artist = document.getElementById('ie-artist').value || s.artist || '';
const title = document.getElementById('ie-title').value || s.title || '';
const status = document.getElementById('info-edit-status');
status.textContent = '🔍 Apple Music…';
status.textContent = '🔍 Deezer…';
try {
const r = await fetch(`${S.cfg.backendUrl}/api/itunes/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
const r = await fetch(`${S.cfg.backendUrl}/api/meta/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
const d = await r.json();
if (!d.found) { status.textContent = '❌ Kein Treffer auf iTunes'; return; }
if (!d.found) { status.textContent = '❌ Kein Treffer auf Deezer'; return; }
if (d.title) document.getElementById('ie-title').value = d.title;
if (d.artist) document.getElementById('ie-artist').value = d.artist;
if (d.albumArtist) document.getElementById('ie-albumartist').value = d.albumArtist;
if (d.artist) document.getElementById('ie-artist').value = _fmtArtists(d.artist, '; ');
if (d.albumArtist) document.getElementById('ie-albumartist').value = _fmtArtists(d.albumArtist, ', ');
if (d.album) document.getElementById('ie-album').value = d.album;
if (d.genre) document.getElementById('ie-genre').value = d.genre;
if (d.year) document.getElementById('ie-year').value = d.year;
@@ -225,9 +232,9 @@ export async function autofillItunes() {
S._infoArtData = { url: d.coverUrl };
document.getElementById('info-edit-art-preview').innerHTML =
`<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`;
status.textContent = '✅ Metadaten + Cover von Apple Music';
status.textContent = '✅ Metadaten + Cover von Deezer';
} else {
status.textContent = '✅ Metadaten von Apple Music (kein Cover)';
status.textContent = '✅ Metadaten von Deezer (kein Cover)';
}
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;
@@ -305,11 +312,9 @@ export async function deleteSong() {
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
toast(`🗑 "${s.title}" gelöscht`);
closeInfoModal();
setTimeout(() => {
loadPlaylists();
if (document.getElementById('page-playlist-detail')?.classList.contains('active') && S._currentPlaylist)
loadPlaylistDetail(S._currentPlaylist);
}, 3000);
// Backend delete + Navidrome rescan takes a moment; then refresh whatever view is open (album,
// artist, playlist, starred, …) so the removed song disappears without a manual reload.
setTimeout(() => refreshCurrentView(), 3000);
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;
}
+7 -2
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, coverUrl, ndAuth } from '../api.js';
import { api, coverUrl, ndAuth, invalidateApiCache } from '../api.js';
// Circular — safe (only used inside function bodies)
import { playQueue, dlSongLocal, nativeInsertAt, renderQueue } from '../player/player.js';
import { navigate, loadPlaylists, loadPlaylistDetail } from '../views/views.js';
@@ -87,7 +87,11 @@ export async function openPlModal(songs) {
}
S.cachedPlaylists.forEach(pl => {
const row = document.createElement('div'); row.className = 'pl-row';
row.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/></svg>
const cv = pl.coverArt ? coverUrl(pl.coverArt, 96) : null;
const inner = cv
? `<img src="${cv}" alt="" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">`
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/></svg>';
row.innerHTML = `<span style="width:34px;height:34px;border-radius:6px;flex-shrink:0;background:var(--surface2);display:flex;align-items:center;justify-content:center;overflow:hidden;color:var(--text3)">${inner}</span>
<span>${esc(pl.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--text3)">${pl.songCount} Titel</span>`;
row.onclick = () => addSongsToPlaylist(pl.id, pl.name);
list.appendChild(row);
@@ -231,6 +235,7 @@ export async function deletePlaylist() {
await api('deletePlaylist', `&id=${pl.id}`);
closeEditPlModal();
toast(`🗑 "${pl.name}" gelöscht`);
invalidateApiCache('getPlaylists'); // drop the 3-min cache so the list reloads without the deleted playlist
navigate('playlists');
} catch(e) { toast('Fehler beim Löschen: ' + e.message); }
}
+11 -6
View File
@@ -121,16 +121,21 @@ export async function _notifNavigate(notifId) {
try {
const r = await api('search3', `&query=${encodeURIComponent(albumQuery)}&artistCount=0&albumCount=10&songCount=0`);
const albums = r.searchResult3?.album || [];
const match = albums.find(a => norm(a.name) === norm(albumQuery) && norm(a.artist) === norm(n.navArtist))
|| albums.find(a => norm(a.name) === norm(albumQuery))
|| albums[0];
const na = norm(n.navArtist);
const nameEq = a => norm(a.name) === norm(albumQuery);
// Fuzzy artist compare (multi-artist / album-artist drift), but REQUIRE it when we know the
// artist: several artists share an album title ("I LIKE IT!"), and a name-only fallback used
// to open a completely different artist's album — the notification led to the wrong song.
const artistEq = a => { const aa = norm(a.artist); return !!na && (aa === na || aa.includes(na) || na.includes(aa)); };
const match = albums.find(a => nameEq(a) && artistEq(a))
|| (na ? null : albums.find(nameEq)); // name-only only when the artist is unknown
if (match) {
navigate('album-detail', { id: match.id, name: match.name, artist: match.artist || '', artistId: match.artistId || '', coverArt: match.coverArt || '' });
return;
}
} catch {}
}
toast('Album nicht gefunden — Library-Scan nötig?');
toast('Album noch nicht gefunden — evtl. wird die Library gerade gescannt');
return;
}
@@ -138,9 +143,9 @@ export async function _notifNavigate(notifId) {
try {
const r = await api('search3', `&query=${encodeURIComponent(n.navArtist)}&artistCount=5&albumCount=0&songCount=0`);
const artists = r.searchResult3?.artist || [];
const match = artists.find(a => norm(a.name) === norm(n.navArtist)) || artists[0];
const match = artists.find(a => norm(a.name) === norm(n.navArtist)); // no blind artists[0]
if (match) { navigate('artist-detail', { id: match.id, name: match.name }); return; }
} catch {}
toast('Künstler nicht gefunden — Library-Scan nötig?');
toast('Künstler noch nicht gefunden — evtl. wird die Library gerade gescannt');
}
}
+21
View File
@@ -54,6 +54,27 @@ export function openSettings() {
_renderThemePicker();
document.getElementById('settings-modal').classList.add('open');
checkBackend();
_loadApkInfo();
}
// Show the "download the Android APK" button only if the backend actually has an APK staged.
async function _loadApkInfo() {
const section = document.getElementById('s-apk-section');
const btn = document.getElementById('s-apk-btn');
const meta = document.getElementById('s-apk-meta');
if (!section || !btn) return;
section.style.display = 'none';
const base = S.cfg.backendUrl;
if (!base) return;
try {
const info = await fetch(`${base}/api/apk/info`).then(r => r.json());
if (!info?.available) return;
btn.href = `${base}/api/apk`;
const mb = info.size ? (info.size / 1048576).toFixed(1) + ' MB' : '';
const date = info.updatedAt ? new Date(info.updatedAt).toLocaleDateString('de-DE') : '';
if (meta) meta.textContent = [info.version && `v${info.version}`, mb, date && `Stand ${date}`].filter(Boolean).join(' · ');
section.style.display = '';
} catch {}
}
function _renderThemePicker() {
+293
View File
@@ -0,0 +1,293 @@
// ─── Listening statistics view ──────────────────────────────────────────────
// Opened from Settings ("Meine Statistiken"). Reads the self-hosted scrobble
// service (S.cfg.scrobbleUrl) → GET /scrobble/stats and renders totals, top
// artists/albums/tracks, a per-bucket play series, and a 24h listening clock.
// Self-contained: builds its own overlay DOM, so no index.html markup is needed.
import { S } from '../state.js';
import { esc, formatDur } from '../utils.js';
import { subsonicAuthParams, coverUrl } from '../api.js';
let _overlay = null;
// Time-travel navigator: browse all-time, a specific year, or a specific month.
let _view = 'all'; // all | year | month
let _year = new Date().getFullYear();
let _month = new Date().getMonth(); // 0-11
let _firstPlayedAt = null; // epoch secs of the user's earliest play → navigator lower bound
const _MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
// Human listening time: "3h 12m" / "12m" / "45s"
function _fmtTime(sec) {
sec = Math.floor(sec || 0);
const h = Math.floor(sec / 3600);
const m = Math.floor((sec % 3600) / 60);
if (h) return `${h}h ${m}m`;
if (m) return `${m}m`;
return `${sec}s`;
}
function _tzOffsetSec() { return -new Date().getTimezoneOffset() * 60; }
// from/to (epoch seconds) + series bucket for the current navigator state.
function _range() {
const now = Math.floor(Date.now() / 1000);
if (_view === 'year') {
return {
from: Math.floor(new Date(_year, 0, 1).getTime() / 1000),
to: Math.floor(new Date(_year + 1, 0, 1).getTime() / 1000),
bucket: 'month',
};
}
if (_view === 'month') {
return {
from: Math.floor(new Date(_year, _month, 1).getTime() / 1000),
to: Math.floor(new Date(_year, _month + 1, 1).getTime() / 1000),
bucket: 'day',
};
}
return { from: 0, to: now + 1, bucket: 'month' }; // all time
}
function _periodLabel() {
if (_view === 'year') return String(_year);
if (_view === 'month') return `${_MONTHS[_month]} ${_year}`;
return 'Alles';
}
// True when the shown period is the current (real) one — used to disable stepping into the future.
function _atCurrent() {
const n = new Date();
if (_view === 'year') return _year >= n.getFullYear();
if (_view === 'month') return _year > n.getFullYear() || (_year === n.getFullYear() && _month >= n.getMonth());
return true;
}
// True when the shown period is at/before the user's first logged play — caps stepping backwards.
function _atStart() {
if (!_firstPlayedAt) return false;
const f = new Date(_firstPlayedAt * 1000);
if (_view === 'year') return _year <= f.getFullYear();
if (_view === 'month') return _year < f.getFullYear() || (_year === f.getFullYear() && _month <= f.getMonth());
return false;
}
function _step(dir) {
if (_view === 'year') { _year += dir; }
else if (_view === 'month') {
_month += dir;
if (_month < 0) { _month = 11; _year--; }
if (_month > 11) { _month = 0; _year++; }
}
}
export function openStats() {
if (!_overlay) _buildOverlay();
_overlay.classList.add('open'); // .modal-overlay.open = visible (matches app's modals)
document.body.style.overflow = 'hidden';
_load();
}
export function closeStats() {
if (_overlay) _overlay.classList.remove('open');
document.body.style.overflow = '';
}
function _buildOverlay() {
_overlay = document.createElement('div');
_overlay.className = 'modal-overlay';
_overlay.id = 'stats-overlay';
_overlay.addEventListener('click', e => { if (e.target === _overlay) closeStats(); });
_overlay.innerHTML = `
<div class="modal-card stats-card" style="max-width:720px;width:calc(100vw - 32px);max-height:88vh;display:flex;flex-direction:column;background:var(--surface);border:1px solid var(--border2);border-radius:18px;box-shadow:0 40px 80px #0009;overflow:hidden">
<div class="modal-head" style="display:flex;align-items:center;gap:10px;padding:16px 18px;border-bottom:1px solid var(--border)">
<span style="font-size:18px">📊</span>
<div style="font-size:17px;font-weight:800;letter-spacing:-.3px;flex:1">Meine Statistiken</div>
<button class="hbtn" onclick="closeStats()" title="Schließen"
style="padding:6px 10px;color:var(--text2);font-size:18px;background:none;border:none;cursor:pointer">✕</button>
</div>
<div class="stats-tabs" id="stats-tabs" style="display:flex;gap:6px;padding:12px 18px 0"></div>
<div class="stats-body" id="stats-body" style="padding:16px 18px;overflow-y:auto;min-height:62vh"></div>
</div>`;
// Delegated navigation: click an artist / album / track row → open its page.
_overlay.querySelector('#stats-body').addEventListener('click', async e => {
const row = e.target.closest('[data-nav-type]');
if (!row) return;
const { navType, navId, navName, navArtist } = row.dataset;
closeStats();
const { navigate } = await import('../views/views.js');
if (navType === 'artist') navigate('artist-detail', { id: navId, name: navName });
else if (navType === 'album') navigate('album-detail', { id: navId, name: navName, artist: navArtist || '' });
});
document.body.appendChild(_overlay);
_renderTabs();
}
function _renderTabs() {
const tabs = _overlay.querySelector('#stats-tabs');
const scopes = [['all', 'Alles'], ['year', 'Jahr'], ['month', 'Monat']];
const chip = (k, l) => `
<button class="stats-tab" data-scope="${k}"
style="padding:7px 14px;border-radius:999px;font-family:var(--font);font-size:13px;font-weight:600;cursor:pointer;
border:1px solid var(--border);transition:all .15s;
background:${k === _view ? 'var(--accent)' : 'transparent'};
color:${k === _view ? '#fff' : 'var(--text2)'}">${l}</button>`;
const stepBtn = (dir, label, disabled) => `
<button data-step="${dir}" ${disabled ? 'disabled' : ''}
style="width:30px;height:30px;border-radius:8px;border:1px solid var(--border);background:transparent;
color:var(--text2);cursor:${disabled ? 'default' : 'pointer'};opacity:${disabled ? 0.35 : 1};
font-size:16px;line-height:1;display:flex;align-items:center;justify-content:center">${label}</button>`;
const stepper = _view === 'all' ? '' : `
<div style="display:flex;align-items:center;gap:8px;margin-left:auto">
${stepBtn(-1, '‹', _atStart())}
<div style="min-width:118px;text-align:center;font-weight:700;font-size:14px">${esc(_periodLabel())}</div>
${stepBtn(1, '›', _atCurrent())}
</div>`;
tabs.innerHTML = `<div style="display:flex;align-items:center;gap:6px;width:100%;flex-wrap:wrap">
${scopes.map(([k, l]) => chip(k, l)).join('')}${stepper}
</div>`;
tabs.querySelectorAll('[data-scope]').forEach(b =>
b.addEventListener('click', () => { _view = b.dataset.scope; _renderTabs(); _load(); }));
tabs.querySelectorAll('[data-step]').forEach(b =>
b.addEventListener('click', () => { if (!b.disabled) { _step(+b.dataset.step); _renderTabs(); _load(); } }));
}
async function _load() {
const body = _overlay.querySelector('#stats-body');
if (!S.cfg.scrobbleUrl) {
body.innerHTML = `<div class="stats-empty" style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">
Kein Scrobble-Dienst konfiguriert.<br>Trage die <b>Scrobble-URL</b> in den Einstellungen ein,
damit deine Wiedergaben aufgezeichnet werden.</div>`;
return;
}
const auth = subsonicAuthParams();
if (!auth) { body.innerHTML = _errBox('Nicht angemeldet.'); return; }
// Only show "Lade…" on the very first open — on a period/tab switch keep the current content
// in place until the new data arrives, so the modal doesn't flash small→big (min-height also
// keeps its size stable). A per-request token guards against out-of-order responses.
if (!body.dataset.loaded) body.innerHTML = `<div style="text-align:center;color:var(--text2);padding:40px 0">Lade…</div>`;
const token = String(Date.now());
body.dataset.req = token;
const { from, to, bucket } = _range();
const qs = `${auth}&from=${from}&to=${to}&tz=${_tzOffsetSec()}&bucket=${bucket}&limit=10`;
try {
const res = await fetch(`${S.cfg.scrobbleUrl}/scrobble/stats?${qs}`);
if (body.dataset.req !== token) return; // a newer request superseded this one
if (!res.ok) { body.innerHTML = _errBox(`Fehler ${res.status}`); return; }
const d = await res.json();
if (body.dataset.req !== token) return;
if (d.firstPlayedAt) { _firstPlayedAt = d.firstPlayedAt; _renderTabs(); } // apply back-bound
_render(body, d);
body.dataset.loaded = '1';
} catch {
if (body.dataset.req === token) body.innerHTML = _errBox('Scrobble-Dienst nicht erreichbar.');
}
}
function _errBox(msg) {
return `<div style="text-align:center;color:var(--danger);padding:40px 0">${esc(msg)}</div>`;
}
function _render(body, d) {
if (!d.totalPlays) {
body.innerHTML = `<div style="text-align:center;color:var(--text2);padding:40px 0;line-height:1.6">
Noch keine Wiedergaben in diesem Zeitraum.<br>Spiel ein paar Songs — sie erscheinen hier automatisch.</div>`;
return;
}
body.innerHTML = [
_tiles(d),
_seriesChart(d.series, d.range.bucket),
_topList('Top-Künstler', d.topArtists, a => a.name, a => a.plays, a => a.artistId, true,
a => a.artistId ? { type: 'artist', id: a.artistId, name: a.name } : null),
_topList('Top-Songs', d.topTracks, t => `${t.title}${t.artist ? ' · ' + t.artist : ''}`, t => t.plays, t => t.coverArt || t.albumId || t.songId, false,
t => t.albumId ? { type: 'album', id: t.albumId, name: t.album, artist: t.artist } : null),
_topList('Top-Alben', d.topAlbums, a => `${a.name}${a.artist ? ' · ' + a.artist : ''}`, a => a.plays, a => a.coverArt || a.albumId, false,
a => a.albumId ? { type: 'album', id: a.albumId, name: a.name, artist: a.artist } : null),
_topList('Top-Genres', d.topGenres, g => g.name, g => g.plays),
_hourClock(d.byHour),
].join('');
}
function _tiles(d) {
const tile = (val, label) => `
<div style="flex:1;background:var(--surface2);border:1px solid var(--border);border-radius:12px;padding:14px 16px">
<div style="font-size:24px;font-weight:800;letter-spacing:-.5px">${val}</div>
<div style="font-size:12px;color:var(--text2);margin-top:2px">${label}</div>
</div>`;
return `<div style="display:flex;gap:10px;margin-bottom:18px">
${tile(d.totalPlays.toLocaleString('de-DE'), 'Plays')}
${tile(_fmtTime(d.totalSeconds), 'Hörzeit')}
${tile((d.topArtists?.length || 0) >= 10 ? '10+' : (d.topArtists?.length || 0), 'Künstler')}
</div>`;
}
// Simple vertical bar chart of plays per bucket.
function _seriesChart(series, bucket) {
if (!series?.length) return '';
const max = Math.max(...series.map(s => s.plays), 1);
const fmtLabel = b => bucket === 'month'
? b.slice(5) + '.' + b.slice(2, 4) // "08.25"
: bucket === 'year' ? b : b.slice(8) + '.' + b.slice(5, 7); // "03.08"
const bars = series.map(s => `
<div title="${esc(s.bucket)}: ${s.plays}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;min-width:0">
<div style="width:100%;display:flex;align-items:flex-end;height:90px">
<div style="width:100%;background:var(--accent);border-radius:4px 4px 0 0;height:${Math.round((s.plays / max) * 100)}%;min-height:2px"></div>
</div>
<div style="font-size:10px;color:var(--text2);white-space:nowrap;transform:rotate(-45deg);transform-origin:center;height:16px">${esc(fmtLabel(s.bucket))}</div>
</div>`).join('');
return `${_h('Verlauf')}
<div style="display:flex;gap:3px;align-items:flex-end;margin:6px 0 24px;overflow-x:auto;padding-bottom:8px">${bars}</div>`;
}
function _topList(title, items, labelFn, valFn, idFn = null, circular = false, navFn = null) {
if (!items?.length) return '';
const max = Math.max(...items.map(valFn), 1);
const radius = circular ? '50%' : '6px';
const rows = items.map((it, i) => {
const val = valFn(it);
const cover = idFn ? coverUrl(idFn(it), 80) : null;
const thumb = idFn
? `<div style="width:40px;height:40px;border-radius:${radius};background:var(--surface2);flex-shrink:0;overflow:hidden">
${cover ? `<img src="${esc(cover)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : ''}
</div>`
: '';
const nav = navFn ? navFn(it) : null;
const navAttrs = nav
? `data-nav-type="${nav.type}" data-nav-id="${esc(nav.id)}" data-nav-name="${esc(nav.name || '')}" data-nav-artist="${esc(nav.artist || '')}"`
: '';
return `<div ${navAttrs} style="display:flex;align-items:center;gap:10px;padding:6px 0${nav ? ';cursor:pointer' : ''}">
<div style="width:18px;text-align:right;color:var(--text2);font-size:13px;font-weight:700">${i + 1}</div>
${thumb}
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(labelFn(it))}</div>
<div style="height:5px;background:var(--surface2);border-radius:3px;margin-top:4px;overflow:hidden">
<div style="height:100%;background:var(--accent);width:${Math.round((val / max) * 100)}%"></div>
</div>
</div>
<div style="font-size:13px;color:var(--text2);font-weight:600;min-width:34px;text-align:right">${val}</div>
</div>`;
}).join('');
return `${_h(title)}<div style="margin-bottom:22px">${rows}</div>`;
}
function _hourClock(byHour) {
if (!byHour?.length) return '';
const max = Math.max(...byHour.map(h => h.plays), 1);
const bars = byHour.map(h => `
<div title="${h.hour}:00 — ${h.plays}" style="flex:1;display:flex;flex-direction:column;align-items:center;gap:3px;min-width:0">
<div style="width:100%;display:flex;align-items:flex-end;height:56px">
<div style="width:100%;background:var(--accent);opacity:${h.plays ? 1 : 0.15};border-radius:3px 3px 0 0;height:${Math.round((h.plays / max) * 100)}%;min-height:2px"></div>
</div>
${h.hour % 6 === 0 ? `<div style="font-size:9px;color:var(--text2)">${h.hour}</div>` : '<div style="height:11px"></div>'}
</div>`).join('');
return `${_h('Hör-Uhr (Stunde)')}
<div style="display:flex;gap:2px;align-items:flex-end;margin:6px 0 8px">${bars}</div>`;
}
function _h(t) {
return `<div style="font-size:13px;font-weight:800;text-transform:uppercase;letter-spacing:.4px;color:var(--text2);margin:0 0 8px">${esc(t)}</div>`;
}
+11 -1
View File
@@ -36,7 +36,9 @@ export function md5(input) {
}
export function esc(s) {
return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
// Coerce to string first — callers sometimes pass numbers (e.g. a Deezer artist id), which
// would throw on .replace. null/undefined → ''.
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
export function formatDur(s) {
@@ -45,6 +47,14 @@ export function formatDur(s) {
return `${m}:${sec.toString().padStart(2,'0')}`;
}
// Fan/count formatting in German: full grouped number up to a million (559534 → "559.542"),
// then compact millions (1200000 → "1,2 Mio.").
export function fmtCount(n) {
if (n == null || isNaN(n)) return '';
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '').replace('.', ',') + ' Mio.';
return Math.round(n).toLocaleString('de-DE');
}
export function toast(msg) {
const t = document.createElement('div');
t.className = 'toast';
+186 -62
View File
@@ -1,6 +1,7 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap } from '../api.js';
import { esc, toast, fmtCount } from '../utils.js';
import { api, coverUrl, deezerSearch, deezerLookupAlbum, deezerLookupArtistSongs, deezerLookupArtistAlbums, deezerArtistImage, deezerRelatedArtists, fetchRomajiIndex, dispArtistById, dispAlbumName, cacheArtistSortNames, ensureArtistSortMap, getArtistOverride } from '../api.js';
import { openArtistPin } from '../ui/artistPin.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
import { showCtxMenu, openDlModal } from '../ui/ui.js';
@@ -58,6 +59,9 @@ function _makeTopsongRow(s, num, subtitle, list, idx) {
export async function loadArtistDetail(artist) {
const nameEl = document.getElementById('artist-detail-name');
nameEl.textContent = artist.name;
const fansEl = document.getElementById('artist-detail-fans');
if (fansEl) { fansEl.textContent = ''; fansEl.classList.remove('show'); }
const _showFans = n => { if (fansEl && n) { fansEl.textContent = `${fmtCount(n)} Fans`; fansEl.classList.add('show'); } };
document.getElementById('artist-back-btn').onclick = () => _navGoBack();
if (S.cfg.showRomaji) {
fetchRomajiIndex()
@@ -84,14 +88,32 @@ export async function loadArtistDetail(artist) {
if(albEl) albEl.innerHTML = '<div class="spinner"></div>';
const hasND = !!artist.id;
const knownItunes = artist.itunesArtistId || null;
// Manual pin (same-name fix): use the pinned Deezer artist for id/fans/image instead of guessing.
const _pin = hasND ? getArtistOverride(artist.id) : null;
const knownDeezer = _pin?.deezerArtistId || artist.deezerArtistId || null;
const knownFans = _pin?.fanCount ?? artist.deezerFanCount ?? null;
if (knownFans) _showFans(knownFans); // from the global-search card / pin — show immediately
// "Falscher Künstler?" pin button (local artists only) — top-right of the banner.
if (hasND && hero) {
hero.querySelector('.artist-pin-btn')?.remove();
const pinBtn = document.createElement('button');
pinBtn.className = 'artist-pin-btn';
pinBtn.textContent = '⚙';
pinBtn.title = _pin ? 'Gepinnt — anderen Deezer-Künstler wählen' : 'Falscher Künstler? Richtigen Deezer-Künstler wählen';
// Minimal: a small, subtle icon-only chip in the corner (not a text banner).
pinBtn.style.cssText = `position:absolute;top:10px;right:10px;z-index:5;width:30px;height:30px;display:flex;align-items:center;justify-content:center;border-radius:999px;border:1px solid rgba(255,255,255,.16);background:rgba(0,0,0,.35);color:${_pin ? 'var(--accent)' : 'rgba(255,255,255,.72)'};font-size:14px;line-height:1;cursor:pointer;backdrop-filter:blur(4px)`;
pinBtn.onclick = () => openArtistPin(artist.id, artist.name);
hero.appendChild(pinBtn);
}
// Fire all requests immediately so they run in parallel.
const imgP = hasND ? getArtistImage(artist.id) : Promise.resolve(null);
const imgP = _pin?.image ? Promise.resolve(_pin.image)
: hasND ? getArtistImage(artist.id) : Promise.resolve(null);
const albumP = hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null);
const infoP = hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null);
const topP = hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null);
const iArtistP = knownItunes ? Promise.resolve([{ artistId: knownItunes }]) : itunesSearch(artist.name, 'musicArtist', 5);
const iArtistP = knownDeezer ? Promise.resolve([{ artistId: knownDeezer }]) : deezerSearch(artist.name, 'musicArtist', 5);
const eventsP = fetchEvents(artist.name);
// Phase 1 — hero: only wait for albumP (fast local Navidrome call, no external API).
@@ -133,23 +155,69 @@ export async function loadArtistDetail(artist) {
document.getElementById('artist-hero-pfp-initial')?.style.setProperty('display', 'none');
}).catch(() => {});
// Phase 2 — content (may be slow: Last.fm bio, iTunes, events)
// Phase 2 — content (may be slow: Last.fm bio, Deezer, events)
const [infoRes, topRes, iArtistRes, eventsRes] = await Promise.allSettled([infoP, topP, iArtistP, eventsP]);
const _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean));
const _normName = s => (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const _queryWords = _normWords(artist.name);
const _qNorm = _normName(artist.name);
const iArtistResults = iArtistRes.status === 'fulfilled' ? (iArtistRes.value || []) : [];
const iArtistMatch = iArtistResults.find(r => {
// Prefer EXACT normalized-name matches. "Flay!" must not resolve to the far more popular "K.FLAY"
// (whose name merely contains the word "flay") — exact "flay" == "flay" wins over "kflay". Only fall
// back to the looser word-subset match when there's no exact match. Both lists are fan-sorted already.
const exactMatches = iArtistResults.filter(r => _normName(r.artistName) === _qNorm);
const looseMatches = iArtistResults.filter(r => {
if (_queryWords.size === 0) return true;
const rw = _normWords(r.artistName);
return [..._queryWords].every(w => rw.has(w));
});
const itunesArtistId = knownItunes ?? iArtistMatch?.artistId ?? null;
const nameMatches = exactMatches.length ? exactMatches : looseMatches;
// Default pick = most popular name match (fixes "5× Joji": Deezer's order buries the real one).
let iArtistMatch = nameMatches[0] || null;
const [iAlbumsRes, iTracksRes] = itunesArtistId
// Song-overlap tie-break: when several artists share the name AND this is a local (Navidrome)
// artist we own songs for, prefer the Deezer candidate whose top tracks actually overlap our
// library — each artist has different songs, so this nails the right person. Only runs on a real
// collision (≥2 name matches) and costs a few calls just for those.
if (!knownDeezer && hasND && nameMatches.length > 1) {
const _nt = s => (s || '').toLowerCase().replace(/\([^)]*\)/g, '').replace(/[^a-z0-9]/g, '');
const refTitles = new Set(
((topRes.status === 'fulfilled' ? topRes.value?.topSongs?.song : []) || [])
.map(s => _nt(s.title)).filter(Boolean)
);
if (refTitles.size) {
const cands = nameMatches.slice(0, 3);
const scored = await Promise.all(cands.map(async c => {
const tt = await deezerLookupArtistSongs(c.artistId, 15).catch(() => []);
const overlap = tt.reduce((n, t) => n + (refTitles.has(_nt(t.trackName)) ? 1 : 0), 0);
return { c, overlap };
}));
scored.sort((a, b) => b.overlap - a.overlap || (b.c.fanCount || 0) - (a.c.fanCount || 0));
if (scored[0]?.overlap > 0) iArtistMatch = scored[0].c;
}
}
const deezerArtistId = knownDeezer ?? iArtistMatch?.artistId ?? null;
if (!knownFans) _showFans(iArtistMatch?.fanCount); // local artist: show the matched Deezer artist's fans
// Non-local (Deezer) artist has no Navidrome photo — fetch the Deezer artist picture for the hero.
if (!hasND && deezerArtistId && pfpWrap) {
const dzImg = iArtistMatch?.artworkUrl100 || await deezerArtistImage(deezerArtistId);
if (dzImg) {
const pfpImg = document.createElement('img');
pfpImg.className = 'artist-hero-pfp';
pfpImg.onload = pfpImg.onerror = () => { if (bgEl) bgEl.style.backgroundImage = `url('${dzImg}')`; _revealHero(); };
pfpImg.src = dzImg;
pfpWrap.prepend(pfpImg);
document.getElementById('artist-hero-pfp-initial')?.style.setProperty('display', 'none');
}
}
const [iAlbumsRes, iTracksRes] = deezerArtistId
? await Promise.allSettled([
itunesLookupArtistAlbums(itunesArtistId),
itunesLookupArtistSongs(itunesArtistId, 15),
deezerLookupArtistAlbums(deezerArtistId, 100, artist.name),
deezerLookupArtistSongs(deezerArtistId, 15),
])
: [{ status: 'rejected' }, { status: 'rejected' }];
@@ -163,18 +231,46 @@ export async function loadArtistDetail(artist) {
}
}
// ── Similar artists ──
const similar = info?.similarArtist || [];
if(similar.length && simEl) {
// ── Similar artists (with profile pictures) ──
// When pinned, use Deezer "related" for the pinned artist — Last.fm's list is keyed to the
// (possibly merged/wrong) Navidrome artist, so it goes "weird". Deezer related carry real photos.
if (simEl) {
let simList;
if (_pin?.deezerArtistId) {
const rel = await deezerRelatedArtists(_pin.deezerArtistId, 12);
simList = rel.map(r => ({
name: r.artistName, img: r.artworkUrl100 || '',
nav: { id: null, name: r.artistName, deezerArtistId: r.artistId, deezerFanCount: r.fanCount ?? null },
}));
} else {
simList = (info?.similarArtist || []).map(a => ({
name: a.name, img: coverUrl(a.coverArt || a.id, 160) || '',
nav: { id: a.id || a.name, name: a.name },
}));
}
if (simList.length) {
const _navAttr = n => esc(JSON.stringify(n)).replace(/"/g, '&quot;');
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
<div class="similar-chips">${similar.map(a =>
`<span class="similar-chip" onclick="navigate('artist-detail',{id:'${esc(a.id||a.name)}',name:'${esc(a.name)}'})">
${esc(a.name)}</span>`).join('')}</div>`;
<div class="scroll-row">${simList.map(a => `
<div class="artist-card" style="cursor:pointer;width:88px" onclick='navigate("artist-detail", ${_navAttr(a.nav)})'>
<div class="card-art" style="width:88px;height:88px;border-radius:50%;overflow:hidden">
${a.img ? `<img src="${esc(a.img)}" loading="lazy" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">` : `<div class="no-art">🎤</div>`}
</div>
<div class="card-title" style="text-align:center;font-size:12px;margin-top:6px">${esc(a.name)}</div>
</div>`).join('')}</div>`;
}
}
// ── Top Songs ──
const ndAlbums = _ndArtist?.album || [];
const topSongs = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
// Navidrome can hold the same track twice (e.g. downloaded as a single AND as an album track),
// so getTopSongs returns duplicate rows ("PIXELATED KISSES" x2). Collapse by title+album for display.
const _rawTop = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
const _seenTop = new Set();
const topSongs = _rawTop.filter(s => {
const k = `${s.title}|${s.album}`.toLowerCase().replace(/[^a-z0-9|]/g, '');
return _seenTop.has(k) ? false : (_seenTop.add(k), true);
});
const iTracks = iTracksRes.status === 'fulfilled' ? (iTracksRes.value || []).filter(t => t.wrapperType === 'track') : [];
const iAlbums = iAlbumsRes.status === 'fulfilled' ? (iAlbumsRes.value || []) : [];
@@ -197,7 +293,7 @@ export async function loadArtistDetail(artist) {
}
}
if(iTracks.length) {
const heading = topSongs.length ? 'Weltweit populär (iTunes)' : 'Populäre Titel';
const heading = topSongs.length ? 'Weltweit populär (Deezer)' : 'Populäre Titel';
const sec = document.createElement('div');
sec.innerHTML = `<div class="section-title" style="margin-top:${topSongs.length?'8px':'0'};margin-bottom:10px">${heading}</div>`;
const list = document.createElement('div'); list.className = 'song-list'; list.style.marginBottom = '20px';
@@ -237,36 +333,64 @@ 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();
// Keep Unicode letters/numbers (not just a-z0-9) — otherwise every Japanese/CJK title strips to an
// empty string, so all of an artist's JP singles collapse into one entry and even "match" each
// other (that hid inabakumori's whole singles discography behind one album).
return (s || '').toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, ' ').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) {
// Match each Deezer album to a Navidrome album by normalized name.
const allEntries = iAlbums.map(ia => {
const iName = _normAlbum(ia.collectionName);
const iCount = ia.trackCount || 0;
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);
}
}
for(const na of ndAlbums) {
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, itunes: null });
return { ia, match };
});
// Deezer returns the same album several times (single/EP/album/regional/explicit variants), which
// rendered one owned album 3× ("PIXELATED KISSES"). Collapse to one entry per owned Navidrome album
// and one per not-owned title.
const entries = [];
const seenNd = new Set(), seenName = new Set();
for (const e of allEntries) {
if (e.match) { if (seenNd.has(e.match.id)) continue; seenNd.add(e.match.id); }
else { const k = _normAlbum(e.ia.collectionName); if (seenName.has(k)) continue; seenName.add(k); }
entries.push(e);
}
// 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 deezerLookupAlbum(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, deezer: ia, have, total }); usedNdIds.add(match.id); }
else { downloaded.push({ nd: match, deezer: ia }); usedNdIds.add(match.id); }
}
for(const na of ndAlbums) {
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, deezer: 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));
@@ -287,20 +411,20 @@ function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
}
if(partial.length) {
const g = addGrid('Unvollständig', partial.length);
partial.forEach(({ nd, itunes, have, total }) => {
const art = itunes?.artworkUrl100?.replace('100x100','512x512') || coverUrl(nd.coverArt, 512);
partial.forEach(({ nd, deezer, have, total }) => {
const art = deezer?.artworkUrl100?.replace('100x100','512x512') || coverUrl(nd.coverArt, 512);
const c = makeCard(nd.name, `${have} / ${total} Tracks`, art, '💿');
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(nd); };
c.querySelector('.card-info').onclick = () => itunes ? navigate('partial-album-detail', { nd, itunes }) : navigate('album-detail', { ...nd, _fromArtist: artist });
c.querySelector('.card-info').onclick = () => deezer ? navigate('partial-album-detail', { nd, deezer }) : navigate('album-detail', { ...nd, _fromArtist: artist });
const artEl = c.querySelector('.card-art');
const badge = document.createElement('div');
badge.className = 'album-partial-badge'; badge.textContent = `${have}/${total}`;
artEl.appendChild(badge);
if(itunes) {
if(deezer) {
const btn = document.createElement('button');
btn.className = 'card-dl-btn'; btn.title = 'Fehlende Tracks laden';
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: artist.name, trackName: nd.name, collectionName: nd.name, artworkUrl100: itunes.artworkUrl100||'', isAlbum: true, itunesData: itunes, collectionId: itunes.collectionId }); };
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: artist.name, trackName: nd.name, collectionName: nd.name, artworkUrl100: deezer.artworkUrl100||'', isAlbum: true, deezerData: deezer, collectionId: deezer.collectionId }); };
artEl.appendChild(btn);
}
g.appendChild(c);
@@ -311,12 +435,12 @@ function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
notDl.forEach(ia => {
const art = ia.artworkUrl100?.replace('100x100','300x300');
const c = makeCard(ia.collectionName, ia.releaseDate?.slice(0,4)||'', art, '💿');
c.onclick = () => navigate('itunes-album-detail', ia);
c.onclick = () => navigate('deezer-album-detail', ia);
const artEl = c.querySelector('.card-art');
const btn = document.createElement('button');
btn.className = 'card-dl-btn'; btn.title = 'Album laden';
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: ia.artistName, trackName: ia.collectionName, collectionName: ia.collectionName, artworkUrl100: ia.artworkUrl100||'', isAlbum: true, itunesData: ia, collectionId: ia.collectionId }); };
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: ia.artistName, trackName: ia.collectionName, collectionName: ia.collectionName, artworkUrl100: ia.artworkUrl100||'', isAlbum: true, deezerData: ia, collectionId: ia.collectionId }); };
artEl.appendChild(btn);
g.appendChild(c);
});
@@ -355,32 +479,32 @@ export async function loadArtistAllSongs({ albums, artist }) {
});
}
export async function loadItunesAlbumDetail(album) {
document.getElementById('itunes-album-back-btn').onclick = () => _navGoBack();
document.getElementById('itunes-album-detail-name').textContent = album.collectionName || '—';
document.getElementById('itunes-album-detail-artist').textContent = album.artistName || '';
const cw = document.getElementById('itunes-album-cover-wrap');
export async function loadDeezerAlbumDetail(album) {
document.getElementById('deezer-album-back-btn').onclick = () => _navGoBack();
document.getElementById('deezer-album-detail-name').textContent = album.collectionName || '—';
document.getElementById('deezer-album-detail-artist').textContent = album.artistName || '';
const cw = document.getElementById('deezer-album-cover-wrap');
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
const dlBtn = document.getElementById('itunes-album-dl-btn');
dlBtn.onclick = () => openDlModal({ artistName: album.artistName, trackName: album.collectionName, collectionName: album.collectionName, artworkUrl100: album.artworkUrl100||'', isAlbum: true, itunesData: album, collectionId: album.collectionId });
const sl = document.getElementById('itunes-album-songs');
const dlBtn = document.getElementById('deezer-album-dl-btn');
dlBtn.onclick = () => openDlModal({ artistName: album.artistName, trackName: album.collectionName, collectionName: album.collectionName, artworkUrl100: album.artworkUrl100||'', isAlbum: true, deezerData: album, collectionId: album.collectionId });
const sl = document.getElementById('deezer-album-songs');
sl.innerHTML = '<div class="spinner"></div>';
try {
const tracks = await itunesLookupAlbum(album.collectionId);
const tracks = await deezerLookupAlbum(album.collectionId);
const year = album.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
document.getElementById('itunes-album-detail-stats').textContent = `${tracks.length} Titel${year ? ' · ' + year : ''}`;
document.getElementById('deezer-album-detail-stats').textContent = `${tracks.length} Titel${year ? ' · ' + year : ''}`;
sl.innerHTML = '';
if(!tracks.length) { sl.innerHTML = '<div style="padding:16px;color:var(--text3)">Keine Tracks gefunden</div>'; return; }
tracks.forEach((t, i) => sl.appendChild(makeGlobalTrackItem(t, i, tracks)));
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
export async function loadPartialAlbumDetail({ nd, itunes }) {
export async function loadPartialAlbumDetail({ nd, deezer }) {
document.getElementById('partial-album-back-btn').onclick = () => _navGoBack();
document.getElementById('partial-album-detail-name').textContent = nd.name || '—';
document.getElementById('partial-album-detail-artist').textContent = nd.artist || itunes?.artistName || '';
const art = (itunes?.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600')
document.getElementById('partial-album-detail-artist').textContent = nd.artist || deezer?.artistName || '';
const art = (deezer?.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600')
|| (nd.coverArt ? coverUrl(nd.coverArt, 512) : '');
const cw = document.getElementById('partial-album-cover-wrap');
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
@@ -393,7 +517,7 @@ export async function loadPartialAlbumDetail({ nd, itunes }) {
try {
const [ndRes, iTracks] = await Promise.all([
api('getAlbum', `&id=${nd.id}`),
itunes?.collectionId ? itunesLookupAlbum(itunes.collectionId) : Promise.resolve([]),
deezer?.collectionId ? deezerLookupAlbum(deezer.collectionId) : Promise.resolve([]),
]);
const ndSongs = (ndRes.album?.song || []).sort((a,b) => (a.track||999)-(b.track||999));
@@ -409,9 +533,9 @@ export async function loadPartialAlbumDetail({ nd, itunes }) {
if(ndSongs.length) playQueue(ndSongs, 0);
};
// Download button offers missing via iTunes
// Download button offers missing via Deezer
document.getElementById('partial-album-dl-btn').onclick = () => {
if(itunes) openDlModal({ artistName: itunes.artistName, trackName: itunes.collectionName, collectionName: itunes.collectionName, artworkUrl100: itunes.artworkUrl100||'', isAlbum: true, itunesData: itunes, collectionId: itunes.collectionId });
if(deezer) openDlModal({ artistName: deezer.artistName, trackName: deezer.collectionName, collectionName: deezer.collectionName, artworkUrl100: deezer.artworkUrl100||'', isAlbum: true, deezerData: deezer, collectionId: deezer.collectionId });
};
// Section: downloaded
@@ -467,11 +591,11 @@ export async function loadAlbumDetail(album) {
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
// r.album has sortName (album sort tag) — use it; nav album may not have sortName
document.getElementById('album-detail-name').textContent = dispAlbumName(r.album);
if (songs[0]) {
artistEl.innerHTML = parseArtists(songs[0])
// Album artist comes from the album's own album-artist tags (r.album.artists / .artist), NOT the
// first track — for soundtracks/compilations track 1's artist (e.g. TWICE) is not the album artist.
artistEl.innerHTML = parseArtists(r.album)
.map(a => `<span class="song-link" onclick="event.stopPropagation();s_goArtist(event,'${esc(a.originalName||a.name)}','${esc(a.id||'')}')">${esc(dispArtistById(a.id, a.name))}</span>`)
.join('<span style="color:var(--text3)"> • </span>') || '';
}
document.getElementById('btn-play-album').onclick = () => playQueue(songs, 0);
document.getElementById('btn-dl-album').onclick = () => {
songs.forEach((s,i) => setTimeout(() => dlSongLocal(s), i*400));
+9 -9
View File
@@ -1,11 +1,11 @@
import { S } from '../state.js';
import { esc } from '../utils.js';
import { itunesSearch, itunesTopCharts, itunesTopSongs } from '../api.js';
import { deezerSearch, deezerTopCharts, deezerTopSongs } from '../api.js';
import { makeCard, makeGlobalTrackItem } from '../ui/components.js';
import { playPreview } from '../player/player.js';
import { openDlModal } from '../ui/ui.js';
// Circular — safe (called inside event handlers)
import { loadItunesAlbum, searchAndPreview } from './search.js';
import { loadDeezerAlbum, searchAndPreview } from './search.js';
// ─── NEW RELEASES ───
export async function loadNewReleases() {
@@ -13,12 +13,12 @@ export async function loadNewReleases() {
const songEl = document.getElementById('nr-songs');
albumEl.innerHTML = '<div class="spinner"></div>'; songEl.innerHTML = '';
try {
const [albums, songs] = await Promise.all([itunesTopCharts(), itunesTopSongs()]);
const [albums, songs] = await Promise.all([deezerTopCharts(), deezerTopSongs()]);
albumEl.innerHTML = '';
albums.slice(0,30).forEach(a => {
const art = a.artworkUrl100?.replace('100x100','300x300') || a.artworkUrl;
const c = makeCard(a.name, a.artistName, art, '💿');
c.onclick = () => itunesSearch(a.name+' '+a.artistName,'album',5).then(res => { if(res[0]) loadItunesAlbum(res[0]); });
c.onclick = () => deezerSearch(a.name+' '+a.artistName,'album',5).then(res => { if(res[0]) loadDeezerAlbum(res[0]); });
albumEl.appendChild(c);
});
songs.slice(0,40).forEach((s, i) => {
@@ -36,7 +36,7 @@ export async function loadNewReleases() {
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>`;
el.onclick = () => { itunesSearch(s.name+' '+s.artistName,'musicTrack',1).then(res => { if(res[0]?.previewUrl) playPreview(res[0]); }); };
el.onclick = () => { deezerSearch(s.name+' '+s.artistName,'musicTrack',1).then(res => { if(res[0]?.previewUrl) playPreview(res[0]); }); };
songEl.appendChild(el);
});
} catch(e) { albumEl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
@@ -62,7 +62,7 @@ export async function loadRecommendations() {
if(S.cfg.lastfmKey) {
await _loadLastfmRecommendations(el, artist, title, html);
} else {
await _loadItunesFallbackRecommendations(el, artist, title, html);
await _loadDeezerFallbackRecommendations(el, artist, title, html);
const note = document.createElement('div');
note.style.cssText = 'margin-top:16px;padding:12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text3)';
note.innerHTML = '💡 <strong>Tipp:</strong> Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.';
@@ -123,11 +123,11 @@ async function _loadLastfmRecommendations(el, artist, title, html) {
});
sec.appendChild(list); el.appendChild(sec);
}
} catch { await _loadItunesFallbackRecommendations(el, artist, title, html); }
} catch { await _loadDeezerFallbackRecommendations(el, artist, title, html); }
}
async function _loadItunesFallbackRecommendations(el, artist, title, html) {
const results = await itunesSearch(artist, 'musicTrack', 20);
async function _loadDeezerFallbackRecommendations(el, artist, title, html) {
const results = await deezerSearch(artist, 'musicTrack', 20);
el.innerHTML = html;
const sec = document.createElement('div');
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Mehr von ${esc(artist)}</div>`;
+86 -11
View File
@@ -5,9 +5,11 @@ import { loadSpotifyImporter } from './spotify.js';
import { loadSoundCloudImporter } from './soundcloud.js';
// Circular — safe (all calls happen inside function bodies)
import { loadHomeData, loadAlbumRow, loadArtists, loadAlbums, loadGenreDetail } from './home.js';
import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadItunesAlbumDetail, loadPartialAlbumDetail } from './detail.js';
import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadDeezerAlbumDetail, loadPartialAlbumDetail } from './detail.js';
import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js';
import { loadNewReleases, loadRecommendations } from './discover.js';
import { onHeaderSearch } from './search.js';
import { invalidateApiCache } from '../api.js';
// ─── NAVIGATION HISTORY ───
export function _navSave() {
@@ -36,16 +38,63 @@ export function closeHeaderSearch() {
clearTimeout(S._headerSearchTimer);
}
// Force-refresh the active view after a mutating action (delete a song / playlist, etc.): drop the
// cached API responses so the reload is fresh, then re-run the current nav entry's loader.
export function refreshCurrentView() {
invalidateApiCache();
const entry = S._navStack[S._navCursor];
if (!entry) return;
const { page, data } = entry;
switch (page) {
case 'home': loadHomeData(true); break;
case 'albums': loadAlbums(); break;
case 'artists': loadArtists(); break;
case 'starred': loadStarred(); break;
case 'random': loadRandom(); break;
case 'playlists': loadPlaylists(); break;
case 'playlist-detail': if (data) loadPlaylistDetail(data); break;
case 'album-detail': if (data) loadAlbumDetail(data); break;
case 'artist-detail': if (data) loadArtistDetail(data); break;
case 'genre-detail': if (data) loadGenreDetail(data); break;
}
}
export function navigate(page, data=null) {
// Scroll + search memory: remember where each history entry was scrolled AND any open local
// (header) search, so Back/Forward returns you to the same spot with the same query. The global
// search page keeps its query/results on its own (its DOM persists). A genuine new navigation
// still starts at the top with a clean header search. On a genuine nav we snapshot the OUTGOING
// view here (cursor still points at it); on Back/Forward the snapshot is taken in _navOnPopState.
const _main = document.getElementById('main-content');
const _hsInput = document.getElementById('header-search-input');
S._scrollByIdx = S._scrollByIdx || {};
S._hsByIdx = S._hsByIdx || {};
if(!S._navJumping) {
if(_main) S._scrollByIdx[S._navCursor] = _main.scrollTop;
S._hsByIdx[S._navCursor] = _hsInput?.value?.trim() ? _hsInput.value : '';
}
closeHeaderSearch();
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
const cls = ['discover','new-releases','recommendations','spotify-importer'].includes(page) ? 'active-blue' : 'active';
const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active';
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
const pageEl = document.getElementById(`page-${page}`);
if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; }
if(pageEl) {
pageEl.classList.add('active');
if(S._navJumping) {
// Back/Forward → restore this entry's saved scroll (rAF so the shown page has laid out) and
// re-open the local (header) search if one was active here.
const y = S._scrollByIdx[S._navCursor] || 0;
if(_main) requestAnimationFrame(() => { _main.scrollTop = y; });
const hsq = S._hsByIdx[S._navCursor];
if(hsq && _hsInput) { _hsInput.value = hsq; onHeaderSearch(hsq); }
} else if(_main) {
_main.scrollTop = 0;
}
}
closeSidebar();
if(['discover','new-releases','recommendations','random','spotify-importer'].includes(page)) switchNavMode('discover');
if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover');
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state
@@ -57,13 +106,12 @@ export function navigate(page, data=null) {
if(page==='artist-all-songs' && data) loadArtistAllSongs(data);
if(page==='album-detail' && data) loadAlbumDetail(data);
if(page==='genre-detail' && data) loadGenreDetail(data);
if(page==='itunes-album-detail' && data) loadItunesAlbumDetail(data);
if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data);
if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data);
if(page==='new-releases') loadNewReleases();
if(page==='playlists') loadPlaylists();
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
if(page==='recommendations') loadRecommendations();
if(page==='spotify-importer') { loadSpotifyImporter(); loadSoundCloudImporter(); }
if(page==='audiobooks') {
import('./abs.js').then(m => {
if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId);
@@ -86,16 +134,43 @@ export function navigate(page, data=null) {
}
}
// In-app back button / Backspace: dismiss NowPlaying overlays first, otherwise let the browser step
// back (→ popstate → _navOnPopState). Routing through the History API keeps the browser's back/
// forward stack in sync, so browser/mouse Forward works too.
export function _navGoBack() {
if(document.getElementById('np-sheet')?.classList.contains('open')) { closeNpMenu(); return; }
if(S.npOpen) { closeNowPlaying(); return; }
if(S._navCursor > 0) {
S._navCursor--;
const entry = S._navStack[S._navCursor];
if(S._navCursor > 0) history.back();
}
// Browser/mouse Back OR Forward — both fire popstate. Apply whichever entry the browser moved to
// via nsIdx from the history state; this is what makes Forward work. Never pushState here — that
// would wipe the browser's forward stack. navigate() pushes only on genuine new navigation.
export function _navOnPopState(state) {
// Mirror the soft-back: a Back press with an overlay open just closes it and stays on the page.
if(document.getElementById('np-sheet')?.classList.contains('open')) {
closeNpMenu();
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
return;
}
if(S.npOpen) {
closeNowPlaying();
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
return;
}
const idx = (state && typeof state.nsIdx === 'number') ? state.nsIdx : 0;
if(idx < 0 || idx >= S._navStack.length) return;
// Snapshot the OUTGOING view's scroll + any open local search before we move the cursor.
const _main = document.getElementById('main-content');
const _hsInput = document.getElementById('header-search-input');
S._scrollByIdx = S._scrollByIdx || {};
S._hsByIdx = S._hsByIdx || {};
if(_main) S._scrollByIdx[S._navCursor] = _main.scrollTop;
S._hsByIdx[S._navCursor] = _hsInput?.value?.trim() ? _hsInput.value : '';
S._navCursor = idx;
const entry = S._navStack[idx];
_navSave();
S._navJumping = true;
navigate(entry.page, entry.data);
S._navJumping = false;
}
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
}
+37 -21
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, itunesSearch, itunesLookupAlbum, fetchRomajiIndex, dispArtistById } from '../api.js';
import { esc, toast, fmtCount } from '../utils.js';
import { api, deezerSearch, deezerLastError, deezerLookupAlbum, fetchRomajiIndex, dispArtistById, getArtistOverride } from '../api.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
import { playPreview } from '../player/player.js';
import { openDlModal } from '../ui/ui.js';
@@ -64,12 +64,16 @@ export async function doLocalSearch(q, el) {
c.classList.add('artist-card');
c.onclick = () => navigate('artist-detail', a);
g.appendChild(c);
getArtistImage(a.id).then(img => {
const _setArt = img => {
if(!img) return;
const artEl = c.querySelector('.card-art');
if(!artEl) return;
artEl.innerHTML = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">`;
});
};
// A pin's Deezer picture wins (fixes same-name artists whose Navidrome image is the wrong person).
const _pin = getArtistOverride(a.id);
if (_pin?.image) _setArt(_pin.image);
else getArtistImage(a.id).then(_setArt);
});
s.appendChild(g); el.appendChild(s);
}
@@ -82,7 +86,7 @@ export async function doLocalSearch(q, el) {
if(songs.length) {
const sec = mkSection('Titel');
const list = document.createElement('div'); list.className = 'song-list';
songs.forEach((so,i) => list.appendChild(makeSongItem(so, i, songs)));
songs.forEach((so,i) => list.appendChild(makeSongItem(so, i, songs, { singlePlay: true })));
sec.appendChild(list); el.appendChild(sec);
}
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
@@ -106,7 +110,7 @@ export function onHeaderSearch(val) {
export function onMobileSearch(val) { onHeaderSearch(val); }
// ─── GLOBAL (ITUNES) SEARCH ───
// ─── GLOBAL (DEEZER) SEARCH ───
export function onGlobalSearch(val) {
clearTimeout(S.globalSearchTimer);
const el = document.getElementById('global-search-results');
@@ -119,9 +123,9 @@ export async function doGlobalSearch(q) {
const el = document.getElementById('global-search-results');
try {
const [tracks, albums, artists] = await Promise.all([
itunesSearch(q,'musicTrack',25),
itunesSearch(q,'album',10),
itunesSearch(q,'musicArtist',6),
deezerSearch(q,'musicTrack',25),
deezerSearch(q,'album',10),
deezerSearch(q,'musicArtist',6),
]);
el.innerHTML = '';
const trackResults = tracks.filter(t => t.wrapperType === 'track');
@@ -130,10 +134,12 @@ export async function doGlobalSearch(q) {
const s = mkSection('Künstler');
const row = document.createElement('div'); row.className = 'scroll-row';
artists.forEach(a => {
const c = makeCard(a.artistName, 'iTunes Künstler', null, '🎤');
c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName);
const sub = a.fanCount ? `${fmtCount(a.fanCount)} Fans` : 'Deezer Künstler';
const c = makeCard(a.artistName, sub, a.artworkUrl100 || null, '🎤');
c.onclick = () => loadDeezerArtistResults(a.artistId, a.artistName, a.fanCount);
row.appendChild(c);
itunesSearch(a.artistName,'album',1).then(res => {
if (a.artworkUrl100) return; // Deezer artists already carry a photo — no extra lookup
deezerSearch(a.artistName,'album',1).then(res => {
if(!res[0]) return;
const imgUrl = res[0].artworkUrl100?.replace('100x100','300x300');
if(!imgUrl) return;
@@ -155,15 +161,15 @@ export async function doGlobalSearch(q) {
albums.forEach(a => {
const art = a.artworkUrl100?.replace('100x100','300x300');
const c = makeCard(a.collectionName, a.artistName, art, '💿');
c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); loadItunesAlbum(a); };
c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); loadDeezerAlbum(a); };
c.querySelector('.dl-btn-overlay')?.remove();
const dlOverlay = document.createElement('button');
dlOverlay.className = 'dl-btn-overlay';
dlOverlay.title = 'Auf Server laden';
dlOverlay.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
dlOverlay.onclick = (e) => { e.stopPropagation(); openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,itunesData:a}); };
dlOverlay.onclick = (e) => { e.stopPropagation(); openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,deezerData:a}); };
c.querySelector('.play-overlay').appendChild(dlOverlay);
c.onclick = () => loadItunesAlbum(a);
c.onclick = () => loadDeezerAlbum(a);
row.appendChild(c);
});
s.appendChild(row); el.appendChild(s);
@@ -175,14 +181,24 @@ export async function doGlobalSearch(q) {
s.appendChild(list); el.appendChild(s);
}
if(!artists.length && !albums.length && !trackResults.length) {
const err = deezerLastError();
if (err) {
// Distinguish "genuinely no results" from "search backend is down" so the user knows where
// the problem is.
const detail = err === 'network'
? 'Keine Verbindung zur Deezer-Such-API (Netzwerk/Backend). Läuft dein Backend?'
: `Deezer-Suche nicht verfügbar (Fehler ${err}).`;
el.innerHTML = `<div class="empty-state"><div class="icon">⚠️</div><p>Deezer-Suche gerade nicht verfügbar</p><p style="font-size:12px;color:var(--text3);max-width:420px;margin:8px auto 0">${detail}</p></div>`;
} else {
el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>';
}
}
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`; }
}
export async function loadItunesAlbum(album) {
export async function loadDeezerAlbum(album) {
try {
const tracks = await itunesLookupAlbum(album.collectionId);
const tracks = await deezerLookupAlbum(album.collectionId);
if(!tracks.length) { toast('Keine Tracks gefunden'); return; }
const el = document.getElementById('global-search-results');
const s = document.createElement('div'); s.className = 'search-section';
@@ -192,7 +208,7 @@ export async function loadItunesAlbum(album) {
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div>
</div>
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,itunesData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button>
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,deezerData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button>
</div>
<div class="search-section-title">Tracks</div>`;
const list = document.createElement('div'); list.className = 'song-list';
@@ -202,8 +218,8 @@ export async function loadItunesAlbum(album) {
} catch(e) { toast('Fehler: ' + e.message); }
}
export function loadItunesArtistResults(artistId, artistName) {
navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId });
export function loadDeezerArtistResults(artistId, artistName, fanCount) {
navigate('artist-detail', { id: null, name: artistName, deezerArtistId: artistId, deezerFanCount: fanCount });
}
// ─── SEARCH HELPERS ───
@@ -215,7 +231,7 @@ export function searchSimilarArtist(artistName) {
export async function searchAndPreview(trackName, artistName) {
try {
const res = await itunesSearch(trackName+' '+artistName, 'musicTrack', 1);
const res = await deezerSearch(trackName+' '+artistName, 'musicTrack', 1);
if(res[0]?.previewUrl) playPreview(res[0]); else toast('Kein Preview verfügbar');
} catch { toast('Suche fehlgeschlagen'); }
}
+1
View File
@@ -138,6 +138,7 @@ function _renderSoundCloudPreview(data) {
const artT = t.artworkUrl || '';
el.innerHTML = `
<div class="gtrack-art">${artT ? `<img src="${artT}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>'}</div>
<div class="gtrack-num">${i + 1}</div>
<div class="gtrack-info">
<div class="gtrack-title">${esc(t.trackName || '—')}</div>
<div class="gtrack-sub">${esc(t.artistName || '—')}${t.genre ? ' · ' + esc(t.genre) : ''}${t.duration ? ' · ' + Math.round(t.duration / 60) + ' min' : ''}</div>
+56 -12
View File
@@ -1,6 +1,6 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { itunesSearch } from '../api.js';
import { deezerSearch } from '../api.js';
// Circular imports — safe (only used inside function bodies)
import { makeGlobalTrackItem } from '../ui/components.js';
import { openDlModal } from '../ui/ui.js';
@@ -47,6 +47,50 @@ export function onImporterInput(val) {
}
}
// ─── UNIFIED GLOBAL-SUCHE FIELD ───
// One field on the Global-Suche page handles both jobs: a pasted Spotify/SoundCloud/YouTube link
// switches into the importer flow; any other text runs the Deezer library search. This replaces the
// separate "Song Importer" sidebar entry (the importer now lives inside Global Suche).
const _IMPORT_LINK_RE = /^https?:\/\//i;
const _IMPORT_HOST_RE = /(?:spotify\.com|soundcloud\.com|youtube\.com|youtu\.be)/i;
function _isImportLink(v) { return _IMPORT_LINK_RE.test(v) || _IMPORT_HOST_RE.test(v); }
export function onGlobalInput(val) {
const v = (val || '').trim();
const area = document.getElementById('importer-area');
const results = document.getElementById('global-search-results');
const badge = document.getElementById('importer-badge');
const btn = document.getElementById('importer-btn');
if (_isImportLink(v)) {
// Link → importer mode
if (results) results.style.display = 'none';
if (area) area.style.display = '';
if (badge) badge.style.display = '';
if (btn) btn.style.display = '';
const hidden = document.getElementById('importer-link-input');
if (hidden) hidden.value = v; // mirror so resolveImporterLink() reads it
onImporterInput(v); // badge styling + clear stale previews
const hint = document.getElementById('importer-hint');
if (hint) hint.style.display = ''; // keep the platform hint as a header until a preview loads
} else {
// Text → Deezer search
if (area) area.style.display = 'none';
if (badge) badge.style.display = 'none';
if (btn) btn.style.display = 'none';
if (results) results.style.display = '';
window.onGlobalSearch(v);
}
}
// Enter in the unified field: resolve the link if it is one (search is already live via oninput).
export function tryImporterResolve() {
const v = (document.getElementById('global-search-input')?.value || '').trim();
if (!_isImportLink(v)) return;
const hidden = document.getElementById('importer-link-input');
if (hidden) hidden.value = v;
resolveImporterLink();
}
export async function resolveImporterLink() {
const url = document.getElementById('importer-link-input')?.value.trim();
if (!url) return;
@@ -236,7 +280,7 @@ function _renderSpotifyPreview(data) {
(data.tracks || []).forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, data.tracks)));
section.style.display = '';
// Lazy-load correct per-track artwork and canonical names via iTunes in the background
// Lazy-load correct per-track artwork and canonical names via Deezer in the background
if (count > 0 && S.cfg.backendUrl) {
_loadSpotifyTrackArtwork(data.tracks, list);
}
@@ -247,7 +291,7 @@ function _setEnrichStatus(done, current, total) {
if (!el) return;
if (done) {
el.className = 'done';
el.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> iTunes-Daten geladen';
el.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> Deezer-Daten geladen';
el.style.display = 'flex';
el.style.opacity = '1';
setTimeout(() => {
@@ -256,7 +300,7 @@ function _setEnrichStatus(done, current, total) {
}, 2000);
} else {
el.className = '';
el.innerHTML = `<div class="enrich-spinner"></div> Lade iTunes-Daten… (${current}/${total})`;
el.innerHTML = `<div class="enrich-spinner"></div> Lade Deezer-Daten… (${current}/${total})`;
el.style.display = 'flex';
el.style.opacity = '1';
}
@@ -274,7 +318,7 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
_setArt(el, url);
});
// Enrich each track with iTunes data — same call as global search
// Enrich each track with Deezer data — same call as global search
const CONC = 4;
let enriched = 0;
_setEnrichStatus(false, 0, tracks.length);
@@ -284,18 +328,18 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
const idx = i + bi;
try {
const firstArtist = (t.artistName || '').split(/\s*[,/•]\s*/)[0].trim();
const results = await itunesSearch(`${t.trackName} ${firstArtist}`, 'musicTrack', 1);
const results = await deezerSearch(`${t.trackName} ${firstArtist}`, 'musicTrack', 1);
const match = results[0];
if (!match) return;
// Always safe to enrich: artwork, preview, explicit, iTunes artist name
// Always safe to enrich: artwork, preview, explicit, canonical artist name
if (match.artworkUrl100) t.artworkUrl100 = match.artworkUrl100;
if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness;
if (match.previewUrl) t.previewUrl = match.previewUrl;
// Store iTunes canonical name separately — keeps original Spotify/Japanese in artistName
// Store the Deezer canonical name separately — keeps original Spotify/Japanese in artistName
// so the UI shows the original and YTM search uses the original channel name.
if (match.artistName) t.itunesArtistName = match.artistName;
// Only enrich album/duration if we're confident the iTunes match is the correct song.
// For Japanese titles we can't word-match, so we skip — the backend's lookupItunesTrackMeta
if (match.artistName) t.metaArtistName = match.artistName;
// Only enrich album/duration if we're confident the Deezer match is the correct song.
// For Japanese titles we can't word-match, so we skip — the backend's lookupTrackMeta
// handles it with better matching. For ASCII titles, require at least one shared word.
const JP_RE = /[ぁ-んァ-ン一-龯]/;
const isJapanese = JP_RE.test(t.trackName);
@@ -305,7 +349,7 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) {
if (match.collectionName) t.collectionName = match.collectionName;
if (match.releaseDate) t.releaseDate = match.releaseDate;
if (match.trackNumber) t.trackNumber = match.trackNumber;
// Only use iTunes duration if it looks like a full song (> 30s) — jingles/previews
// Only use Deezer duration if it looks like a full song (> 30s) — jingles/previews
// returning a small trackTimeMillis would corrupt the yt-dlp duration filter.
if (match.trackTimeMillis > 30000) t.durationMs = match.trackTimeMillis;
}
+13 -6
View File
@@ -14,6 +14,11 @@
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}
*:focus{outline:none}
/* App-like feel: dragging near controls must not accidentally select UI chrome
(page titles, labels, buttons). Real text fields and copyable content opt back in. */
#app{-webkit-user-select:none;user-select:none}
#app input,#app textarea,#app [contenteditable=true],#app .selectable,
#app #np-lyrics-content,#app .lyrics-line,#app .lyrics-plain{-webkit-user-select:text;user-select:text}
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);overflow:hidden}
::-webkit-scrollbar{width:4px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--surface2);border-radius:2px}
@@ -69,7 +74,7 @@ html.ns-authed #setup-screen{display:none}
.header-search-wrap input{flex:1;min-width:0;background:none;border:none;outline:none;color:var(--text);font-family:var(--font);font-size:14px}
.header-search-wrap input::placeholder{color:var(--text3)}
#header-search-overlay{position:fixed;left:0;right:0;background:var(--bg);border-bottom:1px solid var(--border);box-shadow:0 12px 32px rgba(0,0,0,.5);padding:6px 12px 20px;z-index:11;overflow-y:auto;max-height:calc(100vh - 120px)}
#header-search-overlay{position:absolute;top:100%;left:12px;right:12px;background:var(--bg);border:1px solid var(--border);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.5);padding:6px 12px 16px;z-index:11;overflow-y:auto;max-height:calc(100vh - 140px)}
@media(max-width:768px){#header-search-overlay{max-height:calc(100vh - var(--player-h) - 160px)}}
@media(max-width:359px){#mobile-header .sidebar-logo span{display:none}}
@@ -209,7 +214,7 @@ nav{flex:1;overflow-y:auto;padding:4px 10px}
/* ── BADGES ── */
.badge{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:600;padding:3px 8px;border-radius:20px;letter-spacing:.3px}
.badge-new{background:var(--blue-dim);color:var(--blue)}
.badge-itunes{background:rgba(252,100,100,.12);color:#fc6464}
.badge-deezer{background:rgba(252,100,100,.12);color:#fc6464}
.badge-local{background:var(--green-dim);color:var(--green)}
/* ── GLOBAL SEARCH RESULTS ── */
@@ -680,8 +685,8 @@ button:focus{outline:none}
.np-cover .no-art{width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:80px}
/* Song info */
.np-info{width:100%;margin-bottom:24px}
.np-text{min-width:0}
.np-info{width:100%;margin-bottom:24px;display:flex;align-items:center;gap:10px}
.np-text{min-width:0;flex:1}
.np-title-row{display:flex;align-items:center;gap:6px;margin-bottom:5px}
.np-title{font-size:22px;font-weight:800;letter-spacing:-.4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0}
.np-artist{font-size:14px;color:rgba(255,255,255,.5);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:color .15s}
@@ -846,8 +851,10 @@ button:focus{outline:none}
.artist-hero-pfp-wrap{aspect-ratio:1/1;height:100%;flex-shrink:0;overflow:hidden;position:relative;background:linear-gradient(135deg,var(--surface2),var(--surface3,var(--surface)))}
.artist-hero-pfp{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.artist-hero-pfp-initial{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:52px;font-weight:700;color:var(--text2);line-height:1}
.artist-hero-info{min-width:0;flex:1;display:flex;align-items:flex-end;padding:20px 24px;background:linear-gradient(to left,rgba(7,7,15,.95) 0%,rgba(7,7,15,.1) 100%)}
.artist-hero-name{font-size:26px;font-weight:800;letter-spacing:-0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.artist-hero-info{min-width:0;flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:flex-start;padding:20px 24px;background:linear-gradient(to left,rgba(7,7,15,.95) 0%,rgba(7,7,15,.1) 100%)}
.artist-hero-name{max-width:100%;font-size:26px;font-weight:800;letter-spacing:-0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:.12em}
.artist-hero-fans{font-size:13px;font-weight:600;color:rgba(255,255,255,.58);margin-top:6px;letter-spacing:.2px;display:none}
.artist-hero-fans.show{display:block}
@media(max-width:768px){
.artist-hero-bg{filter:brightness(0.75) saturate(0.85);transform:scale(1)}
.artist-hero-pfp-wrap{display:none}