feat: integrate Electron for desktop application support
- Added Electron entry point in `electron/main.js` to run the Express server in-process and open the main application window. - Updated `package.json` to include Electron dependencies and scripts for building and running the application. - Refactored server startup logic into `server/start.js` for better modularity and to support both CLI and Electron usage. - Implemented environment variable handling for database and image paths to accommodate Electron's packaging. - Created a script `server/scripts/promote-morphix.js` to merge admin and morphix accounts into a single user. - Adjusted image root path resolution in `server/media/images.js` to allow for environment variable overrides. - Cleaned up `server/index.js` to delegate server initialization to the new `startServer` function.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
dist-electron/
|
||||||
data/db/
|
data/db/
|
||||||
data/images/
|
data/images/
|
||||||
.env
|
.env
|
||||||
|
|||||||
23
README.md
23
README.md
@@ -26,6 +26,29 @@ npm start
|
|||||||
|
|
||||||
The built kiosk is served from `/`, admin from `/admin/`, API from `/api`.
|
The built kiosk is served from `/`, admin from `/admin/`, API from `/api`.
|
||||||
|
|
||||||
|
## Electron (desktop / Pi)
|
||||||
|
|
||||||
|
The Master view also runs as a standalone Electron app. The Express + WebSocket
|
||||||
|
server boots inside the Electron main process; controllers/panels on other
|
||||||
|
devices on the LAN connect to it normally.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install # postinstall rebuilds better-sqlite3 for Electron's ABI
|
||||||
|
npm run electron # builds web assets and launches Electron
|
||||||
|
```
|
||||||
|
|
||||||
|
Packaging (Windows installer + Linux AppImage for Pi targets):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dist:win # NSIS installer
|
||||||
|
npm run dist:linux # AppImage for x64 / arm64 / armv7l
|
||||||
|
```
|
||||||
|
|
||||||
|
In packaged builds, the DB and image cache live under `app.getPath('userData')`
|
||||||
|
(`%APPDATA%/Online Radio Explorer` on Windows, `~/.config/Online Radio Explorer`
|
||||||
|
on Linux). In `npm run electron` dev mode the existing repo `data/` directory
|
||||||
|
is reused.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `server/` — Express, SQLite, WebSocket hub, source adapters, stream resolver.
|
- `server/` — Express, SQLite, WebSocket hub, source adapters, stream resolver.
|
||||||
|
|||||||
158
electron/main.js
Normal file
158
electron/main.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
// Electron entry: runs the Express + WebSocket server in-process, then opens
|
||||||
|
// the Master view in a 1280x800 window. The master client acts as the audio
|
||||||
|
// source; controllers/panels on other devices keep working over the LAN
|
||||||
|
// because the server still binds 0.0.0.0.
|
||||||
|
|
||||||
|
import { app, BrowserWindow, Menu, shell } from 'electron';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
// Pick data location BEFORE importing the server so its module-level path
|
||||||
|
// constants (DB, image root) resolve against the right directory.
|
||||||
|
const repoRoot = resolve(__dirname, '..');
|
||||||
|
const dataRoot = app.isPackaged
|
||||||
|
? app.getPath('userData') // %APPDATA%/<productName> on Windows, ~/.config/... on Linux
|
||||||
|
: repoRoot; // dev: keep using the repo's data/ folder
|
||||||
|
|
||||||
|
const dbPath = resolve(dataRoot, 'data', 'db', 'oradio.sqlite');
|
||||||
|
const imageRoot = resolve(dataRoot, 'data', 'images');
|
||||||
|
|
||||||
|
mkdirSync(dirname(dbPath), { recursive: true });
|
||||||
|
mkdirSync(imageRoot, { recursive: true });
|
||||||
|
|
||||||
|
process.env.DB_PATH = process.env.DB_PATH || dbPath;
|
||||||
|
process.env.ORADIO_IMAGE_ROOT = process.env.ORADIO_IMAGE_ROOT || imageRoot;
|
||||||
|
process.env.PORT = process.env.PORT || '4173';
|
||||||
|
// Bind locally by default; flip to 0.0.0.0 via env if remote controllers
|
||||||
|
// need to reach this instance over the LAN.
|
||||||
|
process.env.HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'electron-default-change-me';
|
||||||
|
process.env.ADMIN_BOOTSTRAP_USER = process.env.ADMIN_BOOTSTRAP_USER || 'admin';
|
||||||
|
process.env.ADMIN_BOOTSTRAP_PASSWORD = process.env.ADMIN_BOOTSTRAP_PASSWORD || 'changeme';
|
||||||
|
|
||||||
|
let serverHandle = null;
|
||||||
|
let mainWindow = null;
|
||||||
|
|
||||||
|
async function bootServer() {
|
||||||
|
const { startServer } = await import('../server/start.js');
|
||||||
|
serverHandle = await startServer();
|
||||||
|
return serverHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMainWindow(port) {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
backgroundColor: '#0b0b0c',
|
||||||
|
webPreferences: {
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Menu.setApplicationMenu(null);
|
||||||
|
|
||||||
|
// Open target/external links in the OS browser instead of inside Electron.
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle DevTools (detached/popout) with F12 or Ctrl+Shift+I.
|
||||||
|
const toggleDevTools = () => {
|
||||||
|
const wc = mainWindow.webContents;
|
||||||
|
if (wc.isDevToolsOpened()) wc.closeDevTools();
|
||||||
|
else wc.openDevTools({ mode: 'detach' });
|
||||||
|
};
|
||||||
|
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||||
|
if (input.type !== 'keyDown') return;
|
||||||
|
const k = (input.key || '').toLowerCase();
|
||||||
|
if (k === 'f12' || (input.control && input.shift && k === 'i')) {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleDevTools();
|
||||||
|
}
|
||||||
|
if (k === 'f5' || (input.control && k === 'r')) {
|
||||||
|
event.preventDefault();
|
||||||
|
mainWindow.webContents.reloadIgnoringCache();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject two floating buttons (top-right): reload + DevTools toggle.
|
||||||
|
// The sandboxed renderer signals the main process via console markers.
|
||||||
|
mainWindow.webContents.on('console-message', (_e, _level, message) => {
|
||||||
|
if (message === '__oradio_open_devtools__') toggleDevTools();
|
||||||
|
else if (message === '__oradio_reload__') mainWindow.webContents.reloadIgnoringCache();
|
||||||
|
});
|
||||||
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
|
mainWindow.webContents.executeJavaScript(`
|
||||||
|
(() => {
|
||||||
|
window.__oradioOpenDevTools = () => console.log('__oradio_open_devtools__');
|
||||||
|
window.__oradioReload = () => console.log('__oradio_reload__');
|
||||||
|
if (document.getElementById('__oradio_devbar')) return;
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.id = '__oradio_devbar';
|
||||||
|
Object.assign(bar.style, {
|
||||||
|
position: 'fixed', top: '6px', right: '6px', zIndex: '2147483647',
|
||||||
|
display: 'flex', gap: '4px'
|
||||||
|
});
|
||||||
|
const mk = (label, title, onclick) => {
|
||||||
|
const b = document.createElement('button');
|
||||||
|
b.type = 'button';
|
||||||
|
b.title = title;
|
||||||
|
b.textContent = label;
|
||||||
|
Object.assign(b.style, {
|
||||||
|
width: '32px', height: '32px', borderRadius: '6px',
|
||||||
|
border: '1px solid rgba(255,255,255,0.25)',
|
||||||
|
background: 'rgba(0,0,0,0.55)', color: '#fff',
|
||||||
|
font: '16px/1 system-ui, sans-serif', cursor: 'pointer',
|
||||||
|
opacity: '0.4'
|
||||||
|
});
|
||||||
|
b.addEventListener('mouseenter', () => b.style.opacity = '1');
|
||||||
|
b.addEventListener('mouseleave', () => b.style.opacity = '0.4');
|
||||||
|
b.addEventListener('click', onclick);
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
bar.appendChild(mk('\u21BB', 'Reload (F5)', () => window.__oradioReload()));
|
||||||
|
bar.appendChild(mk('\u2699', 'DevTools (F12)', () => window.__oradioOpenDevTools()));
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
})();
|
||||||
|
`).catch(() => { });
|
||||||
|
});
|
||||||
|
|
||||||
|
const startUrl = process.env.ORADIO_DEV_URL || `http://localhost:${port}/master`;
|
||||||
|
await mainWindow.loadURL(startUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
try {
|
||||||
|
const { port } = await bootServer();
|
||||||
|
await createMainWindow(port);
|
||||||
|
|
||||||
|
app.on('activate', async () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
await createMainWindow(port);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[electron] failed to start:', err);
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit();
|
||||||
|
});
|
||||||
|
|
||||||
|
let quitting = false;
|
||||||
|
app.on('before-quit', async (event) => {
|
||||||
|
if (quitting || !serverHandle) return;
|
||||||
|
quitting = true;
|
||||||
|
event.preventDefault();
|
||||||
|
try { await serverHandle.stop(); } catch (e) { console.error(e); }
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
10114
package-lock.json
generated
10114
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
50
package.json
50
package.json
@@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Touchscreen kiosk + admin for exploring and playing internet radio.",
|
"description": "Touchscreen kiosk + admin for exploring and playing internet radio.",
|
||||||
|
"main": "electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -k -n web,api -c blue,green \"npm:dev:web\" \"npm:dev:api\"",
|
"dev": "concurrently -k -n web,api -c blue,green \"npm:dev:web\" \"npm:dev:api\"",
|
||||||
"dev:web": "vite",
|
"dev:web": "vite",
|
||||||
@@ -11,7 +12,13 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "node server/index.js",
|
"start": "node server/index.js",
|
||||||
"seed": "node server/scripts/seed.js",
|
"seed": "node server/scripts/seed.js",
|
||||||
"images:fetch": "node server/scripts/download-images.js"
|
"images:fetch": "node server/scripts/download-images.js",
|
||||||
|
"electron": "electron .",
|
||||||
|
"electron:dev": "npm run build && electron .",
|
||||||
|
"postinstall": "electron-builder install-app-deps",
|
||||||
|
"dist": "npm run build && electron-builder",
|
||||||
|
"dist:win": "npm run build && electron-builder --win",
|
||||||
|
"dist:linux": "npm run build && electron-builder --linux"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -24,7 +31,46 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
|
"electron": "^32.3.3",
|
||||||
|
"electron-builder": "^25.1.8",
|
||||||
"hls.js": "^1.5.17",
|
"hls.js": "^1.5.17",
|
||||||
"vite": "^5.4.8"
|
"vite": "^5.4.8"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "nl.oradio.app",
|
||||||
|
"productName": "Online Radio Explorer",
|
||||||
|
"asar": true,
|
||||||
|
"asarUnpack": [
|
||||||
|
"node_modules/better-sqlite3/**"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"electron/**",
|
||||||
|
"server/**",
|
||||||
|
"!server/scripts/**",
|
||||||
|
"data/seed/**",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"output": "dist-electron",
|
||||||
|
"buildResources": "build"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
"nsis"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"linux": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "AppImage",
|
||||||
|
"arch": [
|
||||||
|
"x64",
|
||||||
|
"arm64",
|
||||||
|
"armv7l"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"category": "AudioVideo"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,9 @@
|
|||||||
import 'dotenv/config';
|
// CLI entry: `node server/index.js` (or `npm start`). For the Electron build,
|
||||||
import express from 'express';
|
// see `electron/main.js`, which calls `startServer()` directly.
|
||||||
import { createServer } from 'node:http';
|
|
||||||
import { resolve, dirname } from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
|
|
||||||
import { initDb } from './db/index.js';
|
import { startServer } from './start.js';
|
||||||
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
|
|
||||||
import { applySeedIfEmpty } from './sources/seed.js';
|
|
||||||
import { scheduleHealthCheck } from './streams/checker.js';
|
|
||||||
import { attachWs } from './ws.js';
|
|
||||||
import { ensureImageDirs, getImageRoot } from './media/images.js';
|
|
||||||
|
|
||||||
import { router as authRoutes } from './routes/auth.js';
|
startServer().catch((err) => {
|
||||||
import { router as stationRoutes } from './routes/stations.js';
|
console.error('[oradio] failed to start:', err);
|
||||||
import { router as meRoutes } from './routes/me.js';
|
process.exit(1);
|
||||||
import { router as adminRoutes } from './routes/admin.js';
|
|
||||||
import { router as v1Routes } from './routes/v1.js';
|
|
||||||
import { router as roomRoutes } from './routes/rooms.js';
|
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const PORT = Number(process.env.PORT) || 4173;
|
|
||||||
|
|
||||||
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
|
|
||||||
ensureBootstrapAdmin({
|
|
||||||
username: process.env.ADMIN_BOOTSTRAP_USER,
|
|
||||||
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
|
|
||||||
});
|
|
||||||
const seedResult = applySeedIfEmpty();
|
|
||||||
console.log('[seed]', seedResult);
|
|
||||||
ensureImageDirs();
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json({ limit: '512kb' }));
|
|
||||||
app.use(authMiddleware);
|
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
|
||||||
app.use('/api/stations', stationRoutes);
|
|
||||||
app.use('/api/me', meRoutes);
|
|
||||||
app.use('/api/admin', adminRoutes);
|
|
||||||
app.use('/api/rooms', roomRoutes);
|
|
||||||
app.use('/api/v1', v1Routes);
|
|
||||||
|
|
||||||
// Locally-cached cover art and other media live under data/images and are
|
|
||||||
// served unauthenticated on the LAN. Long cache OK — file names include the
|
|
||||||
// station id and we rewrite the file on update (browsers also revalidate).
|
|
||||||
app.use('/media', express.static(getImageRoot(), {
|
|
||||||
maxAge: '1h',
|
|
||||||
fallthrough: false,
|
|
||||||
setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); }
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
|
|
||||||
// HTML entry files must NOT be cached — they reference hashed JS/CSS that changes
|
|
||||||
// every build. Hashed assets under /assets/ can be cached aggressively.
|
|
||||||
const publicDir = resolve(__dirname, 'public');
|
|
||||||
const sendHtml = (file) => (_req, res) => {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
res.sendFile(resolve(publicDir, file));
|
|
||||||
};
|
|
||||||
if (existsSync(publicDir)) {
|
|
||||||
app.use(express.static(publicDir, {
|
|
||||||
setHeaders(res, filePath) {
|
|
||||||
if (filePath.endsWith('.html')) {
|
|
||||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
||||||
} else if (filePath.includes(`${publicDir}\\assets\\`) || filePath.includes(`${publicDir}/assets/`)) {
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
app.get('/admin', sendHtml('admin/index.html'));
|
|
||||||
app.get('/docs', sendHtml('docs/index.html'));
|
|
||||||
app.get('/master', sendHtml('master/index.html'));
|
|
||||||
app.get('*', sendHtml('index.html'));
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use((err, _req, res, _next) => {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: String(err.message || err) });
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = createServer(app);
|
|
||||||
attachWs(server);
|
|
||||||
scheduleHealthCheck(process.env.STREAM_CHECK_CRON);
|
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`[oradio] api+ws on http://localhost:${PORT}`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { fileURLToPath } from 'node:url';
|
|||||||
import { getDb } from '../db/index.js';
|
import { getDb } from '../db/index.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const ROOT = resolve(__dirname, '..', '..', 'data', 'images');
|
// Where cover art lives. Overridable via env so Electron (packaged) can point
|
||||||
|
// it at app.getPath('userData')/data/images instead of the read-only asar dir.
|
||||||
|
const ROOT = process.env.ORADIO_IMAGE_ROOT
|
||||||
|
? resolve(process.env.ORADIO_IMAGE_ROOT)
|
||||||
|
: resolve(__dirname, '..', '..', 'data', 'images');
|
||||||
const STATIONS_DIR = join(ROOT, 'stations');
|
const STATIONS_DIR = join(ROOT, 'stations');
|
||||||
// A browser-like UA is required by Wikimedia and several CDNs; an opaque
|
// A browser-like UA is required by Wikimedia and several CDNs; an opaque
|
||||||
// UA like "OnlineRadioExplorer/0.1" gets HTTP 400/403 from upload.wikimedia.org.
|
// UA like "OnlineRadioExplorer/0.1" gets HTTP 400/403 from upload.wikimedia.org.
|
||||||
|
|||||||
63
server/scripts/promote-morphix.js
Normal file
63
server/scripts/promote-morphix.js
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// One-off: collapse admin + morphix into a single account named "morphix".
|
||||||
|
// Run with: npx electron server/scripts/promote-morphix.js
|
||||||
|
//
|
||||||
|
// Strategy: if both users exist, delete morphix (which has no favorites/history),
|
||||||
|
// then rename admin -> morphix and reset its password. If only one exists,
|
||||||
|
// just rename/ensure morphix with the new password.
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { app } from 'electron';
|
||||||
|
|
||||||
|
const NEW_NAME = 'morphix';
|
||||||
|
const NEW_PASSWORD = '234Tgb999!';
|
||||||
|
|
||||||
|
const dbPath = process.env.DB_PATH || resolve(process.cwd(), 'data', 'db', 'oradio.sqlite');
|
||||||
|
console.log('[promote] db:', dbPath);
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
const admin = db.prepare('SELECT id FROM users WHERE username = ?').get('admin');
|
||||||
|
const morphix = db.prepare('SELECT id FROM users WHERE username = ?').get('morphix');
|
||||||
|
const hash = bcrypt.hashSync(NEW_PASSWORD, 10);
|
||||||
|
|
||||||
|
db.transaction(() => {
|
||||||
|
if (admin && morphix) {
|
||||||
|
// Drop the empty morphix so the username is free, then rename admin.
|
||||||
|
db.prepare('DELETE FROM users WHERE id = ?').run(morphix.id);
|
||||||
|
db.prepare('UPDATE users SET username = ?, password_hash = ?, role = ? WHERE id = ?')
|
||||||
|
.run(NEW_NAME, hash, 'admin', admin.id);
|
||||||
|
// Ensure profile display name is sane.
|
||||||
|
db.prepare(`INSERT INTO profiles (user_id, display_name) VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`)
|
||||||
|
.run(admin.id, NEW_NAME);
|
||||||
|
console.log(`[promote] merged: morphix (id ${morphix.id}) deleted, admin (id ${admin.id}) renamed to morphix`);
|
||||||
|
} else if (admin && !morphix) {
|
||||||
|
db.prepare('UPDATE users SET username = ?, password_hash = ?, role = ? WHERE id = ?')
|
||||||
|
.run(NEW_NAME, hash, 'admin', admin.id);
|
||||||
|
db.prepare(`INSERT INTO profiles (user_id, display_name) VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET display_name = excluded.display_name`)
|
||||||
|
.run(admin.id, NEW_NAME);
|
||||||
|
console.log(`[promote] renamed admin -> morphix (id ${admin.id})`);
|
||||||
|
} else if (!admin && morphix) {
|
||||||
|
db.prepare('UPDATE users SET password_hash = ?, role = ? WHERE id = ?')
|
||||||
|
.run(hash, 'admin', morphix.id);
|
||||||
|
console.log(`[promote] morphix already exists (id ${morphix.id}); password reset`);
|
||||||
|
} else {
|
||||||
|
const info = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)')
|
||||||
|
.run(NEW_NAME, hash, 'admin');
|
||||||
|
db.prepare('INSERT INTO profiles (user_id, display_name) VALUES (?, ?)')
|
||||||
|
.run(info.lastInsertRowid, NEW_NAME);
|
||||||
|
console.log(`[promote] created morphix (id ${info.lastInsertRowid})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate any cached sessions so existing logins don't end up tied
|
||||||
|
// to a now-deleted user id.
|
||||||
|
db.prepare('DELETE FROM sessions').run();
|
||||||
|
})();
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
console.log('[promote] done. Sessions cleared — log in again as morphix / ' + NEW_PASSWORD);
|
||||||
|
app.quit();
|
||||||
106
server/start.js
Normal file
106
server/start.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Bootstraps the HTTP API + WebSocket hub + static assets.
|
||||||
|
//
|
||||||
|
// Exposed as a function so the Electron main process can boot the server
|
||||||
|
// in-process (sharing the event loop, no IPC). `server/index.js` calls
|
||||||
|
// this for plain `node server/index.js` CLI use.
|
||||||
|
|
||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { resolve, dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { initDb } from './db/index.js';
|
||||||
|
import { authMiddleware, ensureBootstrapAdmin } from './auth.js';
|
||||||
|
import { applySeedIfEmpty } from './sources/seed.js';
|
||||||
|
import { scheduleHealthCheck } from './streams/checker.js';
|
||||||
|
import { attachWs } from './ws.js';
|
||||||
|
import { ensureImageDirs, getImageRoot } from './media/images.js';
|
||||||
|
|
||||||
|
import { router as authRoutes } from './routes/auth.js';
|
||||||
|
import { router as stationRoutes } from './routes/stations.js';
|
||||||
|
import { router as meRoutes } from './routes/me.js';
|
||||||
|
import { router as adminRoutes } from './routes/admin.js';
|
||||||
|
import { router as v1Routes } from './routes/v1.js';
|
||||||
|
import { router as roomRoutes } from './routes/rooms.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
export async function startServer(opts = {}) {
|
||||||
|
const port = Number(opts.port ?? process.env.PORT ?? 4173);
|
||||||
|
const host = opts.host ?? process.env.HOST ?? '0.0.0.0';
|
||||||
|
const dbPath = opts.dbPath ?? process.env.DB_PATH ?? './data/db/oradio.sqlite';
|
||||||
|
|
||||||
|
initDb(dbPath);
|
||||||
|
ensureBootstrapAdmin({
|
||||||
|
username: process.env.ADMIN_BOOTSTRAP_USER,
|
||||||
|
password: process.env.ADMIN_BOOTSTRAP_PASSWORD
|
||||||
|
});
|
||||||
|
const seedResult = applySeedIfEmpty();
|
||||||
|
console.log('[seed]', seedResult);
|
||||||
|
ensureImageDirs();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '512kb' }));
|
||||||
|
app.use(authMiddleware);
|
||||||
|
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/stations', stationRoutes);
|
||||||
|
app.use('/api/me', meRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/rooms', roomRoutes);
|
||||||
|
app.use('/api/v1', v1Routes);
|
||||||
|
|
||||||
|
// Locally-cached cover art and other media live under the configured image
|
||||||
|
// root and are served unauthenticated on the LAN. Long cache OK — file names
|
||||||
|
// include the station id and we rewrite the file on update.
|
||||||
|
app.use('/media', express.static(getImageRoot(), {
|
||||||
|
maxAge: '1h',
|
||||||
|
fallthrough: false,
|
||||||
|
setHeaders(res) { res.setHeader('Cache-Control', 'public, max-age=3600'); }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Static assets (built by Vite). In dev these don't exist; Vite serves them on :5173.
|
||||||
|
const publicDir = opts.publicDir ?? resolve(__dirname, 'public');
|
||||||
|
const sendHtml = (file) => (_req, res) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.sendFile(resolve(publicDir, file));
|
||||||
|
};
|
||||||
|
if (existsSync(publicDir)) {
|
||||||
|
app.use(express.static(publicDir, {
|
||||||
|
setHeaders(res, filePath) {
|
||||||
|
if (filePath.endsWith('.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
} else if (filePath.includes(`${publicDir}\\assets\\`) || filePath.includes(`${publicDir}/assets/`)) {
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
app.get('/admin', sendHtml('admin/index.html'));
|
||||||
|
app.get('/docs', sendHtml('docs/index.html'));
|
||||||
|
app.get('/master', sendHtml('master/index.html'));
|
||||||
|
app.get('*', sendHtml('index.html'));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: String(err.message || err) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = createServer(app);
|
||||||
|
attachWs(server);
|
||||||
|
scheduleHealthCheck(process.env.STREAM_CHECK_CRON);
|
||||||
|
|
||||||
|
await new Promise((resolveListen) => server.listen(port, host, resolveListen));
|
||||||
|
const addr = server.address();
|
||||||
|
const actualPort = (addr && typeof addr === 'object') ? addr.port : port;
|
||||||
|
console.log(`[oradio] api+ws on http://${host}:${actualPort}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
port: actualPort,
|
||||||
|
host,
|
||||||
|
stop: () => new Promise((res) => server.close(() => res()))
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user