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:
1 parent
908b50bcd9
commit
33d24c6ed4
3 files changed
+103
-20
No files matched your search
+11
-6
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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/* &&
|
||||
|
||||
Reference in new issue
Block a user