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 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.

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.
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 stays after it finishes, in seconds, the meaning a Job gives the name. While it stays, kubectl get plays still answers 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, the most specific tier; omit it to inherit the Player.
subtitleLanguages []string no A per-Play override of the subtitle language order, the most specific tier; omit it to inherit the Player.
subtitles string no A per-Play override of when subtitles show, the most specific tier; omit it to inherit the Player. One of: on, off, auto.
volume object no The level this run starts at. The operator writes it to the Player’s volume topic before it creates the pod, so the value becomes the unit’s state and stays after the film ends. Omitted, the run starts at whatever the unit already holds.

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 to a mount on the playback pod. A URI whose scheme the operator does not know 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. One of: movie, series, album.
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.
logo string no The logo art URI, 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, nfs:// or https://, resolved the way the media URI is. The display shows a tile from it on the scrub cursor.

spec.volume

The level this run starts at. The operator writes it to the Player’s volume topic before it creates the pod, so the value becomes the unit’s state and stays after the film ends. Omitted, the run starts at whatever the unit already holds.

Field Type Required Description
level integer no The listening level, 0 to 100. 100 is unity, the player’s own default, and the cap: a software gain above unity only distorts. Omitted, the level the unit already holds stays.
muted boolean no Whether the run starts muted. Omitted, the muted state the unit already holds stays.

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 paused film would force Playing to flap; the paused field beside this one says the rest. 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 resolved audio language order this run applied, the record of what the three tiers settled on.
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 carries 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 carries it, the way audioLanguage reports its track.

On the bus

The plays tree carries one run’s commands, its report, and its availability. See the media bus for the rules every topic follows.

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 translator sidecar, a phone, and a Home Assistant integration all reach the run the same way: publish one JSON command, and the playback pod applies it.

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

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

Action What it does
pause toggles pause
seek moves the playhead by amount seconds
chapter jumps by amount chapters
volume steps the unit’s level by amount
mute toggles the unit’s muted flag
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

A volume or mute command changes no player directly: the pod computes the unit’s next state and publishes it on the Player’s volume topic, and every pod for the unit applies what that topic delivers. An action this build has no case for does nothing, so a command from a newer program has no effect rather than a crash. cycle-focus, the one Keymap action that never travels here, becomes a focus cycle 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"
}

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. One more field, ended, appears when the run is over and stays 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.

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

availability

online or offline, retained. The pod names this topic as its MQTT Last Will with offline as the payload, and publishes online once it connects, so a retained status a killed pod left behind does not read as a live run.