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

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