diff --git a/CHANGELOG.md b/CHANGELOG.md index b23b217..7d4a2dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,12 +72,17 @@ like Android), concert events become **reliable across web/Android**, several An ### Added - **Android APK download in Settings (Web)** — a "🤖 Android App" section in Settings offers the - latest APK so co-users self-serve instead of the maintainer hand-delivering builds. The backend - serves the staged APK (`GET /api/apk`, metadata at `/api/apk/info`); the button appears only when an - APK is present and shows version/size/date. **Maintainer workflow:** drop the release build into the - backend's data volume — `docker cp app-release.apk novasonic-backend:/data/novasonic.apk` (optional - `docker cp version.txt novasonic-backend:/data/novasonic.version.txt`). The Android *source* is - already in the repo; only build artifacts stay out of git. + latest APK so co-users self-serve instead of the maintainer hand-delivering builds. The button + appears only when an APK is available and shows version/size/date. The backend serves it + (`GET /api/apk`, metadata at `/api/apk/info`) from one of two sources, in order: + 1. **A fixed Gitea release** (tag `apk-latest`) whose `.apk` asset you overwrite each build — + constant ~30 MB, uploadable from anywhere. The backend fetches it **server-side** over the LAN + (`GITEA_URL`/`GITEA_REPO`/`GITEA_TOKEN`/`GITEA_APK_TAG` env), so Gitea can stay internal-only and + the token never reaches the browser. Release *assets* live in Gitea's attachment store, not the + git history, so `clone`/`pull` stay small. + 2. **Local fallback** — `docker cp app-release.apk novasonic-backend:/data/novasonic.apk` (optional + `/data/novasonic.version.txt`). + The Android *source* is already in the repo; only build artifacts stay out of git. - **Unified Global Search field (Web)** — the separate Song-Importer page/sidebar entry is gone. The one search field now auto-detects: a **link** (Spotify/SoundCloud/YouTube) opens the importer preview, plain **text** runs a Deezer search. Cleaner left nav, same behaviour as Android. diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index a26e2ef..5183ae5 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -13,6 +13,7 @@ const { execSync, spawn } = require('child_process'); const path = require('path'); const fs = require('fs'); const https = require('https'); +const http = require('http'); const crypto = require('crypto'); const CONFIG = require('./lib/config'); @@ -468,29 +469,100 @@ app.get('/api/events-debug/:artist', (req, res) => { // ── NovaSonic Android APK distribution ────────────────────────────────────── // The web app offers a "download the Android app" button that points here, so co-users always get -// the latest APK without the maintainer hand-delivering it. Drop the latest release build into the -// writable data volume (APK_PATH, default /data/novasonic.apk): -// docker cp app-release.apk novasonic-backend:/data/novasonic.apk -// Optional: a sidecar /data/novasonic.version.txt with a version string (e.g. "2.2") shown in the UI. -const APK_PATH = process.env.APK_PATH || path.join(CONFIG.dataPath, 'novasonic.apk'); -const APK_VER_PATH = path.join(CONFIG.dataPath, 'novasonic.version.txt'); +// the latest APK without the maintainer hand-delivering it. Two sources, tried in order: +// 1) A FIXED Gitea release (default tag "apk-latest") whose .apk asset you overwrite each build — +// constant ~30 MB, uploadable from anywhere. The BACKEND fetches it server-side (same LAN as +// Gitea), so Gitea can stay internal-only and the token never reaches the browser. +// env: GITEA_URL (internal base, e.g. http://gitea:3000), GITEA_REPO (Docker/NovaSonic), +// GITEA_TOKEN (read token; only needed for a private repo), GITEA_APK_TAG (default apk-latest) +// 2) Fallback: a local file in the writable data volume: +// docker cp app-release.apk novasonic-backend:/data/novasonic.apk +// (optional /data/novasonic.version.txt with a version string) +const APK_PATH = process.env.APK_PATH || path.join(CONFIG.dataPath, 'novasonic.apk'); +const APK_VER_PATH = path.join(CONFIG.dataPath, 'novasonic.version.txt'); +const GITEA_URL = (process.env.GITEA_URL || '').replace(/\/$/, ''); +const GITEA_REPO = process.env.GITEA_REPO || ''; +const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; +const GITEA_APK_TAG = process.env.GITEA_APK_TAG || 'apk-latest'; -app.get('/api/apk/info', (req, res) => { - fs.stat(APK_PATH, (err, st) => { - if (err) return res.json({ available: false }); - let version = null; - try { version = fs.readFileSync(APK_VER_PATH, 'utf8').trim() || null; } catch {} - res.json({ available: true, size: st.size, updatedAt: st.mtimeMs, version }); +function _giteaApiJson(pathAndQuery) { + return new Promise((resolve, reject) => { + if (!GITEA_URL || !GITEA_REPO) return reject(new Error('gitea not configured')); + const u = new URL(`${GITEA_URL}/api/v1/repos/${GITEA_REPO}${pathAndQuery}`); + const lib = u.protocol === 'https:' ? https : http; + const headers = { 'Accept': 'application/json', 'User-Agent': 'NovaSonic' }; + if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`; + lib.get(u, { headers }, r => { + let body = ''; + r.on('data', d => body += d); + r.on('end', () => { + if (r.statusCode < 200 || r.statusCode >= 300) return reject(new Error(`gitea ${r.statusCode}`)); + try { resolve(JSON.parse(body)); } catch (e) { reject(e); } + }); + }).on('error', reject); }); -}); +} -app.get('/api/apk', (req, res) => { +// The .apk asset of the fixed release, or null if unavailable. +async function _giteaApkAsset() { + const rel = await _giteaApiJson(`/releases/tags/${encodeURIComponent(GITEA_APK_TAG)}`); + const asset = (rel.assets || []).find(a => /\.apk$/i.test(a.name || '')); + if (!asset) return null; + return { + size: asset.size, + url: asset.browser_download_url, + version: rel.name || rel.tag_name || null, + updatedAt: new Date(asset.created_at || rel.published_at || Date.now()).getTime(), + }; +} + +function _serveLocalApk(res) { + if (res.headersSent) return; fs.stat(APK_PATH, (err) => { if (err) return res.status(404).json({ error: 'Keine APK hinterlegt' }); res.setHeader('Content-Type', 'application/vnd.android.package-archive'); res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"'); res.sendFile(APK_PATH); }); +} + +// Stream a Gitea attachment to the client, following redirects, falling back to the local file. +function _streamApkFromUrl(urlStr, res, redirectsLeft = 3) { + const u = new URL(urlStr); + const lib = u.protocol === 'https:' ? https : http; + const headers = { 'User-Agent': 'NovaSonic' }; + if (GITEA_TOKEN) headers['Authorization'] = `token ${GITEA_TOKEN}`; + lib.get(u, { headers }, up => { + if ([301, 302, 303, 307, 308].includes(up.statusCode) && up.headers.location && redirectsLeft > 0) { + up.resume(); + return _streamApkFromUrl(new URL(up.headers.location, u).toString(), res, redirectsLeft - 1); + } + if (up.statusCode !== 200) { up.resume(); return _serveLocalApk(res); } + res.setHeader('Content-Type', 'application/vnd.android.package-archive'); + res.setHeader('Content-Disposition', 'attachment; filename="NovaSonic.apk"'); + if (up.headers['content-length']) res.setHeader('Content-Length', up.headers['content-length']); + up.pipe(res); + }).on('error', () => _serveLocalApk(res)); +} + +app.get('/api/apk/info', async (req, res) => { + try { + const a = await _giteaApkAsset(); + if (a) return res.json({ available: true, size: a.size, updatedAt: a.updatedAt, version: a.version, source: 'gitea' }); + } catch { /* fall through to local */ } + fs.stat(APK_PATH, (err, st) => { + if (err) return res.json({ available: false }); + let version = null; + try { version = fs.readFileSync(APK_VER_PATH, 'utf8').trim() || null; } catch {} + res.json({ available: true, size: st.size, updatedAt: st.mtimeMs, version, source: 'local' }); + }); +}); + +app.get('/api/apk', async (req, res) => { + let asset = null; + try { asset = await _giteaApkAsset(); } catch { /* fall through to local */ } + if (asset?.url) return _streamApkFromUrl(asset.url, res); + _serveLocalApk(res); }); app.get('/api/health', (req, res) => { diff --git a/docker-compose.yml b/docker-compose.yml index ad723f1..09e4a04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,6 +33,12 @@ services: - MAX_PARALLEL=${MAX_PARALLEL:-2} - DOWNLOAD_BACKEND=${DOWNLOAD_BACKEND:-ytdlp} - TICKETMASTER_API_KEY=${TICKETMASTER_API_KEY} + # Android APK download button — backend proxies a fixed Gitea release ("apk-latest"), same LAN. + # Optional: leave unset to use the local /data/novasonic.apk fallback instead. + - GITEA_URL=${GITEA_URL:-} # internal Gitea base, e.g. http://gitea:3000 + - GITEA_REPO=${GITEA_REPO:-} # owner/repo, e.g. Docker/NovaSonic + - GITEA_TOKEN=${GITEA_TOKEN:-} # read token (only for a private repo) + - GITEA_APK_TAG=${GITEA_APK_TAG:-apk-latest} command: > sh -c " apt-get update && apt-get install -y --no-install-recommends python3 python3-pip ffmpeg curl git unzip && rm -rf /var/lib/apt/lists/* &&