Add master display UI with audio output management and styling
- Implement main.js for the master display functionality, including WebSocket connection, audio output management, and state handling. - Create style.css for the master display's visual design, ensuring a cohesive look and feel with a dark theme and responsive layout. - Integrate device management with a fallback for non-Electron environments, allowing users to select audio outputs. - Add features for managing favorites, including toggling favorites and filtering by genre. - Enhance user experience with a responsive favorites grid and drag-to-scroll functionality.
This commit is contained in:
@@ -34,6 +34,12 @@ function runMigrations(db) {
|
||||
if (!stationCols.has('category')) {
|
||||
db.exec('ALTER TABLE stations ADD COLUMN category TEXT');
|
||||
}
|
||||
if (!stationCols.has('image_path')) {
|
||||
db.exec('ALTER TABLE stations ADD COLUMN image_path TEXT');
|
||||
}
|
||||
if (!stationCols.has('image_source')) {
|
||||
db.exec('ALTER TABLE stations ADD COLUMN image_source TEXT');
|
||||
}
|
||||
const streamCols = new Set(db.prepare("PRAGMA table_info(streams)").all().map((c) => c.name));
|
||||
if (!streamCols.has('uuid')) {
|
||||
db.exec('ALTER TABLE streams ADD COLUMN uuid TEXT');
|
||||
@@ -55,5 +61,15 @@ function runMigrations(db) {
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_stations_uuid ON stations(uuid)');
|
||||
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_streams_uuid ON streams(uuid)');
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_stations_category ON stations(category)');
|
||||
|
||||
// station_plays gained session/listen-time aggregates so the leaderboard
|
||||
// can rank by actual playtime, not just play-button taps.
|
||||
const playCols = new Set(db.prepare("PRAGMA table_info(station_plays)").all().map((c) => c.name));
|
||||
if (!playCols.has('sessions')) {
|
||||
db.exec('ALTER TABLE station_plays ADD COLUMN sessions INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
if (!playCols.has('total_play_ms')) {
|
||||
db.exec('ALTER TABLE station_plays ADD COLUMN total_play_ms INTEGER NOT NULL DEFAULT 0');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS stations (
|
||||
genres TEXT, -- JSON array
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
image_path TEXT, -- relative path under data/images, e.g. "stations/12.jpg"
|
||||
image_source TEXT, -- 'remote' | 'scraped' | 'upload'
|
||||
source TEXT NOT NULL CHECK (source IN ('seed','radiobrowser','manual')),
|
||||
source_ref TEXT,
|
||||
category TEXT,
|
||||
@@ -93,8 +95,43 @@ CREATE INDEX IF NOT EXISTS idx_votes_station ON station_votes(station_id);
|
||||
|
||||
-- Aggregate play counter. Cheaper than COUNT(*) over play_history every render
|
||||
-- and lets anonymous/public listing show play counts without exposing history.
|
||||
-- `total_play_ms` and `sessions` accumulate from closed play_history rows so
|
||||
-- the leaderboard can rank by actual listen time, not just play-button taps.
|
||||
CREATE TABLE IF NOT EXISTS station_plays (
|
||||
station_id INTEGER PRIMARY KEY REFERENCES stations(id) ON DELETE CASCADE,
|
||||
plays INTEGER NOT NULL DEFAULT 0,
|
||||
sessions INTEGER NOT NULL DEFAULT 0,
|
||||
total_play_ms INTEGER NOT NULL DEFAULT 0,
|
||||
last_played_at TEXT
|
||||
);
|
||||
|
||||
-- Named listening rooms. One "display" client + many controller/panel clients
|
||||
-- per room share state (now-playing, volume, votes). A personal room is
|
||||
-- auto-provisioned per user so single-user kiosks Just Work.
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_members (
|
||||
room_id INTEGER NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner','member','guest')),
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (room_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id);
|
||||
|
||||
-- Last-known playback state per room. Persisted so clients reconnecting see
|
||||
-- the same now-playing card immediately, even after a server restart.
|
||||
CREATE TABLE IF NOT EXISTS room_state (
|
||||
room_id INTEGER PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
|
||||
station_id INTEGER REFERENCES stations(id) ON DELETE SET NULL,
|
||||
playing INTEGER NOT NULL DEFAULT 0,
|
||||
volume REAL NOT NULL DEFAULT 0.7,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
@@ -10,12 +10,14 @@ 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));
|
||||
const PORT = Number(process.env.PORT) || 4173;
|
||||
@@ -27,6 +29,7 @@ ensureBootstrapAdmin({
|
||||
});
|
||||
const seedResult = applySeedIfEmpty();
|
||||
console.log('[seed]', seedResult);
|
||||
ensureImageDirs();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '512kb' }));
|
||||
@@ -36,15 +39,40 @@ 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));
|
||||
app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html')));
|
||||
app.get('/docs', (_req, res) => res.sendFile(resolve(publicDir, 'docs/index.html')));
|
||||
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html')));
|
||||
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) => {
|
||||
|
||||
168
server/media/images.js
Normal file
168
server/media/images.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// Local cover-art storage for stations.
|
||||
//
|
||||
// Files live under <repo>/data/images/stations/<id>.<ext> and are served by
|
||||
// express.static at /media/stations/<id>.<ext>. The DB tracks just the
|
||||
// relative path in stations.image_path (e.g. "stations/12.jpg"), while
|
||||
// stations.image_url keeps the original remote URL for refetch/debugging.
|
||||
|
||||
import { mkdirSync, existsSync, writeFileSync, readdirSync, unlinkSync, statSync } from 'node:fs';
|
||||
import { resolve, join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getDb } from '../db/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..', 'data', 'images');
|
||||
const STATIONS_DIR = join(ROOT, 'stations');
|
||||
// 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.
|
||||
// Override via env if you want to publish a contact URL.
|
||||
const UA = process.env.IMAGE_FETCH_UA
|
||||
|| 'Mozilla/5.0 (compatible; OnlineRadioExplorer/0.1; +https://github.com/marcoheine/onlineRadioExplorer)';
|
||||
const FETCH_TIMEOUT_MS = 10_000;
|
||||
const MAX_BYTES = 4 * 1024 * 1024; // 4 MB per image
|
||||
|
||||
const MIME_EXT = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/pjpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
'image/svg+xml': 'svg',
|
||||
'image/x-icon': 'ico',
|
||||
'image/vnd.microsoft.icon': 'ico'
|
||||
};
|
||||
|
||||
export function ensureImageDirs() {
|
||||
mkdirSync(STATIONS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export function getImageRoot() { return ROOT; }
|
||||
|
||||
// Strip any existing file for this station id (with any extension).
|
||||
function removeExistingStationFile(id) {
|
||||
if (!existsSync(STATIONS_DIR)) return;
|
||||
const prefix = `${id}.`;
|
||||
for (const f of readdirSync(STATIONS_DIR)) {
|
||||
if (f.startsWith(prefix)) {
|
||||
try { unlinkSync(join(STATIONS_DIR, f)); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extFromMime(mime) {
|
||||
if (!mime) return null;
|
||||
const base = mime.split(';')[0].trim().toLowerCase();
|
||||
return MIME_EXT[base] || null;
|
||||
}
|
||||
|
||||
function extFromMagic(buf) {
|
||||
if (buf.length < 12) return null;
|
||||
// PNG
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return 'png';
|
||||
// JPEG
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return 'jpg';
|
||||
// GIF
|
||||
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif';
|
||||
// WEBP: "RIFF...WEBP"
|
||||
if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
||||
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return 'webp';
|
||||
// SVG: starts with "<?xml" or "<svg"
|
||||
const head = buf.slice(0, 256).toString('utf8').trimStart().toLowerCase();
|
||||
if (head.startsWith('<?xml') || head.startsWith('<svg')) return 'svg';
|
||||
// ICO
|
||||
if (buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0x01 && buf[3] === 0x00) return 'ico';
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadToBuffer(url) {
|
||||
const ctl = new AbortController();
|
||||
const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': UA, 'Accept': 'image/*' },
|
||||
redirect: 'follow',
|
||||
signal: ctl.signal
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error('no body');
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
received += value.length;
|
||||
if (received > MAX_BYTES) { try { await reader.cancel(); } catch { } throw new Error('too large'); }
|
||||
chunks.push(Buffer.from(value));
|
||||
}
|
||||
return { buffer: Buffer.concat(chunks), contentType: ct };
|
||||
} finally { clearTimeout(t); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a buffer as the station's cover-art file and update the DB.
|
||||
* Returns the relative path (e.g. "stations/12.jpg").
|
||||
*/
|
||||
export function saveStationImageFromBuffer(stationId, buf, mime, { source = 'upload' } = {}) {
|
||||
ensureImageDirs();
|
||||
// Reject obvious HTML responses (404 pages, SPA index, login walls) even
|
||||
// when the upstream lies about the content-type.
|
||||
const head = buf.slice(0, 512).toString('utf8').trimStart().toLowerCase();
|
||||
if (head.startsWith('<!doctype html') || head.startsWith('<html')) {
|
||||
throw new Error('response is HTML, not an image');
|
||||
}
|
||||
const ext = extFromMime(mime) || extFromMagic(buf);
|
||||
if (!ext) throw new Error('unsupported image type');
|
||||
removeExistingStationFile(stationId);
|
||||
const fileName = `${stationId}.${ext}`;
|
||||
writeFileSync(join(STATIONS_DIR, fileName), buf);
|
||||
const rel = `stations/${fileName}`;
|
||||
getDb().prepare(
|
||||
"UPDATE stations SET image_path = ?, image_source = ?, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(rel, source, stationId);
|
||||
return rel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an image from a URL and store it locally for the station.
|
||||
* Returns the relative path or null on failure.
|
||||
*/
|
||||
export async function saveStationImageFromUrl(stationId, url, { source = 'remote' } = {}) {
|
||||
if (!url) return null;
|
||||
let dl;
|
||||
try {
|
||||
dl = await downloadToBuffer(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return saveStationImageFromBuffer(stationId, dl.buffer, dl.contentType, { source });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteStationImage(stationId) {
|
||||
removeExistingStationFile(stationId);
|
||||
getDb().prepare(
|
||||
"UPDATE stations SET image_path = NULL, image_source = NULL, updated_at = datetime('now') WHERE id = ?"
|
||||
).run(stationId);
|
||||
}
|
||||
|
||||
/** Public URL the kiosk should use. Local if cached, else the remote URL, else null. */
|
||||
export function publicImageUrl({ image_path, image_url } = {}) {
|
||||
if (image_path) return `/media/${image_path}`;
|
||||
return image_url || null;
|
||||
}
|
||||
|
||||
/** Total bytes used by the station-image cache (best effort). */
|
||||
export function imageCacheStats() {
|
||||
if (!existsSync(STATIONS_DIR)) return { files: 0, bytes: 0 };
|
||||
let files = 0, bytes = 0;
|
||||
for (const f of readdirSync(STATIONS_DIR)) {
|
||||
try { bytes += statSync(join(STATIONS_DIR, f)).size; files++; } catch { }
|
||||
}
|
||||
return { files, bytes };
|
||||
}
|
||||
@@ -5,10 +5,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radio Admin</title>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/admin-GqZPhz-K.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/dom-BvorgAdo.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/admin-C-qnWY0z.css">
|
||||
</head>
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
server/public/assets/admin-GqZPhz-K.js
Normal file
1
server/public/assets/admin-GqZPhz-K.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
async function a(t,i,o){const s=await fetch(i,{method:t,credentials:"same-origin",headers:o?{"Content-Type":"application/json"}:{},body:o?JSON.stringify(o):void 0});if(s.status===204)return null;const e=(s.headers.get("content-type")||"").includes("json")?await s.json():await s.text();if(!s.ok)throw Object.assign(new Error((e==null?void 0:e.error)||s.statusText),{status:s.status,data:e});return e}const c={get:t=>a("GET",t),post:(t,i)=>a("POST",t,i),put:(t,i)=>a("PUT",t,i),patch:(t,i)=>a("PATCH",t,i),del:t=>a("DELETE",t)};function r(t,i={},...o){const s=document.createElement(t);for(const[n,e]of Object.entries(i||{}))n==="class"?s.className=e:n==="style"&&typeof e=="object"?Object.assign(s.style,e):n.startsWith("on")&&typeof e=="function"?s.addEventListener(n.slice(2).toLowerCase(),e):n==="html"?s.innerHTML=e:e!==!1&&e!=null&&s.setAttribute(n,e===!0?"":e);for(const n of o.flat())n==null||n===!1||s.appendChild(n instanceof Node?n:document.createTextNode(String(n)));return s}function l(t){for(;t.firstChild;)t.removeChild(t.firstChild)}export{c as a,l as c,r as e};
|
||||
File diff suppressed because one or more lines are too long
1
server/public/assets/kiosk-CzWLja7k.js
Normal file
1
server/public/assets/kiosk-CzWLja7k.js
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/kiosk-PzkUrLf6.css
Normal file
1
server/public/assets/kiosk-PzkUrLf6.css
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/master-CpJfsvtJ.css
Normal file
1
server/public/assets/master-CpJfsvtJ.css
Normal file
File diff suppressed because one or more lines are too long
1
server/public/assets/master-kSyrThjc.js
Normal file
1
server/public/assets/master-kSyrThjc.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
server/public/assets/ws-BM1PmMVd.js
Normal file
1
server/public/assets/ws-BM1PmMVd.js
Normal file
@@ -0,0 +1 @@
|
||||
function l(s,n={}){let e,r=0,c=!1;function i(){const a=location.protocol==="https:"?"wss":"ws",o=new URLSearchParams;n.room&&o.set("room",n.room),n.kind&&o.set("kind",n.kind);const d=o.toString();e=new WebSocket(`${a}://${location.host}/ws${d?"?"+d:""}`),e.addEventListener("open",()=>{var t;r=0,(t=n.onOpen)==null||t.call(n)}),e.addEventListener("message",t=>{try{s(JSON.parse(t.data))}catch{}}),e.addEventListener("close",()=>{var t;(t=n.onClose)==null||t.call(n),!c&&(r=Math.min(r+1,6),setTimeout(i,500*2**r))}),e.addEventListener("error",()=>e.close())}return i(),{send(a){(e==null?void 0:e.readyState)===WebSocket.OPEN&&e.send(JSON.stringify(a))},close(){c=!0,e==null||e.close()},get readyState(){return e==null?void 0:e.readyState}}}export{l as c};
|
||||
@@ -21,4 +21,4 @@
|
||||
</header>
|
||||
<main id="app"></main>
|
||||
|
||||
</body>
|
||||
</body>
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<title>Radio Kiosk</title>
|
||||
|
||||
|
||||
<script type="module" crossorigin src="/assets/kiosk-CzWLja7k.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/dom-BvorgAdo.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ws-BM1PmMVd.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/kiosk-PzkUrLf6.css">
|
||||
</head>
|
||||
|
||||
|
||||
19
server/public/master/index.html
Normal file
19
server/public/master/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Radio Master</title>
|
||||
|
||||
<script type="module" crossorigin src="/assets/master-kSyrThjc.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/player-BBOsFRH-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ws-BM1PmMVd.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/master-CpJfsvtJ.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
</body>
|
||||
142
server/rooms.js
Normal file
142
server/rooms.js
Normal file
@@ -0,0 +1,142 @@
|
||||
// Room model: named multi-client listening sessions.
|
||||
//
|
||||
// Each user has an auto-provisioned "personal" room (slug = `u-<id>`) so the
|
||||
// kiosk Just Works on first login. Shared rooms are explicit creations and
|
||||
// have a member list.
|
||||
|
||||
import { getDb } from './db/index.js';
|
||||
import { slugify } from './stations.js';
|
||||
|
||||
function rowToRoom(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
created_by: row.created_by,
|
||||
created_at: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export function getRoomBySlug(slug) {
|
||||
return rowToRoom(getDb().prepare('SELECT * FROM rooms WHERE slug = ?').get(slug));
|
||||
}
|
||||
|
||||
export function getRoomById(id) {
|
||||
return rowToRoom(getDb().prepare('SELECT * FROM rooms WHERE id = ?').get(id));
|
||||
}
|
||||
|
||||
export function isMember(roomId, userId) {
|
||||
if (!roomId || !userId) return false;
|
||||
const row = getDb().prepare(
|
||||
'SELECT 1 FROM room_members WHERE room_id = ? AND user_id = ?'
|
||||
).get(roomId, userId);
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export function listRoomsForUser(userId) {
|
||||
return getDb().prepare(`
|
||||
SELECT r.*, rm.role
|
||||
FROM rooms r
|
||||
JOIN room_members rm ON rm.room_id = r.id
|
||||
WHERE rm.user_id = ?
|
||||
ORDER BY r.name COLLATE NOCASE
|
||||
`).all(userId).map((row) => ({ ...rowToRoom(row), role: row.role }));
|
||||
}
|
||||
|
||||
export function listMembers(roomId) {
|
||||
return getDb().prepare(`
|
||||
SELECT u.id, u.username, rm.role, rm.created_at
|
||||
FROM room_members rm JOIN users u ON u.id = rm.user_id
|
||||
WHERE rm.room_id = ?
|
||||
ORDER BY u.username COLLATE NOCASE
|
||||
`).all(roomId);
|
||||
}
|
||||
|
||||
function uniqueRoomSlug(base) {
|
||||
const db = getDb();
|
||||
let slug = base, n = 1;
|
||||
while (db.prepare('SELECT 1 FROM rooms WHERE slug = ?').get(slug)) {
|
||||
n += 1;
|
||||
slug = `${base}-${n}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export function createRoom({ name, slug, ownerId }) {
|
||||
if (!name) throw new Error('name required');
|
||||
const db = getDb();
|
||||
const baseSlug = slug || slugify(name);
|
||||
const finalSlug = uniqueRoomSlug(baseSlug);
|
||||
const info = db.prepare(
|
||||
'INSERT INTO rooms (slug, name, created_by) VALUES (?, ?, ?)'
|
||||
).run(finalSlug, name, ownerId ?? null);
|
||||
const id = info.lastInsertRowid;
|
||||
if (ownerId) {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')"
|
||||
).run(id, ownerId);
|
||||
}
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO room_state (room_id, playing, volume) VALUES (?, 0, 0.7)'
|
||||
).run(id);
|
||||
return getRoomById(id);
|
||||
}
|
||||
|
||||
export function addMember(roomId, userId, role = 'member') {
|
||||
getDb().prepare(
|
||||
'INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, ?)'
|
||||
).run(roomId, userId, role);
|
||||
}
|
||||
|
||||
export function removeMember(roomId, userId) {
|
||||
return getDb().prepare(
|
||||
'DELETE FROM room_members WHERE room_id = ? AND user_id = ?'
|
||||
).run(roomId, userId).changes > 0;
|
||||
}
|
||||
|
||||
/** Idempotently ensure a personal room exists for the user, returning it. */
|
||||
export function ensurePersonalRoom(user) {
|
||||
if (!user) return null;
|
||||
const slug = `u-${user.id}`;
|
||||
const existing = getRoomBySlug(slug);
|
||||
if (existing) return existing;
|
||||
const db = getDb();
|
||||
db.prepare(
|
||||
'INSERT INTO rooms (slug, name, created_by) VALUES (?, ?, ?)'
|
||||
).run(slug, `${user.username}'s room`, user.id);
|
||||
const room = getRoomBySlug(slug);
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO room_members (room_id, user_id, role) VALUES (?, ?, 'owner')"
|
||||
).run(room.id, user.id);
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO room_state (room_id, playing, volume) VALUES (?, 0, 0.7)'
|
||||
).run(room.id);
|
||||
return room;
|
||||
}
|
||||
|
||||
export function getRoomState(roomId) {
|
||||
const row = getDb().prepare('SELECT * FROM room_state WHERE room_id = ?').get(roomId);
|
||||
if (!row) return { station_id: null, playing: false, volume: 0.7, updated_at: null };
|
||||
return {
|
||||
station_id: row.station_id,
|
||||
playing: !!row.playing,
|
||||
volume: row.volume,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export function setRoomState(roomId, patch) {
|
||||
const cur = getRoomState(roomId);
|
||||
const next = { ...cur, ...patch };
|
||||
getDb().prepare(`
|
||||
INSERT INTO room_state (room_id, station_id, playing, volume, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(room_id) DO UPDATE SET
|
||||
station_id = excluded.station_id,
|
||||
playing = excluded.playing,
|
||||
volume = excluded.volume,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(roomId, next.station_id ?? null, next.playing ? 1 : 0, next.volume ?? 0.7);
|
||||
return getRoomState(roomId);
|
||||
}
|
||||
@@ -1,14 +1,29 @@
|
||||
import { Router } from 'express';
|
||||
import express from 'express';
|
||||
import { requireAdmin } from '../auth.js';
|
||||
import { runHealthCheck } from '../streams/checker.js';
|
||||
import { probeStream } from '../streams/probe.js';
|
||||
import { applySeedIfEmpty } from '../sources/seed.js';
|
||||
import { getDb } from '../db/index.js';
|
||||
import { scrapeIcon } from '../sources/iconScraper.js';
|
||||
import { listStations, getStation, updateStation } from '../stations.js';
|
||||
import {
|
||||
listStations, getStation, updateStation, deleteStation,
|
||||
getStreamsForStation, addStream, deleteStream
|
||||
} from '../stations.js';
|
||||
import {
|
||||
saveStationImageFromUrl, saveStationImageFromBuffer,
|
||||
deleteStationImage, imageCacheStats
|
||||
} from '../media/images.js';
|
||||
import { broadcastGlobal } from '../ws.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(requireAdmin);
|
||||
|
||||
// Raw body parser used only by the image upload route. The global JSON
|
||||
// parser is mounted before us so we have to opt-out for `image/*`.
|
||||
const rawImageBody = express.raw({ type: ['image/*', 'application/octet-stream'], limit: '5mb' });
|
||||
|
||||
|
||||
router.post('/health-check', async (_req, res) => {
|
||||
const n = await runHealthCheck();
|
||||
res.json({ checked: n });
|
||||
@@ -20,25 +35,31 @@ router.post('/reseed', (_req, res) => {
|
||||
|
||||
router.get('/system', (_req, res) => {
|
||||
const db = getDb();
|
||||
const img = imageCacheStats();
|
||||
res.json({
|
||||
stations: db.prepare('SELECT COUNT(*) AS n FROM stations').get().n,
|
||||
streams: db.prepare('SELECT COUNT(*) AS n FROM streams').get().n,
|
||||
users: db.prepare('SELECT COUNT(*) AS n FROM users').get().n,
|
||||
favorites: db.prepare('SELECT COUNT(*) AS n FROM favorites').get().n,
|
||||
image_cache: img,
|
||||
node: process.version,
|
||||
uptime_s: Math.round(process.uptime())
|
||||
});
|
||||
});
|
||||
|
||||
// Scrape an icon for a single station.
|
||||
// Scrape an icon for a single station and cache it locally.
|
||||
router.post('/stations/:id/scrape-icon', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const st = getStation(id);
|
||||
if (!st) return res.status(404).json({ error: 'not found' });
|
||||
const url = await scrapeIcon(st);
|
||||
if (!url) return res.status(404).json({ error: 'no icon found' });
|
||||
const updated = updateStation(id, { image_url: url });
|
||||
res.json({ id, image_url: url, station: updated });
|
||||
// Persist the remote URL as the canonical source...
|
||||
updateStation(id, { image_url: url });
|
||||
// ...and try to cache it locally. Failure to cache is non-fatal.
|
||||
const rel = await saveStationImageFromUrl(id, url, { source: 'scraped' });
|
||||
const station = getStation(id);
|
||||
res.json({ id, image_url: url, image_path: rel, station });
|
||||
});
|
||||
|
||||
// Bulk: scrape icons for every station (optionally only those missing one).
|
||||
@@ -56,8 +77,9 @@ router.post('/scrape-icons', async (req, res) => {
|
||||
const url = await scrapeIcon(s);
|
||||
if (url) {
|
||||
updateStation(s.id, { image_url: url });
|
||||
const rel = await saveStationImageFromUrl(s.id, url, { source: 'scraped' });
|
||||
results.updated++;
|
||||
results.items.push({ id: s.id, name: s.name, image_url: url });
|
||||
results.items.push({ id: s.id, name: s.name, image_url: url, image_path: rel });
|
||||
} else {
|
||||
results.failed++;
|
||||
results.items.push({ id: s.id, name: s.name, image_url: null });
|
||||
@@ -71,3 +93,213 @@ router.post('/scrape-icons', async (req, res) => {
|
||||
await Promise.all(Array.from({ length: concurrency }, worker));
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
|
||||
// ---------- Station edit (admin can override DB fields) ----------
|
||||
// Plain PATCH /api/stations/:id already exists for admins. We add a sibling
|
||||
// here so the admin UI can hit /api/admin/stations/:id consistently.
|
||||
router.patch('/stations/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const st = updateStation(id, req.body || {});
|
||||
if (!st) return res.status(404).json({ error: 'not found' });
|
||||
broadcastGlobal({ type: 'station-updated', stationId: id });
|
||||
res.json(st);
|
||||
});
|
||||
|
||||
router.delete('/stations/:id', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!deleteStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
deleteStationImage(id);
|
||||
broadcastGlobal({ type: 'station-deleted', stationId: id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ---------- Image management ----------
|
||||
// Raw upload: PUT /api/admin/stations/:id/image (Content-Type: image/*)
|
||||
router.put('/stations/:id/image', rawImageBody, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
const buf = req.body;
|
||||
if (!Buffer.isBuffer(buf) || !buf.length) return res.status(400).json({ error: 'no body' });
|
||||
const mime = req.get('content-type') || 'application/octet-stream';
|
||||
try {
|
||||
const rel = saveStationImageFromBuffer(id, buf, mime, { source: 'upload' });
|
||||
broadcastGlobal({ type: 'station-updated', stationId: id });
|
||||
res.json({ id, image_path: rel, station: getStation(id) });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: String(err.message || err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Re-download the current remote image_url into the local cache.
|
||||
router.post('/stations/:id/image/refetch', async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const st = getStation(id);
|
||||
if (!st) return res.status(404).json({ error: 'not found' });
|
||||
const target = (req.body && req.body.url) || st.image_url;
|
||||
if (!target) return res.status(400).json({ error: 'no image_url to refetch' });
|
||||
if (req.body && req.body.url) updateStation(id, { image_url: target });
|
||||
const rel = await saveStationImageFromUrl(id, target, { source: 'remote' });
|
||||
if (!rel) return res.status(502).json({ error: 'download failed' });
|
||||
broadcastGlobal({ type: 'station-updated', stationId: id });
|
||||
res.json({ id, image_path: rel, station: getStation(id) });
|
||||
});
|
||||
|
||||
// Drop the local cache entry (keeps remote image_url).
|
||||
router.delete('/stations/:id/image', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
deleteStationImage(id);
|
||||
broadcastGlobal({ type: 'station-updated', stationId: id });
|
||||
res.json({ ok: true, station: getStation(id) });
|
||||
});
|
||||
|
||||
// ---------- Streams CRUD ----------
|
||||
router.get('/stations/:id/streams', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
res.json(getStreamsForStation(id));
|
||||
});
|
||||
|
||||
router.post('/stations/:id/streams', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
res.status(201).json(addStream(id, req.body || {}));
|
||||
});
|
||||
|
||||
router.patch('/streams/:streamId', (req, res) => {
|
||||
const sid = Number(req.params.streamId);
|
||||
const db = getDb();
|
||||
const cur = db.prepare('SELECT * FROM streams WHERE id = ?').get(sid);
|
||||
if (!cur) return res.status(404).json({ error: 'not found' });
|
||||
const next = { ...cur, ...(req.body || {}) };
|
||||
db.prepare(`UPDATE streams SET url = ?, format = ?, bitrate = ?, label = ?, priority = ? WHERE id = ?`)
|
||||
.run(next.url, next.format, next.bitrate || null, next.label || null, next.priority || 0, sid);
|
||||
res.json(db.prepare('SELECT * FROM streams WHERE id = ?').get(sid));
|
||||
});
|
||||
|
||||
router.delete('/streams/:streamId', (req, res) => {
|
||||
if (!deleteStream(Number(req.params.streamId))) return res.status(404).json({ error: 'not found' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Probe a single stream on demand (admin UI uses this for a "test" button).
|
||||
router.post('/streams/:streamId/probe', async (req, res) => {
|
||||
const sid = Number(req.params.streamId);
|
||||
const row = getDb().prepare('SELECT * FROM streams WHERE id = ?').get(sid);
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
const status = await probeStream(row.url);
|
||||
getDb().prepare(`UPDATE streams SET last_status = ?, last_checked_at = datetime('now') WHERE id = ?`).run(status, sid);
|
||||
res.json({ id: sid, status });
|
||||
});
|
||||
|
||||
// ---------- Bulk ops ----------
|
||||
router.post('/stations/bulk', async (req, res) => {
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(Number).filter(Number.isFinite) : [];
|
||||
const action = String(req.body?.action || '');
|
||||
if (!ids.length) return res.status(400).json({ error: 'ids required' });
|
||||
const results = { action, count: ids.length, ok: 0, failed: 0, items: [] };
|
||||
for (const id of ids) {
|
||||
try {
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
if (deleteStation(id)) { deleteStationImage(id); results.ok++; }
|
||||
else results.failed++;
|
||||
break;
|
||||
case 'enable':
|
||||
case 'disable':
|
||||
updateStation(id, { enabled: action === 'enable' });
|
||||
results.ok++;
|
||||
break;
|
||||
case 'scrape-icon': {
|
||||
const st = getStation(id);
|
||||
if (!st) { results.failed++; break; }
|
||||
const url = await scrapeIcon(st);
|
||||
if (url) {
|
||||
updateStation(id, { image_url: url });
|
||||
await saveStationImageFromUrl(id, url, { source: 'scraped' });
|
||||
results.ok++;
|
||||
} else results.failed++;
|
||||
break;
|
||||
}
|
||||
case 'refetch-image': {
|
||||
const st = getStation(id);
|
||||
if (!st?.image_url) { results.failed++; break; }
|
||||
const rel = await saveStationImageFromUrl(id, st.image_url, { source: 'remote' });
|
||||
if (rel) results.ok++; else results.failed++;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return res.status(400).json({ error: 'unknown action' });
|
||||
}
|
||||
results.items.push({ id, ok: true });
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
results.items.push({ id, error: String(err.message || err) });
|
||||
}
|
||||
}
|
||||
broadcastGlobal({ type: 'bulk-completed', action });
|
||||
res.json(results);
|
||||
});
|
||||
|
||||
// ---------- Moderation ----------
|
||||
router.delete('/stations/:id/votes', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
const n = getDb().prepare('DELETE FROM station_votes WHERE station_id = ?').run(id).changes;
|
||||
broadcastGlobal({ type: 'vote', stationId: id, stats: { up: 0, down: 0, score: 0 }, by: 'admin' });
|
||||
res.json({ ok: true, removed: n });
|
||||
});
|
||||
|
||||
router.delete('/stations/:id/plays', (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
const n = getDb().prepare('DELETE FROM station_plays WHERE station_id = ?').run(id).changes;
|
||||
broadcastGlobal({ type: 'plays', stationId: id, plays: 0 });
|
||||
res.json({ ok: true, removed: n });
|
||||
});
|
||||
|
||||
router.get('/leaderboard', (req, res) => {
|
||||
const db = getDb();
|
||||
const top = db.prepare(`
|
||||
SELECT s.id, s.uuid, s.name, s.country, s.image_path, s.image_url,
|
||||
COALESCE((SELECT COUNT(*) FROM station_votes v WHERE v.station_id = s.id AND v.value = 1), 0) AS up,
|
||||
COALESCE((SELECT COUNT(*) FROM station_votes v WHERE v.station_id = s.id AND v.value = -1), 0) AS down,
|
||||
COALESCE(p.plays, 0) AS plays,
|
||||
COALESCE(p.sessions, 0) AS sessions,
|
||||
COALESCE(p.total_play_ms, 0) AS total_play_ms,
|
||||
p.last_played_at AS last_played_at
|
||||
FROM stations s
|
||||
LEFT JOIN station_plays p ON p.station_id = s.id
|
||||
ORDER BY total_play_ms DESC, plays DESC, up DESC
|
||||
LIMIT 50
|
||||
`).all();
|
||||
for (const r of top) {
|
||||
r.avg_session_ms = r.sessions > 0 ? Math.round(r.total_play_ms / r.sessions) : 0;
|
||||
// Mirror what listStations() does so admin UIs can use a single field.
|
||||
r.image_display_url = r.image_path ? `/media/${r.image_path}` : (r.image_url || null);
|
||||
}
|
||||
res.json(top);
|
||||
});
|
||||
|
||||
// ---------- Room admin ----------
|
||||
router.get('/rooms', (_req, res) => {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT r.id, r.slug, r.name, r.created_by, r.created_at,
|
||||
(SELECT COUNT(*) FROM room_members m WHERE m.room_id = r.id) AS members,
|
||||
(SELECT COUNT(*) FROM room_state rs WHERE rs.room_id = r.id AND rs.station_id IS NOT NULL) AS active
|
||||
FROM rooms r
|
||||
ORDER BY r.created_at DESC
|
||||
`).all();
|
||||
res.json(rows);
|
||||
});
|
||||
|
||||
router.delete('/rooms/:slug', (req, res) => {
|
||||
const db = getDb();
|
||||
const row = db.prepare('SELECT id, slug FROM rooms WHERE slug = ?').get(req.params.slug);
|
||||
if (!row) return res.status(404).json({ error: 'not found' });
|
||||
if (row.slug.startsWith('u-')) return res.status(400).json({ error: 'cannot delete personal rooms' });
|
||||
db.prepare('DELETE FROM rooms WHERE id = ?').run(row.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
61
server/routes/rooms.js
Normal file
61
server/routes/rooms.js
Normal file
@@ -0,0 +1,61 @@
|
||||
// /api/rooms — list/create rooms, manage members, fetch state.
|
||||
|
||||
import { Router } from 'express';
|
||||
import { requireUser } from '../auth.js';
|
||||
import {
|
||||
listRoomsForUser, getRoomBySlug, createRoom,
|
||||
addMember, removeMember, listMembers, isMember, getRoomState,
|
||||
ensurePersonalRoom
|
||||
} from '../rooms.js';
|
||||
import { getStation } from '../stations.js';
|
||||
|
||||
export const router = Router();
|
||||
router.use(requireUser);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
// Guarantee a personal room exists for every authenticated user.
|
||||
ensurePersonalRoom(req.user);
|
||||
res.json(listRoomsForUser(req.user.id));
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name, slug } = req.body || {};
|
||||
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
|
||||
const room = createRoom({ name: name.trim(), slug: slug?.trim() || undefined, ownerId: req.user.id });
|
||||
res.status(201).json(room);
|
||||
});
|
||||
|
||||
router.get('/:slug', (req, res) => {
|
||||
const room = getRoomBySlug(req.params.slug);
|
||||
if (!room) return res.status(404).json({ error: 'not found' });
|
||||
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
|
||||
const state = getRoomState(room.id);
|
||||
const station = state.station_id ? getStation(state.station_id) : null;
|
||||
res.json({ ...room, state: { ...state, station } });
|
||||
});
|
||||
|
||||
router.get('/:slug/members', (req, res) => {
|
||||
const room = getRoomBySlug(req.params.slug);
|
||||
if (!room) return res.status(404).json({ error: 'not found' });
|
||||
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
|
||||
res.json(listMembers(room.id));
|
||||
});
|
||||
|
||||
router.post('/:slug/members', (req, res) => {
|
||||
const room = getRoomBySlug(req.params.slug);
|
||||
if (!room) return res.status(404).json({ error: 'not found' });
|
||||
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
|
||||
const userId = Number(req.body?.user_id);
|
||||
if (!userId) return res.status(400).json({ error: 'user_id required' });
|
||||
const role = req.body?.role === 'guest' ? 'guest' : 'member';
|
||||
addMember(room.id, userId, role);
|
||||
res.json(listMembers(room.id));
|
||||
});
|
||||
|
||||
router.delete('/:slug/members/:userId', (req, res) => {
|
||||
const room = getRoomBySlug(req.params.slug);
|
||||
if (!room) return res.status(404).json({ error: 'not found' });
|
||||
if (!isMember(room.id, req.user.id)) return res.status(403).json({ error: 'not a member' });
|
||||
removeMember(room.id, Number(req.params.userId));
|
||||
res.json(listMembers(room.id));
|
||||
});
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
import { resolveStream } from '../streams/resolver.js';
|
||||
import { requireAdmin, requireUser } from '../auth.js';
|
||||
import * as radiobrowser from '../sources/radiobrowser.js';
|
||||
import { castVote, getStationStats, getStatsMap, recordPlay, sortByMode } from '../stats.js';
|
||||
import { castVote, getStationStats, getStatsMap, recordPlay, endPlaySession, sortByMode } from '../stats.js';
|
||||
import { broadcastGlobal } from '../ws.js';
|
||||
|
||||
export const router = Router();
|
||||
|
||||
@@ -51,15 +52,41 @@ router.post('/:id/vote', requireUser, (req, res) => {
|
||||
: raw === 0 || raw === '0' || raw === null || raw === 'clear' ? 0
|
||||
: NaN;
|
||||
if (Number.isNaN(value)) return res.status(400).json({ error: 'value must be 1, -1 or 0' });
|
||||
res.json(castVote(req.user.id, id, value));
|
||||
const stats = castVote(req.user.id, id, value);
|
||||
// Tell every open client so other panels' vote counts update live.
|
||||
broadcastGlobal({ type: 'vote', stationId: id, stats: { up: stats.up, down: stats.down, score: stats.score }, by: req.user.username });
|
||||
res.json(stats);
|
||||
});
|
||||
|
||||
// Lightweight play-count ping (called when the kiosk actually starts a station).
|
||||
// Opens a listening session in play_history; the returned `sessionId` should be
|
||||
// echoed back to POST /api/stations/:id/play/end so we can credit total listen
|
||||
// time toward the leaderboard score.
|
||||
router.post('/:id/play', requireUser, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
recordPlay(id);
|
||||
res.json(getStationStats(id, req.user.id));
|
||||
const streamId = Number.isFinite(Number(req.body?.streamId)) ? Number(req.body.streamId) : null;
|
||||
const sessionId = recordPlay(id, req.user.id, streamId);
|
||||
const stats = getStationStats(id, req.user.id);
|
||||
broadcastGlobal({ type: 'plays', stationId: id, plays: stats.plays });
|
||||
res.json({ ...stats, sessionId });
|
||||
});
|
||||
|
||||
// Close a session opened by POST /:id/play. Idempotent — calling twice or with
|
||||
// an unknown id silently no-ops. Accepts an optional `duration_ms` so a client
|
||||
// that knows the real listened time (e.g. minus buffering stalls) can be honest.
|
||||
router.post('/:id/play/end', requireUser, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
|
||||
const sessionId = Number(req.body?.sessionId);
|
||||
if (!Number.isFinite(sessionId)) return res.status(400).json({ error: 'sessionId required' });
|
||||
const rawMs = req.body?.duration_ms;
|
||||
const ms = rawMs == null ? null : Number(rawMs);
|
||||
const closed = endPlaySession(sessionId, req.user.id, ms);
|
||||
if (closed == null) return res.json({ ok: false });
|
||||
const stats = getStationStats(closed, req.user.id);
|
||||
broadcastGlobal({ type: 'plays', stationId: closed, plays: stats.plays });
|
||||
res.json({ ok: true, ...stats });
|
||||
});
|
||||
|
||||
router.post('/:id/resolve', requireUser, async (req, res) => {
|
||||
|
||||
@@ -50,7 +50,9 @@ function publicStation(s) {
|
||||
country: s.country,
|
||||
genres: s.genres,
|
||||
description: s.description,
|
||||
image_url: s.image_url,
|
||||
// Prefer the locally-cached file when available so public API consumers
|
||||
// get a stable, fast URL on this host instead of upstream link rot.
|
||||
image_url: s.image_display_url || s.image_url,
|
||||
category: s.category,
|
||||
enabled: s.enabled,
|
||||
up: s.up ?? 0,
|
||||
|
||||
65
server/scripts/download-images.js
Normal file
65
server/scripts/download-images.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// Backfill cover-art for every station missing a local image.
|
||||
//
|
||||
// node server/scripts/download-images.js # only stations missing a local file
|
||||
// node server/scripts/download-images.js --all # re-download even cached stations
|
||||
// node server/scripts/download-images.js --no-scrape # skip homepage scrape fallback
|
||||
//
|
||||
// Strategy per station:
|
||||
// 1. Try downloading the existing image_url.
|
||||
// 2. On failure (404, HTML, dead host, ...), call scrapeIcon() to find a
|
||||
// fresh icon from Radio-Browser / the station homepage, persist the new
|
||||
// URL, and try again.
|
||||
//
|
||||
// Safe to re-run.
|
||||
|
||||
import 'dotenv/config';
|
||||
import { initDb } from '../db/index.js';
|
||||
import { listStations, updateStation } from '../stations.js';
|
||||
import { saveStationImageFromUrl, ensureImageDirs } from '../media/images.js';
|
||||
import { scrapeIcon } from '../sources/iconScraper.js';
|
||||
|
||||
const FORCE = process.argv.includes('--all');
|
||||
const NO_SCRAPE = process.argv.includes('--no-scrape');
|
||||
const CONCURRENCY = 4;
|
||||
|
||||
initDb(process.env.DB_PATH || './data/db/oradio.sqlite');
|
||||
ensureImageDirs();
|
||||
|
||||
const all = listStations({ enabled: null });
|
||||
const todo = all.filter((s) => FORCE || !s.image_path);
|
||||
console.log(`[images] ${todo.length} of ${all.length} stations to fetch (force=${FORCE}, scrape=${!NO_SCRAPE})`);
|
||||
|
||||
let i = 0, ok = 0, scraped = 0, fail = 0;
|
||||
async function worker() {
|
||||
while (i < todo.length) {
|
||||
const idx = i++;
|
||||
const s = todo[idx];
|
||||
const label = `[${idx + 1}/${todo.length}] ${s.name}`;
|
||||
|
||||
// 1. Try the existing remote URL.
|
||||
let rel = s.image_url
|
||||
? await saveStationImageFromUrl(s.id, s.image_url, { source: s.image_source || 'remote' })
|
||||
: null;
|
||||
|
||||
// 2. Fallback: scrape a fresh icon from the homepage / Radio-Browser.
|
||||
if (!rel && !NO_SCRAPE) {
|
||||
const found = await scrapeIcon(s);
|
||||
if (found && found !== s.image_url) {
|
||||
updateStation(s.id, { image_url: found });
|
||||
rel = await saveStationImageFromUrl(s.id, found, { source: 'scraped' });
|
||||
if (rel) scraped++;
|
||||
}
|
||||
}
|
||||
|
||||
if (rel) {
|
||||
ok++;
|
||||
console.log(` ${label} -> ${rel}`);
|
||||
} else {
|
||||
fail++;
|
||||
console.log(` ${label} ✗ (${s.image_url || 'no url'})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
||||
console.log(`[images] done. ok=${ok} (scraped=${scraped}) fail=${fail}`);
|
||||
process.exit(0);
|
||||
@@ -7,7 +7,9 @@
|
||||
// 3. HEAD-probe /favicon.ico at the homepage origin.
|
||||
// Returns the best absolute URL found, or null.
|
||||
|
||||
const UA = 'OnlineRadioExplorer/0.1 (+icon-scraper)';
|
||||
// Browser-like UA: many station homepages (Cloudflare, Wikimedia) block opaque bots.
|
||||
const UA = process.env.IMAGE_FETCH_UA
|
||||
|| 'Mozilla/5.0 (compatible; OnlineRadioExplorer/0.1; +https://github.com/marcoheine/onlineRadioExplorer)';
|
||||
const FETCH_TIMEOUT_MS = 8000;
|
||||
const MAX_HTML_BYTES = 256 * 1024;
|
||||
const RB_BASE = 'https://de1.api.radio-browser.info';
|
||||
@@ -45,13 +47,47 @@ async function fetchText(url) {
|
||||
}
|
||||
|
||||
async function head(url) {
|
||||
// We can't trust real HEAD: many CDNs/SPAs return 200 for *every* path with
|
||||
// HTML. So we issue a small ranged GET and check the response is actually
|
||||
// an image (content-type AND/OR magic bytes).
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, { method: 'HEAD', headers: { 'User-Agent': UA }, signal: t.signal, redirect: 'follow' });
|
||||
return res.ok;
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': UA, 'Accept': 'image/*', 'Range': 'bytes=0-1023' },
|
||||
signal: t.signal,
|
||||
redirect: 'follow'
|
||||
});
|
||||
if (!res.ok && res.status !== 206) return false;
|
||||
const ct = (res.headers.get('content-type') || '').toLowerCase().split(';')[0].trim();
|
||||
if (ct.startsWith('text/') || ct.includes('html')) return false;
|
||||
// Sniff the first chunk to make sure it's not HTML masquerading as image/*.
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return ct.startsWith('image/');
|
||||
const { value } = await reader.read();
|
||||
try { await reader.cancel(); } catch { }
|
||||
const buf = value ? Buffer.from(value) : Buffer.alloc(0);
|
||||
const head = buf.slice(0, 256).toString('utf8').trimStart().toLowerCase();
|
||||
if (head.startsWith('<!doctype') || head.startsWith('<html')) return false;
|
||||
if (ct.startsWith('image/')) return true;
|
||||
// No content-type but bytes look like a known image format -> accept.
|
||||
return isImageMagic(buf);
|
||||
} catch { return false; } finally { t.done(); }
|
||||
}
|
||||
|
||||
function isImageMagic(buf) {
|
||||
if (buf.length < 4) return false;
|
||||
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) return true; // PNG
|
||||
if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) return true; // JPEG
|
||||
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return true; // GIF
|
||||
if (buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
|
||||
&& buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50) return true; // WEBP
|
||||
if (buf[0] === 0x00 && buf[1] === 0x00 && buf[2] === 0x01 && buf[3] === 0x00) return true; // ICO
|
||||
const head = buf.slice(0, 256).toString('utf8').trimStart().toLowerCase();
|
||||
if (head.startsWith('<?xml') || head.startsWith('<svg')) return true; // SVG
|
||||
return false;
|
||||
}
|
||||
|
||||
function abs(base, href) {
|
||||
if (!href) return null;
|
||||
try { return new URL(href, base).toString(); } catch { return null; }
|
||||
@@ -93,14 +129,16 @@ function parseIconCandidates(html, baseUrl) {
|
||||
|
||||
async function fromRadioBrowserByName(name) {
|
||||
if (!name) return null;
|
||||
const q = String(name).trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(name)}&limit=3&hidebroken=true&order=clickcount&reverse=true`;
|
||||
const url = `${RB_BASE}/json/stations/search?name=${encodeURIComponent(q)}&limit=3&hidebroken=true&order=clickcount&reverse=true`;
|
||||
const t = withTimeout(FETCH_TIMEOUT_MS);
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA }, signal: t.signal });
|
||||
t.done();
|
||||
if (!res.ok) return null;
|
||||
const list = await res.json();
|
||||
const target = name.toLowerCase().trim();
|
||||
const target = q.toLowerCase();
|
||||
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target);
|
||||
const pick = exact || list[0];
|
||||
if (pick?.favicon) return pick.favicon;
|
||||
@@ -119,12 +157,23 @@ async function fromHomepage(homepage) {
|
||||
if (await head(c.href)) return c.href;
|
||||
}
|
||||
}
|
||||
// last resort: /favicon.ico
|
||||
// last resort on this host: /favicon.ico
|
||||
const ico = `${base.origin}/favicon.ico`;
|
||||
if (await head(ico)) return ico;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Final fallback: Google's public favicon service. Returns a real PNG (the
|
||||
// browser-side favicon Google has on file) for virtually any homepage, so
|
||||
// even SPA/JS-only sites end up with *some* artwork.
|
||||
function fromGoogleFavicon(homepage, size = 128) {
|
||||
if (!homepage) return null;
|
||||
let host;
|
||||
try { host = new URL(homepage).hostname; } catch { return null; }
|
||||
if (!host) return null;
|
||||
return `https://www.google.com/s2/favicons?sz=${size}&domain=${encodeURIComponent(host)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find an icon URL for a station.
|
||||
* @param {{ name?: string, homepage?: string|null, source?: string }} station
|
||||
@@ -135,7 +184,13 @@ export async function scrapeIcon(station) {
|
||||
// For non-RB stations, RB often still has an entry → cheap win.
|
||||
if (station.source !== 'radiobrowser') {
|
||||
const rb = await fromRadioBrowserByName(station.name);
|
||||
if (rb) return rb;
|
||||
if (rb && await head(rb)) return rb;
|
||||
}
|
||||
return fromHomepage(station.homepage);
|
||||
const fromPage = await fromHomepage(station.homepage);
|
||||
if (fromPage) return fromPage;
|
||||
// Last-ditch: ask Google's favicon service. It almost always returns a
|
||||
// 128×128 PNG, even for SPA-only homepages where direct scraping fails.
|
||||
const g = fromGoogleFavicon(station.homepage, 128);
|
||||
if (g && await head(g)) return g;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { getDb } from './db/index.js';
|
||||
|
||||
function rowToStation(row) {
|
||||
if (!row) return null;
|
||||
const imagePath = row.image_path || null;
|
||||
const remote = row.image_url || null;
|
||||
return {
|
||||
id: row.id,
|
||||
uuid: row.uuid,
|
||||
@@ -12,7 +14,12 @@ function rowToStation(row) {
|
||||
country: row.country,
|
||||
genres: row.genres ? JSON.parse(row.genres) : [],
|
||||
description: row.description,
|
||||
image_url: row.image_url,
|
||||
// image_url remains the remote/source URL (what admins edit).
|
||||
// image_display_url is what UIs should render — prefers the local cache.
|
||||
image_url: remote,
|
||||
image_path: imagePath,
|
||||
image_source: row.image_source || null,
|
||||
image_display_url: imagePath ? `/media/${imagePath}` : remote,
|
||||
source: row.source,
|
||||
source_ref: row.source_ref,
|
||||
category: row.category,
|
||||
|
||||
111
server/stats.js
111
server/stats.js
@@ -1,23 +1,47 @@
|
||||
// Vote + play stats and the ranking algorithm.
|
||||
//
|
||||
// Score combines two signals:
|
||||
// Score combines three signals:
|
||||
// - voteZ = (up - down) / sqrt(up + down + 1) z-like, penalizes small N
|
||||
// - playLog = log10(plays + 1) gentle popularity boost
|
||||
// - score = voteZ + 0.5 * playLog
|
||||
// - timeLog = log10(hours_listened + 1) rewards actual listen time
|
||||
// - score = voteZ + 0.5 * playLog + 0.4 * timeLog
|
||||
//
|
||||
// Net effect:
|
||||
// * A handful of downvotes on an obscure station sinks it hard.
|
||||
// * One stray upvote on a brand new station barely moves it.
|
||||
// * Popular stations float up only if they aren't being actively buried.
|
||||
// * Pressing play and skipping immediately barely counts; sticking with a
|
||||
// station for hours/days pushes it up the leaderboard.
|
||||
// * Established + positively-voted stations dominate the top.
|
||||
|
||||
import { getDb } from './db/index.js';
|
||||
|
||||
export function computeScore({ up = 0, down = 0, plays = 0 } = {}) {
|
||||
// Sessions longer than this are almost certainly a forgotten-tab leak (laptop
|
||||
// closed, browser put to sleep). Clamp so one user can't poison total_play_ms.
|
||||
const MAX_SESSION_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||
// Sessions shorter than this don't get credited toward listen time (people
|
||||
// scrubbing through stations should still bump `plays`, but not `total_play_ms`).
|
||||
const MIN_SESSION_MS = 3 * 1000;
|
||||
|
||||
export function computeScore({ up = 0, down = 0, plays = 0, totalPlayMs = 0 } = {}) {
|
||||
const n = up + down;
|
||||
const voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 1);
|
||||
const playLog = Math.log10(plays + 1);
|
||||
return voteZ + 0.5 * playLog;
|
||||
const hours = totalPlayMs / 3600000;
|
||||
const timeLog = Math.log10(hours + 1);
|
||||
return voteZ + 0.5 * playLog + 0.4 * timeLog;
|
||||
}
|
||||
|
||||
function statsFromRow(r) {
|
||||
const up = r.up || 0;
|
||||
const down = r.down || 0;
|
||||
const plays = r.plays || 0;
|
||||
const sessions = r.sessions || 0;
|
||||
const totalPlayMs = r.total_play_ms || 0;
|
||||
const avgSessionMs = sessions > 0 ? Math.round(totalPlayMs / sessions) : 0;
|
||||
return {
|
||||
up, down, plays, sessions, totalPlayMs, avgSessionMs,
|
||||
score: computeScore({ up, down, plays, totalPlayMs })
|
||||
};
|
||||
}
|
||||
|
||||
export function getStationStats(stationId, userId = null) {
|
||||
@@ -28,14 +52,15 @@ export function getStationStats(stationId, userId = null) {
|
||||
COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS down
|
||||
FROM station_votes WHERE station_id = ?
|
||||
`).get(stationId) || { up: 0, down: 0 };
|
||||
const p = db.prepare('SELECT plays FROM station_plays WHERE station_id = ?').get(stationId);
|
||||
const plays = p?.plays || 0;
|
||||
const p = db.prepare(`
|
||||
SELECT plays, sessions, total_play_ms FROM station_plays WHERE station_id = ?
|
||||
`).get(stationId) || {};
|
||||
let myVote = 0;
|
||||
if (userId) {
|
||||
const r = db.prepare('SELECT value FROM station_votes WHERE user_id = ? AND station_id = ?').get(userId, stationId);
|
||||
myVote = r?.value || 0;
|
||||
}
|
||||
return { up: v.up, down: v.down, plays, myVote, score: computeScore({ up: v.up, down: v.down, plays }) };
|
||||
return { ...statsFromRow({ ...v, ...p }), myVote };
|
||||
}
|
||||
|
||||
// Bulk stats for many stations in one query. Returns a Map<station_id, stats>.
|
||||
@@ -46,7 +71,9 @@ export function getStatsMap(userId = null) {
|
||||
s.id AS station_id,
|
||||
COALESCE(v.up, 0) AS up,
|
||||
COALESCE(v.down, 0) AS down,
|
||||
COALESCE(p.plays, 0) AS plays
|
||||
COALESCE(p.plays, 0) AS plays,
|
||||
COALESCE(p.sessions, 0) AS sessions,
|
||||
COALESCE(p.total_play_ms, 0) AS total_play_ms
|
||||
FROM stations s
|
||||
LEFT JOIN (
|
||||
SELECT station_id,
|
||||
@@ -65,11 +92,7 @@ export function getStatsMap(userId = null) {
|
||||
}
|
||||
const out = new Map();
|
||||
for (const r of rows) {
|
||||
const myVote = my.get(r.station_id) || 0;
|
||||
out.set(r.station_id, {
|
||||
up: r.up, down: r.down, plays: r.plays, myVote,
|
||||
score: computeScore({ up: r.up, down: r.down, plays: r.plays })
|
||||
});
|
||||
out.set(r.station_id, { ...statsFromRow(r), myVote: my.get(r.station_id) || 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -89,18 +112,67 @@ export function castVote(userId, stationId, value) {
|
||||
return getStationStats(stationId, userId);
|
||||
}
|
||||
|
||||
export function recordPlay(stationId) {
|
||||
getDb().prepare(`
|
||||
INSERT INTO station_plays (station_id, plays, last_played_at) VALUES (?, 1, datetime('now'))
|
||||
// Record the start of a listening session. Bumps the play counter immediately
|
||||
// (so spam-clickers still register as taps) and opens a play_history row that
|
||||
// `endPlaySession` will close with a duration. Returns the new session id when
|
||||
// a user is known, or null for anonymous plays (which still bump the counter).
|
||||
export function recordPlay(stationId, userId = null, streamId = null) {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO station_plays (station_id, plays, sessions, total_play_ms, last_played_at)
|
||||
VALUES (?, 1, 0, 0, datetime('now'))
|
||||
ON CONFLICT(station_id) DO UPDATE SET
|
||||
plays = station_plays.plays + 1,
|
||||
last_played_at = datetime('now')
|
||||
`).run(stationId);
|
||||
if (!userId) return null;
|
||||
const info = db.prepare(`
|
||||
INSERT INTO play_history (user_id, station_id, stream_id, started_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
`).run(userId, stationId, streamId || null);
|
||||
return info.lastInsertRowid;
|
||||
}
|
||||
|
||||
// Close a session opened by recordPlay. `durationMs` is optional — when the
|
||||
// client knows the real wall-clock listen time (e.g. an `audio.currentTime`
|
||||
// derivative) it should pass it; otherwise we compute it from started_at.
|
||||
// Returns the station_id we closed against, or null when the session is
|
||||
// unknown / already closed / belongs to someone else.
|
||||
export function endPlaySession(sessionId, userId, durationMs = null) {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`
|
||||
SELECT id, station_id, user_id, started_at, ended_at
|
||||
FROM play_history WHERE id = ?
|
||||
`).get(sessionId);
|
||||
if (!row || row.user_id !== userId || row.ended_at) return null;
|
||||
|
||||
let ms = Number.isFinite(durationMs) && durationMs >= 0
|
||||
? Math.floor(durationMs)
|
||||
: Math.max(0, Date.now() - Date.parse(String(row.started_at).replace(' ', 'T') + 'Z'));
|
||||
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
||||
if (ms > MAX_SESSION_MS) ms = MAX_SESSION_MS;
|
||||
|
||||
db.prepare(`UPDATE play_history SET ended_at = datetime('now') WHERE id = ?`).run(sessionId);
|
||||
|
||||
// Only credit listen-time aggregates for "real" sessions; sub-second
|
||||
// sessions don't earn the station any score, but they already bumped
|
||||
// `plays` in recordPlay so they aren't completely free either.
|
||||
if (ms >= MIN_SESSION_MS) {
|
||||
db.prepare(`
|
||||
INSERT INTO station_plays (station_id, plays, sessions, total_play_ms, last_played_at)
|
||||
VALUES (?, 0, 1, ?, datetime('now'))
|
||||
ON CONFLICT(station_id) DO UPDATE SET
|
||||
sessions = station_plays.sessions + 1,
|
||||
total_play_ms = station_plays.total_play_ms + excluded.total_play_ms,
|
||||
last_played_at = datetime('now')
|
||||
`).run(row.station_id, ms);
|
||||
}
|
||||
return row.station_id;
|
||||
}
|
||||
|
||||
// Sort helper used by routes. Mutates the array.
|
||||
export function sortByMode(items, mode, statsMap) {
|
||||
const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, score: 0 };
|
||||
const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, totalPlayMs: 0, score: 0 };
|
||||
switch (mode) {
|
||||
case 'hot':
|
||||
items.sort((a, b) => s(b.id).score - s(a.id).score || a.name.localeCompare(b.name));
|
||||
@@ -111,6 +183,9 @@ export function sortByMode(items, mode, statsMap) {
|
||||
case 'plays':
|
||||
items.sort((a, b) => s(b.id).plays - s(a.id).plays || a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'playtime':
|
||||
items.sort((a, b) => s(b.id).totalPlayMs - s(a.id).totalPlayMs || a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'controversial':
|
||||
items.sort((a, b) => {
|
||||
const A = s(a.id), B = s(b.id);
|
||||
|
||||
217
server/ws.js
217
server/ws.js
@@ -1,8 +1,102 @@
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { getUserBySession, readSessionToken } from './auth.js';
|
||||
// Room-aware WebSocket hub.
|
||||
//
|
||||
// Connect to `/ws?room=<slug>&kind=display|controller|panel`. Auth via the
|
||||
// session cookie. The server is the single source of truth for room state
|
||||
// (now-playing, volume); clients send `command` (intent) and the elected
|
||||
// display client emits `state` (truth) which is persisted to `room_state`
|
||||
// and rebroadcast.
|
||||
//
|
||||
// Message envelope: `{ type, ...payload }`. Types:
|
||||
// - hello server->client: { room, peers, state, role, you }
|
||||
// - presence server->room: { peers }
|
||||
// - command client->server: { action: play|pause|stop|volume|setSink, ... }
|
||||
// server->room: forwarded as-is
|
||||
// - state display->server: ground-truth playback snapshot
|
||||
// server->room: persisted snapshot
|
||||
// - devices display->server: { list, current } server->room: same
|
||||
// - vote server->room: { stationId, stats } emitted after castVote
|
||||
// - plays server->room: { stationId, plays } emitted after recordPlay
|
||||
|
||||
// per-user channel hub: any client of user U receives messages targeted to U.
|
||||
const channels = new Map(); // userId -> Set<ws>
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { URL } from 'node:url';
|
||||
import { getUserBySession, readSessionToken } from './auth.js';
|
||||
import {
|
||||
getRoomBySlug, ensurePersonalRoom, isMember,
|
||||
getRoomState, setRoomState
|
||||
} from './rooms.js';
|
||||
import { getStation } from './stations.js';
|
||||
|
||||
// roomSlug -> Set<ws>
|
||||
const rooms = new Map();
|
||||
// userId -> Set<ws>
|
||||
const byUser = new Map();
|
||||
|
||||
function addToIndex(map, key, ws) {
|
||||
if (!map.has(key)) map.set(key, new Set());
|
||||
map.get(key).add(ws);
|
||||
}
|
||||
function removeFromIndex(map, key, ws) {
|
||||
const set = map.get(key);
|
||||
if (!set) return;
|
||||
set.delete(ws);
|
||||
if (!set.size) map.delete(key);
|
||||
}
|
||||
|
||||
function presenceFor(roomSlug) {
|
||||
const set = rooms.get(roomSlug);
|
||||
if (!set) return [];
|
||||
const out = [];
|
||||
for (const ws of set) {
|
||||
out.push({
|
||||
user: { id: ws.user.id, username: ws.user.username },
|
||||
kind: ws.kind,
|
||||
since: ws.since
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function send(ws, msg) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
export function broadcastToRoom(roomSlug, msg, except) {
|
||||
const set = rooms.get(roomSlug);
|
||||
if (!set) return;
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const ws of set) {
|
||||
if (ws === except) continue;
|
||||
if (ws.readyState === ws.OPEN) ws.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send to every connection of the given user, across all their rooms. */
|
||||
export function broadcastToUser(userId, msg, except) {
|
||||
const set = byUser.get(userId);
|
||||
if (!set) return;
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const ws of set) {
|
||||
if (ws === except) continue;
|
||||
if (ws.readyState === ws.OPEN) ws.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcast to every open WS, regardless of room. Used for global events. */
|
||||
export function broadcastGlobal(msg) {
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const set of rooms.values()) {
|
||||
for (const ws of set) {
|
||||
if (ws.readyState === ws.OPEN) ws.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasDisplay(roomSlug) {
|
||||
const set = rooms.get(roomSlug);
|
||||
if (!set) return false;
|
||||
for (const ws of set) if (ws.kind === 'display') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function attachWs(server) {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
@@ -16,41 +110,110 @@ export function attachWs(server) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const slug = url.searchParams.get('room') || `u-${user.id}`;
|
||||
let kindRaw = url.searchParams.get('kind') || 'controller';
|
||||
if (!['display', 'controller', 'panel'].includes(kindRaw)) kindRaw = 'controller';
|
||||
|
||||
// Resolve room. The personal room is auto-provisioned on demand.
|
||||
let room = getRoomBySlug(slug);
|
||||
if (!room && slug === `u-${user.id}`) room = ensurePersonalRoom(user);
|
||||
if (!room) {
|
||||
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!isMember(room.id, user.id)) {
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// One display per room: subsequent claimers are silently demoted
|
||||
// to passive panels (no audio, no device picker).
|
||||
let kind = kindRaw;
|
||||
if (kind === 'display' && hasDisplay(room.slug)) kind = 'panel';
|
||||
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
ws.user = user;
|
||||
addClient(user.id, ws);
|
||||
ws.on('close', () => removeClient(user.id, ws));
|
||||
ws.kind = kind;
|
||||
ws.room = room;
|
||||
ws.since = Date.now();
|
||||
addToIndex(rooms, room.slug, ws);
|
||||
addToIndex(byUser, user.id, ws);
|
||||
|
||||
ws.on('close', () => {
|
||||
removeFromIndex(rooms, room.slug, ws);
|
||||
removeFromIndex(byUser, user.id, ws);
|
||||
broadcastToRoom(room.slug, { type: 'presence', peers: presenceFor(room.slug) });
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
||||
// Re-broadcast every message to all connections of the same user.
|
||||
// (e.g. phone sends `{type:"command", action:"play", stationId:7}` → kiosk receives)
|
||||
broadcastToUser(user.id, msg, ws);
|
||||
handleClientMessage(ws, msg);
|
||||
});
|
||||
ws.send(JSON.stringify({ type: 'hello', user: { id: user.id, username: user.username, role: user.role } }));
|
||||
|
||||
// Send hello snapshot.
|
||||
const state = getRoomState(room.id);
|
||||
const station = state.station_id ? getStation(state.station_id) : null;
|
||||
send(ws, {
|
||||
type: 'hello',
|
||||
you: { id: user.id, username: user.username, role: user.role, kind },
|
||||
room: { id: room.id, slug: room.slug, name: room.name },
|
||||
state: { ...state, station },
|
||||
peers: presenceFor(room.slug)
|
||||
});
|
||||
broadcastToRoom(room.slug, { type: 'presence', peers: presenceFor(room.slug) }, ws);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
function addClient(userId, ws) {
|
||||
if (!channels.has(userId)) channels.set(userId, new Set());
|
||||
channels.get(userId).add(ws);
|
||||
}
|
||||
function removeClient(userId, ws) {
|
||||
const set = channels.get(userId);
|
||||
if (!set) return;
|
||||
set.delete(ws);
|
||||
if (!set.size) channels.delete(userId);
|
||||
}
|
||||
function handleClientMessage(ws, msg) {
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
const slug = ws.room.slug;
|
||||
|
||||
export function broadcastToUser(userId, msg, except) {
|
||||
const set = channels.get(userId);
|
||||
if (!set) return;
|
||||
const payload = JSON.stringify(msg);
|
||||
for (const ws of set) {
|
||||
if (ws === except) continue;
|
||||
if (ws.readyState === ws.OPEN) ws.send(payload);
|
||||
switch (msg.type) {
|
||||
case 'command': {
|
||||
// Controllers express intent. Forward to all peers; the display
|
||||
// is responsible for actually changing audio output. We also
|
||||
// optimistically reflect simple intents into room_state so a
|
||||
// late-joining peer sees the latest target station/volume even
|
||||
// before the display emits a confirmation `state`.
|
||||
if (msg.action === 'play' && Number.isFinite(msg.stationId)) {
|
||||
setRoomState(ws.room.id, { station_id: Number(msg.stationId), playing: true });
|
||||
} else if (msg.action === 'stop') {
|
||||
setRoomState(ws.room.id, { playing: false });
|
||||
} else if (msg.action === 'volume' && typeof msg.value === 'number') {
|
||||
setRoomState(ws.room.id, { volume: Math.max(0, Math.min(1, msg.value)) });
|
||||
}
|
||||
broadcastToRoom(slug, msg, null); // include sender so its UI mirrors
|
||||
return;
|
||||
}
|
||||
case 'state': {
|
||||
// Only the display's state messages are persisted as truth.
|
||||
if (ws.kind !== 'display') return;
|
||||
const patch = {};
|
||||
if ('stationId' in msg) patch.station_id = msg.stationId ?? null;
|
||||
if ('playing' in msg) patch.playing = !!msg.playing;
|
||||
if (typeof msg.volume === 'number') patch.volume = msg.volume;
|
||||
const next = setRoomState(ws.room.id, patch);
|
||||
const station = next.station_id ? getStation(next.station_id) : null;
|
||||
broadcastToRoom(slug, { type: 'state', ...next, station });
|
||||
return;
|
||||
}
|
||||
case 'devices': {
|
||||
if (ws.kind !== 'display') return;
|
||||
broadcastToRoom(slug, { type: 'devices', list: msg.list || [], current: msg.current || null });
|
||||
return;
|
||||
}
|
||||
case 'ping':
|
||||
send(ws, { type: 'pong', t: Date.now() });
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user