Compare commits

..
14 Commits
Author SHA1 Message Date
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
25 changed files with 541 additions and 85 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/
+57
View File
@@ -64,6 +64,63 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an
---
## [Unreleased]
### Fixed
- **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,
+33 -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,23 @@ 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.
- **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"]
+34 -5
View File
@@ -26,9 +26,30 @@ 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;
@@ -50,8 +71,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(' & ') : '';
}
@@ -424,4 +453,4 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ra
return { success: true, dir };
}
module.exports = { downloadTrack, embedMetadata };
module.exports = { downloadTrack, embedMetadata, buildTitleKeywords, buildVariantFilter };
+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) => {
+78
View File
@@ -0,0 +1,78 @@
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);
});
+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"]