325 lines
14 KiB
JavaScript
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 });
|
|
});
|