AgentSkillsCN

jellyfin-integration

Jellyfin API接口、认证流程、评分体系、流媒体传输、收听记录同步,以及视频播放功能。适用于Jellyfin集成开发、媒体库浏览、播放效果统计,或视频投屏功能的实现。

SKILL.md
--- frontmatter
name: jellyfin-integration
description: Jellyfin API, authentication flow, rating scale, streaming, scrobbling, and video playback. Use when working on Jellyfin integration, library browsing, playback reporting, or video casting.

Jellyfin Integration

NullPlayer supports Jellyfin media servers for music streaming, video playback (movies and TV shows), browsing, and scrobbling.

Architecture

FilePurpose
Jellyfin/JellyfinModels.swiftDomain models (Server, Artist, Album, Song, Playlist, Movie, Show, Season, Episode) and API DTOs
Jellyfin/JellyfinServerClient.swiftHTTP client for Jellyfin REST API (music + video)
Jellyfin/JellyfinManager.swiftSingleton managing connections, caching, and track conversion (music + video)
Jellyfin/JellyfinPlaybackReporter.swiftAudio scrobbling and "now playing" reporting
Jellyfin/JellyfinVideoPlaybackReporter.swiftVideo scrobbling with periodic timeline updates
Jellyfin/JellyfinLinkSheet.swiftServer 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 AccessToken and User.Id
    • The access token is stored in keychain
  • Ping: GET /System/Ping (returns 200 if server is reachable)

Library Browsing

  • Music libraries: GET /Users/{userId}/Views (filter where CollectionType == "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 (filter CollectionType == "movies" or "tvshows")
  • Movies: GET /Users/{userId}/Items?parentId={libId}&IncludeItemTypes=Movie&MediaTypes=Video
    • MediaTypes=Video excludes 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/

Images

  • Image: GET /Items/{itemId}/Images/Primary?maxHeight={size}&maxWidth={size}&tag={imageTag}
    • imageTag is from ImageTags.Primary in 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"}
  • Progress: POST /Sessions/Playing/Progress

    • Body: {"ItemId":"{id}","PositionTicks":{ticks},"IsPaused":false}
  • Stopped: POST /Sessions/Playing/Stopped

    • Body: {"ItemId":"{id}","PositionTicks":{ticks}}

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/Progress with PositionTicks
  • Tracks pause/resume state with IsPaused flag
  • 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 JellyfinCurrentMusicLibraryID UserDefaults 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) — needsJellyfinProxy flag
  • Artwork is loaded via JellyfinManager.shared.imageURL()
  • Stream URLs use api_key auth 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:

  1. Use track.contentType if available (set by JellyfinManager.convertToTrack() from the Container field)
  2. If nil, send a HEAD request to the upstream URL and read the Content-Type response header
  3. Pass the detected content type to both the proxy registration and the DIDL-Lite metadata
  4. 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/jellyfinServerId are saved/restored by AppStateManager