Jellyfin (films and TV)
Jellyfin identifies films and series in library/movies and
library/tv, keeps their metadata and artwork, and - the reason it is here - transcodes
video a browser cannot play. SoundStorm decides nothing about video formats itself; it asks Jellyfin
and does what it is told.
Why Jellyfin
Asked and answered rather than assumed. Plex needs a plex.tv account, so it cannot be provisioned without a human logging into a cloud service - fatal to the zero-keys promise. Emby went closed-source. Everything else in the space is too thin to bet on. And video fails the rule for what SoundStorm owns: it is not self-describing and does need transcoding. The honest cost is API churn (Jellyfin 12 broke two documented auth methods) and a 2.5 GB image.
Provisioning
Jellyfin's startup wizard is a plain REST API - /Startup/Configuration,
/Startup/User, /Startup/RemoteAccess, /Startup/Complete - and it
stops accepting calls once setup completes, which makes driving it safe. SoundStorm then signs in
(/Users/AuthenticateByName) and creates two libraries with
/Library/VirtualFolders: films on /media/movies and shows on
/media/tv, both mounted read-only.
Films and series are separate Jellyfin libraries with different collection types and scrapers, so
one Jellyfin is registered as two sources sharing one token: jellyfin
(Movie) and jellyfin-tv (Series, Episode). The house shares one Jellyfin account, which is
why film and episode positions are kept by SoundStorm per person rather than in Jellyfin.
The calls SoundStorm makes
| Call | Used for |
|---|---|
/Items | Search and browse; IncludeItemTypes=Series to list shows; SortBy=SortName only when browsing |
/Items/{id}/PlaybackInfo | Negotiating playback with a device profile |
/videos/{id}/master.m3u8 and children | HLS transcodes, proxied under /api/hls/ |
/Videos/{item}/{source}/Subtitles/{n}/Stream.vtt | Subtitles as WebVTT |
DELETE /Videos/ActiveEncodings | Stopping a transcode when the viewer leaves |
/Shows/{id}/Episodes | A show's page, season by season |
/Items/{id}/Images/Primary | Posters, resized by Jellyfin |
POST /Library/Refresh | "Look now" after an upload (every library; there is no per-library trigger without its id) |
Playback is negotiated, never assumed
Whether a file needs transcoding depends on container, video codec, audio codec, profile and level.
Jellyfin knows all five; SoundStorm knows none. So StreamTarget POSTs a device profile to
PlaybackInfo and follows the answer. The profile errs conservative on purpose - h264/aac in
mp4, VPx/AV1 in webm - because claiming a codec the browser cannot decode is the silent failure: Jellyfin
hands over the original and the video element shows nothing, with no error anywhere. Claiming too little
only costs an unnecessary transcode.
- Transcodes are HLS, for seeking. A progressive transcode has no length until it finishes, so a 20 s file showed 9.9 s that grew, and seeking to 15 s snapped back to 3 s. Jellyfin's HLS is a VOD playlist listing every segment, which gives a real timeline.
- Playlists reference their children relatively, so
/api/hls/{source}/{path...}mirrors Jellyfin's own/videos/namespace and the browser resolves every segment onto SoundStorm by itself, with no playlist rewriting. - Each play is its own conversion. Jellyfin names a conversion after the file, the
device and the play session - and SoundStorm is always one device - so the
playSessionIdfromPlaybackInfogoes in the HLS query. Without it, a change of quality or language got the old conversion's pieces. A person's live conversions are capped at four. - Film quality is per device: under the bitrate cap Jellyfin copies the picture
untouched and converts only sound a browser cannot play (TrueHD, DTS) to AAC with up to 5.1 channels.
Another audio language is always HLS with
AudioStreamIndex. - Transcodes must be stopped. A transcode is an ffmpeg process that outlives the
request, so
Target.OnDonesendsDELETE /Videos/ActiveEncodings(verified: it answers 204 where a made-up path answers 404).
Verified against live servers
- Jellyfin 12.1.0 rejects
X-Emby-Tokenand?api_key=with 401. The only form it accepts isAuthorization: MediaBrowser ... Token="..."; most docs still show the other two. This is whysource.Targetcarries headers. - An unknown query parameter is silently ignored with a 200, so a 200 is not evidence
a filter applied.
IsVirtualItem=truereturned a real film, exactly like an invented parameter. Test a filter by asking for the opposite and checking the count changes.IsMissing=falsedoes work, and keeps a Series (which has no file of its own). - Empty search:
searchTermmust be omitted, not blank, to browse. - Direct play is not trusted alone. 12.1.0 says
SupportsDirectPlay: truefor an MKV even when the profile offers only mp4, so the container is checked again against the profile's list. - Subtitle URLs are built, not taken.
DeliveryUrlcomes back empty, but theStream.vttpath works for embedded and sidecar tracks and converts SRT on the fly. Only text subtitles are offered: PGS and VOBSUB are pictures and would render nothing.
Keeping the admin token in
One account serves two sources, so every Jellyfin target checks the item comes back from a query
restricted to its own types (cached ten minutes, since every HLS segment asks) - otherwise a member
refused films could play one through the TV source. HLS paths must match the shapes Jellyfin's own
playlists use; a doubly-encoded .. once reached the admin API. And some options make
Jellyfin write URLs carrying the admin token into the master playlist - HLS subtitles, subtitles in the
manifest, trickplay tiles - so every subtitle key is stripped and enableTrickplay=false is
forced.
Deleted files
Jellyfin removes items for deleted files on its next validation - unless the library folder is
empty. Then it logs that the folder is "inaccessible or empty, skipping" and changes nothing,
because it cannot tell "everything was deleted" from "the drive did not mount". So every deleted film
stayed searchable forever. The folders' README.txt placeholders are what keep them never
empty, and EnsurePlaceholders writes them back before every scan. The adapter also sends
IsMissing=false, so episodes Jellyfin manufactures for gaps in a series never appear.
Version
The image follows latest; everything above was checked against 12.1.0. Jellyfin's API has
moved under SoundStorm before, so re-verify authentication and the HLS query keys when it changes.