dart_jellyfin 0.0.2
dart_jellyfin: ^0.0.2 copied to clipboard
Dart client for Jellyfin. Supports authentication, library browsing, playlists, streaming, search and more. Targets macOS, Windows, Linux, iOS and Android.
dart_jellyfin #
Jellyfin client for Flutter and Dart.
![]() |
dart_jellyfin is a Dart client for Jellyfin v10.11.9. It covers libraries, playlists, audio streaming, lyrics, playback reporting and search through typed Dart objects, and handles the session lifecycle for you. |
Installation #
Add dart_jellyfin to your pubspec.yaml:
dependencies:
dart_jellyfin: ^0.0.2
Contents #
Features #
| Pure Dart no native plugin, no Flutter dependency. Runs on every Dart-supported platform. |
Typed DTOsJellyfinItem, JellyfinMediaSource, JellyfinMediaStream, JellyfinUserData, JellyfinView, JellyfinAuthResult, JellyfinLyrics, JellyfinSearchHint, JellyfinQueryResult<T>, each with factory fromJson and a .raw map for forward compatibility. |
||
Both auth flowsuser.authenticateByName() for credentials, plus quickConnect.initiate() + state() + user.authenticateWithQuickConnect() for the Quick Connect link flow. |
Authorization header builderMediaBrowser Client="…", Device="…", DeviceId="…", Version="…", Token="…" built correctly and refreshed when the token changes. The historical X-Emby-Authorization alias is sent in parallel. |
||
Stateful façadeJellyfinClient holds the credentials, the active base URL, the access token and the user id; sub-APIs reuse the same Dio internally. |
Built on the official OpenAPI spec endpoint names, field names and casing match the upstream contract at api.jellyfin.org. Nothing is reinvented. |
||
Lyrics with timingJellyfinLyrics exposes parsed lines with ticks-to-ms helpers, plus .toLrc() to render LRC for any synced-lyrics player. |
Audio streamingaudio.universalStreamUrl() lets the server decide direct-play vs transcoding; audio.directStreamUrl() gives the zero-transcode original. |
||
| Semantic errors one JellyfinException hierarchy with JellyfinErrorType enum (auth, notFound, connection, timeout, serverError, parse, …), never raw Dio exceptions in your code. |
Escape hatchclient.request<T>() and client.requestBytes() for endpoints not yet covered by the typed sub-APIs. |
Quick start #
import 'package:dart_jellyfin/dart_jellyfin.dart';
Future<void> main() async {
final jellyfin = JellyfinClient(
baseUrl: 'https://jellyfin.example.com',
credentials: const JellyfinCredentials(
client: 'MyApp',
device: 'iPhone',
deviceId: 'PUT-YOUR-UUID-HERE', // stable per install
version: '1.0.0',
),
);
// 1. Authenticate.
final auth = await jellyfin.user.authenticateByName(
username: 'me',
password: 'hunter2',
);
jellyfin.setSession(token: auth.accessToken, userId: auth.user.id);
// 2. Browse the user's libraries.
final views = await jellyfin.library.userViews();
final music = views.firstWhere((v) => v.isMusic);
// 3. List 50 albums.
final albums = await jellyfin.items.list(
parentId: music.id,
includeItemTypes: const [JellyfinItemKind.musicAlbum],
sortBy: const ['SortName'],
limit: 50,
);
// 4. Play a track.
final track = albums.items.first;
final url = jellyfin.audio.universalStreamUrl(
itemId: track.id,
maxStreamingBitrate: 320000,
audioCodec: 'aac',
playSessionId: 'my-session-uuid',
);
// hand `url` to your audio engine
}
Guide #
1. Initialization and lifecycle #
1.1 Creating a client
final jellyfin = JellyfinClient(
baseUrl: 'https://jellyfin.example.com',
credentials: const JellyfinCredentials(
client: 'MyApp',
device: 'iPhone',
deviceId: '2f5b-…-uuid',
version: '1.0.0',
),
);
The baseUrl is optional and can be set later via connect(). The
constructor optionally accepts a custom dio: Dio() plus
connectTimeout / receiveTimeout. By default it owns its own Dio.
1.2 Credentials and the Authorization header
JellyfinCredentials describes who the client is. Every request
carries:
Authorization: MediaBrowser Client="MyApp", Device="iPhone", DeviceId="…uuid…", Version="1.0.0"
After login the same line gets a , Token="…" suffix. The
historical X-Emby-Authorization alias (same payload without the
MediaBrowser prefix) is sent in parallel for compatibility with
older servers.
deviceId MUST be a stable per-installation UUID. Jellyfin
tracks sessions by it. Generate it once, persist it (SharedPreferences,
Keychain, Android Keystore…), and reuse it forever.
You can also build the header value directly via the public helper:
final header = JellyfinAuthHeader.build(credentials, token: someToken);
1.3 Connecting to a server
jellyfin.connect('https://jellyfin.example.com');
Trailing slashes are stripped. Calling connect() again switches the
client to a new server; the session (token + userId) is not
cleared automatically. Call clearSession() first if needed.
1.4 Session management
jellyfin.setSession(token: '…', userId: '…');
jellyfin.clearSession(); // drop token + userId, keep baseUrl
jellyfin.disconnect(); // drop everything
token and userId are exposed read-only via getters so you can
persist them yourself:
final token = jellyfin.token;
final userId = jellyfin.userId;
1.5 Disposing
JellyfinClient doesn't own any native resources, so a disconnect()
is sufficient. There's no dispose() to call. If you injected a
custom Dio, dispose it yourself.
2. Authentication #
2.1 Username and password
final auth = await jellyfin.user.authenticateByName(
username: 'me',
password: 'hunter2',
);
jellyfin.setSession(token: auth.accessToken, userId: auth.user.id);
Returns a JellyfinAuthResult with accessToken, serverId, and the
full JellyfinUser. Throws JellyfinException(type: auth) on bad
credentials.
2.2 Quick Connect flow
Quick Connect lets a new device authenticate without typing a password, by approving a 6-character code on an already-logged-in device.
// 0. Is Quick Connect enabled on the server?
if (!await jellyfin.quickConnect.enabled()) { return; }
// 1. Start the flow on the new device.
final init = await jellyfin.quickConnect.initiate();
print('Open Jellyfin elsewhere → Settings → Quick Connect → enter ${init.code}');
// 2. Poll until the user authorises.
while (true) {
await Future<void>.delayed(const Duration(seconds: 2));
final state = await jellyfin.quickConnect.state(init.secret);
if (state.authenticated) break;
}
// 3. Exchange the secret for a real token.
final auth = await jellyfin.user.authenticateWithQuickConnect(secret: init.secret);
jellyfin.setSession(token: auth.accessToken, userId: auth.user.id);
To approve a code from an already-authenticated device (the "authorise other device" side):
await jellyfin.quickConnect.authorize('ABC123');
2.3 Current user
final me = await jellyfin.user.currentUser();
print('${me.name} (${me.id})');
2.4 Public users
final users = await jellyfin.user.publicUsers();
for (final u in users) {
print('${u.name} (primaryImageTag=${u.primaryImageTag})');
}
No authentication required. Use this to render the server's "who's logging in?" picker.
3. System info #
3.1 Public system info (no auth)
final info = await jellyfin.system.publicInfo();
// info.serverName, info.version, info.id, info.productName
Useful for reachability checks and to confirm the URL really points at a Jellyfin server before asking the user for credentials.
3.2 Authenticated info
final info = await jellyfin.system.info();
Same shape, but includes operating-system details and other admin-only fields when the token belongs to an admin user.
3.3 Ping
if (await jellyfin.system.ping()) { /* server reachable */ }
Swallows all transport errors. Hits /System/Info/Public under the
hood.
4. Library browsing #
4.1 User views
final views = await jellyfin.library.userViews();
for (final v in views) {
print('${v.collectionType ?? '?'} → ${v.name}');
}
collectionType is one of music, movies, tvshows,
musicvideos, photos, books, livetv, homevideos, boxsets,
playlists, folders. Convenience getters: view.isMusic,
view.isMovies, view.isTvShows, view.isPhotos.
4.2 Listing items
/Items is Jellyfin's workhorse and accepts ~85 query parameters.
Pass them through items.list():
final page = await jellyfin.items.list(
parentId: music.id,
includeItemTypes: const [JellyfinItemKind.musicAlbum],
sortBy: const ['SortName', 'ProductionYear'],
descending: false,
startIndex: 0,
limit: 50,
searchTerm: 'pink', // optional substring filter
filters: const ['IsFavorite'], // optional ItemFilters
artistIds: artistId,
genreIds: genreId,
fields: JellyfinItemsApi.musicFields, // sensible default; override as needed
);
print('${page.items.length} of ${page.totalRecordCount}');
The result is JellyfinQueryResult<JellyfinItem> carrying items,
totalRecordCount and startIndex.
Fields preset. JellyfinItemsApi.musicFields includes
Overview, Genres, MediaSources, MediaStreams, ProviderIds, PrimaryImageAspectRatio, SortName, DateCreated, ChildCount, ParentId, Path, OriginalTitle, AlbumPrimaryImageTag. Pass your own list when
you need different fields, Jellyfin defaults to a very thin payload.
4.3 Counting items
final count = await jellyfin.items.count(
parentId: music.id,
includeItemTypes: const [JellyfinItemKind.musicAlbum],
);
Issues a request with Limit=0&EnableTotalRecordCount=true so the
server answers with just the total.
4.4 Single item
final item = await jellyfin.items.byId(
'12345',
fields: JellyfinItemsApi.musicFields,
);
if (item != null) {
print(item.name);
print(item.mediaSources.first.bitrate);
}
Returns null on 404.
4.5 Resume and latest
final resume = await jellyfin.items.resume(
mediaTypes: const ['Audio'],
limit: 10,
);
final latest = await jellyfin.items.latest(
parentId: music.id,
includeItemTypes: const [JellyfinItemKind.musicAlbum],
limit: 10,
);
resume() returns JellyfinQueryResult<JellyfinItem> ("Continue
Listening or Watching"). latest() returns List<JellyfinItem>; the
endpoint does not wrap them in a query result.
4.6 BaseItemKind reference
Use JellyfinItemKind constants as includeItemTypes values:
| Constant | String | Notes |
|---|---|---|
audio |
Audio |
music tracks |
audioBook |
AudioBook |
|
musicAlbum |
MusicAlbum |
|
musicArtist |
MusicArtist |
|
musicGenre |
MusicGenre |
|
musicVideo |
MusicVideo |
|
playlist |
Playlist |
works for any mediaType |
movie |
Movie |
|
series |
Series |
TV |
season |
Season |
TV |
episode |
Episode |
TV |
photo |
Photo |
|
photoAlbum |
PhotoAlbum |
|
folder |
Folder |
|
collectionFolder |
CollectionFolder |
library roots |
userView |
UserView |
|
genre |
Genre |
|
person |
Person |
|
studio |
Studio |
|
book |
Book |
5. Playlists #
5.1 Creating a playlist
final playlist = await jellyfin.playlists.create(
name: 'My Mix',
mediaType: 'Audio', // 'Audio' | 'Video' | 'Photo' | 'Book'
itemIds: const ['12345', '12346'],
isPublic: false,
);
5.2 Listing items
final page = await jellyfin.playlists.items(
playlistId: playlist.id,
startIndex: 0,
limit: 100,
);
5.3 Adding and removing
await jellyfin.playlists.addItems(
playlistId: playlist.id,
itemIds: const ['99999', '99998'],
);
// To remove, find PlaylistItemId from .raw.
final items = await jellyfin.playlists.items(playlistId: playlist.id);
final entryIds = items.items
.map((i) => i.raw['PlaylistItemId'] as String?)
.whereType<String>()
.toList();
await jellyfin.playlists.removeItems(
playlistId: playlist.id,
entryIds: entryIds.take(2).toList(),
);
⚠️
removeItemsexpects PlaylistItemId entries, not the underlying item id. Reading from.rawis currently the only way; a typed field will be promoted in a future release.
5.4 Renaming and deleting
await jellyfin.playlists.rename(playlistId: '123', name: 'New name');
await jellyfin.playlists.delete('123');
6. Search #
6.1 Search hints
final hints = await jellyfin.search.hints(
query: 'pink floyd',
includeItemTypes: const [
JellyfinItemKind.musicAlbum,
JellyfinItemKind.musicArtist,
JellyfinItemKind.audio,
],
limit: 30,
);
for (final h in hints.items) {
print('${h.type}: ${h.name}');
}
Returns JellyfinQueryResult<JellyfinSearchHint>, a flat list with
itemId, name, matchedTerm, type, mediaType, runtime hints,
plus a primaryImageTag you can pass straight to images.url(...).
7. Audio streaming #
These methods primarily build URLs. They don't fetch the audio
stream themselves; hand the URL to your audio engine (mpv, AVPlayer,
ExoPlayer, …). The token is appended as api_key=… so segment
requests work without custom headers.
7.1 Universal stream URL
The recommended endpoint: the server decides direct-play vs transcoding from the supplied parameters.
final url = jellyfin.audio.universalStreamUrl(
itemId: track.id,
containers: const ['mp3', 'aac', 'flac', 'ogg', 'opus'],
maxStreamingBitrate: 320000,
audioCodec: 'aac',
transcodingProtocol: 'hls', // 'hls' | 'http'
transcodingContainer: 'ts',
playSessionId: 'my-uuid',
);
Common pinning knobs: audioBitRate, audioChannels,
maxAudioChannels, maxAudioSampleRate, maxAudioBitDepth,
startTimeTicks.
7.2 Direct stream URL
Bypass transcoding entirely (best quality, smallest server CPU):
final (url, ext) = jellyfin.audio.directStreamUrl(
itemId: track.id,
container: 'flac',
isStatic: true,
);
7.3 Lyrics
final lyrics = await jellyfin.audio.lyrics(track.id);
if (lyrics == null) { /* no lyrics on server */ }
else if (lyrics.isSynced) {
print(lyrics.toLrc()); // mm:ss.cc LRC document
} else {
print(lyrics.toPlainText()); // line per entry, no timestamps
}
JellyfinLyrics exposes the parsed lines (startTicks in
100-nanosecond units, text) plus convenience renderers. To grab the
raw .lrc / .txt body without going through JellyfinLyrics:
final raw = await jellyfin.audio.lyricsRaw(track.id);
8. Video streaming #
Mirrors the Videos OpenAPI tag. URL builders only — like
audio they don't fetch the stream themselves.
Hand the URL to your video engine (mpv, AVPlayer, ExoPlayer, …). The
token is appended as api_key=… so HLS segment requests work without
custom headers.
For proper direct-play vs transcoding negotiation, run
mediaInfo.postedInfo() first; its
response carries a transcodingUrl you can use verbatim.
8.1 Stream URL
final (url, ext) = jellyfin.videos.streamUrl(
itemId: movie.id,
mediaSourceId: movie.mediaSources.first.id,
maxStreamingBitrate: 8000000,
videoCodec: 'h264',
audioCodec: 'aac',
audioStreamIndex: 1,
subtitleStreamIndex: 3,
maxAudioChannels: 2,
maxWidth: 1920,
maxHeight: 1080,
isStatic: false,
playSessionId: 'my-uuid',
);
Pass isStatic: true to force the original-file passthrough (no
muxing, no transcoding). Set params to replay a server-decided
transcode whose query string came from
PlaybackInfo.mediaSources[0].transcodingUrl.
8.2 Additional parts
Movies split across multiple files (CD1, CD2, parts and halves):
final parts = await jellyfin.videos.additionalParts(movieId);
for (final p in parts) {
print('${p['Name']} (${p['Id']})');
}
Returns the raw List<Map<String,dynamic>>; a typed DTO can be
promoted later if usage grows.
8.3 Recommended pre-flight
final info = await jellyfin.mediaInfo.postedInfo(
itemId: movie.id,
deviceProfile: myDeviceProfile,
maxStreamingBitrate: 8000000,
);
final source = info.mediaSources.first;
if (source.supportsDirectPlay) {
final (url, _) = jellyfin.videos.streamUrl(itemId: movie.id, isStatic: true);
play(url);
} else if (source.transcodingUrl != null) {
// Server already built the right transcoding URL — use it verbatim.
play('${jellyfin.baseUrl}${source.transcodingUrl}');
}
See section 12. Media info and playback negotiation for the full flow.
9. HLS playlists #
Wraps the DynamicHls and HlsSegment tags. Both audio and video
have a master.m3u8 (adaptive bandwidth) and main.m3u8
(single-variant). Use the master when you want the player to do its
own ABR, the variant when you've already pinned a quality.
9.1 Audio master and variant
final masterUrl = jellyfin.hls.audioMasterUrl(
itemId: track.id,
maxStreamingBitrate: 320000,
audioCodec: 'aac',
);
final mainUrl = jellyfin.hls.audioVariantUrl(
itemId: track.id,
audioBitRate: 256000,
audioCodec: 'mp3',
);
9.2 Video master and variant
final masterUrl = jellyfin.hls.videoMasterUrl(
itemId: movie.id,
maxStreamingBitrate: 8000000,
videoCodec: 'h264',
audioCodec: 'aac',
maxWidth: 1920,
maxHeight: 1080,
subtitleStreamIndex: 3,
subtitleMethod: 'Hls',
);
9.3 Segments and live
Manual segment URLs are rarely needed (the player walks the playlist), but they're exposed for cache-warming or debugging:
final segUrl = jellyfin.hls.videoSegmentUrl(
itemId: movie.id,
playlistId: 'main',
segmentId: 0,
container: 'ts',
);
For live TV channels the server keeps the playlist growing in real-time. Use the live variant:
final liveUrl = jellyfin.hls.videoLiveUrl(
itemId: channelId,
videoCodec: 'h264',
audioCodec: 'aac',
);
10. Subtitles #
Subtitles live alongside a video's mediaSources[*].mediaStreams
(entries where type == 'Subtitle'). The index you pass below is
the stream's index field, the same value you'd use as
subtitleStreamIndex in videos.streamUrl().
10.1 Subtitle stream URL
final url = jellyfin.subtitles.streamUrl(
itemId: movie.id,
mediaSourceId: movie.mediaSources.first.id,
index: 3,
format: JellyfinSubtitlesApi.formatVtt,
);
For seeking mid-playback through transcoded subs, pass a tick offset:
final url = jellyfin.subtitles.streamWithTicksUrl(
itemId: movie.id,
mediaSourceId: msid,
index: 3,
startPositionTicks: 6000_000_000, // 10 minutes
format: 'vtt',
);
For HLS-delivered subtitles (when subtitleMethod: 'Hls' was set on
the video URL):
final playlistUrl = jellyfin.subtitles.playlistUrl(
itemId: movie.id,
mediaSourceId: msid,
index: 3,
segmentLength: 10,
);
10.2 Fetching subtitle text
For sidecar rendering (LRC-style overlays, custom positioning), pull the body as a string:
final body = await jellyfin.subtitles.fetch(
itemId: movie.id,
mediaSourceId: msid,
index: 3,
format: JellyfinSubtitlesApi.formatSrt,
);
if (body == null) {
// 404 → subtitle stream removed server-side.
}
10.3 Supported formats
| Constant | Wire | Notes |
|---|---|---|
formatSrt |
srt |
recommended for sidecar; widely supported |
formatVtt |
vtt |
HTML5 <track> |
formatAss |
ass |
libass for advanced styling |
formatSsa |
ssa |
legacy SubStation Alpha |
formatSub |
sub |
DVD-style bitmap (rare) |
10.4 Upload and delete
// `data` is base64-encoded subtitle file content.
await jellyfin.subtitles.upload(
itemId: movie.id,
language: 'eng',
format: 'srt',
data: base64Subtitle,
isForced: false,
isHearingImpaired: false,
);
await jellyfin.subtitles.delete(itemId: movie.id, index: 3);
Both calls require an admin token.
10.5 Remote search and download
When the server has a subtitle provider plugin installed (OpenSubtitles, Addic7ed, …), search and pull missing subtitles without leaving the client:
final hits = await jellyfin.subtitles.searchRemote(
itemId: movie.id,
language: 'eng',
);
for (final h in hits) {
print('${h['ProviderName']} – ${h['Name']} '
'(${h['DownloadCount']} downloads, ${h['Format']})');
}
await jellyfin.subtitles.downloadRemote(
itemId: movie.id,
subtitleId: hits.first['Id'] as String,
);
10.6 Fallback fonts
For libass-rendered .ass / .ssa subtitles, the server hosts a
set of fallback fonts the client can fetch on demand:
final fonts = await jellyfin.subtitles.fallbackFonts();
final fontUrl = jellyfin.subtitles.fallbackFontUrl(
name: fonts.first['Name'] as String,
);
11. Trickplay #
Scrubbing thumbnails. Each video item carries one or more trickplay
resolutions (320×, 160×, …) discoverable on
JellyfinItem.raw['Trickplay']. Pick a width the server has
pre-generated, then index the tile by playhead position.
11.1 Tile URL
// Tile index is computed from playhead position and the resolution's
// `Interval` (also in raw['Trickplay']).
final url = jellyfin.trickplay.tileUrl(
itemId: movie.id,
width: 320,
index: tileIndex,
);
11.2 HLS tile playlist
If your player wants to lazy-load tiles via HTTP range from a playlist instead of building each URL itself:
final playlistUrl = jellyfin.trickplay.hlsPlaylistUrl(
itemId: movie.id,
mediaSourceId: msid,
width: 320,
);
12. Media info and playback negotiation #
/Items/{itemId}/PlaybackInfo is the heart of any non-trivial video
client. The server inspects a DeviceProfile describing what the
client can decode and answers with a [JellyfinPlaybackInfo] whose
mediaSources already carries supportsDirectPlay,
supportsDirectStream, supportsTranscoding and a transcodingUrl
to use verbatim.
12.1 Quick playback info
GET /Items/{itemId}/PlaybackInfo, the lightweight version. No device
profile, so the server falls back to generic defaults — fine for
audio, often wrong for video.
final info = await jellyfin.mediaInfo.info(
itemId: track.id,
maxStreamingBitrate: 320000,
);
12.2 Posting a device profile
POST /Items/{itemId}/PlaybackInfo — the right call for video.
const profile = JellyfinDeviceProfile(
name: 'MyApp',
maxStreamingBitrate: 8000000,
musicStreamingTranscodingBitrate: 320000,
directPlayProfiles: ['mp4', 'mkv', 'webm'],
// For finer-grained codec/container constraints, pass them in
// transcodingProfiles / codecProfiles / containerProfiles. The
// `extra` map is merged verbatim into the JSON body for any other
// top-level field the spec exposes.
);
final info = await jellyfin.mediaInfo.postedInfo(
itemId: movie.id,
deviceProfile: profile,
maxStreamingBitrate: 8000000,
subtitleStreamIndex: 3,
);
final src = info.mediaSources.first;
print('directPlay=${src.supportsDirectPlay} transcoding=${src.transcodingUrl}');
12.3 Live streams
Sources whose mediaSources[0].requiresOpening == true (live TV,
on-the-fly transcodes) need an explicit open and close:
final opened = await jellyfin.mediaInfo.openLiveStream(
openToken: src.raw['OpenToken'] as String,
itemId: movie.id,
deviceProfile: profile,
);
final liveStreamId = opened.mediaSources.first.raw['LiveStreamId'];
// … play …
await jellyfin.mediaInfo.closeLiveStream(liveStreamId: liveStreamId);
12.4 Bitrate probe
The server streams [size] zero-bytes; use the elapsed time to pick
a starting maxStreamingBitrate:
final stopwatch = Stopwatch()..start();
final size = await jellyfin.mediaInfo.bitrateTestBytesLength(size: 1_000_000);
stopwatch.stop();
final bps = (size ?? 0) * 8 * 1000 ~/ stopwatch.elapsedMilliseconds;
13. Images #
Jellyfin's image URLs are deterministic: /Items/{id}/Images/{type}
with a tag query parameter (the hash from
JellyfinItem.imageTags[type]) for cache busting. The response is a
pre-sized JPEG, no client-side scaling needed.
13.1 Building an image URL
final url = jellyfin.images.url(
itemId: track.albumId ?? track.id,
type: JellyfinImagesApi.typePrimary,
tag: track.imageTags['Primary'], // cache-bust hash
fillWidth: 500,
fillHeight: 500,
quality: 85,
);
13.2 Fetching bytes
final Uint8List? bytes = await jellyfin.images.fetch(
itemId: track.albumId ?? track.id,
type: JellyfinImagesApi.typePrimary,
tag: track.imageTags['Primary'],
fillWidth: 500,
fillHeight: 500,
);
Returns null on 404. Throws JellyfinException on transient failures
(5xx, network down) so the caller can distinguish "no image ever"
from "try again later".
13.3 Image types
| Constant | Wire value | |
|---|---|---|
typePrimary |
Primary |
Album cover, movie poster, track art |
typeArt |
Art |
Fan art (clear logo placement) |
typeBackdrop |
Backdrop |
Hero / cinematic backdrop |
typeBanner |
Banner |
Wide banner |
typeLogo |
Logo |
Title logo |
typeThumb |
Thumb |
Wide thumbnail |
typeDisc |
Disc |
Disc art |
14. Playback reporting #
Jellyfin uses 100-nanosecond "ticks". 1 ms = 10 000 ticks, 1 s =
10 000 000 ticks. The API accepts Duration directly and converts
internally.
14.1 Start
await jellyfin.playback.start(
itemId: track.id,
playSessionId: 'my-uuid',
mediaSourceId: track.mediaSources.first.id,
playMethod: 'DirectPlay', // 'DirectPlay' | 'DirectStream' | 'Transcode'
);
14.2 Progress
Send every ~10 s and on every state change.
await jellyfin.playback.progress(
itemId: track.id,
position: Duration(seconds: 42),
isPaused: false,
volumeLevel: 80,
playSessionId: 'my-uuid',
repeatMode: 'RepeatNone', // 'RepeatNone' | 'RepeatAll' | 'RepeatOne'
);
14.3 Stopped
await jellyfin.playback.stopped(
itemId: track.id,
position: Duration(seconds: 240),
playSessionId: 'my-uuid',
);
14.4 Ping
Keeps an active transcode session alive while the player buffers.
await jellyfin.playback.ping(playSessionId: 'my-uuid');
15. Sessions and remote control #
GET /Sessions plus the /Sessions/{id}/... command family. Use
this to list every other client connected to the same Jellyfin
server, register this client as a cast target, and push playback
commands to remote sessions.
15.1 Listing sessions
final sessions = await jellyfin.sessions.list();
for (final s in sessions) {
print('${s.userName} on ${s.deviceName} (${s.client})'
' nowPlaying=${s.nowPlayingItem?.name}');
}
// Scope to sessions the current user is allowed to control:
final controllable = await jellyfin.sessions.list(
controllableByUserId: jellyfin.userId,
activeWithinSeconds: 30,
);
15.2 Registering this client as a cast target
Once capabilities are posted, other Jellyfin clients will list this device as a cast target.
await jellyfin.sessions.postCapabilities(
playableMediaTypes: const ['Audio', 'Video'],
supportedCommands: const [
'Play', 'Pause', 'Stop', 'NextTrack', 'PreviousTrack',
'Seek', 'SetVolume', 'Mute', 'Unmute', 'ToggleMute',
'SetAudioStreamIndex', 'SetSubtitleStreamIndex',
],
supportsMediaControl: true,
);
When the user logs out, call jellyfin.sessions.reportSessionEnded()
so the server clears any pending now-playing entries.
15.3 Playing on a remote session
final target = sessions.firstWhere((s) => s.supportsMediaControl);
await jellyfin.sessions.play(
sessionId: target.id,
itemIds: const ['12345', '12346'],
playCommand: 'PlayNow', // PlayNext | PlayLast | Shuffle
startPositionTicks: 0,
);
15.4 Playstate commands
await jellyfin.sessions.sendPlaystateCommand(
sessionId: target.id,
command: JellyfinPlaystateCommand.pause,
);
await jellyfin.sessions.sendPlaystateCommand(
sessionId: target.id,
command: JellyfinPlaystateCommand.seek,
seekPositionTicks: 1200_000_000, // 2 minutes
);
Constants on [JellyfinPlaystateCommand]: stop, pause, unpause,
playPause, nextTrack, previousTrack, seek, rewind,
fastForward.
15.5 General and system commands
// Single-token commands (volume, mute, fullscreen):
await jellyfin.sessions.sendCommand(
sessionId: target.id,
command: JellyfinGeneralCommand.volumeUp,
);
// Commands with arguments (set a specific volume, switch audio track):
await jellyfin.sessions.sendFullCommand(
sessionId: target.id,
name: JellyfinGeneralCommand.setVolume,
arguments: {'Volume': '80'},
);
// System-level (GoHome, GoToSettings, Restart):
await jellyfin.sessions.sendSystemCommand(
sessionId: target.id,
command: 'GoHome',
);
15.6 Messages and DisplayContent
// Toast on the remote device:
await jellyfin.sessions.sendMessage(
sessionId: target.id,
text: 'Now playing on the kitchen TV.',
header: 'Cast',
timeoutMs: 4000,
);
// Open the detail page of an item without playing it:
await jellyfin.sessions.displayContent(
sessionId: target.id,
itemId: album.id,
itemType: 'MusicAlbum',
itemName: album.name,
);
16. User data #
16.1 Favorites
await jellyfin.userData.markFavorite(track.id);
await jellyfin.userData.unmarkFavorite(track.id);
// Convenience toggle:
await jellyfin.userData.setFavorite(track.id, true);
Each call returns a fresh JellyfinUserData with the new state.
16.2 Played flag
await jellyfin.userData.markPlayed(track.id);
await jellyfin.userData.markUnplayed(track.id);
16.3 Reading and writing the record
Both calls return the freshest [JellyfinUserData] (isFavorite,
playCount, playbackPositionTicks, lastPlayedDate). You can also
fetch and write the whole record:
final data = await jellyfin.userData.get(itemId);
print('position=${data.playbackPositionTicks} count=${data.playCount}');
await jellyfin.userData.update(
itemId: itemId,
userData: data.copyWithPosition(0), // your own helper
);
Jellyfin 10.11 dropped the legacy
/Users/{userId}/FavoriteItems/...and/Users/{userId}/PlayedItems/...routes; the wrappers above already speak the new flat/UserFavoriteItems/{itemId}and/UserPlayedItems/{itemId}. For pre-10.11 servers, use the escape hatch.
17. Instant Mix #
Server-side radio. Jellyfin generates a "Mix" — a list of related audio items — from any seed (song, album, artist, playlist, music genre). The mix quality depends on the server's metadata + similar artist recommendations.
17.1 From any seed item
The seed can be any item the server understands:
final mix = await jellyfin.instantMix.fromItem(
itemId: track.id,
limit: 50,
);
for (final item in mix.items) {
print('${item.name} – ${item.albumArtist}');
}
17.2 From a typed seed
When you know the seed's kind, the typed variants give clearer call sites:
final fromSong = await jellyfin.instantMix.fromSong(songId: track.id);
final fromAlbum = await jellyfin.instantMix.fromAlbum(albumId: album.id);
final fromArtist = await jellyfin.instantMix.fromArtist(artistId: artist.id);
final fromList = await jellyfin.instantMix.fromPlaylist(playlistId: playlist.id);
final fromGenre = await jellyfin.instantMix.fromMusicGenre(name: 'Rock');
Each helper returns a [JellyfinQueryResult<JellyfinItem>] — same
shape as items.list(), so the result drops straight into a UI list.
18. Live TV #
Channels, EPG, recordings and timers. The wrapper covers the
consumer-facing slice (~14 of the 41 upstream operations on the
LiveTv tag). Tuner provisioning and listings-provider setup are
admin-only and stay behind the escape hatch.
18.1 Channels
final channels = await jellyfin.liveTv.channels(
type: 'TV', // or 'Radio'
isFavorite: false,
sortBy: const ['DefaultChannelOrder'],
limit: 50,
);
for (final ch in channels.items) {
print('${ch.indexNumber} ${ch.name}');
}
final ch = await jellyfin.liveTv.channel(channelId);
// ch.raw['CurrentProgram'] carries the now-airing show.
18.2 Programs and EPG
final now = DateTime.now().toUtc();
final epg = await jellyfin.liveTv.programs(
channelIds: const ['ch1', 'ch2', 'ch3'],
minStartDate: now.toIso8601String(),
maxStartDate: now.add(const Duration(hours: 6)).toIso8601String(),
sortBy: const ['StartDate'],
);
// Recommended airings, server-curated.
final recommended = await jellyfin.liveTv.recommendedPrograms(
isAiring: true,
isMovie: true,
limit: 20,
);
18.3 Recordings
final recordings = await jellyfin.liveTv.recordings(
isInProgress: false,
limit: 100,
);
for (final rec in recordings.items) {
print('${rec.name} — ${rec.runTimeTicks} ticks');
}
final detail = await jellyfin.liveTv.recording(recordingId);
await jellyfin.liveTv.deleteRecording(recordingId);
18.4 Timers
Both one-shot timers (record this airing) and series timers (record every airing of a series):
final scheduled = await jellyfin.liveTv.timers(isActive: true);
await jellyfin.liveTv.createTimer(body: {
'ProgramId': 'pgm-1234',
'PrePaddingSeconds': 60,
'PostPaddingSeconds': 300,
});
await jellyfin.liveTv.deleteTimer(timerId);
// Recurring rules:
final series = await jellyfin.liveTv.seriesTimers();
await jellyfin.liveTv.createSeriesTimer(body: {
'ProgramId': 'pgm-1234',
'RecordAnyTime': false,
'RecordNewOnly': true,
});
19. SyncPlay #
Synchronised playback across multiple clients ("watch party"). One
client creates a group, others join; pause / seek / next-track
actions propagate to every member. Each member calls
syncPlay.ready() when buffered so the server
can resume the group simultaneously.
19.1 Listing and joining groups
final groups = await jellyfin.syncPlay.list();
for (final g in groups) {
print('${g['GroupName']} (${g['Participants']} participants)');
}
await jellyfin.syncPlay.createGroup(groupName: 'Friday movie night');
await jellyfin.syncPlay.joinGroup(groupId: groups.first['GroupId'] as String);
await jellyfin.syncPlay.leaveGroup();
19.2 Group playback control
Every command propagates to every member of the group:
await jellyfin.syncPlay.pause();
await jellyfin.syncPlay.unpause();
await jellyfin.syncPlay.seek(positionTicks: 1200_000_000); // 2 min
await jellyfin.syncPlay.stop();
await jellyfin.syncPlay.nextItem(playlistItemId: nextEntryId);
await jellyfin.syncPlay.previousItem(playlistItemId: prevEntryId);
await jellyfin.syncPlay.setPlaylistItem(playlistItemId: entryId);
await jellyfin.syncPlay.setRepeatMode(mode: 'RepeatAll');
await jellyfin.syncPlay.setShuffleMode(mode: 'Shuffle');
19.3 Group queue
// Replace the queue.
await jellyfin.syncPlay.queue(
itemIds: const ['12345', '12346', '12347'],
mode: 'Default',
);
// Reorder and remove.
await jellyfin.syncPlay.movePlaylistItem(
playlistItemId: entryId,
newIndex: 0,
);
await jellyfin.syncPlay.removeFromPlaylist(
playlistItemIds: const ['entry-1', 'entry-2'],
);
// While buffering / when ready:
await jellyfin.syncPlay.buffering(
playlistItemId: entryId,
positionTicks: 0,
isPlaying: false,
);
await jellyfin.syncPlay.ready(
playlistItemId: entryId,
positionTicks: 0,
isPlaying: false,
);
20. TV Shows #
Series-aware browsing endpoints. Use these when listing episodes per
season, seasons per series, building a "Next Up" rail, or surfacing
"coming soon" premieres. Each helper returns a JellyfinQueryResult<JellyfinItem>
so the result plugs straight into the same UI lists used by items.list().
20.1 Seasons of a series
final seasons = await jellyfin.tvShows.seasons(
seriesId: someSeriesId,
isSpecialSeason: false,
);
for (final s in seasons.items) {
print('${s.indexNumber} ${s.name}');
}
20.2 Episodes of a season
final episodes = await jellyfin.tvShows.episodes(
seriesId: someSeriesId,
season: 2,
fields: const ['Overview', 'PrimaryImageAspectRatio'],
);
Use seasonId instead of season to scope by the season item id
rather than its index number.
20.3 Next Up
final nextUp = await jellyfin.tvShows.nextUp(
limit: 12,
enableResumable: true,
enableRewatching: false,
);
disableFirstEpisode skips never-watched series. nextUpDateCutoff
restricts results to episodes whose last-watched date is on or after
the cutoff (ISO 8601 string).
20.4 Upcoming episodes
final upcoming = await jellyfin.tvShows.upcoming(
limit: 20,
);
Returns episodes whose premiere date is in the future (typically fed by the upstream metadata provider).
21. Movies #
Movie-specific browsing. The Jellyfin server precomputes recommendation "rows" (categories like "Because you watched X", "Top picks") that group recommended titles by reason.
21.1 Movie recommendations
final rails = await jellyfin.movies.recommendations(
parentId: someMovieLibraryId,
categoryLimit: 6,
itemLimit: 12,
);
for (final rail in rails) {
print('${rail.recommendationType} ${rail.baselineItemName}: ${rail.items.length} items');
}
Each entry exposes categoryId, recommendationType
(e.g. SimilarToRecentlyPlayed, HasDirectorFromRecentlyPlayed),
baselineItemName, and the [JellyfinItem] list that belongs to the row.
22. Suggestions #
A single endpoint that returns a server-side curated mix of items for
the current user. Different from movies.recommendations() (which is
movie-specific and groups by category): suggestions.list() returns
a flat, mixed-kind feed.
22.1 Homepage suggestions
final picks = await jellyfin.suggestions.list(
mediaType: const ['Audio'],
type: const ['MusicAlbum', 'Audio'],
limit: 30,
);
for (final item in picks.items) {
print('${item.type}: ${item.name}');
}
23. Media segments #
Annotated time ranges on a media item (intro, recap, outro, commercial, preview). Produced by Jellyfin plugins (Intro Skipper and similar) and consumed by players to draw a "skip" button at the right moment.
23.1 Listing segments
final segments = await jellyfin.mediaSegments.forItem(
itemId: episode.id,
includeSegmentTypes: const [
JellyfinMediaSegmentType.intro,
JellyfinMediaSegmentType.outro,
],
);
for (final s in segments.items) {
print('${s.type}: ${s.start} to ${s.end}');
}
23.2 Segment types
JellyfinMediaSegmentType enumerates the upstream string values:
unknown, commercial, preview, recap, outro, intro. The
[JellyfinMediaSegment.start] and [JellyfinMediaSegment.end] getters
convert the raw tick fields into Durations for use in player UIs.
24. Filters #
Dynamic facet endpoints. Ask the server which genres, tags, years,
and ratings actually exist inside a library, then feed the user's
selection back into items.list(genreIds: …) to drive filter chips.
24.1 Modern facets (with ids)
final facets = await jellyfin.filter.facets(
parentId: musicLibraryId,
includeItemTypes: const ['MusicAlbum'],
);
for (final g in facets.genres) {
print('${g.id} ${g.name}');
}
Pair the returned ids with items.list(genreIds: ids.join(',')).
24.2 Legacy facets
final legacy = await jellyfin.filter.legacy(
parentId: movieLibraryId,
includeItemTypes: const ['Movie'],
);
print(legacy.years); // [1999, 2001, 2024, ...]
print(legacy.officialRatings); // ['PG-13', 'R', ...]
The legacy endpoint returns flat string arrays plus the year and rating facets the modern endpoint omits.
25. Artists #
Artist-aware endpoints that respect the server's canonical artist
deduplication (an artist appears once even when credited on many
tracks). items.list() works for raw item browsing but uses item
records as the unit, so the same artist can appear N times. Use
artists.list() or artists.albumArtists() for an "Artists" tab.
25.1 Listing artists
final all = await jellyfin.artists.list(
parentId: musicLibraryId,
searchTerm: 'beat',
limit: 50,
);
25.2 Album artists only
final albumArtists = await jellyfin.artists.albumArtists(
parentId: musicLibraryId,
limit: 100,
);
Only artists credited on at least one album are returned. This is the right call for an "Artists" library tab.
25.3 Lookup by name
final ai = await jellyfin.artists.byName('Aphex Twin');
if (ai != null) {
print(ai.id);
}
Returns null on 404.
26. Display preferences #
Per-client UI state stored on the server: view type, sort order,
scroll direction, sidebar visibility, plus a free-form customPrefs
map for arbitrary key/value layout state. Layout choices follow the
user across devices.
The displayPreferencesId is the namespace for the document (e.g.
usersettings, or a library id for library-scoped state). The
client query parameter disambiguates between clients writing into
the same id.
26.1 Reading a document
final prefs = await jellyfin.displayPreferences.get(
displayPreferencesId: 'usersettings',
client: 'my_app',
);
print('${prefs.viewType} sorted by ${prefs.sortBy}');
26.2 Writing back
await jellyfin.displayPreferences.update(
displayPreferencesId: 'usersettings',
client: 'my_app',
preferences: JellyfinDisplayPreferences(
id: 'usersettings',
client: 'my_app',
viewType: 'Poster',
sortBy: 'SortName',
sortOrder: 'Ascending',
rememberSorting: true,
),
);
26.3 Custom prefs
customPrefs is a Map<String, String> for app-specific state the
server itself doesn't interpret:
await jellyfin.displayPreferences.update(
displayPreferencesId: 'usersettings',
client: 'my_app',
preferences: JellyfinDisplayPreferences(
client: 'my_app',
customPrefs: const {
'home.layout': 'compact',
'home.rails': 'continueWatching,nextUp,recentlyAdded',
},
),
);
27. Lyrics #
The audio sub-API exposes a read-only lyrics() getter. This
dedicated sub-API adds the write side: uploading a lyric file,
deleting the attached one, and searching remote lyric providers.
27.1 Reading attached lyrics
final lyrics = await jellyfin.lyrics.forItem(track.id);
print(lyrics?.toLrc());
Mirror of jellyfin.audio.lyrics(itemId). Use whichever entry point
is closer to the call site.
27.2 Uploading a file
final bytes = await File('track.lrc').readAsBytes();
await jellyfin.lyrics.upload(
itemId: track.id,
fileName: 'track.lrc',
body: bytes,
);
The server picks the parser from the file extension.
27.3 Deleting
await jellyfin.lyrics.delete(track.id);
27.4 Remote search and download
final hits = await jellyfin.lyrics.searchRemote(track.id);
for (final hit in hits) {
print('${hit['ProviderName']}: ${hit['Id']}');
}
final attached = await jellyfin.lyrics.downloadRemote(
itemId: track.id,
lyricId: hits.first['Id'] as String,
);
Use previewRemote(lyricId) to fetch a remote result without
attaching it.
28. Channels #
Plugin-provided content sources (YouTube, podcasts, online radio). Not Live TV. Each channel surfaces a tree of items the user can browse like a regular library.
28.1 Listing channels
final channels = await jellyfin.channels.list(
supportsLatestItems: true,
);
28.2 Items inside a channel
final episodes = await jellyfin.channels.items(
channelId: someChannelId,
limit: 50,
sortBy: const ['DateCreated'],
descending: true,
);
Pass folderId to drill into a sub-folder.
28.3 Latest across channels
final latest = await jellyfin.channels.latest(
channelIds: const [channelA, channelB],
limit: 20,
);
28.4 Channel features
final feats = await jellyfin.channels.features(channelId);
print(feats['SupportsContentDownloading']);
final all = await jellyfin.channels.allFeatures();
allFeatures() returns the feature map for every channel in one
call (useful for showing capability badges in a UI).
29. Collections #
Curated groupings of items (BoxSet-typed). Collections live in
the library like any other item, browsable through
items.list(includeItemTypes: ['BoxSet']).
29.1 Creating a collection
final result = await jellyfin.collection.create(
name: 'Best of 2024',
ids: const [albumA, albumB, albumC],
);
final id = result['Id'] as String;
29.2 Adding and removing
await jellyfin.collection.addItems(
collectionId: id,
ids: const [albumD, albumE],
);
await jellyfin.collection.removeItems(
collectionId: id,
ids: const [albumA],
);
Removed items keep existing as regular library items; only the collection membership is cleared.
30. User views (alternate) #
library.userViews() covers the common case. This sub-API exposes
the richer /UserViews endpoint with hidden libraries, external
(channel/plugin) content, and the server's grouping recommendations.
30.1 Listing with filters
final views = await jellyfin.userViews.list(
includeHidden: true,
includeExternalContent: false,
presetViews: const ['music', 'tvshows'],
);
30.2 Grouping options
final options = await jellyfin.userViews.groupingOptions();
Returned as a flat list of option maps; each map describes a recommended way to group the user's views in the UI.
31. Browse facets #
Five small sub-APIs that each wrap an "entity index" endpoint pair:
a paged list() to enumerate the entities, plus a byName() or
byYear() to look one up. Each entity is returned as a
[JellyfinItem], so callers can pipe the result into the same UI
widgets as items.list().
31.1 Persons
final actors = await jellyfin.persons.list(
personTypes: const ['Actor'],
searchTerm: 'cumberbatch',
limit: 20,
);
final one = await jellyfin.persons.byName('Benedict Cumberbatch');
Pass [appearsInItemId] to scope the list to people credited on a specific item (e.g. cast for one movie).
31.2 Studios
final studios = await jellyfin.studios.list(
parentId: movieLibraryId,
nameStartsWith: 'A',
);
final pixar = await jellyfin.studios.byName('Pixar');
31.3 Genres
final genres = await jellyfin.genres.list(
parentId: movieLibraryId,
includeItemTypes: const ['Movie'],
);
final action = await jellyfin.genres.byName('Action');
Generic across-library variant.
31.4 Music genres
final musicGenres = await jellyfin.musicGenres.list(
parentId: musicLibraryId,
);
final ambient = await jellyfin.musicGenres.byName('Ambient');
Music-specific variant. Prefer this over [genres] for music UIs.
31.5 Years
final yrs = await jellyfin.years.list(
parentId: musicLibraryId,
includeItemTypes: const ['MusicAlbum'],
sortBy: const ['ProductionYear'],
descending: true,
);
final y1999 = await jellyfin.years.byYear(1999);
32. Localization #
Server-side catalogs that picker UIs need to populate: countries, cultures (languages), parental rating scales, and the localization options the admin configured. Reading these saves the client from hard-coding values that the server may not accept.
32.1 Countries and cultures
final countries = await jellyfin.localization.countries();
final cultures = await jellyfin.localization.cultures();
Each entry is a raw map. Country entries carry Name,
DisplayName, TwoLetterISORegionName; cultures carry the two-
and three-letter ISO codes plus display names.
32.2 Parental ratings
final ratings = await jellyfin.localization.parentalRatings();
for (final r in ratings) {
print('${r['Value']}: ${r['Name']}');
}
The numeric Value is what the server stores; Name is the
display string (e.g. 'PG-13', 'TV-14').
32.3 Server localization options
final options = await jellyfin.localization.options();
The choices the admin picked for preferred metadata language and fallback behaviour.
33. Error handling #
33.1 JellyfinException and JellyfinErrorType
Every public call throws JellyfinException on failure:
try {
await jellyfin.items.list(parentId: '…');
} on JellyfinException catch (e) {
print('${e.type} → ${e.statusCode} → ${e.message}');
}
JellyfinErrorType values: connection, timeout, auth,
notFound, badRequest, serverError, parse, state, unknown.
33.2 Retriable vs terminal
} on JellyfinException catch (e) {
if (e.isRetriable) { // connection or timeout
scheduleRetry();
} else if (e.isAuthError) { // 401 or 403, token rejected
await reAuthenticate();
} else {
surfaceError(e.message);
}
}
33.3 Auth invalidation
JellyfinException.isAuthError is the signal to re-run
authenticateByName or the Quick Connect flow. The library will not
automatically re-fetch a token; that's an app-level policy decision.
34. Escape hatch #
When the typed sub-APIs don't yet cover an endpoint, drop down to:
34.1 Raw request
final response = await jellyfin.request<Map<String, dynamic>>(
'/Users/${jellyfin.userId}/Items',
queryParameters: {
'IncludeItemTypes': 'Audio',
'Recursive': true,
'Limit': 0,
},
);
final total = response.data?['TotalRecordCount'];
Same Dio, same headers, same JellyfinException translation as the
typed sub-APIs. Pass method: 'POST'/'DELETE', extraHeaders,
data, absoluteUrl: true as needed.
34.2 Raw bytes
final response = await jellyfin.requestBytes(
'${jellyfin.baseUrl}/Items/$id/Download?api_key=${jellyfin.token}',
);
final bytes = response.data;
Project background #
All the typed DTOs, sub-APIs, and architectural patterns were implemented through the use of Claude Code.
Developed by Alessandro Di Ronza

