Add API documentation and underground station importer

- Introduced a new HTML documentation page for the oradio API, including a JavaScript file to handle dynamic content and API requests.
- Added a CSS file for styling the documentation page.
- Implemented an underground station importer script that fetches data from Radio-Browser and writes it to a JSON file.
- Created a stats module to compute and manage vote and play statistics for radio stations.
- Added a polyfill for modulepreload to ensure compatibility with older browsers.
This commit is contained in:
Marco Mooren
2026-05-11 02:06:48 +02:00
parent e0a60f7b64
commit 00246389bc
52 changed files with 6280 additions and 2475 deletions

View File

@@ -1,63 +1,86 @@
import { Router } from 'express';
import { requireUser } from '../auth.js';
import { getDb } from '../db/index.js';
import { getStatsMap } from '../stats.js';
export const router = Router();
router.use(requireUser);
router.get('/favorites', (req, res) => {
const rows = getDb().prepare(`
const rows = getDb().prepare(`
SELECT s.*, f.position
FROM favorites f JOIN stations s ON s.id = f.station_id
WHERE f.user_id = ? AND s.enabled = 1
ORDER BY f.position ASC, f.created_at ASC
`).all(req.user.id);
res.json(rows.map((r) => ({
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position
})));
const stats = getStatsMap(req.user.id);
res.json(rows.map((r) => {
const st = stats.get(r.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
return {
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category, position: r.position,
up: st.up, down: st.down, plays: st.plays, my_vote: st.myVote, score: st.score
};
}));
});
// Pick one random favorite. Returns 404 if the user has none.
router.get('/favorites/random', (req, res) => {
const rows = getDb().prepare(`
SELECT s.* FROM favorites f JOIN stations s ON s.id = f.station_id
WHERE f.user_id = ? AND s.enabled = 1
`).all(req.user.id);
if (!rows.length) return res.status(404).json({ error: 'no favorites' });
const r = rows[Math.floor(Math.random() * rows.length)];
const stats = getStatsMap(req.user.id).get(r.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
res.set('Cache-Control', 'no-store');
res.json({
id: r.id, uuid: r.uuid, name: r.name, slug: r.slug, homepage: r.homepage, country: r.country,
genres: r.genres ? JSON.parse(r.genres) : [], image_url: r.image_url, category: r.category,
up: stats.up, down: stats.down, plays: stats.plays, my_vote: stats.myVote, score: stats.score
});
});
router.put('/favorites/:stationId', (req, res) => {
const stationId = Number(req.params.stationId);
const position = Number(req.body?.position ?? 0);
getDb().prepare(`
const stationId = Number(req.params.stationId);
const position = Number(req.body?.position ?? 0);
getDb().prepare(`
INSERT INTO favorites (user_id, station_id, position) VALUES (?, ?, ?)
ON CONFLICT(user_id, station_id) DO UPDATE SET position = excluded.position
`).run(req.user.id, stationId, position);
res.json({ ok: true });
res.json({ ok: true });
});
router.delete('/favorites/:stationId', (req, res) => {
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
.run(req.user.id, Number(req.params.stationId));
res.json({ ok: true });
getDb().prepare('DELETE FROM favorites WHERE user_id = ? AND station_id = ?')
.run(req.user.id, Number(req.params.stationId));
res.json({ ok: true });
});
router.get('/profile', (req, res) => {
const row = getDb().prepare('SELECT * FROM profiles WHERE user_id = ?').get(req.user.id);
res.json(row || { user_id: req.user.id, display_name: req.user.username, theme: 'dark', default_volume: 0.7 });
const row = getDb().prepare('SELECT * FROM profiles WHERE user_id = ?').get(req.user.id);
res.json(row || { user_id: req.user.id, display_name: req.user.username, theme: 'dark', default_volume: 0.7 });
});
router.patch('/profile', (req, res) => {
const { display_name, theme, default_volume } = req.body || {};
getDb().prepare(`
const { display_name, theme, default_volume } = req.body || {};
getDb().prepare(`
INSERT INTO profiles (user_id, display_name, theme, default_volume) VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
display_name = COALESCE(excluded.display_name, profiles.display_name),
theme = COALESCE(excluded.theme, profiles.theme),
default_volume = COALESCE(excluded.default_volume, profiles.default_volume)
`).run(req.user.id, display_name ?? null, theme ?? null, default_volume ?? null);
res.json({ ok: true });
res.json({ ok: true });
});
router.get('/history', (req, res) => {
const rows = getDb().prepare(`
const rows = getDb().prepare(`
SELECT h.*, s.name AS station_name, s.slug AS station_slug
FROM play_history h JOIN stations s ON s.id = h.station_id
WHERE h.user_id = ?
ORDER BY h.started_at DESC LIMIT 50
`).all(req.user.id);
res.json(rows);
res.json(rows);
});