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