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,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>
<link rel="modulepreload" crossorigin href="/assets/dom-BZgKDOeX.js">
<link rel="stylesheet" crossorigin href="/assets/admin-CJZ4D7u-.css">
</head>
<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>
<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>