From f30a143ccd56ef0db020c4ab613cb18e8e6d5ead Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 00:17:44 +0200 Subject: [PATCH 1/2] feat(resolver): offer a mapper's optional asset listing in /admin/status --- docs/mapper-api.md | 17 +++++++++++++++++ docs/operations.md | 4 ++-- examples/mapper/mapper.py | 19 ++++++++++++++++++- src/http/handlers/admin.rs | 3 ++- src/registry/mod.rs | 8 +++++--- src/registry/tests.rs | 39 ++++++++++++++++++++++++++++++++++++++ src/resolver/mapper.rs | 34 ++++++++++++++++++++++++++++++++- src/resolver/mod.rs | 12 +++++++++++- src/testutil.rs | 11 +++++++++++ 9 files changed, 138 insertions(+), 9 deletions(-) diff --git a/docs/mapper-api.md b/docs/mapper-api.md index 3c80206..91ba354 100644 --- a/docs/mapper-api.md +++ b/docs/mapper-api.md @@ -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. @@ -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 (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: diff --git a/docs/operations.md b/docs/operations.md index 57440a0..87bf364 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 diff --git a/examples/mapper/mapper.py b/examples/mapper/mapper.py index 1c0ad25..e50fc38 100644 --- a/examples/mapper/mapper.py +++ b/examples/mapper/mapper.py @@ -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. @@ -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) diff --git a/src/http/handlers/admin.rs b/src/http/handlers/admin.rs index 0219e73..e6d1a91 100644 --- a/src/http/handlers/admin.rs +++ b/src/http/handlers/admin.rs @@ -25,7 +25,8 @@ struct ResolverJson { /// `readiness_probe_interval_ms` is `0`. healthy: bool, base_url: Option, - /// 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>, } diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 248bb01..43c2f8b 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -70,7 +70,7 @@ pub(crate) struct ResolverStatus { pub(crate) healthy: bool, /// The mapper's base URL; `None` for the static catalog. pub(crate) base_url: Option, - /// Asset IDs the resolver knows without asking anything: only the static catalog has them. + /// Asset IDs to offer in a control panel: the static catalog's, or a mapper's optional listing. pub(crate) known_assets: Option>, } @@ -307,8 +307,10 @@ impl AssetRegistry { /// A live snapshot for the admin status endpoint: resolver health, checked right now rather /// than from the background probe, and every asset currently held in the loaded-asset cache. pub(crate) async fn status(&self) -> RegistryStatus { - let healthy = self.resolver.healthy().await; - let known_assets = (self.resolver.kind() == "static").then(|| self.resolver.known_ids()); + let (healthy, known_assets) = tokio::join!( + self.resolver.healthy(), + self.resolver.listed_ids(self.limits.max_assets) + ); let cache = lock(&self.loaded); RegistryStatus { resolver: ResolverStatus { diff --git a/src/registry/tests.rs b/src/registry/tests.rs index aa67b03..220703b 100644 --- a/src/registry/tests.rs +++ b/src/registry/tests.rs @@ -2228,3 +2228,42 @@ async fn every_clip_of_a_sequence_decodes_from_the_served_hls_and_dash() { let frames = decode_each(&h.app, "/dash/seq/video/", &clips, &directory.join("dash")).await; assert_eq!(frames, [90, 90, 60]); } + +// --------------------------------------------------------------------------------------------- +// The optional asset listing (`GET /v1/assets`) +// --------------------------------------------------------------------------------------------- + +async fn known_assets(app: &Router) -> serde_json::Value { + let json: serde_json::Value = + serde_json::from_slice(&fetch(app, "/admin/status").await.2).unwrap(); + json["resolver"]["known_assets"].clone() +} + +#[tokio::test] +async fn admin_status_lists_what_the_mapper_lists_sorted_and_checked() { + let h = harness().await; + *h.mapper.state.listing.lock().unwrap() = + Some(r#"{"assets":["preroll","movie","movie","../etc","","has space"]}"#.to_owned()); + + assert_eq!( + known_assets(&h.app).await, + serde_json::json!(["movie", "preroll"]), + "sorted, once each, and only IDs a request could name" + ); +} + +#[tokio::test] +async fn a_malformed_listing_is_no_listing() { + let h = harness().await; + *h.mapper.state.listing.lock().unwrap() = Some(r#"{"assets":"movie"}"#.to_owned()); + + assert_eq!(known_assets(&h.app).await, serde_json::Value::Null); +} + +#[tokio::test] +async fn a_listing_is_capped_at_max_assets() { + let h = harness_with(|config| config.limits.max_assets = 2).await; + *h.mapper.state.listing.lock().unwrap() = Some(r#"{"assets":["c","b","a"]}"#.to_owned()); + + assert_eq!(known_assets(&h.app).await, serde_json::json!(["a", "b"])); +} diff --git a/src/resolver/mapper.rs b/src/resolver/mapper.rs index 07acc00..94195c5 100644 --- a/src/resolver/mapper.rs +++ b/src/resolver/mapper.rs @@ -18,8 +18,8 @@ use super::{ SubtitleLocation, }; use crate::clip::{ClipWindow, MAX_CLIP_MS}; -use crate::config::MapperConfig; use crate::config::Secret; +use crate::config::{MapperConfig, is_valid_asset_id}; use crate::observability::request_id; const MAX_VERSION_BYTES: usize = 256; @@ -175,6 +175,38 @@ impl HttpResolver { .is_ok_and(|response| response.status().is_success()) } + /// `GET {base}/v1/assets`: the asset IDs the mapper chooses to list, for status reporting + /// only (see `docs/mapper-api.md#list-assets`). The endpoint is optional, so any failure, + /// including the `404` of a mapper that does not implement it, is `None` rather than an + /// error. IDs a request could not name are dropped; the rest are sorted, deduplicated, and + /// capped at `max`. + pub(crate) async fn list(&self, max: usize) -> Option> { + #[derive(Deserialize)] + struct Listing { + assets: Vec, + } + let response = self + .authorized(self.client.get(format!("{}/v1/assets", self.base_url))) + .header(ACCEPT, "application/json") + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + let body = self.read_body(response).await.ok()?; + let listing: Listing = serde_json::from_slice(&body).ok()?; + let mut ids = listing + .assets + .into_iter() + .filter(|id| is_valid_asset_id(id)) + .collect::>(); + ids.sort(); + ids.dedup(); + ids.truncate(max); + Some(ids) + } + fn authorized(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { match &self.token { Some(token) => request.header(AUTHORIZATION, format!("Bearer {}", token.expose())), diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index e5e4c39..cd035a0 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -218,7 +218,17 @@ impl AssetResolver { } } - /// Asset IDs known without asking a remote service (only the static catalog has them). + /// Asset IDs a control panel can offer: the static catalog's, or whatever a mapper lists at + /// its optional `GET /v1/assets` (`None` when it lists nothing). At most `max` of them. + pub(crate) async fn listed_ids(&self, max: usize) -> Option> { + match self { + Self::Static(resolver) => Some(resolver.ids()), + Self::Http(resolver) => resolver.list(max).await, + } + } + + /// Asset IDs known without asking a remote service (only the static catalog has them); these + /// are what startup preloads, so a mapper's listing is never among them. pub(crate) fn known_ids(&self) -> Vec { match self { Self::Static(resolver) => resolver.ids(), diff --git a/src/testutil.rs b/src/testutil.rs index f39a94c..8069e8d 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -161,6 +161,9 @@ pub(crate) struct MapperState { pub(crate) delay_ms: AtomicU64, /// When set, a `304` is never sent, so every lookup gets a full answer (a fresh signature). pub(crate) always_full: AtomicBool, + /// The body `GET /v1/assets` answers with; `None` answers `404`, as a mapper that does not + /// implement the optional listing would. + pub(crate) listing: Mutex>, } impl MapperState { @@ -191,6 +194,7 @@ impl MockMapper { let app = Router::new() .route("/v1/assets/{id}", get(mapper_asset)) .route("/v1/health", get(mapper_health)) + .route("/v1/assets", get(mapper_listing)) .with_state(Arc::clone(&state)); Self { address: serve(app).await, @@ -211,6 +215,13 @@ async fn mapper_health(State(state): State>) -> StatusCode { } } +async fn mapper_listing(State(state): State>) -> Response { + match state.listing.lock().unwrap().clone() { + Some(body) => ([(CONTENT_TYPE, "application/json")], body).into_response(), + None => StatusCode::NOT_FOUND.into_response(), + } +} + async fn mapper_asset( State(state): State>, Path(id): Path, From 185d0abb31ada8a3241e87058ee57c195698af19 Mon Sep 17 00:00:00 2001 From: includeamin Date: Thu, 1 Oct 2026 00:17:44 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(demo,admin):=20pick=20the=20asset=20fr?= =?UTF-8?q?om=20a=20dropdown,=20with=20Custom=E2=80=A6=20for=20any=20other?= =?UTF-8?q?=20name?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/index.html | 62 +++++++++++++++++++++++++++++-------------- demo/index.html | 68 ++++++++++++++++++++++++++++++++---------------- docs/usage.md | 4 +-- 3 files changed, 90 insertions(+), 44 deletions(-) diff --git a/admin/index.html b/admin/index.html index 257cc11..b17c516 100644 --- a/admin/index.html +++ b/admin/index.html @@ -68,10 +68,10 @@

segmentor control panel

Player

-
@@ -163,7 +163,7 @@

segmentor control panel

if (!diagnose()) log('cannot start, see warnings under the player'); $('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); @@ -197,28 +197,45 @@

segmentor control panel

} /// 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 = '' - + ids.map(id => ``).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 => ``).join('') + + ''; + 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() { @@ -349,7 +366,8 @@

segmentor control panel

: ''; 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) { @@ -406,7 +424,11 @@

segmentor control panel

$('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(); diff --git a/demo/index.html b/demo/index.html index f9f1801..ea60256 100644 --- a/demo/index.html +++ b/demo/index.html @@ -31,16 +31,16 @@ #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); }

segmentor demo

- + + @@ -48,7 +48,6 @@

segmentor demo

-
@@ -106,23 +105,45 @@

Resolver & cache

const tile = (k, v, cls='') => `
${k}
${v}
`; const escapeHtml = s => String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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 => ``).join('') + + ''; + 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 => - ``).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() { @@ -149,7 +170,7 @@

Resolver & cache

teardown(); if (!diagnose()) log('cannot start, see warnings under the player'); $('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); @@ -300,8 +321,11 @@

Resolver & cache

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(); diff --git a/docs/usage.md b/docs/usage.md index 0fea305..e93ee7e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 @@ -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