Compare commits

...
18 Commits
Author SHA1 Message Date
marcandClaude Opus 5 a05c15df93 docs(changelog): YTM-Wächter, Album-Zurück und Scrobble-Gruppierung
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:52:03 +02:00
marcandClaude Opus 5 da32d2911a fix(scrobble): Metadaten-Bearbeitung zerlegt die Statistik nicht mehr
Top-Titel gruppierte nach song_id. Wird eine Datei beim Bearbeiten umbenannt,
vergibt Navidrome eine neue id — der Titel erschien danach zweimal mit geteilten
Zaehlern. Kuenstler/Alben/Genres gruppierten nach lower(), sodass schon ein
doppeltes Leerzeichen zwei Eintraege ergab.

Schluessel ist jetzt durchgehend normalisiert (lower + trim + Leerzeichen
gefaltet), Top-Titel gruppiert nach Titel+Kuenstler statt nach song_id. Die
Anzeigefelder kommen aus dem JUENGSTEN Play statt aus MAX(), sonst zeigte die
Statistik nach einer Korrektur weiter die alte Schreibweise. Am laufenden Dienst
geprueft: 40 Plays vor und 10 nach einer Umbenennung erscheinen als ein Eintrag
mit 50 Plays, neuer Schreibweise und neuer songId.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:47 +02:00
marcandClaude Opus 5 39d47b78d4 fix(web): Zurueck im Album fuehrt dorthin zurueck, wo man geklickt hat
album-back-btn war die einzige Detailseite, die per navigate() VORWAERTS auf die
Albenliste sprang statt einen Schritt im Verlauf zurueck. Dadurch landete man
nicht dort, wo man das Album angeklickt hatte (z. B. Home), und es kam ein
zusaetzlicher History-Eintrag auf den Stapel, durch den sich der Browser-Zurueck-
Knopf erst arbeiten musste. Jetzt _navGoBack() wie bei Artist, Genre, Deezer- und
Partial-Album; die Albenliste bleibt nur das Ziel, wenn es keinen Verlauf gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:47 +02:00
marcandClaude Opus 5 eee98d193f fix(backend): Kanaltreffer in der YTM-Suche verwirft nicht mehr die Trefferliste
getYtmVideoUrls wertete jedes /browse/UC in der Ausgabe als "yt-dlp hat einen
Artist-Channel aufgeklappt" und gab [] zurueck — samt der bereits gesammelten
Song-URLs. Bei "DAT ADAM UFO" stand der Kanal auf Platz 9 und riss sieben gute
Treffer mit, darunter den offiziellen Art Track auf Platz 1; uebrig blieb der
YouTube-Fallback mit einem Live-Mitschnitt. An 8 echten Suchen gemessen feuerte
das in 7 Faellen, Schritt 1 der Kaskade war also praktisch tot.

Mit --flat-playlist kann gar nichts aufgeklappt werden, ein Kanal ist EINE Zeile.
pickYtmVideoUrls() behaelt daher alle watch?v=-Zeilen und ueberspringt browse/UC
(Kanal) wie browse/MPREb_ (Album). Ueber 20k Zufallsausgaben geprueft: die neue
Funktion ist eine Obermenge der alten, kann also nur Kandidaten hinzufuegen.
Drei echte Downloads liefen end-to-end sauber durch (Dauer sekundengenau auf
Deezer, korrekte Tags), alle aus Schritt 1 statt aus dem Fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:32 +02:00
marcandClaude Opus 5 33b3df609a fix(backend): Live-Mitschnitte aus der Suche ausschliessen
is_live meint nur "laeuft gerade als Stream" — eine Konzert-AUFNAHME kam
ungehindert durch und landete als Albumtrack in der Bibliothek ("Dat Adam - Ufo
Video Days 2015 Live" auf dem echten Kuenstlerkanal, Dauer im Fenster). Der
Variantenfilter bekommt daher eine live-Regel. Sie schaltet sich ab, wenn der
gesuchte Titel selbst live enthaelt; die Pruefung nutzt \blive\b statt includes,
sonst wuerde "deliver" die Regel aushebeln. Drei Tests dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:35:44 +02:00
marcandClaude Opus 5 6d8a8a1d5e fix(backend): Neuauflage-Jahreszahl nicht als Pflicht-Keyword fordern
Folgekorrektur zu 02b5905: "… - 2020 Remaster" steht auf der Veroeffentlichung,
aber nie im YouTube-Titel — als Pflicht-Keyword haette die Zahl den richtigen
Upload abgelehnt. Jahreszahlen neben einem Qualifier (remaster/version/edition/
anniversary/mono/stereo) fallen daher wieder raus; ein Titel, der selbst eine
Jahreszahl ist ("1999", "Berlin 2020"), behaelt sie. Gegen 29 Beispieltitel
gemessen verhalten sich 24 exakt wie vorher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:20:44 +02:00
marcandClaude Opus 5 02b590505a fix(backend): Zahlen im Titel sind Pflicht-Keywords, Abkuerzungen erlaubt
"700 Main Street" lud "777 Main Street" eines fremden Artists: buildTitleKeywords
behielt nur die zwei laengsten Woerter (Street, Main), die unterscheidende 700
fiel als zu kurz raus — und Schritt 3 der Kaskade prueft den Kuenstler-Channel
nicht mehr. Zahlen werden jetzt immer als \b<n>\b gefordert. Dazu eine kleine
Abkuerzungstabelle (street/st, saint/st, mount/mt, doctor/dr, versus/vs,
number/no), denn der korrekte Art Track heisst "700 Main st" und scheiterte
bisher an jedem Schritt. Tests in backend/test/ytdlp.test.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:04:00 +02:00
marcandClaude Opus 5 9f0d4c3153 docs(changelog): 2.3 — Pull-Stack, APK-Download-Fix, Alben-Tab-Scrollfix
Fasst die Aenderungen seit 2.2 als Release zusammen: image-basiertes
docker-compose.yml (Bauen wandert nach .dev.yml), der auf GITEA_URL
umgeschriebene APK-Asset-Download und der Alben-Tab-Fix, dazu das
veroeffentlichte versionCode 3. Am Handy bestaetigt: Update-Dialog,
In-Place-Install und die Scrollpositionen funktionieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:36:59 +02:00
marcandClaude Opus 5 e91708bc97 chore(android): versionCode 3 veroeffentlicht (Scroll-Fixes im Release)
APK mit dem Debug-Key vom alten Rechner gebaut (Signatur 1039a00e…, passend zur
installierten App) und als Asset im Release apk-latest ersetzt, Body auf
versionCode=3. Dazu das ausfuehrbare Bit auf gradlew, das im Clone fehlte, und
.idea/ ignoriert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:25:25 +02:00
marcandClaude Opus 5 0029b89de4 fix(android): Alben-Tab behaelt die Scrollposition beim Zurueck
LaunchedEffect(state.albumSort) { scrollToItem(0) } feuerte nicht nur beim
Sortierwechsel, sondern auch beim ersten Compose nach der Rueckkehr aus einem
Album — die im ViewModel gehaltene Position wurde also sofort ueberschrieben.
Das Hochscrollen sitzt jetzt in setAlbumSort() und laeuft nur bei echtem
Wechsel; dazu zwei ungenutzte remember*State-Importe entfernt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:21:38 +02:00
marcandClaude Opus 5 3e8897b551 fix(backend): APK-Download nutzt den erreichbaren Gitea-Host statt der ROOT_URL
Gitea baut browser_download_url aus seiner eigenen ROOT_URL (hier
192.168.178.153:3000), die vom Backend-Container nicht erreichbar ist. Der
API-Call gelang, der Asset-Download nicht — der stille Fallback auf die nicht
vorhandene lokale Datei meldete dann "Keine APK hinterlegt". Asset- und
Redirect-URLs werden jetzt auf GITEA_URL umgeschrieben; scheitert der Weg
trotzdem, sagen Log und Fehlermeldung welcher der beiden Wege fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:21:31 +02:00
marcandClaude Opus 5 043ec3a108 refactor(docker): docker-compose.yml ist der Pull-Weg, Bauen wandert nach .dev.yml
Namen getauscht: das Standard-Compose zieht jetzt die fertigen Registry-Images
(vorher docker-compose.prod.yml), die Build-Variante heisst docker-compose.dev.yml.
Weniger verwirrend, weil der Produktivfall ohne -f auskommt. Kopfkommentare,
README, CLAUDE.md, .env.example und build-push-images.mjs entsprechend angepasst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:15:46 +02:00
marcandClaude Opus 5 62391e4aae feat(docker): pull-based prod stack (docker-compose.prod.yml + Registry-Images)
Produktivsystem baut nicht mehr selbst, sondern zieht die drei fertigen Images
(novasonic-frontend/-backend/-scrobble) aus der Gitea-Registry. Das bestehende
docker-compose.yml bleibt der Build-auf-dem-Host-Weg. Dazu .dockerignore fuers
Frontend-Image (android/, backend/, .git raus) und README/CLAUDE.md aktualisiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:13:46 +02:00
HoneyandClaude Opus 4.8 767d088665 refactor(docker): default compose builds from Dockerfiles (baked deps, cached)
Chosen flow: build on the host from backend/scrobble Dockerfiles instead of
pulling from a registry (no registry / no Docker-on-dev needed). Heavy deps are
baked, so restarts are instant and rebuilds after git pull are cache-fast.
Removed the now-obsolete docker-compose.dev.yml (runtime-install variant); the
optional push-to-registry script stays for a future CI flow. Updated README,
CLAUDE.md, .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:34:00 +02:00
HoneyandClaude Opus 4.8 1d8d8143f2 refactor(docker): make image-based the default compose, build variant is .dev
Less confusion: docker-compose.yml is now the default (pulls pre-built images)
and the build-on-host variant is docker-compose.dev.yml. Updated all references
(CLAUDE.md, README, .env.example, build-push-images.mjs, compose header).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 08:03:14 +02:00
HoneyandClaude Opus 4.8 149443e4f7 feat(docker): image-based prod stack (Dockerfiles + prod compose + push script)
Adds a pull-based production option so the host no longer builds/installs deps
on every restart:
- backend/Dockerfile + scrobble/Dockerfile bake the heavy deps (ffmpeg, Deno,
  yt-dlp, node libs). Backend still refreshes yt-dlp on start (non-fatal).
- docker-compose.prod.yml pulls ${REGISTRY}/novasonic-*:${TAG}.
- scripts/build-push-images.mjs (npm run push-images) builds + pushes all three.
- .env.example documents every var incl. REGISTRY/TAG; .env gitignored.

The build-on-host docker-compose.yml stays as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:45:17 +02:00
HoneyandClaude Opus 4.8 93ee958434 feat: artist-page scroll/search memory (android) + move APK button to bottom (web)
- Android: the artist page's main list and the "Alle anzeigen" album overlay
  (grid scroll + search query) are hoisted into the Artist/Explore ViewModels,
  so drilling into an album/artist and back keeps position + query. Covers the
  library artist and Deezer (Explore) artist screens.
- Web: the "Android App" download section now sits at the very bottom of
  Settings, below the Navidrome-Server (account) section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:42:24 +02:00
HoneyandClaude Opus 4.8 a780f6622b chore(android): versionCode 2 (first apk-latest release upload)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 07:27:06 +02:00
27 changed files with 687 additions and 121 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Frontend build context (root Dockerfile) — keep it to what `vite build` needs.
.git
node_modules
dist
android
docs
scripts
backend
scrobble
.env
*.log
+31 -15
View File
@@ -1,20 +1,36 @@
# Copy to .env and fill in your values
# Copy to .env next to your docker-compose file and fill in. cp .env.example .env
# Used by docker-compose.yml (zieht fertige Images) und docker-compose.dev.yml (baut lokal).
# REGISTRY/TAG unten braucht nur der Pull-Weg (docker-compose.yml).
# 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)
# ── URLs pre-filled on the frontend setup screen ─────────────────────────────
NAVIDROME_URL=https://music.example.com
BACKEND_URL=https://backend.example.com
SCROBBLE_URL=https://scrobble.example.com
# Absolute path to your Navidrome music library on the host
MUSIC_PATH=/srv/navidrome/music
AUDIO_FMT=mp3 # mp3 | flac | opus | best (used by yt-dlp)
AUDIO_QUALITY=0 # 0 = best quality (used by yt-dlp)
# ── Library / downloads (backend) ────────────────────────────────────────────
# Absolute music path — mounted 1:1 into the backend (no remapping), same value inside the container.
MUSIC_PATH=/opt/navidrome/music
AUDIO_FMT=mp3
AUDIO_QUALITY=0
MAX_PARALLEL=2
DOWNLOAD_BACKEND=ytdlp
# Ticketmaster Events (optional — free API key)
# 1. Sign up at https://developer.ticketmaster.com/
# 2. Create an app — copy the Consumer Key
TICKETMASTER_API_KEY=
# ── Android APK download button + in-app updater ─────────────────────────────
# The backend proxies the fixed Gitea release ("apk-latest") over the LAN, so Gitea can stay internal.
GITEA_URL=http://192.168.2.51:3000 # Gitea-Basis, die der BACKEND-CONTAINER erreicht.
# Muss nicht Giteas eigene ROOT_URL sein — das Backend baut
# die Asset-URL auf diesen Host um (Test aus dem Container:
# curl -sI $GITEA_URL/api/v1/version).
GITEA_REPO=Docker/NovaSonic
GITEA_TOKEN= # read token (only needed for a private repo)
GITEA_APK_TAG=apk-latest
# ── Fertige Images (nur docker-compose.yml) ──────────────────────────────────
# REGISTRY = where `npm run push-images` pushed to and where the host pulls from.
# Gitea container registry (lowercase owner): gitea.honeyy.duckdns.org/docker
# Docker Hub: docker.io/youruser
# Plain-HTTP internal registries need the host's Docker daemon to allow it
# (/etc/docker/daemon.json → {"insecure-registries":["192.168.2.51:3000"]}).
REGISTRY=gitea.honeyy.duckdns.org/docker
TAG=latest
+2
View File
@@ -3,3 +3,5 @@
node_modules/
dist/
scripts/.env.release
.env
.idea/
+74
View File
@@ -64,6 +64,80 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
---
## [Unreleased]
### Fixed
- **YT-Music-Suche war praktisch tot** — `getYtmVideoUrls` verwarf die komplette Trefferliste, sobald
irgendwo ein Kanal-Eintrag (`browse/UC…`) auftauchte, samt der bereits gesammelten Song-URLs. An
acht echten Suchen gemessen passierte das in **sieben** Fällen: Schritt 1 der Kaskade — die Stufe
mit den offiziellen Art Tracks — lieferte nie etwas, faktisch lief jeder Download über den
YouTube-Fallback. Das erklärt eine ganze Serie falscher Treffer (u. a. „Easy" als Mixtape- statt
Albumversion). Kanal- und Album-Einträge werden jetzt übersprungen statt abzubrechen; über 20 000
Zufallsausgaben geprüft ist die neue Auswahl eine Obermenge der alten, kann also nur Kandidaten
hinzufügen.
- **Zurück im Album führte in die Albenliste** — statt dorthin, wo man das Album angeklickt hatte.
Der Knopf navigierte vorwärts, legte dabei einen History-Eintrag an und brachte so auch den
Browser-Zurück-Knopf durcheinander. Jetzt geht er einen Schritt im Verlauf zurück wie jede andere
Detailseite.
- **Metadaten bearbeiten zerlegte die Hörstatistik** — Top-Titel gruppierten nach `song_id`, und beim
Bearbeiten umbenannte Dateien bekommen von Navidrome eine neue ID: der Titel erschien doppelt mit
geteilten Zählern, Künstler ebenso bei geänderter Schreibweise. Gruppiert wird jetzt über einen
normalisierten Schlüssel (Titel + Künstler), Anzeigenamen kommen aus dem jüngsten Play. Playlists
waren nie betroffen, die werden beim Bearbeiten ohnehin auf die neue ID übertragen.
- **Downloader grabbed a same-shaped title with a different number** — searching DAT ADAM's
"700 Main Street" saved **"777 Main Street" by an unrelated artist**. `buildTitleKeywords` required
only the two longest words (Street, Main); "700" was dropped for being short, and the cascade step
that widens to the top 10 no longer checks the artist channel, so nothing else caught it. Numbers
are now always required keywords. In the same pass, a small abbreviation map (street/st, saint/st,
mount/mt, doctor/dr, versus/vs, number/no) lets a candidate spelled "700 Main St" match a release
titled "700 Main Street" — that mismatch had rejected the correct official Art Track in every step,
which is why the search fell through to the wrong video in the first place. A re-release year next
to a qualifier ("… - 2020 Remaster") is exempt from the number rule: it appears on the release but
never in the YouTube title. Measured against a 29-title sample, 24 titles keep their previous
behaviour exactly.
- **Downloader saved a concert recording as the album track** — searching DAT ADAM's "UFO" stored
"Dat Adam - Ufo Video Days 2015 Live". It passed every gate legitimately: right channel, 209 s
inside the duration window, "Ufo" in the title, and `!is_live` rejects only *running streams*, not
live recordings. The variant blocklist (remix/cover/acoustic/…) now includes `live`. As with the
other rules it switches itself off when the searched title says live, so "Live Forever" and
deliberately requested live versions are unaffected.
---
## [2.3] — 2026-09-03
A deployment-and-delivery release: production **pulls pre-built images** instead of compiling on the
server, the **APK download works again** (it had been broken by Gitea's own link host), and the
scroll memory from 2.2 finally behaves on the Alben tab — plus it reaches an actual build.
### Added
- **Pull-based production stack** — `docker-compose.yml` now references
`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}` and builds nothing on the host:
`docker compose pull && docker compose up -d`. Building locally from the Dockerfiles moved to
`docker-compose.dev.yml` (no registry needed there). Images are pushed from the dev machine with
`npm run push-images`. A `.dockerignore` keeps the frontend build context to what `vite build`
needs, so `android/`, `backend/` and `.git` no longer travel into the image.
### Fixed
- **APK download failed with "Keine APK hinterlegt"** — Gitea builds `browser_download_url` from its
own `ROOT_URL`, which pointed at a host the backend cannot reach (a leftover from an earlier
network). The API call succeeded, so the download button appeared, but fetching the asset ran into
the dead address and fell back silently to a local file that does not exist. Asset and redirect
URLs are now rewritten onto `GITEA_URL`; if the Gitea path still fails, a warning log and the 404
body name both sources that were tried instead of claiming no APK exists.
- **Alben tab jumped back to the top on Back (Android)** — `LaunchedEffect(state.albumSort)` scrolled
to item 0. That effect runs on first composition too, not only on a sort change, so returning from
an album discarded the position the ViewModel had correctly kept. Scrolling to the top now happens
in `LibraryViewModel.setAlbumSort()`, on a real sort change only.
### Changed
- **APK release at versionCode 3** — carries the Alben-tab fix above and, for the first time in a
published build, the artist-page and "Alle anzeigen" overlay scroll/search memory (2.2 shipped the
code, but the release APK predated it). Signed with the established debug key, so it installs
in place over versionCode 2 and app data survives.
---
## [2.2] — 2026-08-22
A cleanup-and-parity release: the **Song Importer moves into the Global Search field** (one field,
+41 -3
View File
@@ -41,9 +41,22 @@ npm run build # production build → dist/
npm run preview # preview the production build
```
Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives at
`/opt/navidrome/music`. Restart after backend changes: `docker compose restart novasonic-backend`
(this also re-pulls the latest yt-dlp).
Der Stack läuft in Docker. **`docker-compose.yml` ist der Normalfall und zieht fertige Images**
(`${REGISTRY}/novasonic-frontend|-backend|-scrobble:${TAG:-latest}`) — der Server baut nichts:
`docker compose pull && docker compose up -d`. Ein einfacher Restart ist `docker compose restart
novasonic-backend` (das Backend zieht bei jedem Start das neueste yt-dlp nach).
Gepusht wird von der Dev-Maschine mit `npm run push-images` (`scripts/build-push-images.mjs`, liest
`REGISTRY`/`TAG` aus `.env`); ohne Node tut es direkt `docker build -t $REGISTRY/<name>:latest
<context>` + `docker push` mit den Kontexten `.`, `backend/`, `scrobble/`. Registry ist die
Gitea-Registry (`…/docker`, Owner klein geschrieben).
**`docker-compose.dev.yml`** ist die Variante, die stattdessen lokal aus den Dockerfiles baut
(`backend/Dockerfile` + `scrobble/Dockerfile`), ohne Registry: `docker compose -f
docker-compose.dev.yml up -d --build`. Die schweren Deps (ffmpeg, Deno, yt-dlp, node libs) sind in
den Images gebacken, deshalb sind **Restarts sofort da** und Rebuilds nach `git pull` cache-schnell.
On the deploy host the music lives at `/opt/navidrome/music`.
## Download pipeline (hard-won knowledge)
@@ -80,6 +93,31 @@ Backend runs in Docker (see `docker-compose.yml`); on the deploy host it lives a
matches, download **nothing** and fail cleanly with "Nicht eindeutig gefunden — bitte
Direktlink im Importer nutzen". Never fall back to a blind title-only/no-filter grab — it
pulled same-titled covers by other uploaders, and wrong Piano Echoes for Japanese tracks.
- **Numbers in a title are always required keywords.** `buildTitleKeywords` kept only the two
LONGEST words, and numbers are short — so "700 Main Street" was filtered on Street+Main only and
the cascade happily saved **"777 Main Street" by a different artist** (step 3 drops the channel
check, so nothing else caught it). Numeric tokens are now emitted as `\b<n>\b` in addition to the
two longest words. Same fix carries a small abbreviation map (street/st, saint/st, mount/mt,
doctor/dr, versus/vs, number/no): uploaders write "700 Main St" where the release says "Street",
which had rejected the correct official Art Track in every step. One exception: a **re-release year
next to a qualifier** ("… - 2020 Remaster") is dropped again — it sits on the release but never in
the YouTube title, so requiring it would reject the correct upload. A title that IS a year ("1999",
"Berlin 2020") keeps it. Unit-tested in `backend/test/ytdlp.test.js`.
- **Live recordings are excluded by title, not by `is_live`.** `!is_live` only means "not a running
stream" — a concert RECORDING passes it. On the artist's own channel nothing else distinguishes it
from the studio track, which is how "Dat Adam - Ufo Video Days 2015 Live" (channel `Dat Adam`,
209 s, title contains UFO) got saved as the album track. `buildVariantFilter` therefore carries a
`live` rule like remix/cover/…. It self-disables when the searched title itself says live, so
"Live Forever" or a deliberately requested live version still works — that check uses a `\blive\b`
regex (`skipRe`), because a plain substring test would let "deliver"/"delivery" switch the rule off.
- **A channel hit in the YTM results is a line to skip, not a reason to abort.** `getYtmVideoUrls`
used to treat any `/browse/UC…` in the output as "yt-dlp expanded an artist channel" and returned
`[]` — discarding the song URLs it had already collected. Measured on 8 real searches, that fired
in **7 of 8**, so step 1 (YTM, where the official Art Tracks live) was effectively dead and almost
every download came from the YouTube fallback. With `--flat-playlist` a channel is printed as ONE
line, so expansion cannot happen; `pickYtmVideoUrls()` now keeps every `watch?v=` line and skips
`browse/UC…` (channel) and `browse/MPREb_…` (album) alike. It is a strict superset of the old
behaviour — fuzz-checked over 20k random outputs — so it can only add candidates, never remove any.
- **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
+18 -1
View File
@@ -87,7 +87,8 @@ NovaSonic/
├── styles.css
├── vite.config.js
├── Dockerfile
├── docker-compose.yml
├── docker-compose.yml # Standard: zieht die fertigen Images aus der Registry
├── docker-compose.dev.yml # Variante: baut lokal aus den Dockerfiles
├── nginx.conf
└── docker-entrypoint.sh # Injects NAVIDROME_URL / BACKEND_URL / SCROBBLE_URL at runtime
```
@@ -123,9 +124,25 @@ TICKETMASTER_API_KEY= # optional — free key from developer.ticketmaster.com
### 2. Start
```bash
docker compose pull
docker compose up -d
```
Zieht die drei fertigen Images aus der Registry — nichts wird auf dem Server gebaut. Dafür in der
`.env` `REGISTRY` (und optional `TAG`) setzen; die Images heißen
`$REGISTRY/novasonic-frontend|-backend|-scrobble`. Neue Stände lädt man von der Dev-Maschine mit
`npm run push-images` hoch (bzw. `docker build -t $REGISTRY/<name>:latest <context> && docker push …`
— Kontexte: `.` (frontend), `backend/`, `scrobble/`).
**Selbst bauen statt ziehen** (keine Registry nötig):
```bash
docker compose -f docker-compose.dev.yml up -d --build
```
Baut lokal aus den Dockerfiles; die Deps stecken in den Images, also sind Restarts sofort da und
Rebuilds nach einem `git pull` dank Layer-Cache schnell.
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
+1 -1
View File
@@ -14,7 +14,7 @@ android {
applicationId = "com.novasonic.app"
minSdk = 26
targetSdk = 35
versionCode = 1
versionCode = 3
versionName = "1.0"
}
@@ -215,11 +215,14 @@ fun ArtistDetailScreen(
title = openAllAlbums ?: "",
entries = allAlbumEntries,
onBack = { openAllAlbums = null },
gridState = vm.allAlbumsGrid,
query = vm.allAlbumsQuery.value,
onQueryChange = { vm.allAlbumsQuery.value = it },
)
return
}
LazyColumn(Modifier.fillMaxSize()) {
LazyColumn(state = vm.pageListState, modifier = Modifier.fillMaxSize()) {
// Back link
item {
Row(
@@ -624,8 +627,10 @@ internal fun AllAlbumsOverlay(
title: String,
entries: List<AllAlbumEntry>,
onBack: () -> Unit,
gridState: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
query: String = "",
onQueryChange: (String) -> Unit = {},
) {
var query by remember { mutableStateOf("") }
val filtered = remember(query, entries) {
if (query.isBlank()) entries
else entries.filter { it.name.contains(query, ignoreCase = true) }
@@ -660,11 +665,11 @@ internal fun AllAlbumsOverlay(
// Search bar
OutlinedTextField(
value = query,
onValueChange = { query = it },
onValueChange = onQueryChange,
placeholder = { Text("Suchen…", color = NsOnSurface2) },
leadingIcon = { Icon(Icons.Default.Search, null, tint = NsOnSurface2) },
trailingIcon = if (query.isNotEmpty()) { {
IconButton(onClick = { query = "" }) {
IconButton(onClick = { onQueryChange("") }) {
Icon(Icons.Default.Clear, null, tint = NsOnSurface2)
}
} } else null,
@@ -684,6 +689,7 @@ internal fun AllAlbumsOverlay(
)
LazyVerticalGrid(
state = gridState,
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -145,6 +145,9 @@ fun ExploreScreen(
allAlbumsOpen = state.artistAllAlbums,
onOpenAllAlbums = vm::openArtistAllAlbums,
onCloseAllAlbums = vm::closeArtistAllAlbums,
allAlbumsGrid = vm.allAlbumsGrid,
allAlbumsQuery = vm.allAlbumsQuery.value,
onAllAlbumsQueryChange = { vm.allAlbumsQuery.value = it },
)
}
}
@@ -109,6 +109,9 @@ fun ItunesArtistScreen(
allAlbumsOpen: String?,
onOpenAllAlbums: (String) -> Unit,
onCloseAllAlbums: () -> Unit,
allAlbumsGrid: androidx.compose.foundation.lazy.grid.LazyGridState = androidx.compose.foundation.lazy.grid.rememberLazyGridState(),
allAlbumsQuery: String = "",
onAllAlbumsQueryChange: (String) -> Unit = {},
) {
val artist = artistState.artist
val cats = if (!artistState.isLoading)
@@ -144,6 +147,9 @@ fun ItunesArtistScreen(
title = allAlbumsOpen,
entries = allAlbumEntries,
onBack = onCloseAllAlbums,
gridState = allAlbumsGrid,
query = allAlbumsQuery,
onQueryChange = onAllAlbumsQueryChange,
)
return
}
@@ -7,9 +7,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
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
@@ -167,7 +165,9 @@ fun LibraryScreen(
}
}
val albumGridState = vm.albumsGridState // hoisted → survives album drill-in/back
LaunchedEffect(state.albumSort) { albumGridState.scrollToItem(0) }
// Kein LaunchedEffect(state.albumSort) zum Hochscrollen: der liefe auch beim ersten
// Compose nach dem Zurückkommen und würde die gemerkte Position sofort verwerfen.
// Das Hochscrollen passiert jetzt in vm.setAlbumSort(), also nur bei echtem Wechsel.
LazyVerticalGrid(
state = albumGridState,
columns = GridCells.Adaptive(minSize = 150.dp),
@@ -70,6 +70,12 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
private val _state = MutableStateFlow(ArtistUiState())
val state: StateFlow<ArtistUiState> = _state
// Scroll of the artist page + the "alle anzeigen" overlay, hoisted so tapping an album/artist and
// coming back returns you to the same spot (the artist route entry, hence this VM, is retained).
val pageListState = androidx.compose.foundation.lazy.LazyListState()
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
init {
if (artistId.isNullOrBlank()) {
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
@@ -79,6 +79,10 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
// Global-search results scroll — hoisted so it survives an album/artist drill-in and back.
val searchListState = LazyListState()
// "Alle anzeigen" overlay (Deezer artist albums): scroll + search query, hoisted so an album
// drill-in and back keeps both (the overlay itself leaves composition on drill-in).
val allAlbumsGrid = androidx.compose.foundation.lazy.grid.LazyGridState()
val allAlbumsQuery = androidx.compose.runtime.mutableStateOf("")
private var searchJob: Job? = null
@@ -45,7 +45,12 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
init { loadPlaylists() }
fun setAlbumSort(sort: AlbumSort) {
if (sort == _state.value.albumSort) return
_state.value = _state.value.copy(albumSort = sort)
// Eine andere Sortierung heißt eine andere Liste — dort oben anfangen. Bewusst hier und
// nicht als LaunchedEffect im Screen: der feuert auch bei jedem Neu-Composen (z. B. nach
// dem Zurück aus einem Album) und hätte die gemerkte Scrollposition jedes Mal verworfen.
viewModelScope.launch { albumsGridState.scrollToItem(0) }
}
fun selectTab(tab: LibraryTab) {
Vendored Regular → Executable
View File
File mode changed.
+32
View File
@@ -0,0 +1,32 @@
# NovaSonic download backend — bakes the heavy deps (ffmpeg, Deno, yt-dlp, node libs) so restarts are
# instant instead of re-installing everything on every boot. Build context = ./backend.
FROM node:22-slim
# System deps: python (yt-dlp), ffmpeg (tagging/convert), curl/git/unzip (installers), ca-certs.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip ffmpeg curl git unzip ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# yt-dlp (nightly) + the Spotify helper tooling.
RUN pip install --break-system-packages -U --pre 'yt-dlp[default]' \
&& pip install --break-system-packages spotapi 'git+https://github.com/TzurSoffer/spotify-downloader'
# Deno — yt-dlp uses it to solve YouTube's JS signature/nsig challenge.
RUN curl -fsSL https://deno.land/install.sh | sh
ENV PATH="/root/.deno/bin:${PATH}"
# Node libs the server loads (express, kuromoji) — kept out of the app dir like the compose setup did.
WORKDIR /deps
RUN npm install express kuromoji
ENV NODE_PATH=/deps/node_modules
# App code.
WORKDIR /app
COPY . /app/
ENV PORT=3700 DATA_PATH=/data
EXPOSE 3700
# Refresh yt-dlp on start (heavy deps already baked, so this is a quick pull) — non-fatal if offline,
# the baked version still works. Keeps the "always-latest yt-dlp" behaviour of the old compose command.
CMD ["sh","-c","pip install --break-system-packages -U --pre 'yt-dlp[default]' >/dev/null 2>&1 || true; node novasonic-server.js"]
+62 -23
View File
@@ -26,14 +26,57 @@ function buildTitleKeywords(title) {
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(w => w.length >= 3 && !VARIANT_WORDS.has(w.toLowerCase()) && !STOPWORDS.has(w.toLowerCase()));
const top2 = words.sort((a, b) => b.length - a.length).slice(0, 2);
const toPattern = w => (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
const result = top2.map(toPattern);
// Numbers are the most distinctive token a title has and they are usually SHORT, so the
// longest-two rule threw them away: "700 Main Street" kept only Street+Main and happily matched
// "777 Main Street" by a different artist. Numbers are therefore always required, on top of the
// two longest words.
// Ausnahme: die Jahreszahl einer Neuauflage ("… - 2020 Remaster") steht auf der Veroeffentlichung,
// aber nie im YouTube-Titel — als Pflicht-Keyword wuerde sie den richtigen Upload ablehnen. Nur
// wenn ein solches Qualifier-Wort daneben steht; ein Titel, der SELBST eine Jahreszahl ist
// ("1999", "Berlin 2020"), behaelt sie.
const RELEASE_QUALIFIER = /\b(remaster|remastered|version|edition|anniversary|mono|stereo)\b/i;
const isEditionYear = w => /^(19|20)\d{2}$/.test(w) && RELEASE_QUALIFIER.test(title || '');
const isRequiredNum = w => /^\d+$/.test(w) && !isEditionYear(w);
const numbers = words.filter(isRequiredNum);
const wordsNoNum = words.filter(w => !isRequiredNum(w));
const top2 = wordsNoNum.sort((a, b) => b.length - a.length).slice(0, 2);
// Uploaders abbreviate the same word the release spells out ("700 Main St" vs "700 Main Street"),
// which rejected the official Art Track in every cascade step. Accept either spelling; \b keeps
// the short form from matching inside another word.
const ABBREV = { street: 'st', saint: 'st', mount: 'mt', doctor: 'dr', versus: 'vs', number: 'no' };
const toPattern = w => {
const abbr = ABBREV[w.toLowerCase()];
if (abbr) return `\\b(?:${w}|${abbr})\\b`;
return (w.length >= 4 && w.endsWith('s')) ? w + '?' : w;
};
const result = [...numbers.map(n => `\\b${n}\\b`), ...top2.map(toPattern)];
const qualifier = lower.includes('mix') ? MIX_QUALIFIERS.find(q => lower.includes(q)) : null;
if (qualifier && !top2.some(w => w.toLowerCase() === qualifier)) result.push(qualifier);
return result;
}
// Aus der YTM-Suchausgabe (--flat-playlist --print webpage_url) die Song-URLs herausziehen.
// Eine Suche liefert drei Sorten Zeilen: watch?v=… (ein Song), browse/MPREb_… (ein Album) und
// browse/UC… (ein Kuenstlerkanal). Nur die erste ist ein Kandidat; die anderen beiden sind
// Listeneintraege und werden uebersprungen.
//
// Frueher galt eine browse/UC-Zeile als "yt-dlp hat einen Kanal aufgeklappt" und die GESAMTE
// Trefferliste wurde verworfen — auch die bereits gesammelten Songs davor. Bei "DAT ADAM UFO" stand
// der Kanal auf Platz 9 und riss sieben gute Treffer mit, darunter den offiziellen Art Track auf
// Platz 1; uebrig blieb der YouTube-Fallback, der einen Live-Mitschnitt zog. Aufklappen kann hier
// ohnehin nicht passieren: mit --flat-playlist druckt yt-dlp einen Kanal als EINE Zeile.
function pickYtmVideoUrls(stdout) {
const urls = [];
let skipped = 0;
for (const line of (stdout || '').split('\n')) {
const u = line.trim();
if (!u) continue;
if (u.includes('watch?v=')) urls.push(u);
else if (u.includes('/browse/')) skipped++;
}
return { urls, skipped };
}
function buildVariantFilter(title) {
const t = (title || '').toLowerCase();
// !~= (?i) = case-insensitive regex "does not match". Use \b word boundaries so that
@@ -50,8 +93,16 @@ function buildVariantFilter(title) {
{ key: 'cover', expr: 'title !~= (?i)\\bcover\\b' },
{ key: 'acoustic', expr: 'title !~= (?i)\\bacoustic\\b' },
{ key: 'instrumental', expr: 'title !~= (?i)\\binstrumental\\b' },
// Konzertmitschnitte: `!is_live` heisst nur "laeuft nicht gerade als Stream" und laesst eine
// Live-AUFNAHME durch. Auf dem echten Kuenstlerkanal ist die sonst nicht vom Studiotrack zu
// unterscheiden — so landete "Dat Adam - Ufo Video Days 2015 Live" in der Bibliothek.
// skipRe statt includes(): "deliver"/"delivery" enthalten "live" als Teilstring und wuerden
// die Regel sonst faelschlich abschalten.
{ key: 'live', expr: 'title !~= (?i)\\blive\\b', skipRe: /\blive\b/i },
];
const parts = RULES.filter(r => !t.includes(r.key)).map(r => r.expr);
// Eine Regel entfaellt, wenn der GESUCHTE Titel das Wort selbst traegt — wer "Live Forever" oder
// bewusst eine Live-Version sucht, soll sie auch bekommen.
const parts = RULES.filter(r => r.skipRe ? !r.skipRe.test(t) : !t.includes(r.key)).map(r => r.expr);
return parts.length ? parts.join(' & ') : '';
}
@@ -202,14 +253,10 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// actual video URLs (watch?v=…). This prevents yt-dlp from following an artist channel
// result and treating its uploads as the search result set.
//
// When YTM returns an artist channel as the top hit, yt-dlp prints the channel's
// browse URL (/browse/UC…) instead of video URLs. We detect this on stdout and
// return [] so the caller falls through to the ytsearch fallback.
// NOTE: do NOT check for [youtube:tab] in stderr — that fires for all YTM search
// page processing, not just channel expansion, and would wrongly return [] always.
const getYtmVideoUrls = (maxItems) => new Promise(resolve => {
const urls = [];
let channelExpanded = false;
let out = '';
const proc = spawn(CONFIG.ytdlpBin, [
ytmUrl,
'--flat-playlist',
@@ -221,21 +268,13 @@ async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, alb
// native Songs shelf (where the actual artist track lives).
'--extractor-args', 'youtube:player_client=web_music,default',
], { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
proc.stdout.on('data', d => {
const text = d.toString();
if (text.includes('/browse/UC')) { channelExpanded = true; return; }
if (!channelExpanded) {
text.split('\n').forEach(line => {
const u = line.trim();
if (u.includes('watch?v=')) urls.push(u);
});
}
});
proc.stdout.on('data', d => { out += d.toString(); });
proc.on('error', () => resolve([]));
proc.on('close', () => {
if (channelExpanded) log(jobId, `⚠️ YTM: Artist-Channel in Suchergebnissen — überspringe`);
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden`);
resolve(channelExpanded ? [] : urls);
const { urls, skipped } = pickYtmVideoUrls(out);
if (!urls.length) log(jobId, `⚠️ YTM: keine Song-Treffer (${skipped} Listeneintrag/-eintraege)`);
else log(jobId, `📡 YTM: ${urls.length} Ergebnis(se) gefunden${skipped ? ` (${skipped} Album/Kanal uebersprungen)` : ''}`);
resolve(urls);
});
});
@@ -424,4 +463,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
return { success: true, dir };
}
module.exports = { downloadTrack, embedMetadata };
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter, pickYtmVideoUrls };
+31 -5
View File
@@ -503,6 +503,19 @@ function _giteaApiJson(pathAndQuery) {
});
}
// Gitea baut seine browser_download_url aus der eigenen ROOT_URL — die kann eine ganz andere Adresse
// sein als die, unter der das Backend Gitea erreicht (interne IP vs. Reverse-Proxy-Domain). Deshalb
// Schema+Host+Port immer durch GITEA_URL ersetzen und nur den Pfad übernehmen.
function _viaGiteaHost(urlStr) {
if (!urlStr || !GITEA_URL) return urlStr;
try {
const base = new URL(GITEA_URL);
const u = new URL(urlStr);
if (u.origin === base.origin) return urlStr;
return new URL(u.pathname + u.search, base).toString();
} catch { return urlStr; }
}
// The .apk asset of the fixed release, or null if unavailable.
async function _giteaApkAsset() {
const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`);
@@ -513,7 +526,7 @@ async function _giteaApkAsset() {
const vcMatch = /versionCode\s*[=:]\s*(\d+)/i.exec(rel.body || '');
return {
size: asset.size,
url: asset.browser_download_url,
url: _viaGiteaHost(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(),
@@ -523,7 +536,13 @@ async function _giteaApkAsset() {
function _serveLocalApk(res) {
if (res.headersSent) return;
fs.stat(APK_PATH, (err) => {
if (err) return res.status(404).json({ error: 'Keine APK hinterlegt' });
// Ohne lokale Datei UND ohne erreichbares Gitea-Asset: sagen, welcher der beiden Wege fehlt,
// sonst sucht man die APK an der falschen Stelle (die Logs oben nennen den Grund).
if (err) return res.status(404).json({
error: (GITEA_URL && GITEA_REPO)
? `APK-Release nicht erreichbar (${GITEA_URL}, Tag ${GITEA_APK_TAG}) und keine lokale Datei unter ${APK_PATH}`
: 'Keine APK hinterlegt',
});
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"');
res.sendFile(APK_PATH);
@@ -539,14 +558,21 @@ function _streamApkFromUrl(urlStr, res, redirectsLeft = 3) {
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);
return _streamApkFromUrl(_viaGiteaHost(new URL(up.headers.location, u).toString()), res, redirectsLeft - 1);
}
if (up.statusCode !== 200) {
up.resume();
console.warn(`[apk] Gitea-Asset lieferte HTTP ${up.statusCode} (${u.origin}) — fallback auf ${APK_PATH}`);
return _serveLocalApk(res);
}
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));
}).on('error', (e) => {
console.warn(`[apk] Gitea-Asset nicht erreichbar (${u.origin}): ${e.message} — fallback auf ${APK_PATH}`);
_serveLocalApk(res);
});
}
app.get('/api/apk/info', async (req, res) => {
+109
View File
@@ -0,0 +1,109 @@
const test = require('node:test');
const assert = require('node:assert');
const { buildTitleKeywords } = require('../lib/ytdlp.js');
// The keywords become `title ~= (?i)<kw>` match-filters in yt-dlp, i.e. every keyword must match
// the candidate title case-insensitively. This mirrors that check.
const accepts = (title, candidate) =>
buildTitleKeywords(title).every(kw => new RegExp(kw, 'i').test(candidate));
test('a number in the title is always required, however short', () => {
const kws = buildTitleKeywords('700 Main Street');
assert.ok(kws.some(k => k.includes('700')), `700 fehlt in ${JSON.stringify(kws)}`);
});
test('rejects a same-shaped title with a different number', () => {
// The real regression: "777 Main Street" (Release - Topic) was downloaded for DAT ADAM's
// "700 Main Street", because only the two longest words (Street, Main) were required.
assert.equal(accepts('700 Main Street', '777 Main Street'), false);
assert.equal(accepts('700 Main Street', '700 Main Street'), true);
});
test('accepts the abbreviated spelling uploaders use', () => {
assert.equal(accepts('700 Main Street', '700 Main st (cr-Version)'), true);
assert.equal(accepts('700 Main Street', 'DAT ADAM - 700 Main St [FULL SONG]'), true);
});
test('the abbreviation does not match inside another word', () => {
assert.equal(accepts('700 Main Street', '700 Main Stuff'), false);
});
test('titles without numbers keep the previous two-longest-words behaviour', () => {
assert.deepEqual(buildTitleKeywords('Bohemian Rhapsody'), ['Bohemian', 'Rhapsody']);
assert.equal(accepts('Bohemian Rhapsody', 'Queen - Bohemian Rhapsody (Official Video)'), true);
assert.equal(accepts('Bohemian Rhapsody', 'Queen - Another One Bites The Dust'), false);
});
test('a remaster year is not required — YouTube titles never carry it', () => {
// "… - 2020 Remaster" steht auf der Veröffentlichung, der Upload heißt schlicht
// "Take Me Home, Country Roads". Vor dieser Ausnahme hätte die 2020 ihn abgelehnt.
assert.equal(accepts('Take Me Home, Country Roads - 2020 Remaster',
'John Denver - Take Me Home, Country Roads'), true);
});
test('a year that IS the title stays required', () => {
assert.equal(accepts('1999', 'Prince - 1999 (Official Music Video)'), true);
assert.equal(accepts('1999', 'Prince - 1984 (Official Music Video)'), false);
});
const { buildVariantFilter } = require('../lib/ytdlp.js');
// Der Variantenfilter ist eine yt-dlp-Ausdruckskette "title !~= (?i)\bx\b & …".
// Diese Hilfsfunktion wertet sie so aus, wie yt-dlp es tut.
const variantAccepts = (searchTitle, candidate) => {
const f = buildVariantFilter(searchTitle);
if (!f) return true;
return f.split(' & ').every(part => {
const m = /^title !~= \(\?i\)(.+)$/.exec(part);
return !new RegExp(m[1], 'i').test(candidate);
});
};
test('rejects a live recording — is_live only covers running streams', () => {
// Der echte Fall: auf dem Kuenstlerkanal, Dauer im Fenster, Titel passend.
assert.equal(variantAccepts('UFO', 'Dat Adam - Ufo Video Days 2015 Live'), false);
assert.equal(variantAccepts('UFO', 'DAT ADAM - 700 Main St | ROCK AM RING 2017 [LIVE]'), false);
assert.equal(variantAccepts('UFO', 'UFO'), true);
});
test('a deliberately live title keeps its live results', () => {
assert.equal(variantAccepts('Live Forever', 'Oasis - Live Forever'), true);
assert.equal(variantAccepts('Unplugged in New York (Live)', 'Nirvana - All Apologies (Live)'), true);
});
test('"live" inside another word does not disable the rule', () => {
// "deliver" enthaelt "live" als Teilstring — die Regel muss trotzdem greifen.
assert.equal(variantAccepts('Deliver', 'Artist - Deliver (Live at Wembley)'), false);
assert.equal(variantAccepts('Deliver', 'Artist - Deliver'), true);
});
const { pickYtmVideoUrls } = require('../lib/ytdlp.js');
// Echte Ausgabe der YTM-Suche "DAT ADAM UFO": Songs, ein Album (MPREb_) und der
// Kuenstlerkanal (UC…) auf Platz 9.
const YTM_OUT = [
'https://music.youtube.com/watch?v=ZG4XhPn3Yb4',
'https://music.youtube.com/watch?v=2KVU6aikZF0',
'https://music.youtube.com/browse/MPREb_IhxJPixC1QT',
'https://music.youtube.com/watch?v=phLOTtJaqZk',
'https://music.youtube.com/watch?v=p935nH6aj8Y',
'https://music.youtube.com/watch?v=Pb2YcDBm5fA',
'https://music.youtube.com/browse/MPREb_S7MMKVMduma',
'https://music.youtube.com/watch?v=mNfsX2RbZ2M',
'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA',
'https://music.youtube.com/watch?v=O034JUlf1J4',
].join('\n');
test('a channel hit no longer discards the whole result list', () => {
const { urls, skipped } = pickYtmVideoUrls(YTM_OUT);
assert.equal(urls.length, 7);
assert.equal(skipped, 3); // 2 Alben + 1 Kanal
assert.match(urls[0], /ZG4XhPn3Yb4/); // der offizielle Art Track
assert.ok(!urls.some(u => u.includes('/browse/')));
});
test('nothing but list entries yields no candidates', () => {
const only = 'https://music.youtube.com/browse/UC5hilNPCUHEPf4VpG5_YpYA';
assert.deepEqual(pickYtmVideoUrls(only).urls, []);
assert.deepEqual(pickYtmVideoUrls('').urls, []);
});
+67
View File
@@ -0,0 +1,67 @@
# NovaSonic — DEV/Selbstbauen: baut lokal aus den Dockerfiles statt Images zu ziehen. Die schweren
# Deps (ffmpeg, Deno, yt-dlp, node libs) stecken in den Images, also sind RESTARTS sofort da und ein
# Rebuild nach Code-Änderungen ist schnell (Docker cacht die Dep-Layer — nur der COPY-Layer ändert sich).
#
# git pull
# docker compose -f docker-compose.dev.yml up -d --build # geänderte Images neu bauen + starten
# docker compose -f docker-compose.dev.yml up -d # nur starten, mit den gebauten Images
#
# Keine Registry nötig. Der Normalfall (fertige Images ziehen) ist docker-compose.yml.
services:
# ── Frontend (nginx serving the built PWA) ───────────────────────────────────
novasonic-frontend:
build: .
container_name: novasonic-frontend
restart: unless-stopped
ports:
- "8080:80" # route this port through your reverse proxy
environment:
- NAVIDROME_URL=${NAVIDROME_URL} # pre-filled on the setup screen
- BACKEND_URL=${BACKEND_URL}
- SCROBBLE_URL=${SCROBBLE_URL}
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
novasonic-backend:
build:
context: ./backend
container_name: novasonic-backend
restart: unless-stopped
volumes:
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
- novasonic-data:/data # writable data dir for overrides, state, etc.
ports:
- "3700:3700"
environment:
- PORT=3700
- MUSIC_PATH=${MUSIC_PATH}
- DATA_PATH=/data
- AUDIO_FMT=${AUDIO_FMT:-mp3}
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
- MAX_PARALLEL=${MAX_PARALLEL:-2}
- DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp}
- TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY}
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
- GITEA_URL=${GITEA_URL:-}
- GITEA_REPO=${GITEA_REPO:-}
- GITEA_TOKEN=${GITEA_TOKEN:-}
- GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest}
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
novasonic-scrobble:
build:
context: ./scrobble
container_name: novasonic-scrobble
restart: unless-stopped
volumes:
- novasonic-scrobble-data:/data # own DB volume, isolated from downloads
ports:
- "3710:3710"
environment:
- PORT=3710
- DATA_PATH=/data
- NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification
volumes:
novasonic-data:
novasonic-scrobble-data:
+24 -35
View File
@@ -1,75 +1,64 @@
# NovaSonic — Standard/Produktiv: zieht die fertigen Images aus der Registry, baut nichts.
# Auf dem Server nur .env danebenlegen (siehe .env.example) und:
#
# docker compose pull
# docker compose up -d
#
# Die Images kommen von der Dev-Maschine: `npm run push-images` (baut die drei Dockerfiles und
# pusht nach ${REGISTRY}). Selbst bauen statt ziehen: docker-compose.dev.yml.
services:
# ── Frontend (nginx) ──────────────────────────────────────────────────────────
# ── Frontend (nginx serving the built PWA) ───────────────────────────────────
novasonic-frontend:
build: .
image: ${REGISTRY}/novasonic-frontend:${TAG:-latest}
container_name: novasonic-frontend
restart: unless-stopped
ports:
- "8080:80" # route this port through your reverse proxy
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_URL=${BACKEND_URL}
- SCROBBLE_URL=${SCROBBLE_URL}
# ── Backend (yt-dlp download server) ─────────────────────────────────────────
novasonic-backend:
image: node:22-slim
image: ${REGISTRY}/novasonic-backend:${TAG:-latest}
container_name: novasonic-backend
restart: unless-stopped
working_dir: /app
volumes:
- ./backend:/app:ro # mount entire backend dir (server + lib/)
- ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping
- novasonic-data:/data # writable data dir for overrides, state, etc.
ports:
- "3700:3700" # route this port through your reverse proxy
- "3700:3700"
environment:
- PORT=3700
- MUSIC_PATH=${MUSIC_PATH} # same path inside the container
- MUSIC_PATH=${MUSIC_PATH}
- DATA_PATH=/data
- AUDIO_FMT=${AUDIO_FMT:-mp3}
- AUDIO_QUALITY=${AUDIO_QUALITY:-0}
- 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)
# Android APK download button — backend proxies the fixed Gitea release ("apk-latest").
- GITEA_URL=${GITEA_URL:-}
- GITEA_REPO=${GITEA_REPO:-}
- GITEA_TOKEN=${GITEA_TOKEN:-}
- 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 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 && PATH=/root/.deno/bin:$$PATH NODE_PATH=/deps/node_modules node novasonic-server.js
"
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ────────
# ── Scrobble service (isolated timestamped play-log → stats + Wrapped) ───────
novasonic-scrobble:
image: node:22-slim
image: ${REGISTRY}/novasonic-scrobble:${TAG:-latest}
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
- "3710:3710"
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)
novasonic-data:
novasonic-scrobble-data:
+12 -12
View File
@@ -605,18 +605,6 @@
<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>
@@ -709,6 +697,18 @@
<div class="field"><label>Benutzername</label><input id="s-user" type="text" placeholder="admin"></div>
<div class="field"><label>Passwort</label><input id="s-pass" type="password" placeholder="(unverändert lassen)"></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>
<!-- Sticky footer -->
<div style="display:flex;flex-direction:column;gap:8px;padding:12px 24px 16px;border-top:1px solid var(--border);flex-shrink:0;background:var(--surface);border-radius:0 0 18px 18px">
+2 -1
View File
@@ -6,7 +6,8 @@
"build": "vite build",
"preview": "vite preview",
"icons": "node scripts/gen-icons.mjs",
"publish-apk": "node scripts/publish-apk.mjs"
"publish-apk": "node scripts/publish-apk.mjs",
"push-images": "node scripts/build-push-images.mjs"
},
"devDependencies": {
"sharp": "^0.34.5",
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
// Build the three NovaSonic images and push them to your registry, so the production host just PULLS
// (docker-compose.yml) instead of building (that's docker-compose.dev.yml). Run from your dev machine:
//
// npm run push-images # builds + pushes REGISTRY/*:TAG (and :latest)
//
// Config (env or a gitignored scripts/.env.release / repo .env):
// REGISTRY e.g. gitea.honeyy.duckdns.org/docker (Gitea uses a LOWERCASE owner)
// TAG image tag (default: latest)
// You must `docker login <registry-host>` once first. Plain-HTTP internal registries also need the
// daemon's insecure-registries list (see .env.example).
import { spawnSync } from 'node:child_process';
import { readFileSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
// Load REGISTRY/TAG from a local env file if present (repo .env, then scripts/.env.release).
for (const f of [join(root, '.env'), join(root, 'scripts', '.env.release')]) {
if (!existsSync(f)) continue;
for (const line of readFileSync(f, '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 REGISTRY = (process.env.REGISTRY || '').replace(/\/$/, '');
const TAG = process.env.TAG || 'latest';
if (!REGISTRY) { console.error('\n✖ REGISTRY muss gesetzt sein (env oder .env). Siehe .env.example.'); process.exit(1); }
// name → build context (relative to repo root). Frontend uses the root Dockerfile.
const images = {
'novasonic-frontend': '.',
'novasonic-backend': 'backend',
'novasonic-scrobble': 'scrobble',
};
function run(cmd, args) {
console.log(`\n$ ${cmd} ${args.join(' ')}`);
const r = spawnSync(cmd, args, { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' });
if (r.status !== 0) { console.error(`\n✖ Fehlgeschlagen: ${cmd} ${args.join(' ')}`); process.exit(1); }
}
for (const [name, ctx] of Object.entries(images)) {
const ref = `${REGISTRY}/${name}:${TAG}`;
const latest = `${REGISTRY}/${name}:latest`;
console.log(`\n=== ${name} → ${ref} ===`);
run('docker', ['build', '-t', ref, '-t', latest, ctx]);
run('docker', ['push', ref]);
if (latest !== ref) run('docker', ['push', latest]);
}
console.log(`\n✔ Alle Images gebaut + gepusht (${REGISTRY}/*:${TAG}). Auf dem Server:`);
console.log(' docker compose pull && docker compose up -d');
+15
View File
@@ -0,0 +1,15 @@
# NovaSonic scrobble service — isolated timestamped play-log. Uses Node's built-in SQLite
# (--experimental-sqlite), so the only dependency is express. Build context = ./scrobble.
FROM node:22-slim
WORKDIR /deps
RUN npm install express
ENV NODE_PATH=/deps/node_modules
WORKDIR /app
COPY . /app/
ENV PORT=3710 DATA_PATH=/data
EXPOSE 3710
CMD ["node","--experimental-sqlite","scrobble-server.js"]
+56 -17
View File
@@ -172,6 +172,14 @@ app.get('/scrobble/history', async (req, res) => {
// ?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)
// Gruppierungsschluessel fuer Texte: Klein-/Grossschreibung, Rand-Leerzeichen und mehrfache
// Leerzeichen duerfen keine zwei Eintraege aus einem machen. Genau das passiert sonst, sobald
// jemand Metadaten bearbeitet ("Dat Adam" -> "DAT ADAM" waeren zwei Kuenstler in der Statistik).
// SQLite kennt kein Regex, deshalb wird die Leerzeichen-Faltung zweimal angewandt (deckt bis zu
// vier aufeinanderfolgende Leerzeichen ab — mehr kommt in Tags praktisch nicht vor).
const normKey = (col) =>
`lower(trim(replace(replace(replace(COALESCE(${col},''), char(9), ' '), ' ', ' '), ' ', ' ')))`;
// Buckets are computed on the fly (GROUP BY strftime) — no rollup tables.
const _STATS_FMT = { day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' };
@@ -202,31 +210,62 @@ app.get('/scrobble/stats', async (req, res) => {
// 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;
// Anzeigename aus dem JUENGSTEN Play, nicht per MAX(): sonst gewinnt lexikografisch die alte
// Schreibweise und die Statistik zeigt nach einer Korrektur weiter den alten Namen.
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
WITH neueste AS (
SELECT ${normKey('artist')} AS k, artist, artist_id, MAX(played_at)
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
), zaehler AS (
SELECT ${normKey('artist')} AS k, COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds
FROM plays WHERE ${WHERE} AND artist IS NOT NULL AND artist <> '' GROUP BY k
)
SELECT n.artist AS name, n.artist_id AS artistId, z.plays AS plays, z.seconds AS seconds
FROM neueste n JOIN zaehler z ON z.k = n.k
ORDER BY z.plays DESC, z.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
WITH neueste AS (
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2,
album, album_id, artist, cover_art, MAX(played_at)
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
), zaehler AS (
SELECT ${normKey('album')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
FROM plays WHERE ${WHERE} AND album IS NOT NULL AND album <> '' GROUP BY k1, k2
)
SELECT n.album AS name, n.album_id AS albumId, n.artist AS artist,
n.cover_art AS coverArt, z.plays AS plays
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
ORDER BY z.plays DESC LIMIT :limit
`).all(winL);
// Nicht nach song_id gruppieren: wird eine Datei beim Metadaten-Bearbeiten umbenannt, vergibt
// Navidrome eine NEUE id — der Titel erschiene dann zweimal mit geteilten Zaehlern. Schluessel ist
// deshalb Titel+Kuenstler; Anzeigefelder (inkl. song_id fuer die Navigation) kommen aus dem
// JUENGSTEN Play, also mit den frisch bearbeiteten Tags. Das `MAX(played_at)` mit ansonsten
// blanken Spalten ist dokumentiertes SQLite-Verhalten: es liefert die Zeile des Maximums.
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
WITH neueste AS (
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2,
title, artist, album, album_id, artist_id, cover_art, song_id, MAX(played_at)
FROM plays WHERE ${WHERE}
GROUP BY k1, k2
), zaehler AS (
SELECT ${normKey('title')} AS k1, ${normKey('artist')} AS k2, COUNT(*) AS plays
FROM plays WHERE ${WHERE}
GROUP BY k1, k2
)
SELECT n.title AS title, n.artist AS artist, n.album AS album, n.album_id AS albumId,
n.artist_id AS artistId, n.cover_art AS coverArt, n.song_id AS songId, z.plays AS plays
FROM neueste n JOIN zaehler z ON z.k1 = n.k1 AND z.k2 = n.k2
ORDER BY z.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
GROUP BY ${normKey('genre')} ORDER BY plays DESC LIMIT :limit
`).all(winL);
const series = db.prepare(`
@@ -280,14 +319,14 @@ app.get('/scrobble/compare', async (req, res) => {
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
SELECT ${normKey('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)
GROUP BY ${normKey('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,
SELECT MAX(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
FROM plays WHERE ${WHERE} GROUP BY ${normKey('title')}, ${normKey('artist')}
`).all({ user: u, from, to });
const totalsOf = u => db.prepare(
`SELECT COUNT(*) AS plays, COALESCE(SUM(duration),0) AS seconds FROM plays WHERE ${WHERE}`
+6 -1
View File
@@ -572,8 +572,13 @@ export async function loadPartialAlbumDetail({ nd, deezer }) {
// ─── ALBUM DETAIL ───
export async function loadAlbumDetail(album) {
if (S.cfg.showRomaji) await ensureArtistSortMap().catch(() => {});
// Zurück heißt zurück: einen Eintrag im Verlauf zurückgehen, wie auf jeder anderen Detailseite.
// Vorher navigierte dieser Knopf VORWÄRTS auf die Albenliste (bzw. den Artist) — das landete nicht
// dort, wo man das Album angeklickt hatte, und schob zusätzlich einen neuen History-Eintrag auf den
// Stapel, sodass der Browser-Zurück-Knopf sich durch diesen Müll arbeiten musste statt nach Home.
// Nur wenn es keinen Verlauf gibt (Direkteinstieg auf ein Album), bleibt die Albenliste das Ziel.
document.getElementById('album-back-btn').onclick = () =>
album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums');
S._navCursor > 0 ? _navGoBack() : navigate('albums');
document.getElementById('album-detail-name').textContent = dispAlbumName(album);
const artistEl = document.getElementById('album-detail-artist');
artistEl.innerHTML = parseArtists(album)