Local delivery
When a viewer is on the same network as the hub holding their media, the bytes do not need to travel to a gateway and back. Local delivery is how a client finds that shorter path, and how it proves the thing answering really is the hub.
It is always optional and always degradable. Every failure — no permission, no answer, a router that filters the address, an expired certificate — resolves to the gateway path, which is the one that always works. A client that ignores this page entirely still plays everything correctly.
What a playback response carries
StartPlayback and RenewPlayback return a MediaDelivery. Two of its fields
describe the short path:
| Field | Meaning |
|---|---|
gateway_url | The path that always works, over the internet. Present in every response. |
local_urls | The same resource, once per private address the hub reports. Empty is the normal case. |
local_urls is repeated because a hub is routinely multi-homed — wifi and
ethernet, a docker bridge, a VPN interface — and the platform cannot know which
of its addresses you can reach. It offers every candidate in preferred order;
deciding is yours, because only your machine can.
Each entry is a full URL for the primary resource, with the media token already
in its query string, exactly as gateway_url is:
https://10-1-2-50.k3f9x2ab.xmbsrv.com:8443/media/{uuid}/file/media.mkv?t=…The hostname encodes the address it resolves to. That is what lets a private address have a real, publicly-trusted certificate: the name is public, the address it names is not routable from anywhere else.
local_url (singular, field 3) is deprecated and always absent. It was
designed for this and never assigned, because one URL is the wrong shape for a
multi-homed hub. Read local_urls.
Which hub is on the other end
MediaDelivery also names the hub serving the bytes:
| Field | Meaning |
|---|---|
hub_uuid | The hub's handle. Always present. |
hub_name | What its owner called it. May be empty — a hub nobody has named. |
The gateway hostname does begin with the uuid today, but that is an implementation detail of one URL scheme rather than a contract; read these fields instead of parsing it. Neither field authorises anything and neither affects which URL you should use — they are there so a client can say which machine is serving a file, which is the first thing anyone needs when a stream misbehaves and the household has more than one hub.
Subtitles, chapters and trickplay
These are not given local forms on the wire. They share gateway_url's
origin, so once you have chosen a local origin you swap it into those URLs
yourself:
const local = new URL(chosenLocalUrl).origin;
const subtitleUrl = Object.assign(new URL(track.url), {
protocol: new URL(local).protocol,
host: new URL(local).host,
}).toString();This is safe because the media token is origin-agnostic: it is bound to the hub's identity, not to a route, and the hub verifies it identically on either listener. It is also what makes failover a URL swap rather than a re-mint.
HLS needs nothing at all — the hub's playlists carry relative URIs, so a playlist fetched from a local origin resolves every segment there.
Choosing a path
Probe on a schedule, not at play time. Each origin answers GET /healthz
with the hub's own uuid:
GET https://10-1-2-50.k3f9x2ab.xmbsrv.com:8443/healthz
{"status":"ok","uuid":"6f1c9d2e-…"}The certificate already proves which hub answered; the echo is what makes a misconfiguration legible rather than mysterious. Use a short timeout — a second or two — and treat every failure identically. A refused connection, a filtered DNS name and a rejected certificate all mean the same thing: use the gateway.
Never trust a probe at play time. A probe is a statement about the past,
and the network changing between the probe and the play is the normal case, not
the edge case. Start on a local origin if it is warm, but if the first byte has
not arrived in a second or two, or the stream errors, fail over to
gateway_url and stop trusting that origin. A stale positive must cost a
moment of startup, never a failed play.
Re-probe on events rather than a timer alone: coming online, waking, or returning to the app after an idle period are when a device has plausibly moved between networks.
Telling the platform you could use it
ClientCapabilities.local_trust says how you would trust a hub's certificate,
and it is also the demand signal that gets one issued:
| Value | Meaning |
|---|---|
LOCAL_TRUST_ANCHOR_PUBLIC_CA | You trust the public PKI. This is what causes a certificate to be ordered. |
LOCAL_TRUST_ANCHOR_PINNED_SPKI | You pin pin_spki_sha256. Needs no certificate, so it never causes an order. |
LOCAL_TRUST_ANCHOR_NONE | You cannot use a local path. |
Certificates are issued lazily — for hubs somebody actually watches from over
their own network, rather than for the whole fleet — so this field is what
starts that. Send PUBLIC_CA only when you could really use it today, not
merely because you are a browser. In particular, see the permission below.
Browsers: the local-network permission
Chrome 142 and later gate any request from a public page to a private address behind a permission prompt. The permission is per-origin and sticky, so a denial is close to permanent.
The state is readable without prompting:
const status = await navigator.permissions.query({
name: 'local-network-access' as PermissionName,
});
// 'granted' | 'denied' | 'prompt'Read it first, and design around what it says:
prompt— do not probe from the player. A prompt landing on somebody who just pressed play is how a permanent denial is earned. Ask from a settings screen, with an explanation already on the page.denied— do not probe at all, and reportlocal_trustas something other thanPUBLIC_CA. Ordering a certificate for a viewer who can never reach the hub wastes a shared issuance quota.granted— probe freely.
A browser that does not implement the permission does not enforce it, so treat
a query that throws as granted.
Listing hubs and how you reach them
ConnectivityService.ListServerHubs answers, for a viewer rather than an
administrator, which hubs a server plays from. It requires only
xmb:user_read — every member of the server sees the same answer, because it
is a fact about the household's infrastructure rather than about the person
asking.
{
"hubs": [
{
"uuid": "6f1c9d2e-…",
"name": "Loft NAS",
"status": "HUB_STATUS_ACTIVE",
"lastSeenAt": "2026-08-24T21:04:11Z",
"lastHandshakeAt": "2026-08-24T21:04:09Z",
"localDeliveryAvailable": true,
"localOrigins": ["https://10-1-2-50.k3f9x2ab.xmbsrv.com:8443"],
"sharesPublicAddress": true
}
]
}local_delivery_available says the platform's half is ready: a certificate
that has not expired, a listener the hub reports as bound, and at least one
private address. It says the short path is offered, never that it works —
only your own probe can know that, because only you know which network you are
on.
local_origins is present whenever the hub has usable private addresses, even
when the flag is false, because a client that pins needs the origins and not
the certificate.
shares_public_address is the platform's answer to a question only it can
ask: does this caller reach the internet from the same public address the
hub's WireGuard handshakes arrive from? Same egress usually means same
household. Use it to decide when to speak, never what to allow — show an
unprompted "enable local playback" banner only when it is true, but keep the
permission reachable from settings regardless. Carrier-grade NAT makes
strangers share an address, and a dual-stack home whose browser egresses IPv6
while the hub handshakes IPv4 makes real neighbours look distant; the first
costs one failed probe, and the second must not lock anyone out.
Reading this list never causes a certificate to be ordered. Opening a settings screen is curiosity; pressing play is demand.
What the local path carries
Media bytes and generated preview imagery. Nothing else.
Library, metadata, watch state, search, authentication and every RPC continue to go to the API over the internet, whatever the local path is doing. This is deliberate: an attacker who defeated every control here gets to serve fake video frames and holds one short-lived token scoped to one file. They do not get the library, the session, or the ability to enumerate anything.