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