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:
Marco Mooren
2026-05-11 17:55:09 +02:00
parent 86690c3753
commit b86dcfbb8d
40 changed files with 3943 additions and 274 deletions

View File

@@ -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');
}
}

View File

@@ -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
);

View File

@@ -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
View 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 };
}

View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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};

View File

@@ -21,4 +21,4 @@
</header>
<main id="app"></main>
</body>
</body>

View File

@@ -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>

View 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
View 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);
}

View File

@@ -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
View 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));
});

View File

@@ -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) => {

View File

@@ -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,

View 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);

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;
}
}