Jellyfin Integration
NullPlayer supports Jellyfin media servers for music streaming, video playback (movies and TV shows), browsing, and scrobbling.
Architecture
| File | Purpose |
|---|---|
Jellyfin/JellyfinModels.swift | Domain models (Server, Artist, Album, Song, Playlist, Movie, Show, Season, Episode) and API DTOs |
Jellyfin/JellyfinServerClient.swift | HTTP client for Jellyfin REST API (music + video) |
Jellyfin/JellyfinManager.swift | Singleton managing connections, caching, and track conversion (music + video) |
Jellyfin/JellyfinPlaybackReporter.swift | Audio scrobbling and "now playing" reporting |
Jellyfin/JellyfinVideoPlaybackReporter.swift | Video scrobbling with periodic timeline updates |
Jellyfin/JellyfinLinkSheet.swift | Server add/edit/manage UI dialogs |
Authentication
All requests include header: Authorization: MediaBrowser Client="NullPlayer", Device="Mac", DeviceId="{uuid}", Version="1.0". After auth, also include X-Emby-Token: {accessToken}.
- •
Auth:
POST /Users/AuthenticateByName- •Body:
{"Username":"x","Pw":"y"} - •Returns JSON with
AccessTokenandUser.Id - •The access token is stored in keychain
- •Body:
- •
Ping:
GET /System/Ping(returns 200 if server is reachable)
Library Browsing
- •Music libraries:
GET /Users/{userId}/Views(filter whereCollectionType == "music") - •Artists:
GET /Artists/AlbumArtists?parentId={libId}&userId={userId}&Recursive=true&SortBy=SortName - •Albums:
GET /Users/{userId}/Items?parentId={libId}&IncludeItemTypes=MusicAlbum&Recursive=true - •Artist albums:
GET /Users/{userId}/Items?AlbumArtistIds={artistId}&IncludeItemTypes=MusicAlbum - •Album tracks:
GET /Users/{userId}/Items?parentId={albumId}&IncludeItemTypes=Audio - •Playlists:
GET /Users/{userId}/Items?IncludeItemTypes=Playlist&Recursive=true - •Search:
GET /Items?searchTerm={q}&IncludeItemTypes=Audio,MusicAlbum,MusicArtist,Movie,Series,Episode
Video Browsing
- •Video libraries:
GET /Users/{userId}/Views(filterCollectionType == "movies"or"tvshows") - •Movies:
GET /Users/{userId}/Items?parentId={libId}&IncludeItemTypes=Movie&MediaTypes=Video- •
MediaTypes=Videoexcludes non-video files
- •
- •Series:
GET /Users/{userId}/Items?parentId={libId}&IncludeItemTypes=Series - •Seasons:
GET /Shows/{seriesId}/Seasons?userId={userId} - •Episodes:
GET /Shows/{seriesId}/Episodes?userId={userId}&seasonId={seasonId}&MediaTypes=Video
Streaming
- •Audio Stream:
GET /Audio/{itemId}/stream?static=true&api_key={token} - •Video Stream:
GET /Videos/{itemId}/stream?static=true&api_key={token}- •Note: Uses
/Videos/path, not/Audio/
- •Note: Uses
Images
- •Image:
GET /Items/{itemId}/Images/Primary?maxHeight={size}&maxWidth={size}&tag={imageTag}- •
imageTagis fromImageTags.Primaryin the item response
- •
User Actions
- •Favorite:
POST /Users/{userId}/FavoriteItems/{itemId}(add),DELETE(remove) - •Rate:
POST /Users/{userId}/Items/{itemId}/Rating?likes=true - •Scrobble:
POST /Users/{userId}/PlayedItems/{itemId}
Playback Reporting
- •
Start:
POST /Sessions/Playing- •Body:
{"ItemId":"{id}","CanSeek":true,"PlayMethod":"DirectStream"}
- •Body:
- •
Progress:
POST /Sessions/Playing/Progress- •Body:
{"ItemId":"{id}","PositionTicks":{ticks},"IsPaused":false}
- •Body:
- •
Stopped:
POST /Sessions/Playing/Stopped- •Body:
{"ItemId":"{id}","PositionTicks":{ticks}}
- •Body:
Rating Scale
Jellyfin UserData.Rating is 0-100%. The app uses 0-10 internal scale.
Mapping:
- •
jellyfin_rating = internal_rating * 10 - •
internal_rating = jellyfin_rating / 10 - •Each star = 20%
Ticks
Jellyfin uses ticks for duration/position: 1 tick = 10,000 nanoseconds = 0.00001 seconds.
Convert: ticks = seconds * 10_000_000
Track Identification
Jellyfin tracks in the playlist are identified by:
- •
track.jellyfinId— the Jellyfin item UUID - •
track.jellyfinServerId— which Jellyfin server the track belongs to
Scrobbling
JellyfinPlaybackReporter follows the same rules as SubsonicPlaybackReporter:
- •Reports "now playing" immediately on track start (via
POST /Sessions/Playing) - •Reports progress periodically (via
POST /Sessions/Playing/Progress) - •Scrobbles after 50% of track or 4 minutes, whichever comes first
- •Reports stopped on track end/stop (via
POST /Sessions/Playing/Stopped)
Video Playback Reporter
JellyfinVideoPlaybackReporter mirrors PlexVideoPlaybackReporter with Jellyfin API:
- •Video scrobble threshold: 90% (vs 50% for audio)
- •Minimum play time: 60s before scrobbling
- •Periodic timeline updates every 10s via
POST /Sessions/Playing/ProgresswithPositionTicks - •Tracks pause/resume state with
IsPausedflag - •Uses ticks (1 tick = 100ns,
seconds × 10_000_000) for Jellyfin API
Music Library Selection
Unlike Subsonic (which has a single library), Jellyfin can have multiple music libraries. The JellyfinManager handles this:
- •
musicLibraries: [JellyfinMusicLibrary]— all available music libraries - •
currentMusicLibrary: JellyfinMusicLibrary?— currently selected library - •Auto-selects if only one library exists
- •Persisted via
JellyfinCurrentMusicLibraryIDUserDefaults key
Video Library Selection
- •
videoLibraries: [JellyfinMusicLibrary]— all movie/tvshow libraries - •
currentMovieLibrary: JellyfinMusicLibrary?— selected movie library - •
currentShowLibrary: JellyfinMusicLibrary?— selected TV show library - •Auto-selection priority: saved UserDefaults ID → match by
collectionType→ single library fallback
Artist Expansion Performance
When expanding a Jellyfin artist in the library browser, albums are resolved from the preloaded cache (cachedJellyfinAlbums) by filtering on artistId, making expansion instant. Network fallback only occurs if the cache has no matching albums.
Important: Expand tasks must use Task.detached (not Task { }) to avoid inheriting cancellation state from the calling context.
Casting
Jellyfin tracks support casting to Sonos, Chromecast, and DLNA devices:
- •Sonos requires proxy (like Subsonic) —
needsJellyfinProxyflag - •Artwork is loaded via
JellyfinManager.shared.imageURL() - •Stream URLs use
api_keyauth parameter, not header auth
Content Type Detection for Sonos Casting
Jellyfin (and Subsonic) streaming URLs use paths like /Audio/{id}/stream with no file extension. This means CastManager.detectAudioContentType(for:) defaults to audio/mpeg, which is wrong for FLAC/WAV/etc.
Fix: CastManager.prepareProxyURL(for:device:) handles this with a multi-layer strategy:
- •Use
track.contentTypeif available (set byJellyfinManager.convertToTrack()from theContainerfield) - •If nil, send a HEAD request to the upstream URL and read the
Content-Typeresponse header - •Pass the detected content type to both the proxy registration and the DIDL-Lite metadata
- •Fall back to
detectAudioContentType(for:)only as a last resort
State restoration: SavedTrack.contentType persists the MIME type across app restarts.
Video Casting
Jellyfin movies and episodes can be cast to video-capable devices (Chromecast, DLNA TVs):
- •
CastManager.castJellyfinMovie(_:to:startPosition:)— cast a movie - •
CastManager.castJellyfinEpisode(_:to:startPosition:)— cast an episode - •Stream URL uses
/Videos/{id}/stream?static=true&api_key={token}
Credential Storage
Jellyfin credentials are stored using KeychainHelper:
- •Key:
jellyfin_servers - •Stores:
[JellyfinServerCredentials](includes access token and userId)
State Persistence
- •Current server ID:
JellyfinCurrentServerID(UserDefaults) - •Current music library ID:
JellyfinCurrentMusicLibraryID(UserDefaults) - •Current movie library ID:
JellyfinCurrentMovieLibraryID(UserDefaults) - •Current show library ID:
JellyfinCurrentShowLibraryID(UserDefaults) - •Playlist tracks with
jellyfinId/jellyfinServerIdare saved/restored byAppStateManager