Files
2026-05-27 12:54:56 +02:00

325 lines
14 KiB
JavaScript

import { Router } from 'express';
import express from 'express';
import { requireAdmin, createApiKey, listApiKeys, revokeApiKey } 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, 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);
// --- API key management ---
router.get('/api-keys', (_req, res) => {
res.json(listApiKeys());
});
router.post('/api-keys', (req, res) => {
const label = String(req.body?.label || '').trim();
const userId = Number(req.body?.userId) || req.user.id;
const key = createApiKey(userId, label);
res.status(201).json({ key }); // plaintext key shown exactly once
});
router.delete('/api-keys/:id', (req, res) => {
revokeApiKey(Number(req.params.id));
res.json({ ok: true });
});
// 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 });
});
router.post('/reseed', (_req, res) => {
res.json(applySeedIfEmpty());
});
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 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' });
// 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).
router.post('/scrape-icons', async (req, res) => {
const onlyMissing = req.query.all !== '1';
const stations = listStations({ enabled: null }).filter((s) => !onlyMissing || !s.image_url);
const results = { total: stations.length, updated: 0, skipped: 0, failed: 0, items: [] };
// Limit concurrency to avoid hammering hosts.
const concurrency = 4;
let i = 0;
async function worker() {
while (i < stations.length) {
const s = stations[i++];
try {
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, image_path: rel });
} else {
results.failed++;
results.items.push({ id: s.id, name: s.name, image_url: null });
}
} catch (err) {
results.failed++;
results.items.push({ id: s.id, name: s.name, error: String(err?.message || err) });
}
}
}
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 });
});