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

@@ -13,7 +13,9 @@ const state = {
favorites: [],
history: [],
query: '',
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7 }
sort: 'hot', // hot | top | plays | name | controversial — applied in Browse
randomMode: localStorage.getItem('oradio.randomMode') === 'favorites' ? 'favorites' : 'all',
player: { stationId: null, stationName: null, genres: [], playing: false, loading: false, volume: 0.7, votes: null }
};
const player = new Player({
@@ -39,7 +41,7 @@ async function bootstrap() {
async function refreshAll() {
const [stations, favs, history, categories] = await Promise.all([
api.get('/api/stations'),
api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`),
api.get('/api/me/favorites').catch(() => []),
api.get('/api/me/history').catch(() => []),
api.get('/api/v1/categories').catch(() => [])
@@ -50,11 +52,15 @@ async function refreshAll() {
state.categories = categories;
}
async function refreshStations() {
state.stations = await api.get(`/api/stations?sort=${encodeURIComponent(state.sort)}`);
}
function handleWs(msg) {
if (msg.type === 'command') {
if (msg.action === 'play' && msg.stationId) {
const st = state.stations.find((s) => s.id === msg.stationId);
if (st) player.play(st);
if (st) playStation(st);
} else if (msg.action === 'pause') player.togglePause();
else if (msg.action === 'volume') player.setVolume(msg.value);
else if (msg.action === 'stop') player.stop();
@@ -99,6 +105,7 @@ function render() {
const p = state.player;
const favIds = new Set(state.favorites.map((f) => f.id));
const v = p.votes; // { up, down, plays, myVote, score } or null
const now = el('section', { class: 'now' },
el('div', { class: 'meta' },
@@ -108,10 +115,26 @@ function render() {
el('div', { class: 'tags' }, ...(p.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g)))
),
el('div', { class: 'controls' },
el('div', { class: 'vote-group', title: 'Vote on current station' },
el('button', {
class: `vote up ${v?.myVote === 1 ? 'on' : ''}`,
disabled: !p.stationId,
title: 'Upvote',
onClick: () => votePlayer(1)
}, el('span', { class: 'vote-icon' }, '▲'),
el('span', { class: 'vote-count' }, String(v?.up ?? 0))),
el('button', {
class: `vote down ${v?.myVote === -1 ? 'on' : ''}`,
disabled: !p.stationId,
title: 'Downvote',
onClick: () => votePlayer(-1)
}, el('span', { class: 'vote-icon' }, '▼'),
el('span', { class: 'vote-count' }, String(v?.down ?? 0)))
),
el('button', {
class: `btn-play ${p.loading ? 'loading' : ''}`,
title: p.playing ? 'Pause' : 'Play',
onClick: () => p.stationId ? player.togglePause() : (state.favorites[0] && player.play(state.favorites[0]))
onClick: () => p.stationId ? player.togglePause() : (state.favorites[0] && playStation(state.favorites[0]))
}, p.playing ? '❚❚' : '▶'),
el('button', {
class: 'btn-stop',
@@ -143,10 +166,36 @@ function render() {
)
),
el('div', { class: 'header-tools' },
state.tab === 'browse'
? el('select', {
class: 'sort',
title: 'Sort browse list',
onChange: (e) => { state.sort = e.target.value; savedGridScroll = 0; refreshStations().then(render); }
},
el('option', { value: 'hot', selected: state.sort === 'hot' }, '🔥 Hot (smart)'),
el('option', { value: 'top', selected: state.sort === 'top' }, '▲ Top voted'),
el('option', { value: 'plays', selected: state.sort === 'plays' }, '▶ Most played'),
el('option', { value: 'controversial', selected: state.sort === 'controversial' }, '⚡ Controversial'),
el('option', { value: 'name', selected: state.sort === 'name' }, 'A → Z')
)
: null,
el('input', {
class: 'search', type: 'search', placeholder: 'Search…', value: state.query,
onInput: (e) => { state.query = e.target.value; renderGrid(); }
}),
el('button', {
class: 'btn-random',
title: `Play random station (mode: ${state.randomMode}). Right-click to switch mode.`,
onClick: playRandom,
onContextMenu: (e) => { e.preventDefault(); toggleRandomMode(); }
},
el('span', { class: 'rand-icon' }, '🎲'),
el('span', { class: 'rand-mode' }, state.randomMode === 'favorites' ? '★' : 'All')
),
el('a', {
class: 'btn-docs', href: '/docs/', target: '_blank', rel: 'noopener',
title: 'Open API reference'
}, 'API'),
isAdmin ? el('button', { class: 'btn-add', title: 'Add station', onClick: openAddStation }, '+') : null
)
);
@@ -222,11 +271,14 @@ function paintGrid(grid, favIds) {
}
const p = state.player;
for (const s of items) {
const score = typeof s.score === 'number' ? s.score : 0;
const net = (s.up ?? 0) - (s.down ?? 0);
const badgeClass = net > 0 ? 'pos' : net < 0 ? 'neg' : 'neu';
const card = el('div', {
class: `card ${p.stationId === s.id ? 'playing' : ''}`,
role: 'button',
tabindex: 0,
onClick: () => { player.play(s); recordHistory(s.id); },
onClick: () => playStation(s),
onContextMenu: (e) => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, s); }
},
el('div', { class: 'art' },
@@ -249,6 +301,9 @@ function paintGrid(grid, favIds) {
el('div', { class: 'g' },
(s.genres || []).slice(0, 3).join(' · ') || (s.country || '—'))
),
el('div', { class: `score-badge ${badgeClass}`, title: `${s.up ?? 0} · ▼${s.down ?? 0} · ▶${s.plays ?? 0} · score ${score.toFixed(2)}` },
net > 0 ? `+${net}` : String(net)
),
el('button', {
class: `fav ${favIds.has(s.id) ? 'on' : ''}`,
title: favIds.has(s.id) ? 'Remove favorite' : 'Add favorite',
@@ -276,35 +331,135 @@ async function toggleFavorite(station) {
render();
}
function toggleRandomMode() {
state.randomMode = state.randomMode === 'favorites' ? 'all' : 'favorites';
localStorage.setItem('oradio.randomMode', state.randomMode);
toast(`Random mode: ${state.randomMode === 'favorites' ? 'favorites only' : 'all stations'}`);
render();
}
// Pick a random station and play it. Uses the public /api/v1/stations/random
// for "all" mode, /api/me/favorites/random for "favorites" mode. Falls back
// to a local random pick if the network call fails.
async function playRandom() {
try {
const ep = state.randomMode === 'favorites'
? '/api/me/favorites/random'
: '/api/v1/stations/random';
const remote = await api.get(ep);
// The kiosk player needs the internal numeric id (used by /resolve etc.).
// The favorites endpoint returns it directly; the v1 endpoint does not,
// so resolve via the cached station list (or skip if missing).
let station = remote;
if (station.id == null) {
station = state.stations.find((s) => s.uuid === remote.uuid) || null;
}
if (!station) { toast('Random station not in cache'); return; }
playStation(station);
} catch (err) {
// Fallback: pick locally so the button still does something offline.
const pool = state.randomMode === 'favorites' ? state.favorites : state.stations;
if (!pool.length) { toast(err.message || 'No stations available'); return; }
playStation(pool[Math.floor(Math.random() * pool.length)]);
}
}
function recordHistory(stationId) {
// Server-side history insertion can be added later; for now, optimistic local insert.
state.history.unshift({ station_id: stationId, started_at: new Date().toISOString() });
}
// Play wrapper: starts playback, pings server play counter, fetches vote state
// so the up/down buttons in the now-playing bar reflect the current station.
async function playStation(station) {
state.player.votes = null;
player.play(station);
recordHistory(station.id);
try {
const stats = await api.post(`/api/stations/${station.id}/play`);
// Only apply if user hasn't switched stations in the meantime.
if (state.player.stationId === station.id) {
state.player.votes = stats;
// Refresh listing stats in the background so the score badge updates.
mergeStats(station.id, stats);
render();
}
} catch (err) {
try {
const stats = await api.get(`/api/stations/${station.id}/votes`);
if (state.player.stationId === station.id) {
state.player.votes = stats;
mergeStats(station.id, stats);
render();
}
} catch { /* ignore */ }
}
}
async function votePlayer(value) {
const id = state.player.stationId;
if (!id) return;
// Toggle off when clicking the already-active button.
const cur = state.player.votes?.myVote || 0;
const next = cur === value ? 0 : value;
try {
const stats = await api.post(`/api/stations/${id}/vote`, { value: next });
state.player.votes = stats;
mergeStats(id, stats);
render();
} catch (err) {
toast(err.message || 'Vote failed');
}
}
function mergeStats(stationId, stats) {
const list = [state.stations, state.favorites];
for (const arr of list) {
const hit = arr.find((s) => s.id === stationId);
if (hit) {
hit.up = stats.up; hit.down = stats.down;
hit.plays = stats.plays; hit.score = stats.score;
hit.my_vote = stats.myVote;
}
}
}
// ---- API endpoints context menu ----
let menuEl = null;
function closeContextMenu() {
if (menuEl) { menuEl.remove(); menuEl = null; }
}
function apiEndpoints(s) {
if (!s.uuid) return [];
const base = `${location.origin}/api/v1`;
return [
{ label: 'Station detail', url: `${base}/stations/${s.uuid}` },
{ label: 'Stream redirect', url: `${base}/stations/${s.uuid}/stream` },
{ label: 'MP3 stream', url: `${base}/stations/${s.uuid}/stream?format=mp3` },
{ label: 'AAC stream', url: `${base}/stations/${s.uuid}/stream?format=aac` },
{ label: 'HLS stream', url: `${base}/stations/${s.uuid}/stream?format=hls` },
const origin = location.origin;
const base = `${origin}/api/v1`;
const items = [];
// Original (internal) endpoint — always available, keyed by station id.
if (s.id != null) {
items.push({ label: 'Station (original)', url: `${origin}/api/stations/${s.id}` });
}
// Public v1 endpoints — require uuid.
if (s.uuid) {
items.push(
{ label: 'Station detail', url: `${base}/stations/${s.uuid}` },
{ label: 'Stream redirect', url: `${base}/stations/${s.uuid}/stream` },
{ label: 'MP3 stream', url: `${base}/stations/${s.uuid}/stream?format=mp3` },
{ label: 'AAC stream', url: `${base}/stations/${s.uuid}/stream?format=aac` },
{ label: 'HLS stream', url: `${base}/stations/${s.uuid}/stream?format=hls` }
);
}
items.push(
{ label: 'All stations', url: `${base}/stations` },
{ label: 'Health', url: `${base}/health` }
];
);
return items;
}
function openContextMenu(x, y, station) {
closeContextMenu();
const items = apiEndpoints(station);
menuEl = el('div', { class: 'ctx-menu', role: 'menu' },
el('div', { class: 'ctx-title' }, station.name),
el('div', { class: 'ctx-sub' }, station.uuid ? `uuid · ${station.uuid}` : 'no uuid'),
el('div', { class: 'ctx-sub' },
station.uuid ? `uuid · ${station.uuid}` : (station.id != null ? `id · ${station.id} (no uuid — public v1 hidden)` : 'no identifier')),
...(items.length ? items.map((it) => el('div', { class: 'ctx-row' },
el('div', { class: 'ctx-row-text' },
el('div', { class: 'ctx-label' }, it.label),