The media API

media-api serves HTTPS routes for a Player, so you can see and hear what is on the unit right now. It is one Deployment per cluster, in liken-system, next to the operator. Its screen routes redirect to display-api and its audio routes redirect to audio-api, because those operators own the hardware. Its media routes combine the two into one muxed stream, the screen with its sound. No other API does that.

At a glance

Item Value
Service https://media-api.liken-system.svc
CA ConfigMap media-api-ca in liken-system
Discovery /v1/media
OpenAPI /v1/media/openapi.json
Shipped ClusterRole media-capture-viewer
Audit record a Captured Event on the Player
Route reference Routes

The display and audio operators have the same kind of API for a Display, a Sink, and a Source. The three APIs share the same paths, the same HTTP behavior, and the same standards, but no code.

Authentication

There are two ways to identify yourself: a client certificate or a Bearer token. media-api checks for a certificate first, then for a token, in the same order as the Kubernetes API server.

Client certificate

If your TLS connection presents a client certificate signed by the cluster’s own certificate authority, you are that certificate’s subject. Your user name is the subject’s common name, and your groups are the subject’s organization values. This is exactly how the Kubernetes API server reads a client certificate, so the credentials in your kubeconfig identify you here the same way they identify you to kubectl. media-api reads the authority from the ConfigMap extension-apiserver-authentication in kube-system, which is where the API server publishes it, and watches it for changes. A rotated authority takes effect with no restart. A certificate from any other authority ends the TLS handshake.

Bearer token

If the connection has no client certificate, media-api looks for a Bearer token. It validates the token with a TokenReview for the audience media-api and checks status.audiences, so a pod’s default API server token does not work here. A token from an external OIDC issuer must also have that audience.

Authorization

After it identifies you, media-api sends a SubjectAccessReview for the verb get on players/screen, players/audio, or players/media in the API group media.liken.sh, with the Player’s namespace from the path. The discovery and OpenAPI documents need authentication but no authorization. The info route needs get on players.

Grants

RBAC does not check that a subresource exists, so a cluster owner grants capture with the shipped ClusterRole media-capture-viewer, bound per namespace, or with one rule that uses resourceNames. The role grants get on the three subresources and on players, so one binding covers the info route and the captures together. The same role binds to a person. Use kind: User with the common name from their certificate, or kind: Group with one of its organization values.

players/media on a Player is in effect a grant on that Player’s Display and Sinks. A role that grants resources: ["*"] in media.liken.sh includes it, and so does cluster-admin.

A redirect carries no credentials. You follow the 307 with your own credentials, and display-api checks displays/screen for your subject. So to capture a Player’s screen or audio, you also need the grant on its Display and its Sink. The composed route is the exception: media-api calls the sibling APIs under its own ServiceAccount, and you only need players/media.

With a token, you mint a second one for the sibling’s audience. curl -L drops Authorization when the host changes, and --location-trusted keeps it, but neither one works here on its own: the sibling API refuses a token minted for the media-api audience. Read the Location header and repeat the request by hand with a token for the sibling’s audience.

A client certificate works at the sibling API with no second credential. The three APIs read the same authority, so the same --cert and --key identify the same subject at display-api and audio-api. Read the Location header and repeat the request there. Recent curl releases send no client certificate on the second TLS handshake when -L changes the host.

Every request that produces bytes writes a Captured Event on the Player. The message names the subject and the aspect, so kubectl describe player tells you who looked and when. The same subject who takes the same capture again within ten minutes adds to the count of one Event, so kubectl describe prints one line for the repeats. The API’s log has one line for each request.

Routes

Every path has the form /v1/{domain}/[namespaces/{ns}/]{plural}/{name}/{aspect}[.{ext}]. domain is the first label of the CRD’s API group, so media.liken.sh gives media. A namespaced kind has namespaces/{ns} before its plural, the same as the Kubernetes API. In the table, .../ stands for /v1/media/namespaces/{ns}/players/{name}/.

Method Path Response Notes
GET, HEAD /v1/media 200 application/json The discovery document
GET, HEAD /v1/media/openapi.json 200 application/openapi+json OpenAPI 3.1
GET, HEAD /v1/media/namespaces/{ns}/players/{name} 200 application/json The Player’s capture facts
GET, HEAD a document route, with a matching If-None-Match 304, no body Not modified
GET, HEAD .../screen[.ext] 307 Redirect to the Display route on display-api
GET, HEAD .../audio[.ext] 307 Redirect to the Sink route on audio-api
GET, HEAD .../media 200, negotiated The composed stream. Default video/mp4
GET, HEAD .../media.mp4 200 video/mp4; codecs="avc1.64001f,Opus" The composed stream
GET, HEAD .../media.mkv 200 video/matroska The composed stream
OPTIONS any of the above 204, no body Allow: GET, HEAD, OPTIONS

The Routes page lists every route from the OpenAPI document, with its parameters, responses, and fields. The OpenAPI 3.1 document lists each extension path as its own path item, with no {.ext}. media-api fills servers: [{url: ...}] from the configured public base URL, or else from the origin of the request that fetched the document. So servers names the host you reached, and it is the one member that changes with the caller. Its media type, application/openapi+json, is provisional: draft-ietf-httpapi-rest-api-mediatypes registers it, and IANA does not list it yet.

Query parameters

media-api forwards these parameters to the API that owns the capture, on a redirect and on a composition alike.

Parameter Applies to Values Default Rejected with 400 when
t screen, audio, media Media Fragments NPT: t=begin,end, t=begin, or t=,end, half-open none t=a,b with a >= b; a begin over the 60 s limit
xywh screen, media pixel: (the default) or percent:, then x,y,w,h the whole screen display-api rejects the region
width screen, media the width in pixels, as display-api defines it set by display-api width= together with height=
height screen, media the height in pixels, as display-api defines it set by display-api height= together with width=
framerate screen, media frames per second, as display-api defines it 15 display-api rejects the value
quality screen, media the JPEG quality, as display-api defines it set by display-api display-api rejects the value
bitrate audio, media the Opus bitrate, as audio-api defines it set by audio-api audio-api rejects the value

A repeated dimension and an unknown key are 400 as well. A 400 that an upstream raised carries that API’s own detail and an upstream member.

t= and xywh= follow W3C Media Fragments 1.0. The parser follows section 5.1.1: it splits on & and = first and percent-decodes second, so t=10%2C20, t=%6ept:10, and t=npt%3a10 all parse, as section 6.1.1 requires. t=begin,end is half-open: “the begin time is considered part of the interval whereas the end time is considered to be the first time point that is not part of the interval”. A region that runs off the edge is clipped, per section 6.1.2, by the display capture container that owns the frame.

Media Fragments tells a user agent to ignore an invalid, unknown, or non-existent dimension (sections 6.2, 6.2.1, 6.3.1). This API returns 400 instead. A query produces a new resource (sections 3.1 and 7.4), so a client that asked for a region must not silently get the whole screen.

Media Fragments puts NPT’s zero at the start of the source media (section 6.1.1). A live tap has no start, so this API puts the zero at the instant the capture container accepts the request. The clock: format is in the advanced Media Fragments document, not in version 1.0. The capture container sends the headers at once, starts its pipeline at once, and discards frames or samples until begin on its own clock. So t=5,7 discards five seconds and records two.

Content negotiation

An extension names one fixed representation. With no extension, the API negotiates on Accept with q-values. No Accept header means the aspect’s default, which is video/mp4 for media. The negotiated route returns Content-Location as an absolute path, /v1/media/namespaces/{ns}/players/{name}/media.mp4. A redirect includes the extension that the negotiation chose, so the sibling API does not negotiate again. video/x-matroska is the deprecated alias of video/matroska and also matches.

Request Response
GET .../media, no Accept 200 video/mp4, Content-Location: .../media.mp4
GET .../media, Accept: video/matroska 200 video/matroska, Content-Location: .../media.mkv
GET .../media, Accept: video/*;q=0.5, video/matroska 200 video/matroska
GET .../media.mp4, Accept: image/png 406, acceptable: [{type: video/mp4, href: .../media.mp4}, {type: video/matroska, href: .../media.mkv}]
GET .../screen, Accept: image/jpeg 307 to /v1/display/displays/{d}/screen.jpg
GET .../screen, no Accept 307 to /v1/display/displays/{d}/screen.png
GET .../audio, Accept: audio/ogg 307 to /v1/audio/sinks/{s}/audio.opus

Response headers

Every response has these headers:

Header Value Standard
Content-Type the media type of the body RFC 9110
Vary Accept, on every response, extension routes included RFC 9110 section 12.5.5
Link </v1/media/openapi.json>; rel="service-desc" RFC 8631
Link <https://liken.sh/media/docs/reference/api/>; rel="service-doc" RFC 8631
Link on a Player route: <https://kubernetes.default.svc/apis/media.liken.sh/v1alpha1/namespaces/{ns}/players/{name}>; rel="describedby" RFC 8288

RFC 9110 section 12.5.5 gives Vary a second purpose: it tells a cache which request headers the server reads, whether or not they changed the response.

A document route (discovery, OpenAPI, or the info route) has an ETag and Cache-Control: no-cache, and a 304 carries the ETag and Vary: Accept. The info document adds one Link related per capture route. A document route has none of the capture headers: no Content-Disposition, no Accept-Ranges, and no chunked body.

A composed response has these headers instead:

Header Value Standard
Cache-Control no-store RFC 9111 section 5.2.2.5
Content-Disposition inline, with a file name such as media-studio-2026-09-16T21-02-16Z.mp4 RFC 6266
Accept-Ranges none RFC 9110 section 14.3
Transfer-Encoding chunked, on HTTP/1.1 RFC 9112 section 7.1
Content-Location on media only: the absolute path of the extension route that was served RFC 9110 section 8.7
Link rel="https://liken.sh/rel/screen", and one rel="https://liken.sh/rel/audio" per track RFC 8288
Link on media only: rel="alternate" to media.mp4 and to media.mkv RFC 8288

A 307 has Location, Cache-Control: no-store, and a Link related to media.mp4. A HEAD returns the GET’s status and headers. It calls no upstream, and its Content-Type has no codecs parameter. A HEAD on an error has no body (RFC 9110 section 15.5).

Relation Registered On Points to
alternate IANA, from HTML: “Refers to a substitute for this context” the media route with no extension media.mp4 and media.mkv, each with a bare type
related IANA, RFC 4287: “Identifies a related resource” a screen.* or audio.* 307, and the info document the composed media.mp4 with the same t=; each capture route
describedby IANA, from W3C POWDER every Player route the Player object on the API server
service-desc, service-doc IANA, RFC 8631 every response the OpenAPI document; this page
https://liken.sh/rel/screen an extension relation, a URI per RFC 8288 section 2.1.2 a composed response the display-api route the video came from
https://liken.sh/rel/audio an extension relation a composed response, once per audio track the audio-api route each track came from

A redirect links to the composed form as related, because the screen with its sound muxed in is a different aspect of the Player, not a substitute for the screen alone. The two extension relations are not registered. Registration needs a specification and expert review, and a URI already identifies the relation. describedby is absolute because a relative reference would resolve against media-api, which does not serve the Player.

Errors

Every error body is an RFC 9457 problem document, application/problem+json, with five members. type is a URI that names the kind of problem. title is a short phrase for it. status repeats the HTTP status. detail says what went wrong in this request, in the source’s own words: a rejected query names the part that was rejected, and a relayed upstream problem has the sibling’s own detail. instance is the request path, then #, then the request id, which the API’s log line also has.

Status When Extra header or member
400 a query the grammar rejects, or an upstream 400 an upstream member when relayed
401 no client certificate and no token WWW-Authenticate: Bearer realm="media-api"
401 the TokenReview refuses the token WWW-Authenticate: Bearer realm="media-api", error="invalid_token", error_description="<the TokenReview's words>"
403 the SubjectAccessReview denies WWW-Authenticate: Bearer realm="media-api", error="insufficient_scope", scope="players/media"
404 no such Player, or an upstream 404 an upstream member when relayed
405 a method other than GET, HEAD, or OPTIONS Allow
406 Accept excludes the extension’s type, or nothing offered is acceptable an acceptable member
409 no status.screen, no running Play for an audio aspect, or an upstream 409 on a composed stream a detail that says what to do, and an upstream member when relayed
502 an upstream answer that is not HTTP or not a problem document type upstream-failed
503 an upstream 503, a refused connection, or this API’s own composition limit Retry-After: 5, or the upstream’s own value relayed
504 upstream headers take more than 10 s plus begin

A 406’s acceptable member is a list of {"type", "href"} pairs. On an extension route it lists the route’s own type and its siblings (RFC 9110 section 15.5.7). The extension members upstream and acceptable are allowed by RFC 9457 section 3.2, and a client ignores members it does not know. With about:blank, title is the status phrase (section 4.2.1).

The problem types are shared with the display and audio APIs.

Type URI Meaning Status
https://liken.sh/problems/no-node the Player has no status.screen 409
https://liken.sh/problems/not-playing no Play is running for the aspect you asked for 409
https://liken.sh/problems/not-acceptable nothing the route serves is acceptable 406
https://liken.sh/problems/capture-busy an upstream capture is busy, or this API is composing all it can at once 503
https://liken.sh/problems/upstream-failed an upstream answered something this API cannot relay 502
https://liken.sh/problems/away a sibling API reports the object away, for example a Display with no panel on its connector. A client meets the sibling’s own problem after a redirect, and this type on a composed stream, with the sibling’s detail 409

Discovery

GET /v1/media returns the shared document shape, keyed by plural name:

{
  "resources": {
    "players": {
      "self": "/v1/media/namespaces/{namespace}/players/{name}",
      "aspects": {
        "screen": {"template": "/v1/media/namespaces/{namespace}/players/{name}/screen{.ext}{?t,xywh,width,height,framerate,quality}",
                   "mediaTypes": ["image/png", "image/jpeg", "video/mp4", "multipart/x-mixed-replace"],
                   "extensions": ["png", "jpg", "mp4", "mjpeg"], "redirects": true},
        "audio":  {"template": "/v1/media/namespaces/{namespace}/players/{name}/audio{.ext}{?t,bitrate}",
                   "mediaTypes": ["audio/wav", "audio/flac", "audio/ogg"],
                   "extensions": ["wav", "flac", "opus"], "redirects": true},
        "media":  {"template": "/v1/media/namespaces/{namespace}/players/{name}/media{.ext}{?t,xywh,width,height,framerate,quality,bitrate}",
                   "mediaTypes": ["video/mp4", "video/matroska"],
                   "extensions": ["mp4", "mkv"], "redirects": false, "leadIn": "1s"}
      }
    }
  },
  "openapi": "/v1/media/openapi.json"
}

The screen and audio entries are copied from the sibling APIs' documents at request time.

Composition

media.mp4 is H.264 in fragmented MP4 with Opus audio. The container follows “Encapsulation of Opus in ISO Base Media File Format”, version 0.6.8 of 28 April 2016: an OpusSampleEntry with the coding name Opus and a dOps box, which ffmpeg’s movenc.c writes. That document has no codecs section. The string comes from RFC 6381 section 3.3, where the first element is the sample entry’s four-character code and “values are case sensitive”: video/mp4; codecs="avc1.64001f,Opus". The avc1 part is copied from the display upstream. Browsers use lowercase opus, from the MSE byte stream convention.

MDN’s audio codec guide says “Safari supports Opus in the <audio> element only when packaged in a CAF file”, and caniuse.com marks Safari as partial through version 27. Chrome, Firefox, mpv, and VLC play it. AAC is not in v1: audio-api does not serve it, and an encode on the API node would break the rule that the public API never encodes.

media.mkv is the same streams through the matroska muxer with the live option, “Write files assuming it is a live stream”, served as video/matroska. WebM is not offered, because it allows only VP8, VP9, and AV1 video.

Each capture container’s t= zero is the instant it accepts the request, so the two streams’ timestamp 0 are two different instants. To line them up, media-api composes with a lead-in of 1 s. It asks both upstreams for t=begin+1,end+1, so the composed stream is one second behind “now”. The discovery document reports this as "leadIn": "1s". Both capture containers then have a running pipeline before their zero, and the correction is the difference between the two accept instants. media-api measures those as the instants the headers arrive on its own clock: it opens both requests at once, records each instant, and starts ffmpeg with -itsoffset on the audio input equal to headersAt[audio] - headersAt[video].

The first byte of a composition arrives after the lead-in plus the slower sibling’s first keyframe. media-api holds nothing back: it opens both upstreams at once and writes what the muxer writes. The muxer writes nothing until it has a keyframe from the video and a packet from each sink. A screen capture at the default 15 frames per second has a keyframe every second, so a second of lead-in and a second of keyframe wait are the floor, and the muxer’s first fragment adds the rest. In our tests, media.mp4?t=0,10 took 4.09 to 4.34 s to first byte over ten runs, and media.mkv took 4.18 s, with upstream headers arriving at 1.12 to 1.38 s. Those numbers are from before display-api sent its headers at the accept instant, so they are a ceiling, not the current cost.

Examples

Four commands capture ten seconds of a Player. The first mints a token for the media-api audience under a ServiceAccount that has the grant. The second reads the API’s CA certificate from its ConfigMap, so curl can verify the server. The third opens a port-forward to the Service. The fourth captures the composed stream from the first second to the tenth and saves it.

TOKEN=$(kubectl create token media-api-client -n media --audience media-api --duration 10m)
kubectl get configmap media-api-ca -n liken-system -o jsonpath='{.data.ca\.crt}' > media-api-ca.crt
kubectl port-forward -n liken-system svc/media-api 8443:443 &
curl --cacert media-api-ca.crt -H "Authorization: Bearer $TOKEN" \
  --resolve media-api.liken-system.svc:8443:127.0.0.1 \
  "https://media-api.liken-system.svc:8443/v1/media/namespaces/media/players/studio/media.mp4?t=0,10" \
  -o studio.mp4

The composed route works through one port-forward, because media-api fetches the upstream streams itself. A redirect does not. Its Location names another Service, so you open a second port-forward to that Service and repeat the request there, with the credentials Authentication describes.

If your kubeconfig has a client certificate, send that instead. You need no ServiceAccount and no token. The port-forward is a TCP tunnel, so the TLS handshake runs end to end and the certificate reaches the API unchanged.

kubectl config view --raw --minify \
  -o jsonpath='{.users[0].user.client-certificate-data}' | base64 -d > client.crt
kubectl config view --raw --minify \
  -o jsonpath='{.users[0].user.client-key-data}' | base64 -d > client.key
curl --cacert media-api-ca.crt --cert client.crt --key client.key \
  --resolve media-api.liken-system.svc:8443:127.0.0.1 \
  "https://media-api.liken-system.svc:8443/v1/media/namespaces/media/players/studio/media.mp4?t=0,10" \
  -o studio.mp4

From a pod on the cluster network, the same two files work against https://media-api.liken-system.svc/v1/media/... with no port-forward and no --resolve. Through the port-forward above, the same curl with /v1/media/openapi.json in place of the capture path fetches the OpenAPI document, with the same token and CA.

One composed request and its answer:

GET /v1/media/namespaces/media/players/studio/media.mp4?t=0,10&width=960 HTTP/1.1
Host: media-api.liken-system.svc
Authorization: Bearer eyJ...

HTTP/1.1 200 OK
Content-Type: video/mp4; codecs="avc1.64001f,Opus"
Content-Disposition: inline; filename="media-studio-2026-09-16T21-02-16Z.mp4"
Cache-Control: no-store
Vary: Accept
Accept-Ranges: none
Transfer-Encoding: chunked
Link: <https://display-api.liken-system.svc/v1/display/displays/boe-1080/screen.mp4?t=1,11&width=960>;
      rel="https://liken.sh/rel/screen"
Link: <https://audio-api.liken-system.svc/v1/audio/sinks/hdmi-0-pch/audio.opus?t=1,11>;
      rel="https://liken.sh/rel/audio"
Link: <https://kubernetes.default.svc/apis/media.liken.sh/v1alpha1/namespaces/media/players/studio>;
      rel="describedby"
Link: </v1/media/openapi.json>; rel="service-desc"
Link: <https://liken.sh/media/docs/reference/api/>; rel="service-doc"

The Link headers are wrapped here for reading. Each is one header line on the wire. The upstream t= is the caller’s t= plus the lead-in. The same request for screen.png returns 307 with an absolute Location: https://display-api.liken-system.svc/v1/display/displays/boe-1080/screen.png, because the client reaches that server with its own credentials, and a relative Link: </v1/media/namespaces/media/players/studio/media.mp4>; rel="related"; type="video/mp4". A busy upstream returns 503 with its Retry-After relayed and a capture-busy problem document whose detail is the upstream’s own and whose upstream member is its URL.

Notes

The info route. It returns the Display name and node, each Sink name, whether a Play is running, and the number of streams, with related links to every capture route. So a client can find out first whether media.mp4 will compose a stream or answer 409.

Health and metrics. /healthz and /readyz answer 200 or 503 as text/plain. /metrics on port 9200 answers 200 as text/plain; version=0.0.4 for Prometheus.

A failure after the first byte. A failure after the stream began ends the response. The client sees a truncated body, and the request’s log line has the fault.