Play

A Play is one run of media on a Player : a film, an album, or a season of episodes, played in order. Its lifecycle is analogous to a Job’s: it runs once to completion, and it stays for its status until ttlSecondsAfterFinished passes or a person deletes it. Create a Play to start it, delete it to stop it early, and kubectl get plays lists what plays right now.

The operator reconciles a Play into one playback pod and the claims that pod needs, all owned by the Play, so deleting the Play is the whole teardown: the garbage collector takes the pod and the claims with it. A Finished run leaves nothing running.

The operator holds the finalizer media.liken.sh/bus-topics on every Play, so a delete completes only after the operator has torn the run down. Inside that window it deletes the playback pod, waits until the pod is gone, and clears the run’s retained status and availability topics on the bus. The finalizer owns the clear because a Play must never be gone from the API server while its topics still stand on the broker, and because the pod’s own closing messages have to land before the operator’s clear or they would overwrite it. A Play that stays deleting for longer than a few seconds has a pod that will not go, and kubectl describe on the pod says why.

The spec is immutable, like a Job’s template. A Play whose player or media changed mid-run would describe a different run; delete the Play and create another.

One Player runs one Play. When two unfinished Plays name the same Player, the newest one by creation time, and then by name, is the one that runs, and the operator deletes every older one. So a Play created while a film plays ends that film. The deleted Play reports its last position before its pod ends, and a Play that resumes it names that position in spec.start.

spec.next names the work that follows this run. The display offers it on the scrubber, and when a person takes the offer the program that wrote the Play creates the next one. The Play carries the offer because the display reads no catalog, and the program that wrote it decides what follows.

apiVersion: media.liken.sh/v1alpha1
kind: Play
metadata:
  name: dune
  namespace: media
spec:
  players: [studio]
  items:
    - uri: nfs://nas/media/movies/Dune (2021)/Dune.mkv
      presentation:
        type: video
        hint: movie
        title: Dune
        year: 2021
  start: "0:10:00"

A Play is one run of media on a Player: a film, an album, or a season of episodes, played in order. Create a Play to start it; delete the Play to stop it early.

spec

What to play and where. The spec is immutable: a different film or a different player is a different Play.

Field Type Required Description
players []string yes The Players this Play runs on, by name, in this namespace. One entry today.
items []object yes The media to play in order. Each entry is a URI and an optional presentation that declares how the display should render it.
start string no Where in the first item the run begins, as a time the player accepts, such as 0:10:00 or 600. Omitted, the run begins at the start. Later items always begin at their own start. This is also how a run resumes: a new Play with the position a finished or deleted one reported.
next object no The work that follows this run. The display offers it on the scrubber as a chip, and as a card near the end of the run. The card stays up for five seconds, and the OSD shows it again after that. When the item’s marks place credits in its second half, the card rises at the start of the credits, or after the scene that follows them, as the marks field describes. Otherwise a film’s card rises when three percent of the item or three minutes remain, whichever is less, and an episode’s card, for an item whose presentation names a series, rises when one and a half percent of the item or ninety seconds remain, whichever is less. With the OSD down, the risen card shows focused, and select takes it, unless a skip control is up beside it. Then select does what it does for the skip control alone. A select on the offer publishes the request below on the Player’s commands topic, where the program that wrote the Play reads it back and creates the next Play. A Play with no next block offers nothing.
trickplayInterval string no The seconds one trickplay tile covers, as a Go duration like 10s. Jellyfin writes no manifest beside the sheets, so the Play declares it. Omitted, it defaults to 10s, the Jellyfin default.
ttlSecondsAfterFinished integer no How long this Play remains after it finishes, in seconds, the meaning a Job gives the name. While it remains, kubectl get plays still shows what just played and where it stopped; deleting the Play deletes that record. Omitted, it is 300 seconds. Zero deletes the Play as soon as it finishes. The playback pod does not wait for this window: it is deleted as soon as the run finishes.
audioLanguages []string no A per-Play override of the audio language order. Omit it to use the Player’s.
subtitleLanguages []string no A per-Play override of the subtitle language order. Omit it to use the Player’s.
subtitles string no A per-Play override of when subtitles show. Omit it to use the Player’s. One of: on, off, auto.

spec.items[]

One entry in the playlist: the URI to play and, optionally, how it should look.

Field Type Required Description
uri string yes The operator resolves https:// to a stream the player reads directly, and nfs://host/export/path and claim://namespace/claim/path to a mount on the playback pod. A claim URI names the Play’s own namespace, because a pod mounts only a claim in its own namespace. pattern://white, pattern://bars, and pattern://speakers play a test pattern the player image carries, at the frame the URI names, such as pattern://bars/1920x1080, or at the frame of the Player’s screen when it names none. A URI whose scheme the operator does not know, a claim URI that names no namespace or another namespace, or a pattern or frame the image does not carry, fails the Play before any pod exists.
presentation object no How the item should look, for the fields the display cannot read from the file. The library that fed the item supplies these, and the display prefers them over the container’s tags. Omit the block for a loose file, and the display falls back to the file’s own tags.

spec.items[].presentation

How the item should look, for the fields the display cannot read from the file. The library that fed the item supplies these, and the display prefers them over the container’s tags. Omit the block for a loose file, and the display falls back to the file’s own tags.

Field Type Required Description
type string no The media type the display tunes its layout by. mpv cannot infer this, and the display does not read it from the file name. One of: video, music, image.
hint string no The finer kind within the type. A video is a movie or a series, and music is an album. It selects the layout the display draws. An album also declares that the item’s URI names a directory, which the playback pod expands into one timeline of the audio files it holds. The directory must hold at least one audio file, or the run fails. One of: movie, series, album.
role string no What the item is, when it is not the work itself. The only value is trailer, and the display marks a trailer on the line under the title. Omit it for the work itself. One of: trailer.
title string no The item’s name, which overrides the file’s own tag. Set it when the tag is wrong or absent.
series string no The series this episode belongs to.
season integer no The season number of the episode.
episode integer no The episode number within its season.
episodeTitle string no The title of the episode.
year integer no The release year, shown under a movie’s title.
date string no The air date of an episode, shown on its line. Give it as an ISO date like 2017-03-05, and the display formats it.
artist string no The artist of a music item, shown in the header under the title. For an album this field is the one source; a standalone track can also leave it unset and let its own tags supply it.
album string no The record a music item belongs to, shown in the header beside the year. It fills in the same way the artist does.
art string no The cover art URI, claim://, nfs:// or https://, resolved the way the media URI is. It is the first place the cover is looked for; a picture embedded in the file and a cover.jpg beside it follow, and the pod reads both of those itself.
logo string no The logo art URI, claim://, nfs:// or https://, resolved the way the media URI is. The display shows it in the header in place of the title.
trickplay string no The X.trickplay directory URI, claim://, nfs:// or https://, resolved the way the media URI is. The display shows a tile from it on the scrub cursor.
appearances string no The URI of the item’s spans file, claim://, nfs:// or https://, resolved the way the media URI is. library-operator writes it as X.spans.json in .liken/appearances/ beside the video. When the film pauses, the display shows the credited people on screen at the playhead.
contributors string no The URI of the library’s .contributors directory, claim://, nfs:// or https://, resolved the way the media URI is. The spans file names each portrait by its path under it.
marks []object no The spans in the file where the intro, the recap, the credits, the scene after the credits, and the preview are. A database can give several candidate spans for one kind, and the list carries every one. The display merges the candidates that overlap into one span, from the median start and the median end. It offers a skip control while the playhead is inside an intro or a recap. With the OSD down, the control shows focused, and select skips. The credits count when they start in the second half of the item. A scene after them is a post-credits span, or the gap between two credits spans. Inside the credits before a scene, the display offers a skip to the scene, and select takes it only while the OSD is up. The up-next card rises at the start of the credits, or, when a scene follows them, at the later of the start of the last credits span and the end of the last scene. A card after a scene never rises later than it would with no marks. An item with no marks plays with no skip control, and its card rises by the time that remains, as the next field describes.

spec.items[].presentation.marks[]

The spans in the file where the intro, the recap, the credits, the scene after the credits, and the preview are. A database can give several candidate spans for one kind, and the list carries every one. The display merges the candidates that overlap into one span, from the median start and the median end. It offers a skip control while the playhead is inside an intro or a recap. With the OSD down, the control shows focused, and select skips. The credits count when they start in the second half of the item. A scene after them is a post-credits span, or the gap between two credits spans. Inside the credits before a scene, the display offers a skip to the scene, and select takes it only while the OSD is up. The up-next card rises at the start of the credits, or, when a scene follows them, at the later of the start of the last credits span and the end of the last scene. A card after a scene never rises later than it would with no marks. An item with no marks plays with no skip control, and its card rises by the time that remains, as the next field describes.

Field Type Required Description
kind string yes What the span holds: intro, recap, credits, post-credits, or preview. post-credits is the scene after the credits. The display ignores a kind it does not know.
start number no Where the span starts, in seconds from the start of the file. Omit it for a span that starts with the file.
end number no Where the span ends, in seconds from the start of the file. Omit it for a span that ends with the file.
source string no The database the span came from, such as theintrodb. The display does not read it.

spec.next

The work that follows this run. The display offers it on the scrubber as a chip, and as a card near the end of the run. The card stays up for five seconds, and the OSD shows it again after that. When the item’s marks place credits in its second half, the card rises at the start of the credits, or after the scene that follows them, as the marks field describes. Otherwise a film’s card rises when three percent of the item or three minutes remain, whichever is less, and an episode’s card, for an item whose presentation names a series, rises when one and a half percent of the item or ninety seconds remain, whichever is less. With the OSD down, the risen card shows focused, and select takes it, unless a skip control is up beside it. Then select does what it does for the skip control alone. A select on the offer publishes the request below on the Player’s commands topic, where the program that wrote the Play reads it back and creates the next Play. A Play with no next block offers nothing.

Field Type Required Description
reason string no The first line of the card, which says why this work follows: “Next in The Saga”, for one. The display draws it as given, in uppercase.
title string no The second line of the card, the name of the work that follows. The chip draws this line alone.
detail string no The third line of the card, under the title: the series and the season, or the year and the runtime. The display draws it as given.
art string no The art of the work that follows, as a claim://, nfs://, or https:// URI, resolved the way an item’s art is. The display fits it inside the card’s box and keeps its ratio, so a poster is letterboxed.
request object no An object the operator never reads. When a person takes the offer, it is published byte for byte on the Player’s commands topic, so the program that wrote the Play gets its own words back and needs no other record of what it offered.

status

What the playback pod reports, written only by the media operator. The playback pod itself holds no API credentials; it reports to the operator, and the operator writes here.

Field Type Required Description
phase string no Where the run is in its life, in the words Jobs and Pods use. Pending is declared but not yet performing, Running is the pod performing the play, paused or not, and Finished and Failed are the two ends. The word is Running rather than Playing because a phase moves forward only, and a phase of Playing would switch back and forth each time a film paused. The paused field reports the pause. One of: Pending, Running, Finished, Failed.
activity string no The one word for what the Play does right now, the phase and the paused flag folded together. Starting is Pending, Playing and Paused both mean Running, and Finished and Failed match the phase. The phase is the lifecycle; the activity is what a person reads at a glance. One of: Starting, Playing, Paused, Finished, Failed.
paused boolean no True while the player holds the current item still. The phase stays Running, because a pause does not advance the lifecycle.
item integer no Which URI plays now, counting from 1 in spec order. The third of five episodes shows 3.
position string no The playhead inside the current item, as H:MM:SS.
duration string no The length of the current item, as H:MM:SS, once the player has read it.
pod string no The playback pod’s name, for kubectl describe and logs. The pod is owned by this Play and is deleted with it.
message string no The reason for the phase, as one line of text: the resolver refused a URI, the Player does not exist, the pod failed.
finishedAt string no When the operator first read this run’s phase as Finished. The time-to-live after finishing counts from here and not from the Play’s creation, so the window measures the end of the film. It is written here rather than held in the operator, so an operator that restarts reads the clock back.
audioLanguages []string no The audio language order this run applied, after the operator combined the Play, the Player, and MediaPreferences.
subtitleLanguages []string no The resolved subtitle language order this run applied.
subtitles string no The resolved subtitle setting this run applied, one of on, off, or auto.
audioLanguage string no The language of the audio track mpv chose, so you can see when a code matched no track. The value is the track’s own tag as the file contains it, for Matroska the three-letter ISO 639-2 code, whatever form the preference used.
subtitleLanguage string no The language of the subtitle track mpv chose; empty when none plays. The value is the track’s own tag as the file contains it, the way audioLanguage reports its track.
conditions []object no The run’s conditions. There is one, DisplayAlive, and it reports the playback pod’s display container, the native sidecar that draws the on-screen display. It is True with reason Running while the container runs, at any restart count, and False with reason Restarting only while the container is down after an exit the kubelet recorded. The message states the restart count with the reason and exit code of the last termination, and a display that has never restarted has no message. More than two restarts in one run set the phase to Finished with the same message, and the run retires the way a finished film does. The condition is absent while the pod reports no display container or the container has not started yet. lastTransitionTime moves only when the status does.

status.conditions[]

The run’s conditions. There is one, DisplayAlive, and it reports the playback pod’s display container, the native sidecar that draws the on-screen display. It is True with reason Running while the container runs, at any restart count, and False with reason Restarting only while the container is down after an exit the kubelet recorded. The message states the restart count with the reason and exit code of the last termination, and a display that has never restarted has no message. More than two restarts in one run set the phase to Finished with the same message, and the run retires the way a finished film does. The condition is absent while the pod reports no display container or the container has not started yet. lastTransitionTime moves only when the status does.

Field Type Required Description
type string yes The condition’s name, DisplayAlive.
status string yes The condition’s status. One of: True, False, Unknown.
reason string no One word for the status: Running or Restarting.
message string no The restart count, with the reason and exit code of the last termination the kubelet recorded.
lastTransitionTime string no When the status last changed, so a reader can tell how long the display has been down or up.
observedGeneration integer no The Play’s metadata.generation the condition was derived from, so a reader can tell a condition on the current spec from a stale one.

Test patterns

A test pattern gives the display a known picture under it, so a check of the OSD, the scrims, or the color does not depend on a scene in a film. The player image carries each pattern as a ten-minute file with a chapter each minute, so the scrubber, the chapter marks, and the time left behave as they do for a film. A pattern needs no storage and no network.

apiVersion: media.liken.sh/v1alpha1
kind: Play
metadata:
  name: white
spec:
  players: [theater]
  items:
    - uri: pattern://white
Pattern What it shows
white a white screen, the worst case for the dark scrims behind the OSD
bars the SMPTE HD color bars, which show a color or a range error
speakers a walk of a 7.1 layout: pink noise from one speaker at a time, named on the screen

speakers plays pink noise for 5 seconds from each speaker in turn: front left, center, front right, side right, back right, back left, side left, and then the subwoofer, whose noise stops at 120 Hz. The screen names the speaker that should play, and each speaker is a chapter, so a chapter skip moves to the next one. The file is 7.1, so a system with fewer speakers plays a downmix, and the walk shows where each missing speaker’s channel lands. The walk is 40 seconds long and carries the one frame 1280x720.

The URI can name a frame after the pattern, as in pattern://bars/1920x1080. white and bars carry every frame below:

Frame Shape What it matches
1280x720 16:9 a 720p screen
1920x1080 16:9 a 1080p screen
3840x2160 16:9 a 4K screen
2560x1080 21:9 a wide 1080-row screen
3840x1600 2.4:1 a wide 1600-row screen
1920x804 2.39:1 a scope film, which letterboxes on a 16:9 screen

A URI with no frame takes the frame of the Player’s screen: the largest frame of the screen’s shape that fits on the compositor’s canvas, which the screen’s Display reports. A screen whose shape matches no frame, or a Player whose screen is not known yet, plays 1920x1080, and mpv scales it. The operator chooses the frame when it creates the playback pod, so a Display that changes mode later does not change the run.

Events

The operator posts a Kubernetes Event on the Play for each phase it moves to, for each playback pod it creates again, and for the delete that ends the Play. kubectl describe play prints them. The API server deletes an Event one hour after its last write, so the phase, the conditions, and the operator’s log hold the facts after that.

Reason Type When
PlaybackStarted Normal The phase moved to Running.
PlaybackFinished Normal The phase moved to Finished. The message gives the item and the position.
PlaybackFailed Warning The phase moved to Failed because the playback pod failed. The message is the pod’s own.
InvalidSpec Warning The phase moved to Failed because the Play can never run as written: it names no Player, an item does not resolve, or a Remote the pod needs does not exist.
PodRecreated Warning or Normal The operator created the playback pod again. A pod that failed or is gone is a Warning, and a spec edit that reshaped the pod is Normal. The message gives the position the new pod starts at.
ResumeBackoff Warning The playback pod failed again soon after a recreate. The message gives the count and the wait before the next recreate.
Restarting, Running Warning, Normal The DisplayAlive condition changed, with the condition’s message.
Superseded Normal The operator deleted the Play, because a newer Play on the same Player replaces it.
Retired Normal The operator deleted the Play, because its ttlSecondsAfterFinished passed after it finished.

Superseded and Retired also go on the Player, because the Play’s own Events leave kubectl describe with it.

On the bus

The plays tree contains one run’s commands, report, and availability. The media bus gives the rules every topic follows and lists every writer and reader of each.

Topic Writer Retained Carries
plays/{namespace}/{name}/commands any program no one named command
plays/{namespace}/{name}/status the playback pod yes the run’s report
plays/{namespace}/{name}/availability the playback pod yes online or offline

commands

The topic any program publishes to drive the run. A phone and a Home Assistant integration reach the run the same way: publish one JSON command, and the playback pod applies it. A controller does not reach this topic. The playback pod’s command sidecar reads the Remote’s events topic itself and binds the key names there, so a press becomes one of these commands inside the pod.

{"action": "seek", "amount": -30}

action names a word from the vocabulary below. amount belongs only to the two actions that move by one, and its sign is the direction: seconds for seek, and chapters for chapter.

Action What it does
pause toggles pause
play plays, and leaves a film that plays playing
hold pauses, and leaves a paused film paused
stop ends the run, the way the display’s exit ends it
seek moves the playhead by amount seconds
chapter jumps by amount chapters
subtitles cycles the subtitle track
audio cycles the audio track
info shows the file name and position for a few seconds
up, down, left, right, select, back drive the on-screen display
home asks the unit’s client for its home page, then ends the run
power asks the unit’s client to do what power does between films, then ends the run
power-off asks the unit’s client to turn the room off, then ends the run

Two parts of the on-screen display answer these actions. When an item names an appearances file and the on-screen display is up, playing or paused, the display draws a row of cards above the skip control and the chip, one for each credited person in the scene, and up, left, and right move the focus along it. When an item’s marks place the playhead inside an intro or a recap, or inside the credits before a post-credits scene, the display offers a skip control, and select takes it. A skip to a post-credits scene takes select only while the on-screen display is up. The display never skips on its own.

play, hold, stop, and power-off set the state they name, so a second one changes nothing. The playback pod binds them to the names the kernel’s rc-cec keymap gives a TV remote’s deterministic functions (HDMI-CEC 1.3a, CEC 13.13.3): KEY_PLAYCD, KEY_PAUSECD, KEY_STOPCD, and KEY_SLEEP. The keymap also gives the Pause-Play Function the name KEY_PLAYPAUSE, which a Bluetooth remote’s play button sends too, so it stays a toggle. A Keymap row on the Remote that holds the CEC adapter’s input device can name the Pause-Play Function KEY_PAUSECD.

The level belongs to the unit and not to the run, so this topic carries no volume action. The operator reads the volume keys from the Remote’s events topic, and a program that is not a remote asks on the Player’s volume/commands topic . An action this build has no case for does nothing, so a command from a newer program has no effect rather than a crash. A focus cycle never travels here: the key that asks for one, KEY_CYCLEWINDOWS, becomes a request on the Remote’s tree instead.

status

The run’s report, as the playback pod reads it from the player. The pod publishes it on every change, and every few seconds while the position advances. It is retained, so a restarted operator reads a running Play’s place back from the broker.

{
  "paused": false,
  "item": 1,
  "position": "0:41:22",
  "duration": "1:58:03",
  "audioLanguage": "eng",
  "subtitleLanguage": "eng",
  "pod": "5f0c7a52-8e1d-4c3b-9a27-2d6b1e4f8c90"
}

item counts from 1 in spec order. duration is empty until the player has read the item’s header, and the two language fields are absent while no track of that kind plays. The language values are the track’s own tags as the file carries them, for Matroska the three-letter ISO 639-2 codes, whatever form the preference used. The ended field appears when the run is over and remains set in every later report of the same run. The pod takes seconds to terminate, so the operator reads this mark and returns the unit to idle at once instead of waiting out the pod.

pod is the UID of the playback pod that sent the report. A run’s pod can stop while the Play goes on: the operator recreates it after an edit to the Player, resumes the run after the player crashes, or replaces a pod that something else deleted, such as an eviction. The new pod has the same name, and the old pod reports the ended field when it stops. The operator refuses a report from a pod that its pod watch shows deleting, from a pod other than the one the watch shows standing, and from a pod it knows is gone or replaced. So the old pod’s ending does not move the unit to Idle, and the new pod’s ending is marked and labeled on its own. While the watch shows no pod for the run, the operator takes a report from any pod it does not know is gone, because the new pod can report before the watch shows it.

The operator takes a report with no pod field as the run’s, so a pod that runs an older sidecar image still reports. Two such pods of one run look the same to the operator, so the limits above do not apply to them.

The operator folds each report into the Play’s Kubernetes status, so a program that only needs the current position can read either one.

Two writers clear the topic. The pod clears it with an empty retained payload when its run ends cleanly. The operator clears it as well, which is what a pod that died uncleanly needs, and it does so on its finalizer: it adds media.liken.sh/bus-topics to every Play, and when the Play is deleted it deletes the pod, waits for the pod to be gone, publishes an empty retained payload on status and on availability, and only then takes its finalizer off. So the Play is never gone while its topics remain, and a deleted Play leaves no report on the broker.

availability

online or offline, a space, and the pod’s UID, retained, the availability signal for the report above. For example, offline 5f0c7a52-8e1d-4c3b-9a27-2d6b1e4f8c90. The pod names this topic as its MQTT Last Will with offline and its UID as the payload, publishes online and its UID once it connects, and publishes offline and its UID itself when its run ends cleanly.

The broker publishes a dead pod’s Last Will only when the pod’s keepalive runs out, which can be after the run’s new pod is online. The operator takes an availability on the same terms as a report, so a late offline from an old pod does not drop the new pod’s report. It takes the word with no UID as the run’s. An operator that has just started knows no pod as gone until its pod watch has read the cluster, so the retained offline of an old pod can drop the report it read. The new pod reports again within a second.

The operator clears this topic with an empty retained payload on the same terms as status: on its finalizer, once the Play is deleted and its pod is gone.