feat(backend): APK download proxies a fixed Gitea release (local fallback)

The /api/apk + /api/apk/info routes now fetch a fixed Gitea release
("apk-latest") server-side over the LAN and stream its .apk asset, falling
back to /data/novasonic.apk. Gitea can stay internal-only — the backend holds
the token, the browser only ever talks to the backend. Follows redirects,
constant ~30 MB (overwrite the one release's asset each build).

env: GITEA_URL, GITEA_REPO, GITEA_TOKEN (private repo only), GITEA_APK_TAG.
docker-compose documents them; unset = local-file fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 4.8 committed 2026-08-25 18:16:13 +02:00
1 parent 908b50bcd9
commit 33d24c6ed4
3 files changed
+103 -20

No files matched your search

+11 -6
View File
@@ -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.
+86 -14
View File
@@ -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) => {
+6
View File
@@ -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/* &&