Keep progress with Jellyfin
A cluster that runs Jellyfin beside this operator holds two copies of
one fact: where each person is in each title. The progress store holds
one and Jellyfin holds the other. spec.jellyfin on the Catalog
keeps the two the same, in both directions. A film started on a phone
resumes on the television, and a film started on the television
resumes on the phone.
1. Name the server on the Catalog
Make an API key on the Jellyfin server, under Dashboard and API Keys,
and put it in a Secret in the Catalog’s namespace. One key covers
every user, because an API key has administrator rights.
apiVersion: v1
kind: Secret
metadata:
name: jellyfin-api-key
namespace: media
type: Opaque
stringData:
token: <your key>
Then name the server’s address and that Secret on the Catalog.
apiVersion: library.liken.sh/v1alpha1
kind: Catalog
spec:
jellyfin:
url: http://jellyfin.jellyfin.svc:8096
secretRef:
name: jellyfin-api-key
key: token
The operator creates one pod and one Service, both named after the
Catalog with the suffix -jellyfin, beside the progress store. The
key reaches the pod through a secretKeyRef, so the operator never
reads it. The Catalog
reference describes
every field.
2. Set up the Webhook plugin
Jellyfin tells the role what a person plays through its Webhook
plugin. Install the plugin from Jellyfin’s catalog and add a Generic
destination. Its URL is the Service the operator created, in the
Catalog’s namespace:
http://<catalog>-jellyfin.<namespace>.svc:8080/webhook
Set the rest of the destination like this.
- Notification types:
PlaybackStart,PlaybackProgress,PlaybackStop, andUserDataSaved. - Item types: enable Movies and Episodes.
- Request header:
Content-Typewith the valueapplication/json.
The template renders the body the role reads. Paste it as one line, exactly as it is here. The plugin’s page stores it base64-encoded, so anything that writes the plugin’s configuration through Jellyfin’s API must encode the template field the same way.
{"event":"{{NotificationType}}","user":"{{{NotificationUsername}}}","userId":"{{UserId}}","itemId":"{{ItemId}}","itemType":"{{ItemType}}","seriesId":"{{SeriesId}}","season":"{{SeasonNumber}}","episode":"{{EpisodeNumber}}","positionTicks":"{{PlaybackPositionTicks}}","runTimeTicks":"{{RunTimeTicks}}","paused":"{{IsPaused}}","playedToCompletion":"{{PlayedToCompletion}}","saveReason":"{{SaveReason}}","played":"{{Played}}","tmdb":"{{Provider_tmdb}}","imdb":"{{Provider_imdb}}","tvdb":"{{Provider_tvdb}}"}
3. What crosses
A post from Jellyfin becomes one message on the media bus, and the progress store records the position and the person against the title. The newer timestamp wins, so a screen resumes from the last place anything played.
A Play on a screen goes the other way. The role writes each person’s
position to Jellyfin every ten seconds while the position moves, and
once more when the Play ends.
A work counts as watched when the time left is at or under a twentieth of the work, and at or under five minutes. The rule takes whichever of the two leaves less time. So a short episode is watched near its end, and a long film is watched with up to five minutes left.
Where the file’s marks place the credits in the second half of the work, the work counts as watched from the start of those credits instead, whatever time is left. The role reads the credits marks the way the player’s up-next card does, and the media browser reads them the same way from its catalog. So a title the role writes as played is a title the browser shows as finished. Candidates that overlap become one span, from the median start and the median end of the group, so one submission that is off moves the line little. Spans that do not overlap stay apart, and the line is the start of the earliest span that starts in the second half. A credits span in the first half is an opening title sequence and moves nothing. Where no span starts in the second half, the rule above applies.
A write of a Play sets Jellyfin’s played state only when the position
passes that rule, and never clears it. A write below the line moves the
position and leaves the played state as Jellyfin holds it. So a person
who rewatches ten minutes of a finished film keeps it played in
Jellyfin, and the resume point moves to where they stopped.
A person at the media browser can mark a title watched or clear its progress. The role sends each mark to Jellyfin once for each person at the screen. Mark watched sets the item played, with the position at the end of the work. Clear sets it unplayed, at position 0. Both write the time of the press as the item’s last played date. The role sends a mark on its ten-second pass, not at the moment it arrives, and a write that fails is sent again on the next pass.
Mark watched writes nothing to an item Jellyfin already holds as played with no resume position, because only the date would change. Pick up here marks every earlier episode of a series watched and clears every later episode the people at the screen started, in one press. The role writes each earlier episode that Jellyfin holds and has not played, and sets each later episode unplayed at position 0. Jellyfin has no call that writes several items at once, so a long series takes one read and at most one write for each episode and each person. The role logs one line for each person with the counts, and not a line for each episode.
The bus delivers a mark again each time the role subscribes, for 24 hours after the press. Two checks keep an old mark from undoing a newer change in Jellyfin:
- Before it writes, the role reads the person’s last played date for the item. A date at or after the press is a later play or mark in Jellyfin, and the role leaves that person’s state as it is.
- After it sends a mark, the role publishes a retained record of it on the bus, and it sends no mark it holds a record for. A restarted role reads the record back. Jellyfin clears the last played date on an unplayed toggle, so the date alone does not stop a second send.
One case stays open. A person who marks a title on the screen while the role is down, and then toggles the same title in Jellyfin before the role starts, gets the screen’s mark in Jellyfin. The role cannot tell the order of the two, because Jellyfin records no date for an unplayed toggle.
A UserDataSaved post with the save reason TogglePlayed is a mark a
person set by hand in Jellyfin. The role records it as a position at
the end of the work, because Jellyfin stores a mark set by hand as a
state with no position. An unmark records a position at the start.
The role drops every other save reason. Each of those is the role’s own write to Jellyfin, posted back as a save. Reading one would record the role’s write as a play.
A person’s Jellyfin user name must be their Person name. A Jellyfin
user with no Person of that name still records a row, and no screen
shows it, because no Person claims it.
The reconcile at start
The webhook’s posts are not retained. A toggle or a play in Jellyfin while the jellyfin role or the progress role is down never arrives as a post. So each time the progress role reports online on the bus, and the role’s webhook listener is up, the role reads every user’s played and resumable items once. It publishes each item as a play dated at Jellyfin’s last played date for it. The progress store keeps the newer timestamp, so an item that did not change writes nothing, and a toggle made while a role was down arrives. A start of either role runs one reconcile. The pod log has one line for each, with the users read and the items published and skipped.
The listener comes up before the read, so a toggle made during the read arrives as a post and is not lost between the two. The reconcile writes nothing to Jellyfin. It skips a position the role itself wrote in the last five minutes, the same way the webhook drops the post of the role’s own write.
Jellyfin keeps no date for an item a person marked unplayed, and lists such an item in neither of the two reads. So an unplayed toggle made while a role was down does not arrive, and the title keeps the state the store held.
4. The backfill
The operator runs one backfill on its own, with no step for you to
take. It waits until every durable copy of the progress store is up,
then runs one Job named after the Catalog with the suffix
-jellyfin-backfill.
For every Jellyfin user the Job reads two lists. It records an item
with a resume point at the position Jellyfin holds. It records an item
the user finished at the end of the work. Each row has the date Jellyfin
last recorded a play of that item.
Jellyfin holds one state per item and no list of viewings, so the
backfill records no history. An item with no provider ids is counted
and skipped. The backfill makes no Person: a Jellyfin user with no
Person of that name records rows that no screen shows.
A rerun is safe. The store keeps the newer timestamp, so a backfilled row never overwrites a newer play, and a row the backfill writes twice is the same row.
The reconcile reads the same two lists at every start. The backfill
keeps one job of its own: it waits until every durable copy of the
progress store is up, and it reports in status.jellyfin that the whole
history of a server reached the store once. The reconcile waits only
for the progress role’s online message, and reports in the pod log.
status.jellyfin on the Catalog reports the state of the backfill:
status:
jellyfin:
server: http://jellyfin.jellyfin.svc:8096
backfill: Finished
backfilled: "2026-09-08T14:02:11Z"
backfill is Pending, Running, Failed, or Finished. To run the
backfill again, clear status.jellyfin or change spec.jellyfin.url,
because the status names the server it ran against. The Job’s log
holds the counts of the run: users, items published, and items skipped.
The log stays for an hour after the Job ends.
5. Share the volume with Jellyfin
Jellyfin writes beside the media too, unless its library settings say
otherwise. For this operator to be the only writer of art and
.nfo files, turn these off on each Jellyfin library that reads a
Library’s volume:
- Saving artwork into media folders. The art fact writes the images.
- The NFO metadata saver. The nfo fact writes the
.nfofile. - Any subtitle download plugin. Plan 60 covers subtitles.
Leave Jellyfin’s trickplay extraction on. With extraction off for a
library, Jellyfin deletes the whole .trickplay directory beside every
video on each refresh, whoever made the directory. It deletes the rows
it holds for them too. So on a volume this operator tiles, Jellyfin’s
extraction stays on, and the two race for each new title. The trickplay
phase of the Library’s Job claims the node’s GPU so that it wins. A directory Jellyfin made
first holds the same sheets, so the fact leaves it alone.
Jellyfin imports a tile directory it did not make when the folder name is the same, the files are present, and it holds no row of its own for that width. It records the number of files in the folder as the thumbnail count, where its own extraction records the number of thumbnails. This operator writes nothing but the sheets into the folder, so the count is the sheet count. Verified against Jellyfin 10.11.