Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 42 additions & 20 deletions admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ <h1>segmentor control panel</h1>
<div class="panel">
<div class="panel-head"><h2>Player</h2></div>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:8px">
<label>Known asset
<select id="asset-picker"><option value="">— pick one, or type below —</option></select>
<label>Asset
<select id="asset-pick"><option value="sample">sample</option><option value="">Custom…</option></select>
</label>
<label>Asset <input id="asset" size="14" value="sample"></label>
<label id="asset-custom" hidden>Name <input id="asset" size="14" placeholder="asset id"></label>
<label>Protocol<select id="proto"><option value="hls">HLS</option><option value="dash">DASH</option></select></label>
<button id="load" style="align-self:end">Play</button>
</div>
Expand Down Expand Up @@ -163,7 +163,7 @@ <h1>segmentor control panel</h1>
if (!diagnose()) log('<span class="warn-text">cannot start, see warnings under the player</span>');
$('log').innerHTML = '';
markCurrentRow();
const asset = encodeURIComponent($('asset').value);
const asset = encodeURIComponent(assetId());
if ($('proto').value === 'hls') {
const url = `${base()}/hls/${asset}/master.m3u8`;
log('HLS ' + url);
Expand Down Expand Up @@ -197,28 +197,45 @@ <h1>segmentor control panel</h1>
}

/// Plays a row's asset id when a cache or catalog row is clicked, so browsing needs no typing.
function playAsset(assetId) {
$('asset').value = assetId;
function playAsset(id) {
selectAsset(id);
load();
}

function markCurrentRow() {
const current = $('asset').value;
const current = assetId();
for (const row of document.querySelectorAll('#cache tbody tr'))
row.classList.toggle('current', row.dataset.asset === current);
// The picker only ever shows a known asset; typing a custom name falls back to its
// placeholder rather than pretending one of the listed assets is selected.
const picker = $('asset-picker');
picker.value = [...picker.options].some(o => o.value === current) ? current : '';
}

/// The dropdown next to the Asset field: every asset ID the cache table lists. Picking one plays
/// it; typing a name that isn't listed (a mapper asset nobody has requested yet, for example)
/// keeps working in the text field regardless, since the picker is a convenience, not a gate.
function renderAssetPicker(ids) {
$('asset-picker').innerHTML = '<option value="">— pick one, or type below —</option>'
+ ids.map(id => `<option value="${escapeHtml(id)}">${escapeHtml(id)}</option>`).join('');
markCurrentRow();
// ---------- asset dropdown ----------
// Every asset the server can name (its static catalog, or a mapper's optional listing) plus
// whatever it has loaded, then "Custom…" for any other name. Asset IDs are never empty, so the
// empty value marks "Custom…" without colliding with one.
const assetId = () => $('asset-pick').value || $('asset').value.trim();

function showCustomField() {
$('asset-custom').hidden = $('asset-pick').value !== '';
}

/// Rebuilds the options from `ids`, keeping the current choice: a selected asset stays selected
/// even when the list no longer names it, and "Custom…" stays chosen with its text intact.
function renderAssetOptions(ids) {
const pick = $('asset-pick'), selected = pick.value;
const all = [...new Set(selected ? [...ids, selected] : ids)].sort();
pick.innerHTML = all.map(id => `<option value="${escapeHtml(id)}">${escapeHtml(id)}</option>`).join('')
+ '<option value="">Custom…</option>';
pick.value = selected;
showCustomField();
}

/// Selects `id`, adding it to the dropdown if it is not listed yet.
function selectAsset(id) {
const pick = $('asset-pick');
if (![...pick.options].some(option => option.value === id))
pick.add(new Option(id, id), pick.options.length - 1);
pick.value = id;
showCustomField();
}

function renderPlayerStats() {
Expand Down Expand Up @@ -349,7 +366,8 @@ <h1>segmentor control panel</h1>
: '';
for (const row of $('cache').tBodies[0].rows)
row.onclick = () => playAsset(row.dataset.asset);
renderAssetPicker([...rows.keys()].sort());
renderAssetOptions([...rows.keys()]);
markCurrentRow();
}

function escapeHtml(s) {
Expand Down Expand Up @@ -406,7 +424,11 @@ <h1>segmentor control panel</h1>

$('load').onclick = load;
$('asset').addEventListener('input', markCurrentRow);
$('asset-picker').addEventListener('change', () => { if ($('asset-picker').value) playAsset($('asset-picker').value); });
$('asset').addEventListener('keydown', event => { if (event.key === 'Enter') load(); });
$('asset-pick').addEventListener('change', () => {
showCustomField();
if ($('asset-pick').value) load(); else $('asset').focus();
});
load();
clearTimeout(pollTimer);
poll();
Expand Down
68 changes: 46 additions & 22 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,24 +31,23 @@
#log { height:150px; overflow:auto; font:11px/1.5 ui-monospace,monospace; color:var(--dim); }
#status { font-size:12px; color:var(--dim); }
.ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); }
.chip { background:var(--panel); border:1px solid var(--line); border-radius:99px; padding:2px 10px; color:var(--fg); cursor:pointer; font:inherit; font-size:12px; }
.chip:hover { border-color:var(--acc); color:var(--acc); }
.chip.current { border-color:var(--acc); color:var(--acc); background:rgba(76,194,255,.12); }
</style>
</head>
<body>
<header>
<h1>segmentor demo</h1>
<label>Server <input id="base" size="26" value="http://127.0.0.1:3000"></label>
<label>Asset <input id="asset" size="12" value="sample"></label>
<label>Asset
<select id="asset-pick"><option value="sample">sample</option><option value="">Custom…</option></select>
</label>
<label id="asset-custom" hidden>Name <input id="asset" size="14" placeholder="asset id"></label>
<label>Protocol
<select id="proto"><option value="hls">HLS</option><option value="dash">DASH</option></select>
</label>
<label>Poll (s) <input id="poll" type="number" min="0.5" step="0.5" value="1" style="width:70px"></label>
<button id="load">Load</button>
<span id="status"></span>
</header>
<div id="try-row" style="padding:0 16px 10px;font-size:12px;color:var(--dim)"></div>

<main>
<div class="stack">
Expand Down Expand Up @@ -106,23 +105,45 @@ <h2>Resolver &amp; cache</h2>
const tile = (k, v, cls='') => `<div class="tile"><div class="k">${k}</div><div class="v ${cls}">${v}</div></div>`;
const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));

// ---------- assets to try ----------
// A quick way in besides typing a name: whatever the static catalog lists (if any) plus whatever
// has already been played (any resolver). /admin/status has the same trust model as /metrics,
// which this page already reads, so nothing new is exposed by also reading this.
async function refreshTryRow() {
// ---------- asset dropdown ----------
// Every asset the server can name (its static catalog, or a mapper's optional listing) plus
// whatever it has loaded, then "Custom…" for any other name. Asset IDs are never empty, so the
// empty value marks "Custom…" without colliding with one.
const assetId = () => $('asset-pick').value || $('asset').value.trim();

function showCustomField() {
$('asset-custom').hidden = $('asset-pick').value !== '';
}

/// Rebuilds the options from `ids`, keeping the current choice: a selected asset stays selected
/// even when the list no longer names it, and "Custom…" stays chosen with its text intact.
function renderAssetOptions(ids) {
const pick = $('asset-pick'), selected = pick.value;
const all = [...new Set(selected ? [...ids, selected] : ids)].sort();
pick.innerHTML = all.map(id => `<option value="${escapeHtml(id)}">${escapeHtml(id)}</option>`).join('')
+ '<option value="">Custom…</option>';
pick.value = selected;
showCustomField();
}

/// Selects `id`, adding it to the dropdown if it is not listed yet.
function selectAsset(id) {
const pick = $('asset-pick');
if (![...pick.options].some(option => option.value === id))
pick.add(new Option(id, id), pick.options.length - 1);
pick.value = id;
showCustomField();
}

// /admin/status has the same trust model as /metrics, which this page already reads, so nothing
// new is exposed by also reading it.
async function refreshAssets() {
try {
const status = await (await fetch(base() + '/admin/status')).json();
const ids = [...new Set([...(status.resolver.known_assets || []), ...status.cache.assets.map(a => a.asset_id)])].sort();
const row = $('try-row');
if (!ids.length) { row.textContent = ''; return; }
const current = $('asset').value;
row.innerHTML = 'Try: ' + ids.map(id =>
`<button class="chip${id === current ? ' current' : ''}" data-asset="${escapeHtml(id)}">${escapeHtml(id)}</button>`).join(' ');
for (const chip of row.querySelectorAll('.chip')) chip.onclick = () => { $('asset').value = chip.dataset.asset; load(); };
} catch { /* the try row is a convenience; leave it as it was if the origin is unreachable */ }
renderAssetOptions([...(status.resolver.known_assets || []), ...status.cache.assets.map(a => a.asset_id)]);
} catch { /* the list is a convenience; keep it as it was if the origin is unreachable */ }
}
setInterval(refreshTryRow, 10000);
setInterval(refreshAssets, 10000);

// ---------- player ----------
function teardown() {
Expand All @@ -149,7 +170,7 @@ <h2>Resolver &amp; cache</h2>
teardown();
if (!diagnose()) log('<span class="bad">cannot start, see warnings under the player</span>');
$('log').innerHTML = '';
const asset = encodeURIComponent($('asset').value);
const asset = encodeURIComponent(assetId());
if ($('proto').value === 'hls') {
const url = `${base()}/hls/${asset}/master.m3u8`;
log('HLS ' + url);
Expand Down Expand Up @@ -300,8 +321,11 @@ <h2>Resolver &amp; cache</h2>
pollTimer = setTimeout(poll, interval * 1000);
}

$('load').onclick = () => { load(); clearTimeout(pollTimer); prev = null; poll(); setTimeout(refreshTryRow, 1200); };
load(); poll(); refreshTryRow();
function start() { load(); clearTimeout(pollTimer); prev = null; poll(); setTimeout(refreshAssets, 1200); }
$('load').onclick = start;
$('asset-pick').addEventListener('change', () => { showCustomField(); if ($('asset-pick').value) start(); else $('asset').focus(); });
$('asset').addEventListener('keydown', event => { if (event.key === 'Enter') start(); });
load(); poll(); refreshAssets();
</script>
</body>
</html>
17 changes: 17 additions & 0 deletions docs/mapper-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A mapper answers one question: *where is asset X, and which version of it is cur
| --- | --- | --- |
| `GET` | `/v1/assets/{asset_id}` | Resolve one asset |
| `GET` | `/v1/health` | Optional reachability probe |
| `GET` | `/v1/assets` | Optional list of asset IDs, for control panels; see [List assets](#list-assets) |

`{asset_id}` is 1 to 128 ASCII letters, digits, `-`, or `_`. The server rejects any other ID before contacting the mapper, so no escaping is needed. The mapper must ignore request headers it does not know, and the server ignores response fields it does not know, so either side can add fields without breaking the other.

Expand Down Expand Up @@ -83,6 +84,22 @@ Behavior depends only on the HTTP status, so error bodies are informational. `{"

`GET /v1/health` returning any `2xx` means healthy. It is used only when the server's optional readiness probe is enabled, in which case the server reports itself not ready while the mapper is unreachable. Mappers without this endpoint can leave the probe disabled.

## List assets

`GET /v1/assets` is optional. When a mapper implements it, the server's `/admin/status` offers the IDs it returns as `resolver.known_assets`, so the [demo player](../demo/) and [control panel](../admin/) can list them in their asset dropdowns before anyone has played them. Nothing else uses it: the server never preloads or resolves an asset because it is listed.

```text
GET /v1/assets
Accept: application/json
Authorization: Bearer <token> (if configured)
```

```json
{ "assets": ["big-buck-bunny", "movie-with-preroll", "trailer"] }
```

List whatever is useful to browse; it need not be every asset. The server drops any ID a request could not name (the rules in [Endpoints](#endpoints)), removes duplicates, sorts the rest, and keeps at most `limits.max_assets` (default 1000). The answer is bounded like any other (`max_response_bytes`). Any failure, including a `404` from a mapper that does not implement the endpoint, a malformed body, or an outage, simply means there is no list: `known_assets` is `null` and nothing else changes. `/admin/status` asks on every call, so keep the answer cheap.

## Remote locations

An `http` location makes the server fetch media from a URL the mapper chose, so the server applies the operator's policy before any request:
Expand Down
4 changes: 2 additions & 2 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,9 @@ The service still does not limit connections per client address. Do that on the
| `/health` | The process is running | Liveness probe |
| `/ready` | `200` while serving, `503` once shutdown begins | Readiness probe and load-balancer health check |
| `/metrics` | Prometheus text | Scraping |
| `/admin/status` | JSON: the resolver's live health and every asset in the loaded-asset cache | A control panel or an ad hoc check |
| `/admin/status` | JSON: the resolver's live health, the assets it can list, and every asset in the loaded-asset cache | A control panel or an ad hoc check |

`/admin/status` carries the same trust model as `/metrics`: no authentication, meant to be reached only through the reverse proxy this page already asks you to put in front of the origin (see [Before you expose it](deployment.md#before-you-expose-it)). It names asset IDs, versions, and, for a mapper resolver, the mapper's base URL, so keep it off any path a viewer can reach. Unlike `/ready`'s cached health flag, it checks the resolver live on every call, so it costs one resolver round trip (a file resolver answers this immediately). See [the control panel](../admin/) for a page that reads it.
`/admin/status` carries the same trust model as `/metrics`: no authentication, meant to be reached only through the reverse proxy this page already asks you to put in front of the origin (see [Before you expose it](deployment.md#before-you-expose-it)). It names asset IDs, versions, and, for a mapper resolver, the mapper's base URL, so keep it off any path a viewer can reach. Unlike `/ready`'s cached health flag, it checks the resolver live on every call, so it costs one resolver round trip, two with a mapper that also answers the optional [asset listing](mapper-api.md#list-assets), made concurrently (a file resolver answers both immediately). See [the control panel](../admin/) for a page that reads it.

## Shutdown

Expand Down
4 changes: 2 additions & 2 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Configuration, logging, limits, CORS, and shutdown are described in [Operating t

## Web player demo

`demo/index.html` is a single-file player (hls.js and dash.js, loaded from a CDN) that plays an asset over HLS or DASH and shows live server metrics parsed from `/metrics` next to it: request rate, throughput, per-route latency, errors, and resolver and cache events, plus player-side stats such as buffer, bandwidth, and dropped frames. Besides typing an asset ID, a "Try:" row under the input lists assets you can click straight into: the static catalog's, and anything already played, from `/admin/status`.
`demo/index.html` is a single-file player (hls.js and dash.js, loaded from a CDN) that plays an asset over HLS or DASH and shows live server metrics parsed from `/metrics` next to it: request rate, throughput, per-route latency, errors, and resolver and cache events, plus player-side stats such as buffer, bandwidth, and dropped frames. The Asset dropdown lists every asset `/admin/status` names (the static catalog, or what a mapper returns from its optional [asset listing](mapper-api.md#list-assets), plus anything already loaded); picking one plays it, and its last entry, "Custom…", takes any other ID.

```sh
make serve # terminal 1: the origin on :3000
Expand All @@ -50,7 +50,7 @@ The page reads `/metrics` cross-origin, so keep `[cors]` enabled, as in `vod.exa

## Control panel

`admin/index.html` is a second single-file page, separate from the player demo, for operating a running instance: it polls `/admin/status` for the resolver's live connection state and every asset currently in the loaded-asset cache (version, size, tracks, duration), and `/metrics` for the same request and throughput charts the player demo shows. It also embeds the same HLS/DASH player, with a "Known asset" dropdown next to the Asset field listing everything the status view names, so you can pick a playable asset without leaving the page or typing its ID — the field itself still takes any name, known or not.
`admin/index.html` is a second single-file page, separate from the player demo, for operating a running instance: it polls `/admin/status` for the resolver's live connection state and every asset currently in the loaded-asset cache (version, size, tracks, duration), and `/metrics` for the same request and throughput charts the player demo shows. It also embeds the same HLS/DASH player, with the same Asset dropdown as the player demo: everything the status view names, then "Custom…" for any other ID. Clicking a row in the cache table plays that asset too.

```sh
make serve # terminal 1: the origin on :3000
Expand Down
19 changes: 18 additions & 1 deletion examples/mapper/mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"""A minimal mapper for local development and demos.

Implements just enough of the wire protocol in docs/mapper-api.md for segmentor to resolve
assets against it: GET /v1/health and GET /v1/assets/{id}. It answers from catalog.json, read
assets against it: GET /v1/health, GET /v1/assets/{id}, and the optional GET /v1/assets listing. It answers from catalog.json, read
fresh on every request, so editing that file (or the mount in docker-compose.dev.yml) and waiting
out the short TTL below is enough to see a change without restarting anything.

Expand Down Expand Up @@ -37,6 +37,23 @@ def do_GET(self) -> None: # noqa: N802 - required name for BaseHTTPRequestHandl
self.end_headers()
return

if self.path == "/v1/assets":
# The optional listing: lets the demo and control panel offer every catalog entry.
try:
ids = sorted(load_catalog())
except (OSError, json.JSONDecodeError) as error:
self.send_response(500)
self.end_headers()
self.wfile.write(f"catalog.json: {error}\n".encode())
return
body = json.dumps({"assets": ids}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return

prefix = "/v1/assets/"
if not self.path.startswith(prefix):
self.send_response(404)
Expand Down
3 changes: 2 additions & 1 deletion src/http/handlers/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ struct ResolverJson {
/// `readiness_probe_interval_ms` is `0`.
healthy: bool,
base_url: Option<String>,
/// Every asset ID the resolver can name without being asked; only the static catalog can.
/// Asset IDs to offer: the static catalog's, or what a mapper lists at its optional
/// `GET /v1/assets`; `null` when there is no list.
known_assets: Option<Vec<String>>,
}

Expand Down
Loading
Loading