Compare commits
135
Commits
bed007258a
...
apk-latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da40d9e63 | ||
|
|
2fd4d978d0 | ||
|
|
845f125d76 | ||
|
|
3ceba8a365 | ||
|
|
3000dc1622 | ||
|
|
33d24c6ed4 | ||
|
|
908b50bcd9 | ||
|
|
7996971ba0 | ||
|
|
026b392657 | ||
|
|
ed641e999c | ||
|
|
67642f8ab8 | ||
|
|
b7c96451d8 | ||
|
|
d11de8a69e | ||
|
|
b584159568 | ||
|
|
f56d47800c | ||
|
|
2208ce77b6 | ||
|
|
f27eaf9c82 | ||
|
|
7b4599c50a | ||
|
|
934253a036 | ||
|
|
0a6c81b4a1 | ||
|
|
593c9fdb7b | ||
|
|
cb7a3d77be | ||
|
|
635ba8d353 | ||
|
|
31d4a72e62 | ||
|
|
4daf87b550 | ||
|
|
8fc6a3aaef | ||
|
|
ef019b9b9d | ||
|
|
0990dfadb5 | ||
|
|
e82dec7e01 | ||
|
|
6f455b565f | ||
|
|
7f5d25a03b | ||
|
|
87ee8cd097 | ||
|
|
803331e807 | ||
|
|
26de85a140 | ||
|
|
1ee85482f3 | ||
|
|
a5e5d414b4 | ||
|
|
e34a934460 | ||
|
|
19f82ad076 | ||
|
|
c933847201 | ||
|
|
cbedbba6fc | ||
|
|
ba2306656e | ||
|
|
5f251d555f | ||
|
|
02960cf886 | ||
|
|
b788fedec3 | ||
|
|
75dceba97b | ||
|
|
e61d57425e | ||
|
|
6d084a7b7a | ||
|
|
16f76a4236 | ||
|
|
d195518dfb | ||
|
|
711a084686 | ||
|
|
0c7132a7f2 | ||
|
|
249b693936 | ||
|
|
d35fb01306 | ||
|
|
1d98b273e7 | ||
|
|
3129333f9a | ||
|
|
86eb893f40 | ||
|
|
14c0dea7e4 | ||
|
|
561014c83f | ||
|
|
906b3b48cb | ||
|
|
fff6f9e4da | ||
|
|
cc1ec1653f | ||
|
|
26880016e9 | ||
|
|
e782b75bd9 | ||
|
|
eb55184118 | ||
|
|
71190488ca | ||
|
|
e69fbaf75e | ||
|
|
17bb527e1d | ||
|
|
1e206e8df3 | ||
|
|
bc87a04da5 | ||
|
|
96861bea06 | ||
|
|
e4f030c98d | ||
|
|
b3cf01dd3b | ||
|
|
fbee695aad | ||
|
|
cc4d3b72d2 | ||
|
|
e059c358b3 | ||
|
|
569af968c5 | ||
|
|
640e475c51 | ||
|
|
5528947a93 | ||
|
|
9167bfaf0e | ||
|
|
c92ac3d719 | ||
|
|
065d2f7319 | ||
|
|
1e69831dd8 | ||
|
|
a65613b5a1 | ||
|
|
ad433c4dbc | ||
|
|
68a9cdd7d1 | ||
|
|
ccf704754a | ||
|
|
9c8b4775ea | ||
|
|
d98978a6c7 | ||
|
|
966b318d9e | ||
|
|
72b612f5ff | ||
|
|
8cd3e9ac23 | ||
|
|
a3c8b1afd1 | ||
|
|
04a29b684d | ||
|
|
f097dddc64 | ||
|
|
1e242796fe | ||
|
|
14fd5119f2 | ||
|
|
f21174bd2c | ||
|
|
b1a32f5130 | ||
|
|
179e0eb902 | ||
|
|
e78ce43019 | ||
|
|
48a13fbccb | ||
|
|
4d44a476a5 | ||
|
|
bdf2867bdd | ||
|
|
e8b82c177f | ||
|
|
ea98d81146 | ||
|
|
79100c5321 | ||
|
|
3f57e2ae26 | ||
|
|
315b8aaacc | ||
|
|
c5649ac22f | ||
|
|
a80ac3deb0 | ||
|
|
4503bbe54e | ||
|
|
9270d31608 | ||
|
|
e50b4dc93c | ||
|
|
b07212fdce | ||
|
|
419f80568c | ||
|
|
03046cd631 | ||
|
|
03bc736725 | ||
|
|
abb4047966 | ||
|
|
17a28b3011 | ||
|
|
1775ec5b9b | ||
|
|
d50e22b62d | ||
|
|
ef25ac3100 | ||
|
|
ec4d5d0572 | ||
|
|
8741c2fb88 | ||
|
|
445a7bf583 | ||
|
|
63a7260c94 | ||
|
|
ddb9418603 | ||
|
|
7f7d7740e4 | ||
|
|
17a38e9c64 | ||
|
|
7cba39dfaf | ||
|
|
46e252b494 | ||
|
|
be73f717ac | ||
|
|
e1ec1f3660 | ||
|
|
6131eea9ec | ||
|
|
f252586398 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
*.log
|
||||
node_modules/
|
||||
dist/
|
||||
scripts/.env.release
|
||||
+305
-21
@@ -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** <album>" 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`).
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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})` });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"name": "novasonic-backend",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"kuromoji": "^0.1.2"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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
@@ -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)
|
||||
@@ -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;"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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.`);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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> ⟷ <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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -36,7 +36,9 @@ export function md5(input) {
|
||||
}
|
||||
|
||||
export function esc(s) {
|
||||
return (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
// 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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
@@ -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, '"');
|
||||
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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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,'"')}})">⬇ 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,'"')}})">⬇ 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'); }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user