diff --git a/.env.example b/.env.example index 90ae0f6..de85879 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,32 @@ -# 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 (build-on-host) AND docker-compose.prod.yml (pulls images). -# 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 # internal Gitea base reachable from the backend container +GITEA_REPO=Docker/NovaSonic +GITEA_TOKEN= # read token (only needed for a private repo) +GITEA_APK_TAG=apk-latest + +# ── Pre-built images (docker-compose.prod.yml only) ────────────────────────── +# 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 diff --git a/.gitignore b/.gitignore index e5498b8..1394ae8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ dist/ scripts/.env.release +.env diff --git a/CLAUDE.md b/CLAUDE.md index d2b5f4c..a60dcd2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,9 +41,15 @@ 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). +Backend runs in Docker. Two ways: +- **`docker-compose.yml`** — builds on the host (mounts `./backend`, installs deps at container + start). Restart after backend changes: `docker compose restart novasonic-backend` (re-pulls yt-dlp). +- **`docker-compose.prod.yml`** — PULLS pre-built images (deps baked into `backend/Dockerfile` + + `scrobble/Dockerfile`), so restarts are instant. Build+push from dev with `npm run push-images` + (needs `REGISTRY`/`TAG` in `.env`), then on the host `docker compose -f docker-compose.prod.yml pull + && … up -d`. See `.env.example`. + +On the deploy host the music lives at `/opt/navidrome/music`. ## Download pipeline (hard-won knowledge) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a2809d9 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..fa44c35 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,63 @@ +# Production stack — PULLS pre-built images instead of building on the host. +# +# docker compose -f docker-compose.prod.yml pull +# docker compose -f docker-compose.prod.yml up -d +# +# Build & push the images from your dev machine with: npm run push-images +# (see scripts/build-push-images.mjs). Set REGISTRY + TAG in .env (see .env.example). +services: + + novasonic-frontend: + image: ${REGISTRY}/novasonic-frontend:${TAG:-latest} + pull_policy: always + container_name: novasonic-frontend + restart: unless-stopped + ports: + - "8080:80" + environment: + - NAVIDROME_URL=${NAVIDROME_URL} + - BACKEND_URL=${BACKEND_URL} + - SCROBBLE_URL=${SCROBBLE_URL} + + novasonic-backend: + image: ${REGISTRY}/novasonic-backend:${TAG:-latest} + pull_policy: always + container_name: novasonic-backend + restart: unless-stopped + volumes: + - ${MUSIC_PATH}:${MUSIC_PATH} # exact path from .env, no remapping + - novasonic-data:/data + 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} + + novasonic-scrobble: + image: ${REGISTRY}/novasonic-scrobble:${TAG:-latest} + pull_policy: always + container_name: novasonic-scrobble + restart: unless-stopped + volumes: + - novasonic-scrobble-data:/data + ports: + - "3710:3710" + environment: + - PORT=3710 + - DATA_PATH=/data + - NAVIDROME_URL=${NAVIDROME_URL} # for ping.view auth verification + +volumes: + novasonic-data: + novasonic-scrobble-data: diff --git a/package.json b/package.json index 898d80c..21b9245 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-push-images.mjs b/scripts/build-push-images.mjs new file mode 100644 index 0000000..6a0338b --- /dev/null +++ b/scripts/build-push-images.mjs @@ -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.prod.yml) instead of building. Run from your dev machine after changes: +// +// 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 ` 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 -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d'); diff --git a/scrobble/Dockerfile b/scrobble/Dockerfile new file mode 100644 index 0000000..5c7b491 --- /dev/null +++ b/scrobble/Dockerfile @@ -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"]