Record what a player is playing
This guide shows you how to record a Player: the screen and the
sound of one unit, muxed into one MP4 or MKV file. It also shows the
plain screen and audio routes, which redirect to the operator that
owns the hardware. You need the operator
installed
on your
liken
cluster and a
Player
that has a screen.
media-api serves the routes for a Player. 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, which no other API
does. The API reference
has the full
contract. This guide is the short path through it.
The kubectl liken media capture command
The short path is the CLI. kubectl liken media capture streams a
Player’s composed video and sound to stdout as one MP4, so a file
or a pipe is a single command:
kubectl liken media capture living-room -n media > living-room.mp4
kubectl liken media capture living-room -n media | mpv -
--format mkv writes Matroska instead, and -n (or --namespace)
names the Player’s namespace.
The CLI authenticates with the client certificate in your
kubeconfig, the same subject kubectl uses, so the grant that step
1 describes is all it needs. It opens its own port-forward to
media-api and reads the composed stream through it, so it needs no
in-cluster routing and runs from a laptop.
The Player argument completes to the names the cluster reports.
kubectl runs the plugin’s completion shim on its own, so
kubectl liken media capture <TAB> lists the Players in the
namespace. For a direct call to kubectl-liken-media, load the
script with source <(kubectl liken media completion bash).
The numbered steps below are the HTTP contract the CLI calls, for an application in the cluster or a capture the CLI does not cover.
1. Who may record
You can identify yourself with a client certificate or with a
Bearer token. media-api checks for a certificate first, then for
a token, in the same order as the Kubernetes API server.
If your 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 its organization values. The credentials in your
kubeconfig identify you here the same way they identify you to
kubectl. A certificate from any other authority ends the TLS
handshake.
If you present no certificate, send a Bearer token. media-api
verifies it with a TokenReview for the audience media-api and
checks status.audiences. A pod’s default API server token does
not have that audience, so it does not work here.
After it identifies you, media-api sends a
SubjectAccessReview for the verb get on players/screen,
players/audio, or players/media in the group media.liken.sh,
with the Player’s namespace from the path.
The operator ships the ClusterRole media-capture-viewer, with
get on those three subresources and on players, so one binding
covers the info route and the captures together. A Player is
namespaced, so bind the role per namespace. Read your own subject
from your kubeconfig:
kubectl config view --raw --minify \
-o jsonpath='{.users[0].user.client-certificate-data}' \
| base64 -d | openssl x509 -noout -subject
Then bind the role to the common name that command printed, in the
namespace of the Player:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: media-viewer
namespace: media
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: media-capture-viewer
subjects:
- kind: User
name: <the common name>
To bind a group instead, use kind: Group with one of the
certificate’s organization values as the name.
An application gets the grant through its ServiceAccount:
apiVersion: v1
kind: ServiceAccount
metadata:
name: media-viewer
namespace: media
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: media-viewer
namespace: media
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: media-capture-viewer
subjects:
- kind: ServiceAccount
name: media-viewer
namespace: media
The composed route needs players/media and nothing else, because
media-api calls the sibling APIs under its own ServiceAccount. A
redirect includes no credentials. You must follow the 307 with your
own credentials. display-api then checks displays/screen for your
subject. To use the plain screen and audio routes, you also need a
grant on the Display and on each Sink. The
display
and
audio
guides show
those grants.
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 already includes every
capture, and so does cluster-admin.
Every request that returned bytes writes a Captured Event on the
Player, with the subject and the aspect in its message. A redirect
and a 503 return no bytes, so they write none. The same subject who
takes the same capture again within ten minutes adds to the count of
one Event.
2. Reach the API
media-api is a ClusterIP Service at
https://media-api.liken-system.svc. It serves HTTPS with its own
certificate authority. That authority’s certificate is in the
ConfigMap media-api-ca in liken-system, under the key
ca.crt. display-api and audio-api publish theirs the same way,
in display-api-ca and audio-api-ca.
A port-forward is a single TCP connection through the API server.
The composed route works through one, because media-api fetches
the upstream streams itself. A redirect does not. Its Location
names another Service, so you would need a second port-forward to
that Service. Record from a pod on the cluster network instead,
which is what the rest of this guide does.
Put your client certificate and its key in a Secret that the pod
can mount:
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
kubectl -n liken-system create secret tls media-client \
--cert client.crt --key client.key
Write the pod to record-pod.yaml. It mounts all three certificate
authorities, so one pod can record the composed stream and also
follow a redirect to a sibling API:
apiVersion: v1
kind: Pod
metadata:
name: record
namespace: liken-system
spec:
restartPolicy: Never
containers:
- name: curl
image: curlimages/curl:8.22.0
command: [sleep, "3600"]
volumeMounts:
- name: ca
mountPath: /ca
readOnly: true
- name: client
mountPath: /client
readOnly: true
volumes:
- name: ca
projected:
sources:
- configMap:
name: media-api-ca
items: [{key: ca.crt, path: media.crt}]
- configMap:
name: display-api-ca
items: [{key: ca.crt, path: display.crt}]
- configMap:
name: audio-api-ca
items: [{key: ca.crt, path: audio.crt}]
- name: client
secret:
secretName: media-client
The pod is in liken-system because a volume can only read a
ConfigMap or a Secret from the pod’s own namespace. The pod
sleeps for an hour and then exits, so a pod you forget does not run
forever.
kubectl apply -f record-pod.yaml
kubectl -n liken-system wait --for=condition=Ready pod/record --timeout 60s
curl takes one --cacert file, and a redirect crosses from one
API to another. Join the three certificates into one bundle inside
the pod, once:
kubectl -n liken-system exec record -- sh -c 'cat /ca/*.crt > /tmp/ca.crt'
An application needs no Secret. It runs as the ServiceAccount
you bound in step 1, mounts a token for the API’s audience, and
sends it as Authorization: Bearer:
volumes:
- name: token
projected:
sources:
- serviceAccountToken:
audience: media-api
expirationSeconds: 3600
path: token
Follow a redirect in two steps, never with curl -L. curl 8.22.0
sends no client certificate after a redirect to another host, and it
drops Authorization there too, so the sibling API returns 401. The
credential itself is valid at the sibling: your certificate
identifies the same subject at display-api as it does here,
because the three APIs read the same authority. A token is for one
audience only, so a token caller mints a second token for the
sibling’s audience.
3. Take the recording
List the players and pick one:
kubectl -n media get players
Before you record, ask what the Player has. The info route returns
the Display name and node, each Sink name, whether a Play is
running, and the number of streams:
kubectl -n liken-system exec record -- curl -sS --fail-with-body \
--cacert /tmp/ca.crt --cert /client/tls.crt --key /client/tls.key \
https://media-api.liken-system.svc/v1/media/namespaces/media/players/living-room
Every command below runs in the pod from step 2 and writes its file
there. --fail-with-body makes curl exit non-zero on an error and
still write the problem document, which step 4 reads.
Ten seconds of the screen with its sound, as MP4:
kubectl -n liken-system exec record -- curl -sS --fail-with-body \
--cacert /tmp/ca.crt --cert /client/tls.crt --key /client/tls.key \
-o /tmp/living-room.mp4 \
'https://media-api.liken-system.svc/v1/media/namespaces/media/players/living-room/media.mp4?t=0,10'
The same span as Matroska, scaled to 960 pixels wide:
kubectl -n liken-system exec record -- curl -sS --fail-with-body \
--cacert /tmp/ca.crt --cert /client/tls.crt --key /client/tls.key \
-o /tmp/living-room.mkv \
'https://media-api.liken-system.svc/v1/media/namespaces/media/players/living-room/media.mkv?t=0,10&width=960'
media.mp4 is H.264 in fragmented MP4 with Opus audio: one video
track if the Player has a screen, and one audio track per Sink
in spec.sinks order. media.mkv is the same streams through the
Matroska muxer.
Check streams on the info route before you run either command. A
Player with only one stream returns 307 to that stream instead of
composing, and a Player with no running Play has its screen
alone. A 307 is not an error, so --fail-with-body does not catch
it. The command then exits 0 and leaves an empty file. Follow the
redirect as the next section shows.
The routes that redirect
The plain screen and audio routes return 307 to the sibling API that owns the hardware. Read the redirect first:
kubectl -n liken-system exec record -- curl -sS -i \
--cacert /tmp/ca.crt --cert /client/tls.crt --key /client/tls.key \
https://media-api.liken-system.svc/v1/media/namespaces/media/players/living-room/screen.png
Location is the display-api route for this Player’s Display,
and Link points to the composed media.mp4 as related. Read
that Location into a variable, then request it:
kubectl -n liken-system exec record -- sh -c '
url=$(curl -sS -o /dev/null -w "%{redirect_url}" --cacert /tmp/ca.crt \
--cert /client/tls.crt --key /client/tls.key \
https://media-api.liken-system.svc/v1/media/namespaces/media/players/living-room/screen.png)
curl -sS --fail-with-body --cacert /tmp/ca.crt \
--cert /client/tls.crt --key /client/tls.key -o /tmp/screen.png "$url"'
Five seconds of the sound alone, which redirects to audio-api:
kubectl -n liken-system exec record -- sh -c '
url=$(curl -sS -o /dev/null -w "%{redirect_url}" --cacert /tmp/ca.crt \
--cert /client/tls.crt --key /client/tls.key \
"https://media-api.liken-system.svc/v1/media/namespaces/media/players/living-room/audio.wav?t=0,5")
curl -sS --fail-with-body --cacert /tmp/ca.crt \
--cert /client/tls.crt --key /client/tls.key -o /tmp/living-room.wav "$url"'
An empty url means the route returned something other than a 307.
A Player with no running Play returns 409 on its audio routes.
In that case, drop the second curl and read the first response
with -i.
The query parameters
| Parameter | What it does |
|---|---|
t= |
A W3C Media Fragments time range in seconds. Zero is the instant the capture container accepts the request. t=0,10 records ten seconds. t=5,7 discards five seconds and then records two. A begin over 60 seconds is a 400. Without an end, the recording runs until you close the connection. |
xywh= |
A rectangle of the frame as x,y,width,height, in the frame’s own physical pixels, or in percent with percent:. |
width=, height= |
Scale the frame down. Both together is a 400. |
framerate= |
Frames per second of the video. |
quality= |
JPEG quality of a still. |
bitrate= |
Opus bitrate of the sound. |
Each parameter goes to the sibling API that serves that half of the
recording. The extensions are .mp4 and .mkv on media, the
display operator’s four on screen, and the audio operator’s three
on audio. A path with no extension negotiates on Accept, and
media returns video/mp4 if you send none.
4. Check what you got
Copy a file out of the pod and read it with ffprobe:
kubectl -n liken-system cp record:/tmp/living-room.mp4 living-room.mp4
ffprobe living-room.mp4
A composed file has two streams: h264 at the size of the screen,
and opus at the sink’s sample rate. The duration is the t= span.
The first byte arrives after a lead-in of one second plus the slower
sibling’s first keyframe. The muxer writes nothing until it has a
keyframe from the video and a packet from each sink, and a screen
capture at the default 15 frames per second has a keyframe every
second. 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.
The cluster’s own record of the recording is an Event on the
Player, in the Player’s namespace:
kubectl -n media get events --field-selector reason=Captured
kubectl -n media describe player tells you who recorded which
unit, in which form, and when.
When a recording is refused
Every error is an application/problem+json document with type,
title, status, detail, and instance. A problem relayed from a
sibling API has that sibling’s own detail and an upstream member
with its URL. curl wrote the document to the output file, so read
that file:
kubectl -n liken-system exec record -- cat /tmp/living-room.mp4
| Status | What it means | What to do |
|---|---|---|
| 401 | No client certificate and no token, or the TokenReview refused the token |
Check that the Secret has the certificate and key from the kubeconfig you use, or mint a token for the audience media-api |
| 403 | The SubjectAccessReview said no |
Bind media-capture-viewer in the Player’s namespace, as step 1 shows. For a followed redirect, bind the sibling’s role too |
| 409 | The Player has no status.screen, or no Play is running for an audio route |
detail says what to do |
| 503 | An upstream is busy or refused the connection, or this API is at its composition limit | The response has Retry-After: 5, relayed from the upstream. Wait and try again |
5. Clean up
kubectl -n liken-system delete pod record
kubectl -n liken-system delete secret media-client
rm client.crt client.key
The RoleBinding from step 1 is a standing grant. If the recording
was a one-off, delete it too:
kubectl -n media delete rolebinding media-viewer