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,17 +1,92 @@
[
{ "id": "starter", "label": "Starter pack", "icon": "★", "order": 0 },
{ "id": "dutch-public", "label": "Nederlandse publieke","icon": "🇳🇱", "order": 1 },
{ "id": "dutch-commercial", "label": "Nederlandse commercieel","icon": "🇳🇱", "order": 2 },
{ "id": "bbc", "label": "BBC family", "icon": "🇬🇧", "order": 3 },
{ "id": "fip", "label": "FIP family", "icon": "🇫🇷", "order": 4 },
{ "id": "underground", "label": "Underground & curated","icon": "🌐", "order": 5 },
{ "id": "ambient", "label": "Ambient & lo-fi", "icon": "🌫", "order": 6 },
{ "id": "electronic", "label": "Electronic", "icon": "⚡", "order": 7 },
{ "id": "jazz", "label": "Jazz & blues", "icon": "🎷", "order": 8 },
{ "id": "classical", "label": "Classical", "icon": "🎻", "order": 9 },
{ "id": "rock", "label": "Rock & indie", "icon": "🎸", "order": 10 },
{ "id": "reggae", "label": "Reggae & dub", "icon": "🌴", "order": 11 },
{ "id": "world", "label": "World & regional", "icon": "🌍", "order": 12 },
{ "id": "soma", "label": "SomaFM channels", "icon": "📻", "order": 13 },
{ "id": "nts", "label": "NTS infinite mixtapes","icon": "♾", "order": 14 }
{
"id": "starter",
"label": "Starter pack",
"icon": "",
"order": 0
},
{
"id": "dutch-public",
"label": "Nederlandse publieke",
"icon": "🇳🇱",
"order": 1
},
{
"id": "dutch-commercial",
"label": "Nederlandse commercieel",
"icon": "🇳🇱",
"order": 2
},
{
"id": "bbc",
"label": "BBC family",
"icon": "🇬🇧",
"order": 3
},
{
"id": "fip",
"label": "FIP family",
"icon": "🇫🇷",
"order": 4
},
{
"id": "underground",
"label": "Underground & curated",
"icon": "🌐",
"order": 5
},
{
"id": "ambient",
"label": "Ambient & lo-fi",
"icon": "🌫",
"order": 6
},
{
"id": "electronic",
"label": "Electronic",
"icon": "⚡",
"order": 7
},
{
"id": "jazz",
"label": "Jazz & blues",
"icon": "🎷",
"order": 8
},
{
"id": "classical",
"label": "Classical",
"icon": "🎻",
"order": 9
},
{
"id": "rock",
"label": "Rock & indie",
"icon": "🎸",
"order": 10
},
{
"id": "reggae",
"label": "Reggae & dub",
"icon": "🌴",
"order": 11
},
{
"id": "world",
"label": "World & regional",
"icon": "🌍",
"order": 12
},
{
"id": "soma",
"label": "SomaFM channels",
"icon": "📻",
"order": 13
},
{
"id": "nts",
"label": "NTS infinite mixtapes",
"icon": "♾",
"order": 14
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -78,3 +78,23 @@ CREATE TABLE IF NOT EXISTS play_history (
);
CREATE INDEX IF NOT EXISTS idx_history_user ON play_history(user_id, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_history_station ON play_history(station_id);
-- One vote per user per station. value is +1 (up) or -1 (down). Row is
-- deleted entirely when the user clears their vote, so the COUNT is exact.
CREATE TABLE IF NOT EXISTS station_votes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
station_id INTEGER NOT NULL REFERENCES stations(id) ON DELETE CASCADE,
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, station_id)
);
CREATE INDEX IF NOT EXISTS idx_votes_station ON station_votes(station_id);
-- Aggregate play counter. Cheaper than COUNT(*) over play_history every render
-- and lets anonymous/public listing show play counts without exposing history.
CREATE TABLE IF NOT EXISTS station_plays (
station_id INTEGER PRIMARY KEY REFERENCES stations(id) ON DELETE CASCADE,
plays INTEGER NOT NULL DEFAULT 0,
last_played_at TEXT
);

View File

@@ -43,6 +43,7 @@ const publicDir = resolve(__dirname, 'public');
if (existsSync(publicDir)) {
app.use(express.static(publicDir));
app.get('/admin', (_req, res) => res.sendFile(resolve(publicDir, 'admin/index.html')));
app.get('/docs', (_req, res) => res.sendFile(resolve(publicDir, 'docs/index.html')));
app.get('*', (_req, res) => res.sendFile(resolve(publicDir, 'index.html')));
}

View File

@@ -1,14 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title>
<script type="module" crossorigin src="/assets/admin-CVu6KAFb.js"></script>
<script type="module" crossorigin src="/assets/admin-BRU0y9A4.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/dom-BvorgAdo.js">
<link rel="stylesheet" crossorigin href="/assets/admin-CJZ4D7u-.css">
</head>
<link rel="stylesheet" crossorigin href="/assets/admin-CJZ4D7u-.css">
</head>
<body>
<div id="app"></div>
<div id="app"></div>
</body>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,5 @@
import"./modulepreload-polyfill-B5Qt9EMX.js";const p=`${location.origin}/api/v1`,u=`${location.origin}/api`;document.getElementById("base").textContent=p;const C=[{group:"Public (v1)",items:[{id:"health",method:"GET",path:"/health",summary:"Service heartbeat plus enabled-station count.",tryable:!0},{id:"categories",method:"GET",path:"/categories",summary:"All categories with their station counts.",tryable:!0},{id:"stations-list",method:"GET",path:"/stations",summary:"Paginated station list. Filterable and sortable.",params:[{name:"q",desc:"Substring filter on name / genres / country."},{name:"category",desc:"Category id (see /categories)."},{name:"country",desc:"ISO country code, case-insensitive."},{name:"genre",desc:"Substring match against any genre."},{name:"sort",desc:"hot | top | plays | controversial | name (default: name)."},{name:"limit",desc:"Max items returned (default 200, cap 1000)."}],tryable:!0,tryQuery:"limit=3&sort=hot"},{id:"random",method:"GET",path:"/stations/random",summary:"Pick one random enabled station. Same filters as /stations. Pass redirect=stream for a 302 to the resolved audio URL.",params:[{name:"category",desc:"Restrict pool to a category."},{name:"country",desc:"Restrict pool to a country."},{name:"genre",desc:"Restrict pool by genre substring."},{name:"redirect",desc:'Set to "stream" to 302-redirect to the resolved stream URL.'}],tryable:!0,examples:[`mpv ${p}/stations/random?redirect=stream`,`curl -sLI "${p}/stations/random?redirect=stream" | grep -i location`]},{id:"station",method:"GET",path:"/stations/{uuid}",summary:"Full detail for one station, including its streams.",params:[{name:"uuid",desc:"Station UUID (see list response)."}]},{id:"station-stream",method:"GET",path:"/stations/{uuid}/stream",summary:"302-redirect to the resolved stream URL. Picks the highest-priority stream that was last seen up.",params:[{name:"uuid",desc:"Station UUID."},{name:"format",desc:"Optional preferred format (mp3, aac, ogg, hls)."}]},{id:"stream-by-uuid",method:"GET",path:"/stations/{uuid}/streams/{streamUuid}",summary:"Resolve and 302 to a specific stream. Pass redirect=0 to return JSON metadata instead.",params:[{name:"uuid",desc:"Station UUID."},{name:"streamUuid",desc:"Stream UUID."},{name:"redirect",desc:'Set to "0" to return JSON instead of redirecting.'}]}]},{group:"Authenticated (cookie session)",items:[{id:"me",method:"GET",path:"/auth/me",base:u,summary:"Current signed-in user, or 401.",tryable:!0},{id:"favorites",method:"GET",path:"/me/favorites",base:u,summary:"Your favorites, ordered.",tryable:!0},{id:"favorites-random",method:"GET",path:"/me/favorites/random",base:u,summary:'One random favorite — used by the kiosk dice button in "favorites" mode.',tryable:!0},{id:"history",method:"GET",path:"/me/history",base:u,summary:"Recent play history (last 50).",tryable:!0}]},{group:"Rate limit",items:[{id:"rate",method:"INFO",path:"120 req / minute / IP",summary:"Public /api/v1 endpoints share a per-IP token bucket. Headers X-RateLimit-Limit and X-RateLimit-Remaining tell you where you stand."}]}],f=document.getElementById("app");function e(t,i,...d){const s=document.createElement(t);if(i)for(const[a,o]of Object.entries(i))o==null||o===!1||(a==="class"?s.className=o:a.startsWith("on")&&typeof o=="function"?s.addEventListener(a.slice(2).toLowerCase(),o):s.setAttribute(a,o));for(const a of d)a==null||a===!1||s.appendChild(typeof a=="string"?document.createTextNode(a):a);return s}function E(t){return e("span",{class:`m m-${t.toLowerCase()}`},t)}function T(t){var a,o;const d=`${t.base||p}${t.path}`,s=e("article",{class:"ep",id:t.id});if(s.appendChild(e("header",{class:"ep-head"},E(t.method),e("code",{class:"ep-path"},d))),s.appendChild(e("p",{class:"ep-sum"},t.summary)),(a=t.params)!=null&&a.length){const n=e("table",{class:"params"},e("thead",{},e("tr",{},e("th",{},"Parameter"),e("th",{},"Description"))),e("tbody",{},...t.params.map(m=>e("tr",{},e("td",{},e("code",{},m.name)),e("td",{},m.desc)))));s.appendChild(n)}if((o=t.examples)!=null&&o.length&&s.appendChild(e("div",{class:"examples"},e("div",{class:"examples-h"},"Examples"),...t.examples.map(n=>e("pre",{},e("code",{},n))))),t.tryable&&t.method==="GET"){const n=e("pre",{class:"try-out"},'Click "Try it" to send a live request.'),m=e("input",{class:"try-q",type:"text",placeholder:"?key=value (optional)",value:t.tryQuery?`?${t.tryQuery}`:""}),c=e("button",{class:"try-btn",onClick:async()=>{c.disabled=!0,c.textContent="…";let l=m.value.trim();l&&!l.startsWith("?")&&(l=`?${l}`);const h=`${d}${l}`,g=performance.now();try{const r=await fetch(h,{credentials:"same-origin",redirect:"manual"}),v=Math.round(performance.now()-g);let y;r.type==="opaqueredirect"||r.status>=300&&r.status<400?y="(redirect — open in new tab to follow)":y=(r.headers.get("content-type")||"").includes("json")?JSON.stringify(await r.json(),null,2):(await r.text()).slice(0,4e3),n.textContent=`${r.status} ${r.statusText||""} · ${v} ms
${h}
${y}`}catch(r){n.textContent=`error: ${r.message||r}
${h}`}finally{c.disabled=!1,c.textContent="Try it"}}},"Try it"),b=e("a",{class:"try-open",target:"_blank",rel:"noopener",href:d},"Open ↗");s.appendChild(e("div",{class:"try"},e("div",{class:"try-row"},m,c,b),n))}return s}for(const t of C){f.appendChild(e("h2",{class:"group"},t.group));for(const i of t.items)f.appendChild(T(i))}

View File

@@ -0,0 +1 @@
:root{--bg-0: #07080b;--bg-1: #0e1116;--bg-2: #161a22;--bg-3: #1f242e;--line: #262b36;--fg: #e9ecf2;--muted: #8a90a0;--muted-2: #5d6373;--accent: #ff7a3d;--accent-2: #ffb37a;--good: #4ec9a6;--bad: #ec6a6a;--info: #6ab7ff;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color-scheme:dark}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg-0);color:var(--fg)}a{color:var(--accent-2);text-decoration:none}a:hover{text-decoration:underline}.docs-header{position:sticky;top:0;z-index:10;background:#07080beb;border-bottom:1px solid var(--line);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.docs-header-inner{max-width:980px;margin:0 auto;padding:14px 20px;display:flex;align-items:center;gap:16px}.docs-header h1{margin:0;font-size:18px;letter-spacing:-.01em}.docs-header .back{color:var(--muted);font-size:13px;padding:6px 10px;border:1px solid var(--line);border-radius:8px}.docs-header .back:hover{color:var(--fg);background:var(--bg-2);text-decoration:none}.docs-header .base{margin-left:auto;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12px;color:var(--muted);padding:4px 10px;background:var(--bg-2);border:1px solid var(--line);border-radius:8px}#app{max-width:980px;margin:0 auto;padding:24px 20px 80px}h2.group{margin:32px 0 12px;font-size:13px;text-transform:uppercase;letter-spacing:.1em;color:var(--muted)}h2.group:first-child{margin-top:0}.ep{background:var(--bg-1);border:1px solid var(--line);border-radius:12px;padding:16px;margin-bottom:14px}.ep-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.ep-path{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:13px;color:var(--fg);background:var(--bg-2);padding:4px 10px;border-radius:6px;border:1px solid var(--line);overflow-wrap:anywhere}.ep-sum{margin:10px 0 0;color:var(--muted);font-size:14px;line-height:1.5}.m{display:inline-block;font-size:11px;font-weight:800;letter-spacing:.06em;padding:4px 8px;border-radius:6px;color:#07080b}.m-get{background:var(--good)}.m-post{background:var(--accent)}.m-put{background:#d9b14a}.m-delete{background:var(--bad);color:#fff}.m-info{background:var(--info)}.params{width:100%;border-collapse:collapse;margin-top:14px;font-size:13px}.params th{text-align:left;font-weight:600;color:var(--muted);text-transform:uppercase;font-size:11px;letter-spacing:.06em;padding:8px 10px;border-bottom:1px solid var(--line)}.params td{padding:8px 10px;border-bottom:1px solid var(--line);color:var(--fg)}.params td:first-child{width:160px}.params code{font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12px;color:var(--accent-2);background:var(--bg-2);padding:2px 6px;border-radius:4px}.examples{margin-top:14px}.examples-h{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}.examples pre{margin:0 0 8px;padding:10px 12px;background:var(--bg-0);border:1px solid var(--line);border-radius:8px;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12.5px;color:var(--accent-2);overflow-x:auto}.try{margin-top:14px}.try-row{display:flex;gap:8px;align-items:center;margin-bottom:8px}.try-q{flex:1;background:var(--bg-2);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px 12px;font-size:13px;font-family:ui-monospace,SF Mono,Menlo,monospace;outline:none}.try-q:focus{border-color:var(--accent)}.try-btn{background:var(--accent);color:#1a0a00;border:0;border-radius:8px;padding:8px 16px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit}.try-btn:hover{background:#ff8a55}.try-btn:disabled{opacity:.6;cursor:default}.try-open{color:var(--muted);font-size:12px;padding:6px 10px;border:1px solid var(--line);border-radius:8px}.try-open:hover{color:var(--fg);background:var(--bg-2);text-decoration:none}.try-out{margin:0;padding:12px;background:var(--bg-0);border:1px solid var(--line);border-radius:8px;font-family:ui-monospace,SF Mono,Menlo,monospace;font-size:12px;color:var(--fg);max-height:320px;overflow:auto;white-space:pre-wrap;word-break:break-word}

View File

@@ -1 +0,0 @@
(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))s(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const c of t.addedNodes)c.tagName==="LINK"&&c.rel==="modulepreload"&&s(c)}).observe(document,{childList:!0,subtree:!0});function o(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function s(e){if(e.ep)return;e.ep=!0;const t=o(e);fetch(e.href,t)}})();async function i(r,n,o){const s=await fetch(n,{method:r,credentials:"same-origin",headers:o?{"Content-Type":"application/json"}:{},body:o?JSON.stringify(o):void 0});if(s.status===204)return null;const t=(s.headers.get("content-type")||"").includes("json")?await s.json():await s.text();if(!s.ok)throw Object.assign(new Error((t==null?void 0:t.error)||s.statusText),{status:s.status,data:t});return t}const l={get:r=>i("GET",r),post:(r,n)=>i("POST",r,n),put:(r,n)=>i("PUT",r,n),patch:(r,n)=>i("PATCH",r,n),del:r=>i("DELETE",r)};function a(r,n={},...o){const s=document.createElement(r);for(const[e,t]of Object.entries(n||{}))e==="class"?s.className=t:e==="style"&&typeof t=="object"?Object.assign(s.style,t):e.startsWith("on")&&typeof t=="function"?s.addEventListener(e.slice(2).toLowerCase(),t):e==="html"?s.innerHTML=t:t!==!1&&t!=null&&s.setAttribute(e,t===!0?"":t);for(const e of o.flat())e==null||e===!1||s.appendChild(e instanceof Node?e:document.createTextNode(String(e)));return s}function f(r){for(;r.firstChild;)r.removeChild(r.firstChild)}export{l as a,f as c,a as e};

View File

@@ -0,0 +1 @@
async function a(t,i,o){const s=await fetch(i,{method:t,credentials:"same-origin",headers:o?{"Content-Type":"application/json"}:{},body:o?JSON.stringify(o):void 0});if(s.status===204)return null;const e=(s.headers.get("content-type")||"").includes("json")?await s.json():await s.text();if(!s.ok)throw Object.assign(new Error((e==null?void 0:e.error)||s.statusText),{status:s.status,data:e});return e}const c={get:t=>a("GET",t),post:(t,i)=>a("POST",t,i),put:(t,i)=>a("PUT",t,i),patch:(t,i)=>a("PATCH",t,i),del:t=>a("DELETE",t)};function r(t,i={},...o){const s=document.createElement(t);for(const[n,e]of Object.entries(i||{}))n==="class"?s.className=e:n==="style"&&typeof e=="object"?Object.assign(s.style,e):n.startsWith("on")&&typeof e=="function"?s.addEventListener(n.slice(2).toLowerCase(),e):n==="html"?s.innerHTML=e:e!==!1&&e!=null&&s.setAttribute(n,e===!0?"":e);for(const n of o.flat())n==null||n===!1||s.appendChild(n instanceof Node?n:document.createTextNode(String(n)));return s}function l(t){for(;t.firstChild;)t.removeChild(t.firstChild)}export{c as a,l as c,r as e};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))i(e);new MutationObserver(e=>{for(const r of e)if(r.type==="childList")for(const o of r.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&i(o)}).observe(document,{childList:!0,subtree:!0});function s(e){const r={};return e.integrity&&(r.integrity=e.integrity),e.referrerPolicy&&(r.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?r.credentials="include":e.crossOrigin==="anonymous"?r.credentials="omit":r.credentials="same-origin",r}function i(e){if(e.ep)return;e.ep=!0;const r=s(e);fetch(e.href,r)}})();

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>oradio · API reference</title>
<script type="module" crossorigin src="/assets/docs-CJfnRuXm.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="stylesheet" crossorigin href="/assets/docs-z3ZiwvpP.css">
</head>
<body>
<header class="docs-header">
<div class="docs-header-inner">
<a href="/" class="back">← Kiosk</a>
<h1>oradio API</h1>
<span class="base" id="base"></span>
</div>
</header>
<main id="app"></main>
</body>

View File

@@ -1,14 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title>
<script type="module" crossorigin src="/assets/kiosk-DBnbAN5w.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dom-BZgKDOeX.js">
<link rel="stylesheet" crossorigin href="/assets/kiosk-CL6_kPws.css">
</head>
<script type="module" crossorigin src="/assets/kiosk-C37Mmo8O.js"></script>
<link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-B5Qt9EMX.js">
<link rel="modulepreload" crossorigin href="/assets/dom-BvorgAdo.js">
<link rel="stylesheet" crossorigin href="/assets/kiosk-CdZttV5P.css">
</head>
<body class="kiosk">
<div id="app"></div>
<div id="app"></div>
</body>

View File

@@ -1,6 +1,7 @@
import { Router } from 'express';
import { requireUser } from '../auth.js';
import { getDb } from '../db/index.js';
import { getStatsMap } from '../stats.js';
export const router = Router();
@@ -13,10 +14,32 @@ router.get('/favorites', (req, res) => {
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) => ({
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
})));
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) => {

View File

@@ -6,6 +6,7 @@ import {
import { resolveStream } from '../streams/resolver.js';
import { requireAdmin, requireUser } from '../auth.js';
import * as radiobrowser from '../sources/radiobrowser.js';
import { castVote, getStationStats, getStatsMap, recordPlay, sortByMode } from '../stats.js';
export const router = Router();
@@ -15,6 +16,13 @@ router.get('/', (req, res) => {
source: req.query.source || undefined,
enabled: req.query.all ? null : true
});
const statsMap = getStatsMap(req.user?.id || null);
for (const s of stations) {
const st = statsMap.get(s.id) || { up: 0, down: 0, plays: 0, myVote: 0, score: 0 };
s.up = st.up; s.down = st.down; s.plays = st.plays;
s.my_vote = st.myVote; s.score = st.score;
}
sortByMode(stations, req.query.sort, statsMap);
res.json(stations);
});
@@ -23,9 +31,37 @@ router.get('/:id', (req, res) => {
const station = getStation(id);
if (!station) return res.status(404).json({ error: 'not found' });
station.streams = getStreamsForStation(id);
Object.assign(station, getStationStats(id, req.user?.id || null));
res.json(station);
});
// --- voting ---
router.get('/:id/votes', (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
res.json(getStationStats(id, req.user?.id || null));
});
router.post('/:id/vote', requireUser, (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
const raw = req.body?.value;
const value = raw === 1 || raw === '1' || raw === 'up' ? 1
: raw === -1 || raw === '-1' || raw === 'down' ? -1
: raw === 0 || raw === '0' || raw === null || raw === 'clear' ? 0
: NaN;
if (Number.isNaN(value)) return res.status(400).json({ error: 'value must be 1, -1 or 0' });
res.json(castVote(req.user.id, id, value));
});
// Lightweight play-count ping (called when the kiosk actually starts a station).
router.post('/:id/play', requireUser, (req, res) => {
const id = Number(req.params.id);
if (!getStation(id)) return res.status(404).json({ error: 'not found' });
recordPlay(id);
res.json(getStationStats(id, req.user.id));
});
router.post('/:id/resolve', requireUser, async (req, res) => {
const id = Number(req.params.id);
const streams = getStreamsForStation(id);
@@ -90,7 +126,7 @@ router.get('/:id/proxy', requireUser, async (req, res) => {
}
} catch { /* client disconnect or upstream abort */ }
finally {
try { reader.cancel(); } catch {}
try { reader.cancel(); } catch { }
res.end();
}
};

View File

@@ -9,6 +9,7 @@ import {
import { resolveStream } from '../streams/resolver.js';
import { getDb } from '../db/index.js';
import { loadCategoriesFile } from '../sources/seed.js';
import { getStationStats, getStatsMap, sortByMode } from '../stats.js';
export const router = Router();
@@ -51,7 +52,11 @@ function publicStation(s) {
description: s.description,
image_url: s.image_url,
category: s.category,
enabled: s.enabled
enabled: s.enabled,
up: s.up ?? 0,
down: s.down ?? 0,
plays: s.plays ?? 0,
score: s.score ?? 0
};
}
@@ -112,15 +117,63 @@ router.get('/stations', (req, res) => {
const g = String(req.query.genre).toLowerCase();
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
}
const statsMap = getStatsMap(null);
for (const s of items) {
const st = statsMap.get(s.id) || { up: 0, down: 0, plays: 0, score: 0 };
s.up = st.up; s.down = st.down; s.plays = st.plays; s.score = st.score;
}
sortByMode(items, req.query.sort, statsMap);
res.json({
total: items.length,
items: items.slice(0, limit).map(publicStation)
});
});
// Pick a random enabled station. Optional filters narrow the pool.
// `redirect=stream` issues a 302 to the resolved stream URL — handy for
// `mpv http://host/api/v1/stations/random?redirect=stream`.
router.get('/stations/random', async (req, res) => {
let items = listStations({
category: req.query.category || undefined,
enabled: true
});
if (req.query.country) {
const c = String(req.query.country).toUpperCase();
items = items.filter((s) => (s.country || '').toUpperCase() === c);
}
if (req.query.genre) {
const g = String(req.query.genre).toLowerCase();
items = items.filter((s) => (s.genres || []).some((x) => x.toLowerCase().includes(g)));
}
if (!items.length) return res.status(404).json({ error: 'no stations match' });
const pick = items[Math.floor(Math.random() * items.length)];
Object.assign(pick, getStationStats(pick.id, null));
if (req.query.redirect === 'stream') {
const streams = getStreamsForStation(pick.id);
if (!streams.length) return res.status(404).json({ error: 'no streams' });
const ordered = [...streams].sort((a, b) => {
const au = a.last_status === 'up' ? 0 : 1;
const bu = b.last_status === 'up' ? 0 : 1;
return au - bu || a.priority - b.priority;
});
const resolved = await resolveStream({ url: ordered[0].url, format: ordered[0].format });
res.set('Cache-Control', 'no-store');
res.set('X-Station-Uuid', pick.uuid);
res.set('X-Station-Name', encodeURIComponent(pick.name));
return res.redirect(302, resolved.url);
}
const out = publicStation(pick);
out.streams = getStreamsForStation(pick.id).map(publicStream);
res.set('Cache-Control', 'no-store');
res.json(out);
});
router.get('/stations/:uuid', (req, res) => {
const s = getStationByUuid(req.params.uuid);
if (!s) return res.status(404).json({ error: 'not found' });
Object.assign(s, getStationStats(s.id, null));
const out = publicStation(s);
out.streams = getStreamsForStation(s.id).map(publicStream);
res.json(out);

View File

@@ -0,0 +1,189 @@
// One-shot importer: resolves a curated list of underground / experimental /
// DJ-led stations against Radio-Browser, then writes
// data/seed/stations-underground.json. Re-running is safe: the seeder merges
// by Radio-Browser UUID.
//
// Usage: node server/scripts/import-underground.js
//
// All entries are real DJ-broadcast / community / college / free-form stations
// (no playlist bots), spread across punk, house, jazz, eclectic, underground.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..', '..');
const OUT = path.join(ROOT, 'data', 'seed', 'stations-underground.json');
const RB = 'https://de1.api.radio-browser.info';
const UA = 'OnlineRadioExplorer/0.1 (+import-underground)';
// Each entry: [searchName, countryCodeOrNull, expectedNameSubstring]
// expectedNameSubstring is a lowercase substring used to filter out wrong-station
// hits when the same name is shared (e.g. two "Skylab Radio"s in different countries).
const PICKS = [
// === UK / IE underground & DJ-led ===
['Foundation FM', 'GB', 'foundation fm'],
['Aaja Radio | Channel 1', 'GB', 'aaja'],
['Aaja Radio | Channel 2', 'GB', 'aaja'],
['Bloop London Radio', 'GB', 'bloop london'],
['Reform Radio', 'GB', 'reform radio'],
['1BTN', 'GB', '1btn'],
['Sub.fm', 'GB', 'sub.fm'],
['Radio Wigwam', 'GB', 'wigwam'],
['Skylab Radio', 'GB', 'skylab'],
// === NL underground ===
['Echobox', 'NL', 'echobox'],
['Operator Radio', 'NL', 'operator'],
// === DE / AT / CH underground ===
['byte.fm', 'DE', 'byte'],
['Radio 80000', 'DE', '80000'],
['FluxFM', 'DE', 'fluxfm'],
['FluxFM - Techno Underground', 'DE', 'techno'],
['Radio Eins', 'DE', 'radio eins'],
['Radio Helsinki 98,5 Mhz', 'FI', 'helsinki'],
['Radio Helsinki', 'AT', 'helsinki'],
['RTS Couleur 3', 'CH', 'couleur 3'],
// === GR / HU / FR / EE underground ===
['Movement', 'GR', 'movement.radio 1'],
['Movement', 'GR', 'movement.radio 2'],
['Tilos Rádió', 'HU', 'tilos'],
['Radio Campus Paris', 'FR', 'radio campus paris'],
// === US college & free-form (the punk-friendly ones) ===
['WMBR 88.1', 'US', 'wmbr'],
['WHRB 95.3', 'US', 'whrb'],
['KALX 90.7FM Berkeley', 'US', 'kalx'],
['WREK 91.1', 'US', 'wrek'],
['WXYC 89.3', 'US', 'wxyc'],
['KZSC 88.1', 'US', 'kzsc'],
['KDVS Davis', 'US', 'kdvs'],
['WPRB 103.3 FM', 'US', 'wprb'],
['WZBC', 'US', 'wzbc'],
['KFJC', null, 'kfjc'],
['KXLU 88.9FM', 'US', 'kxlu'],
['KCSB', 'US', 'kcsb'],
['WLUW', 'US', 'wluw'],
['WUSB 90.1', 'US', 'wusb'],
['BFF.fm', 'US', 'bff.fm'],
// === Punk / shoegaze / noise leaning ===
['DKFM Shoegaze Radio', 'CA', 'dkfm'],
['idobi Anthm', 'US', 'anthm'],
['idobi Howl', 'US', 'howl'],
['Radio Free Brooklyn', 'US', 'radio free brooklyn'],
// === AU community indie ===
['Triple R 102.7', 'AU', 'triple r'],
['PBS 106.7FM', 'AU', 'pbs 106.7'],
['FBi Radio', 'AU', 'fbi radio'],
['2SER 107.3 FM', 'AU', '2ser'],
['4zzz', 'AU', '4zzz'],
['RTRFM', 'AU', 'rtrfm']
];
function detectFormat(codec, url) {
const c = (codec || '').toLowerCase();
if (c.includes('mp3')) return 'mp3';
if (c.includes('aac')) return 'aac';
if (c.includes('ogg') || c.includes('vorbis') || c.includes('opus')) return 'ogg';
if (url?.endsWith('.m3u8')) return 'hls';
if (url?.endsWith('.m3u')) return 'm3u';
if (url?.endsWith('.pls')) return 'pls';
return 'unknown';
}
function slugify(name) {
return name.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
}
async function rbSearch(name, country) {
const params = new URLSearchParams({
name, limit: '8', hidebroken: 'true', order: 'clickcount', reverse: 'true'
});
if (country) params.set('countrycode', country);
const res = await fetch(`${RB}/json/stations/search?${params}`, { headers: { 'User-Agent': UA } });
if (!res.ok) throw new Error(`RB ${res.status}`);
return res.json();
}
function pickBest(list, target, expectSub) {
if (!list.length) return null;
const sub = (expectSub || '').toLowerCase();
const t = target.toLowerCase().trim();
// 1. exact name match
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === t);
if (exact) return exact;
// 2. expected substring match
if (sub) {
const subMatch = list.find((s) => (s.name || '').toLowerCase().includes(sub));
if (subMatch) return subMatch;
}
// 3. fall back to top hit
return list[0];
}
function toEntry(s) {
const stream = {
url: s.url_resolved || s.url,
format: detectFormat(s.codec, s.url_resolved || s.url),
bitrate: s.bitrate || null,
label: s.codec ? `${s.codec} ${s.bitrate || ''}`.trim() : null,
priority: 0
};
return {
uuid: s.stationuuid,
slug: `rb-${s.stationuuid.slice(0, 8)}-${slugify(s.name).slice(0, 40)}`,
name: s.name,
category: 'underground',
country: s.countrycode || null,
homepage: s.homepage || null,
genres: (s.tags || '').split(',').map((t) => t.trim()).filter(Boolean).slice(0, 6),
description: null,
image_url: s.favicon || null,
source: 'radiobrowser',
source_ref: s.stationuuid,
streams: [stream]
};
}
async function main() {
const out = [];
const seenUuids = new Set();
let okCount = 0;
let missCount = 0;
let dupCount = 0;
for (const [name, country, expectSub] of PICKS) {
try {
const hits = await rbSearch(name, country);
const pick = pickBest(hits, name, expectSub);
if (!pick) { console.warn(' miss:', name); missCount++; continue; }
if (seenUuids.has(pick.stationuuid)) {
console.warn(' dup :', name, '->', pick.name);
dupCount++;
continue;
}
seenUuids.add(pick.stationuuid);
out.push(toEntry(pick));
console.log(' ok :', name.padEnd(32), '->', pick.name, `(${pick.codec || '?'} ${pick.bitrate || ''})`);
okCount++;
} catch (err) {
console.warn(' err :', name, err.message);
missCount++;
}
await new Promise((r) => setTimeout(r, 80));
}
fs.writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
console.log(`\nwrote ${out.length} entries to ${path.relative(ROOT, OUT)}`);
console.log(` ok=${okCount} miss=${missCount} dup=${dupCount}`);
}
main().catch((e) => { console.error(e); process.exit(1); });

View File

@@ -36,7 +36,7 @@ async function fetchText(url) {
if (done) break;
received += value.length;
chunks.push(value);
if (received >= MAX_HTML_BYTES) { try { await reader.cancel(); } catch {} break; }
if (received >= MAX_HTML_BYTES) { try { await reader.cancel(); } catch { } break; }
}
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8');
} catch {
@@ -104,7 +104,7 @@ async function fromRadioBrowserByName(name) {
const exact = list.find((s) => (s.name || '').toLowerCase().trim() === target);
const pick = exact || list[0];
if (pick?.favicon) return pick.favicon;
} catch {}
} catch { }
return null;
}

125
server/stats.js Normal file
View File

@@ -0,0 +1,125 @@
// Vote + play stats and the ranking algorithm.
//
// Score combines two signals:
// - voteZ = (up - down) / sqrt(up + down + 1) z-like, penalizes small N
// - playLog = log10(plays + 1) gentle popularity boost
// - score = voteZ + 0.5 * playLog
//
// Net effect:
// * A handful of downvotes on an obscure station sinks it hard.
// * One stray upvote on a brand new station barely moves it.
// * Popular stations float up only if they aren't being actively buried.
// * Established + positively-voted stations dominate the top.
import { getDb } from './db/index.js';
export function computeScore({ up = 0, down = 0, plays = 0 } = {}) {
const n = up + down;
const voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 1);
const playLog = Math.log10(plays + 1);
return voteZ + 0.5 * playLog;
}
export function getStationStats(stationId, userId = null) {
const db = getDb();
const v = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END), 0) AS up,
COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS down
FROM station_votes WHERE station_id = ?
`).get(stationId) || { up: 0, down: 0 };
const p = db.prepare('SELECT plays FROM station_plays WHERE station_id = ?').get(stationId);
const plays = p?.plays || 0;
let myVote = 0;
if (userId) {
const r = db.prepare('SELECT value FROM station_votes WHERE user_id = ? AND station_id = ?').get(userId, stationId);
myVote = r?.value || 0;
}
return { up: v.up, down: v.down, plays, myVote, score: computeScore({ up: v.up, down: v.down, plays }) };
}
// Bulk stats for many stations in one query. Returns a Map<station_id, stats>.
export function getStatsMap(userId = null) {
const db = getDb();
const rows = db.prepare(`
SELECT
s.id AS station_id,
COALESCE(v.up, 0) AS up,
COALESCE(v.down, 0) AS down,
COALESCE(p.plays, 0) AS plays
FROM stations s
LEFT JOIN (
SELECT station_id,
SUM(CASE WHEN value = 1 THEN 1 ELSE 0 END) AS up,
SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END) AS down
FROM station_votes GROUP BY station_id
) v ON v.station_id = s.id
LEFT JOIN station_plays p ON p.station_id = s.id
`).all();
const my = new Map();
if (userId) {
for (const r of db.prepare('SELECT station_id, value FROM station_votes WHERE user_id = ?').all(userId)) {
my.set(r.station_id, r.value);
}
}
const out = new Map();
for (const r of rows) {
const myVote = my.get(r.station_id) || 0;
out.set(r.station_id, {
up: r.up, down: r.down, plays: r.plays, myVote,
score: computeScore({ up: r.up, down: r.down, plays: r.plays })
});
}
return out;
}
export function castVote(userId, stationId, value) {
const db = getDb();
if (value === 0 || value == null) {
db.prepare('DELETE FROM station_votes WHERE user_id = ? AND station_id = ?').run(userId, stationId);
} else if (value === 1 || value === -1) {
db.prepare(`
INSERT INTO station_votes (user_id, station_id, value) VALUES (?, ?, ?)
ON CONFLICT(user_id, station_id) DO UPDATE SET value = excluded.value, created_at = CURRENT_TIMESTAMP
`).run(userId, stationId, value);
} else {
throw new Error('vote value must be -1, 0 or 1');
}
return getStationStats(stationId, userId);
}
export function recordPlay(stationId) {
getDb().prepare(`
INSERT INTO station_plays (station_id, plays, last_played_at) VALUES (?, 1, datetime('now'))
ON CONFLICT(station_id) DO UPDATE SET
plays = station_plays.plays + 1,
last_played_at = datetime('now')
`).run(stationId);
}
// Sort helper used by routes. Mutates the array.
export function sortByMode(items, mode, statsMap) {
const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, score: 0 };
switch (mode) {
case 'hot':
items.sort((a, b) => s(b.id).score - s(a.id).score || a.name.localeCompare(b.name));
break;
case 'top':
items.sort((a, b) => (s(b.id).up - s(b.id).down) - (s(a.id).up - s(a.id).down) || a.name.localeCompare(b.name));
break;
case 'plays':
items.sort((a, b) => s(b.id).plays - s(a.id).plays || a.name.localeCompare(b.name));
break;
case 'controversial':
items.sort((a, b) => {
const A = s(a.id), B = s(b.id);
return Math.min(B.up, B.down) - Math.min(A.up, A.down) || a.name.localeCompare(b.name);
});
break;
case 'name':
default:
items.sort((a, b) => a.name.localeCompare(b.name));
}
return items;
}

View File

@@ -28,7 +28,7 @@ export function probeStream(rawUrl) {
const finish = (status) => {
if (settled) return;
settled = true;
try { sock.destroy(); } catch {}
try { sock.destroy(); } catch { }
resolve(status);
};

View File

@@ -17,7 +17,8 @@ export default defineConfig({
rollupOptions: {
input: {
kiosk: resolve(__dirname, 'web/index.html'),
admin: resolve(__dirname, 'web/admin/index.html')
admin: resolve(__dirname, 'web/admin/index.html'),
docs: resolve(__dirname, 'web/docs/index.html')
}
}
}

View File

@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Radio Admin</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</body>
</html>

View File

@@ -28,14 +28,16 @@ async function refresh() {
function showLogin() {
clear(app);
app.appendChild(el('div', { class: 'login' },
el('form', { onSubmit: async (e) => {
el('form', {
onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
try {
state.user = await api.post('/api/auth/login', { username: fd.get('username'), password: fd.get('password') });
await bootstrap();
} catch (err) { e.target.querySelector('.err').textContent = err.message; }
} },
}
},
el('h1', {}, 'Admin sign in'),
el('input', { name: 'username', placeholder: 'Username', required: true }),
el('input', { name: 'password', type: 'password', placeholder: 'Password', required: true }),
@@ -49,8 +51,10 @@ function render() {
const side = el('aside', { class: 'side' },
el('h1', {}, 'Online Radio Explorer'),
...['stations', 'import', 'users', 'system'].map((v) =>
el('button', { class: `nav ${state.view === v ? 'active' : ''}`,
onClick: async () => { state.view = v; await refresh(); render(); } }, label(v))),
el('button', {
class: `nav ${state.view === v ? 'active' : ''}`,
onClick: async () => { state.view = v; await refresh(); render(); }
}, label(v))),
el('div', { class: 'me' }, `Signed in as ${state.user.username}`,
el('br'),
el('a', { href: '#', onClick: async (e) => { e.preventDefault(); await api.post('/api/auth/logout'); location.reload(); } }, 'Sign out'))
@@ -70,8 +74,10 @@ function label(v) {
// ---------- Stations ----------
function renderStations(root) {
root.appendChild(el('div', { class: 'bar' },
el('input', { placeholder: 'Search…', value: state.search,
onInput: (e) => { state.search = e.target.value; renderStationsTable(); } }),
el('input', {
placeholder: 'Search…', value: state.search,
onInput: (e) => { state.search = e.target.value; renderStationsTable(); }
}),
el('button', { class: 'btn primary', onClick: () => openStationDialog() }, '+ Add station'),
el('button', { class: 'btn', onClick: async () => { await api.post('/api/admin/health-check'); alert('Health check finished'); await refresh(); render(); } }, 'Run health check')
));
@@ -102,9 +108,11 @@ function renderStationsTable() {
el('td', {},
el('button', { class: 'btn', onClick: () => openStationDialog(s.id) }, 'Edit'),
' ',
el('button', { class: 'btn danger', onClick: async () => {
el('button', {
class: 'btn danger', onClick: async () => {
if (confirm(`Delete ${s.name}?`)) { await api.del(`/api/stations/${s.id}`); await refresh(); render(); }
} }, 'Delete')
}
}, 'Delete')
)
)))
);
@@ -123,7 +131,7 @@ async function openStationDialog(id) {
for (const s of station.streams || []) {
streamsBox.appendChild(el('div', { class: 'stream-row' },
el('select', { onChange: (e) => s.format = e.target.value },
...['mp3','aac','hls','m3u','pls','ogg','unknown'].map((f) =>
...['mp3', 'aac', 'hls', 'm3u', 'pls', 'ogg', 'unknown'].map((f) =>
el('option', { value: f, selected: s.format === f }, f))),
el('input', { value: s.url, placeholder: 'https://…', onInput: (e) => s.url = e.target.value }),
el('input', { type: 'number', placeholder: 'kbps', value: s.bitrate || '', onInput: (e) => s.bitrate = Number(e.target.value) || null }),
@@ -132,13 +140,16 @@ async function openStationDialog(id) {
el('button', { class: 'btn danger', type: 'button', onClick: () => { station.streams = station.streams.filter((x) => x !== s); paintStreams(); } }, '×')
));
}
streamsBox.appendChild(el('button', { class: 'btn', type: 'button', onClick: () => {
streamsBox.appendChild(el('button', {
class: 'btn', type: 'button', onClick: () => {
station.streams = [...(station.streams || []), { url: '', format: 'mp3', priority: (station.streams?.length || 0) }];
paintStreams();
} }, '+ Add stream'));
}
}, '+ Add stream'));
}
const form = el('form', { method: 'dialog', onSubmit: async (e) => {
const form = el('form', {
method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const payload = {
name: station.name, homepage: station.homepage, country: station.country,
@@ -158,13 +169,14 @@ async function openStationDialog(id) {
dlg.close();
await refresh();
render();
} },
}
},
el('h2', {}, id ? 'Edit station' : 'Add station'),
el('div', { class: 'row' }, el('label', {}, 'Name'), el('input', { value: station.name, onInput: (e) => station.name = e.target.value, required: true })),
el('div', { class: 'row' }, el('label', {}, 'Homepage'), el('input', { value: station.homepage || '', onInput: (e) => station.homepage = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Country'), el('input', { value: station.country || '', maxlength: 4, onInput: (e) => station.country = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Genres'), el('input', { value: (station.genres || []).join(', '), onInput: (e) => station.genres = e.target.value.split(',').map((s) => s.trim()).filter(Boolean) })),
el('div', { class: 'row' }, el('label', {}, 'Image URL'),el('input', { value: station.image_url || '', onInput: (e) => station.image_url = e.target.value })),
el('div', { class: 'row' }, el('label', {}, 'Image URL'), el('input', { value: station.image_url || '', onInput: (e) => station.image_url = e.target.value })),
el('div', { class: 'row col' }, el('textarea', { rows: 2, placeholder: 'Description', onInput: (e) => station.description = e.target.value }, station.description || '')),
el('div', { class: 'row' }, el('label', {}, 'Enabled'), el('input', { type: 'checkbox', checked: station.enabled, onChange: (e) => station.enabled = e.target.checked })),
streamsBox,
@@ -188,7 +200,8 @@ function renderImport(root) {
el('input', { id: 'rbq', placeholder: 'Search by name…' }),
el('input', { id: 'rbcountry', placeholder: 'Country (e.g. NL)', style: { minWidth: '120px' } }),
el('input', { id: 'rbtag', placeholder: 'Tag/genre' }),
el('button', { class: 'btn primary', onClick: async () => {
el('button', {
class: 'btn primary', onClick: async () => {
const params = new URLSearchParams({
q: document.getElementById('rbq').value,
country: document.getElementById('rbcountry').value,
@@ -196,7 +209,8 @@ function renderImport(root) {
});
results = await api.get(`/api/stations/sources/radiobrowser/search?${params}`);
paint();
} }, 'Search')
}
}, 'Search')
));
root.appendChild(resultsBox);
function paint() {
@@ -209,10 +223,12 @@ function renderImport(root) {
el('td', {}, s.country || ''),
el('td', {}, ...(s.genres || []).slice(0, 4).map((g) => el('span', { class: 'tag' }, g))),
el('td', {}, el('small', {}, (s.streams[0]?.format || '') + ' ' + (s.streams[0]?.bitrate || ''))),
el('td', {}, el('button', { class: 'btn primary', onClick: async () => {
el('td', {}, el('button', {
class: 'btn primary', onClick: async () => {
await api.post('/api/stations/sources/radiobrowser/import', s);
alert(`Imported ${s.name}`);
} }, 'Import'))
}
}, 'Import'))
)))
);
resultsBox.appendChild(table);
@@ -232,20 +248,26 @@ function renderUsers(root) {
el('td', {}, u.role),
el('td', {}, u.created_at),
el('td', {},
el('button', { class: 'btn', onClick: async () => {
el('button', {
class: 'btn', onClick: async () => {
const pw = prompt(`New password for ${u.username}:`);
if (pw) { await api.patch(`/api/auth/users/${u.id}`, { password: pw }); alert('Updated'); }
} }, 'Reset PW'),
}
}, 'Reset PW'),
' ',
el('button', { class: 'btn', onClick: async () => {
el('button', {
class: 'btn', onClick: async () => {
const r = u.role === 'admin' ? 'user' : 'admin';
await api.patch(`/api/auth/users/${u.id}`, { role: r });
await refresh(); render();
} }, 'Toggle role'),
}
}, 'Toggle role'),
' ',
u.id !== state.user.id ? el('button', { class: 'btn danger', onClick: async () => {
u.id !== state.user.id ? el('button', {
class: 'btn danger', onClick: async () => {
if (confirm(`Delete ${u.username}?`)) { await api.del(`/api/auth/users/${u.id}`); await refresh(); render(); }
} }, 'Delete') : null
}
}, 'Delete') : null
)
)))
));
@@ -253,7 +275,8 @@ function renderUsers(root) {
function openUserDialog() {
const dlg = el('dialog');
dlg.appendChild(el('form', { method: 'dialog', onSubmit: async (e) => {
dlg.appendChild(el('form', {
method: 'dialog', onSubmit: async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
await api.post('/api/auth/users', {
@@ -261,7 +284,8 @@ function openUserDialog() {
});
dlg.close();
await refresh(); render();
} },
}
},
el('h2', {}, 'New user'),
el('div', { class: 'row' }, el('label', {}, 'Username'), el('input', { name: 'username', required: true })),
el('div', { class: 'row' }, el('label', {}, 'Password'), el('input', { name: 'password', type: 'password', required: true })),

23
web/docs/index.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>oradio · API reference</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<header class="docs-header">
<div class="docs-header-inner">
<a href="/" class="back">← Kiosk</a>
<h1>oradio API</h1>
<span class="base" id="base"></span>
</div>
</header>
<main id="app"></main>
<script type="module" src="./main.js"></script>
</body>
</html>

241
web/docs/main.js Normal file
View File

@@ -0,0 +1,241 @@
// Minimal, dependency-free API reference page for oradio.
// Lists every public endpoint, lets the user fire a live request and inspect
// the response. Intended as a reference companion to the kiosk.
const BASE = `${location.origin}/api/v1`;
const INTERNAL = `${location.origin}/api`;
document.getElementById('base').textContent = BASE;
const endpoints = [
{
group: 'Public (v1)',
items: [
{
id: 'health',
method: 'GET',
path: '/health',
summary: 'Service heartbeat plus enabled-station count.',
tryable: true
},
{
id: 'categories',
method: 'GET',
path: '/categories',
summary: 'All categories with their station counts.',
tryable: true
},
{
id: 'stations-list',
method: 'GET',
path: '/stations',
summary: 'Paginated station list. Filterable and sortable.',
params: [
{ name: 'q', desc: 'Substring filter on name / genres / country.' },
{ name: 'category', desc: 'Category id (see /categories).' },
{ name: 'country', desc: 'ISO country code, case-insensitive.' },
{ name: 'genre', desc: 'Substring match against any genre.' },
{ name: 'sort', desc: 'hot | top | plays | controversial | name (default: name).' },
{ name: 'limit', desc: 'Max items returned (default 200, cap 1000).' }
],
tryable: true,
tryQuery: 'limit=3&sort=hot'
},
{
id: 'random',
method: 'GET',
path: '/stations/random',
summary: 'Pick one random enabled station. Same filters as /stations. Pass redirect=stream for a 302 to the resolved audio URL.',
params: [
{ name: 'category', desc: 'Restrict pool to a category.' },
{ name: 'country', desc: 'Restrict pool to a country.' },
{ name: 'genre', desc: 'Restrict pool by genre substring.' },
{ name: 'redirect', desc: 'Set to "stream" to 302-redirect to the resolved stream URL.' }
],
tryable: true,
examples: [
`mpv ${BASE}/stations/random?redirect=stream`,
`curl -sLI "${BASE}/stations/random?redirect=stream" | grep -i location`
]
},
{
id: 'station',
method: 'GET',
path: '/stations/{uuid}',
summary: 'Full detail for one station, including its streams.',
params: [{ name: 'uuid', desc: 'Station UUID (see list response).' }]
},
{
id: 'station-stream',
method: 'GET',
path: '/stations/{uuid}/stream',
summary: '302-redirect to the resolved stream URL. Picks the highest-priority stream that was last seen up.',
params: [
{ name: 'uuid', desc: 'Station UUID.' },
{ name: 'format', desc: 'Optional preferred format (mp3, aac, ogg, hls).' }
]
},
{
id: 'stream-by-uuid',
method: 'GET',
path: '/stations/{uuid}/streams/{streamUuid}',
summary: 'Resolve and 302 to a specific stream. Pass redirect=0 to return JSON metadata instead.',
params: [
{ name: 'uuid', desc: 'Station UUID.' },
{ name: 'streamUuid', desc: 'Stream UUID.' },
{ name: 'redirect', desc: 'Set to "0" to return JSON instead of redirecting.' }
]
}
]
},
{
group: 'Authenticated (cookie session)',
items: [
{
id: 'me',
method: 'GET',
path: '/auth/me',
base: INTERNAL,
summary: 'Current signed-in user, or 401.',
tryable: true
},
{
id: 'favorites',
method: 'GET',
path: '/me/favorites',
base: INTERNAL,
summary: 'Your favorites, ordered.',
tryable: true
},
{
id: 'favorites-random',
method: 'GET',
path: '/me/favorites/random',
base: INTERNAL,
summary: 'One random favorite — used by the kiosk dice button in "favorites" mode.',
tryable: true
},
{
id: 'history',
method: 'GET',
path: '/me/history',
base: INTERNAL,
summary: 'Recent play history (last 50).',
tryable: true
}
]
},
{
group: 'Rate limit',
items: [
{
id: 'rate',
method: 'INFO',
path: '120 req / minute / IP',
summary: 'Public /api/v1 endpoints share a per-IP token bucket. Headers X-RateLimit-Limit and X-RateLimit-Remaining tell you where you stand.'
}
]
}
];
const app = document.getElementById('app');
function el(tag, attrs, ...children) {
const n = document.createElement(tag);
if (attrs) {
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === 'class') n.className = v;
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2).toLowerCase(), v);
else n.setAttribute(k, v);
}
}
for (const c of children) {
if (c == null || c === false) continue;
n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
return n;
}
function methodChip(m) {
return el('span', { class: `m m-${m.toLowerCase()}` }, m);
}
function renderEndpoint(ep) {
const base = ep.base || BASE;
const fullUrl = `${base}${ep.path}`;
const card = el('article', { class: 'ep', id: ep.id });
card.appendChild(el('header', { class: 'ep-head' },
methodChip(ep.method),
el('code', { class: 'ep-path' }, fullUrl)
));
card.appendChild(el('p', { class: 'ep-sum' }, ep.summary));
if (ep.params?.length) {
const tbl = el('table', { class: 'params' },
el('thead', {}, el('tr', {}, el('th', {}, 'Parameter'), el('th', {}, 'Description'))),
el('tbody', {}, ...ep.params.map((p) =>
el('tr', {}, el('td', {}, el('code', {}, p.name)), el('td', {}, p.desc))
))
);
card.appendChild(tbl);
}
if (ep.examples?.length) {
card.appendChild(el('div', { class: 'examples' },
el('div', { class: 'examples-h' }, 'Examples'),
...ep.examples.map((e) => el('pre', {}, el('code', {}, e)))
));
}
if (ep.tryable && ep.method === 'GET') {
const out = el('pre', { class: 'try-out' }, 'Click "Try it" to send a live request.');
const queryInput = el('input', {
class: 'try-q',
type: 'text',
placeholder: '?key=value (optional)',
value: ep.tryQuery ? `?${ep.tryQuery}` : ''
});
const button = el('button', {
class: 'try-btn',
onClick: async () => {
button.disabled = true;
button.textContent = '…';
let q = queryInput.value.trim();
if (q && !q.startsWith('?')) q = `?${q}`;
const url = `${fullUrl}${q}`;
const t0 = performance.now();
try {
const res = await fetch(url, { credentials: 'same-origin', redirect: 'manual' });
const ms = Math.round(performance.now() - t0);
let body;
if (res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400)) {
body = `(redirect — open in new tab to follow)`;
} else {
const ct = res.headers.get('content-type') || '';
body = ct.includes('json')
? JSON.stringify(await res.json(), null, 2)
: (await res.text()).slice(0, 4000);
}
out.textContent = `${res.status} ${res.statusText || ''} · ${ms} ms\n${url}\n\n${body}`;
} catch (err) {
out.textContent = `error: ${err.message || err}\n${url}`;
} finally {
button.disabled = false;
button.textContent = 'Try it';
}
}
}, 'Try it');
const openLink = el('a', { class: 'try-open', target: '_blank', rel: 'noopener', href: fullUrl }, 'Open ↗');
card.appendChild(el('div', { class: 'try' },
el('div', { class: 'try-row' }, queryInput, button, openLink),
out
));
}
return card;
}
for (const group of endpoints) {
app.appendChild(el('h2', { class: 'group' }, group.group));
for (const ep of group.items) app.appendChild(renderEndpoint(ep));
}

171
web/docs/style.css Normal file
View File

@@ -0,0 +1,171 @@
:root {
--bg-0: #07080b;
--bg-1: #0e1116;
--bg-2: #161a22;
--bg-3: #1f242e;
--line: #262b36;
--fg: #e9ecf2;
--muted: #8a90a0;
--muted-2: #5d6373;
--accent: #ff7a3d;
--accent-2: #ffb37a;
--good: #4ec9a6;
--bad: #ec6a6a;
--info: #6ab7ff;
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
color-scheme: dark;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg-0); color: var(--fg); }
a { color: var(--accent-2); text-decoration: none; }
a:hover { text-decoration: underline; }
.docs-header {
position: sticky; top: 0; z-index: 10;
background: rgba(7, 8, 11, 0.92);
border-bottom: 1px solid var(--line);
backdrop-filter: blur(8px);
}
.docs-header-inner {
max-width: 980px; margin: 0 auto;
padding: 14px 20px;
display: flex; align-items: center; gap: 16px;
}
.docs-header h1 {
margin: 0; font-size: 18px; letter-spacing: -0.01em;
}
.docs-header .back {
color: var(--muted); font-size: 13px;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px;
}
.docs-header .back:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; }
.docs-header .base {
margin-left: auto;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px; color: var(--muted);
padding: 4px 10px; background: var(--bg-2);
border: 1px solid var(--line); border-radius: 8px;
}
#app {
max-width: 980px; margin: 0 auto;
padding: 24px 20px 80px;
}
h2.group {
margin: 32px 0 12px;
font-size: 13px; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--muted);
}
h2.group:first-child { margin-top: 0; }
.ep {
background: var(--bg-1);
border: 1px solid var(--line);
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
}
.ep-head {
display: flex; align-items: center; gap: 10px;
flex-wrap: wrap;
}
.ep-path {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 13px; color: var(--fg);
background: var(--bg-2);
padding: 4px 10px; border-radius: 6px;
border: 1px solid var(--line);
overflow-wrap: anywhere;
}
.ep-sum {
margin: 10px 0 0; color: var(--muted);
font-size: 14px; line-height: 1.5;
}
.m {
display: inline-block;
font-size: 11px; font-weight: 800; letter-spacing: 0.06em;
padding: 4px 8px; border-radius: 6px;
color: #07080b;
}
.m-get { background: var(--good); }
.m-post { background: var(--accent); }
.m-put { background: #d9b14a; }
.m-delete { background: var(--bad); color: #fff; }
.m-info { background: var(--info); }
.params {
width: 100%; border-collapse: collapse;
margin-top: 14px;
font-size: 13px;
}
.params th {
text-align: left; font-weight: 600; color: var(--muted);
text-transform: uppercase; font-size: 11px; letter-spacing: 0.06em;
padding: 8px 10px; border-bottom: 1px solid var(--line);
}
.params td {
padding: 8px 10px; border-bottom: 1px solid var(--line);
color: var(--fg);
}
.params td:first-child { width: 160px; }
.params code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px; color: var(--accent-2);
background: var(--bg-2);
padding: 2px 6px; border-radius: 4px;
}
.examples { margin-top: 14px; }
.examples-h {
font-size: 11px; color: var(--muted);
text-transform: uppercase; letter-spacing: 0.06em;
margin-bottom: 6px;
}
.examples pre {
margin: 0 0 8px; padding: 10px 12px;
background: var(--bg-0); border: 1px solid var(--line);
border-radius: 8px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12.5px; color: var(--accent-2);
overflow-x: auto;
}
.try { margin-top: 14px; }
.try-row {
display: flex; gap: 8px; align-items: center;
margin-bottom: 8px;
}
.try-q {
flex: 1;
background: var(--bg-2); color: var(--fg);
border: 1px solid var(--line); border-radius: 8px;
padding: 8px 12px; font-size: 13px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
outline: none;
}
.try-q:focus { border-color: var(--accent); }
.try-btn {
background: var(--accent); color: #1a0a00;
border: 0; border-radius: 8px;
padding: 8px 16px; font-size: 13px; font-weight: 700;
cursor: pointer;
font-family: inherit;
}
.try-btn:hover { background: #ff8a55; }
.try-btn:disabled { opacity: 0.6; cursor: default; }
.try-open {
color: var(--muted); font-size: 12px;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 8px;
}
.try-open:hover { color: var(--fg); background: var(--bg-2); text-decoration: none; }
.try-out {
margin: 0; padding: 12px;
background: var(--bg-0); border: 1px solid var(--line);
border-radius: 8px;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
font-size: 12px; color: var(--fg);
max-height: 320px; overflow: auto;
white-space: pre-wrap; word-break: break-word;
}

View File

@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=1080, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Radio Kiosk</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body class="kiosk">
</head>
<body class="kiosk">
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</body>
</html>

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 [
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` },
{ 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),

View File

@@ -5,7 +5,7 @@ export function connectWs(onMessage) {
ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.addEventListener('open', () => { retry = 0; });
ws.addEventListener('message', (ev) => {
try { onMessage(JSON.parse(ev.data)); } catch {}
try { onMessage(JSON.parse(ev.data)); } catch { }
});
ws.addEventListener('close', () => {
if (closed) return;

View File

@@ -189,6 +189,37 @@ input, select, textarea { font: inherit; color: inherit; }
.btn-add:hover { background: #ff8a55; }
.btn-add:active { transform: scale(0.94); }
.btn-random {
display: flex; align-items: center; gap: 6px;
height: 36px; padding: 0 12px;
border-radius: 999px;
background: var(--bg-2); color: var(--fg);
border: 1px solid var(--line);
font-size: 13px; font-weight: 600;
transition: background 120ms, border-color 120ms, transform 80ms;
}
.btn-random:hover { background: var(--bg-3); border-color: rgba(255,122,61,0.4); }
.btn-random:active { transform: scale(0.96); }
.btn-random .rand-icon { font-size: 15px; line-height: 1; }
.btn-random .rand-mode {
font-size: 11px; padding: 2px 6px; border-radius: 999px;
background: rgba(255,122,61,0.18); color: var(--accent-2);
border: 1px solid rgba(255,122,61,0.30);
letter-spacing: 0.02em;
}
.btn-docs {
display: flex; align-items: center; justify-content: center;
height: 36px; padding: 0 12px;
border-radius: 999px;
background: var(--bg-2); color: var(--muted);
border: 1px solid var(--line);
font-size: 12px; font-weight: 700; letter-spacing: 0.06em;
text-decoration: none;
transition: background 120ms, color 120ms, border-color 120ms;
}
.btn-docs:hover { background: var(--bg-3); color: var(--fg); border-color: rgba(78,201,166,0.4); }
.chips {
display: flex; flex-wrap: wrap; gap: 5px;
max-height: 64px; overflow-y: auto;
@@ -656,3 +687,83 @@ dialog.add-station select:focus { border-color: var(--accent) !important; box-sh
box-shadow: none !important;
text-transform: uppercase; letter-spacing: 0.06em; font-weight: 800; font-size: 12px;
}
/* ============================================================
Voting: up/down buttons in the now-playing bar, score badge
on every card, and a sort dropdown for the Browse tab.
============================================================ */
.vote-group {
display: flex;
gap: 0;
margin-right: 6px;
}
.vote {
display: flex; align-items: center; gap: 6px;
height: 46px; min-width: 64px;
padding: 0 12px;
background: var(--bg-2) !important;
color: var(--fg);
border: 1px solid var(--line) !important;
font-weight: 800; font-size: 14px;
font-variant-numeric: tabular-nums;
transition: background 80ms linear, color 80ms linear, border-color 80ms linear !important;
}
.vote + .vote { margin-left: -1px; }
.vote:disabled { opacity: 0.35; cursor: not-allowed; }
.vote .vote-icon { font-size: 17px; line-height: 1; }
.vote .vote-count { font-size: 13px; letter-spacing: 0.02em; }
.vote.up:not(:disabled):hover {
background: var(--good) !important; color: #000 !important; border-color: var(--good) !important;
}
.vote.down:not(:disabled):hover {
background: var(--bad) !important; color: #000 !important; border-color: var(--bad) !important;
}
.vote.up.on {
background: var(--good) !important; color: #000 !important; border-color: var(--good) !important;
}
.vote.down.on {
background: var(--bad) !important; color: #000 !important; border-color: var(--bad) !important;
}
/* Sort dropdown next to search */
.sort {
height: 36px;
padding: 0 28px 0 12px;
background: var(--bg-2) !important;
color: var(--fg);
border: 1px solid var(--line) !important;
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
outline: none;
cursor: pointer;
}
.sort:focus, .sort:hover { border-color: var(--accent) !important; }
.sort option { background: var(--bg-1); color: var(--fg); }
/* Grid card: add a column for the score badge. */
.card {
grid-template-columns: 44px 1fr auto auto auto !important;
}
.score-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 36px; height: 26px;
padding: 0 8px;
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--muted);
font-weight: 800; font-size: 12px;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.score-badge.pos { color: var(--good); border-color: rgba(0,210,122,0.35); }
.score-badge.neg { color: var(--bad); border-color: rgba(255,48,48,0.35); }
.score-badge.neu { color: var(--muted-2); }
.card.playing .score-badge { border-color: var(--accent); }