dart_jellyfin 0.3.1 copy "dart_jellyfin: ^0.3.1" to clipboard
dart_jellyfin: ^0.3.1 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.

logo dart_jellyfin is a Dart client for Jellyfin 12.2. It covers libraries, playlists, streaming, lyrics, playback reporting, remote control and search through typed Dart objects, and keeps the session for you.

Installation #

Add dart_jellyfin to your pubspec.yaml:

dependencies:
  dart_jellyfin: ^0.3.0

Contents #


Features #

Pure Dart
no native plugin and no Flutter dependency, so it runs on every Dart platform, the web included.
Typed models
every answer is a typed Dart object with a raw map, and item kinds, fields, sort keys and filters are typed values.
Both sign-in flows
username and password, and Quick Connect with a code approved on another device.
Authorization header
the MediaBrowser header with client, device, version and token, rebuilt when the token changes (see 1.2).
One client
a JellyfinClient holds the identity, server, token and user, and every sub-API works through it.
Follows the OpenAPI document
routes, parameter names and casing match the contract at api.jellyfin.org.
Lyrics with timing
JellyfinLyrics with line and word timing, rendered to LRC or enhanced LRC with toLrc().
Audio streaming
URL builders for the server's choice between direct play and transcoding, the original file, a transcoded file and HLS.
Typed errors
every failure is a JellyfinException with a type to branch on: auth, not found, timeout, connection, cancelled.
Escape hatch
request(), requestBytes() and requestStream() for routes the sub-APIs do not cover.
Any HTTP stack
built on package:http: pass a CupertinoClient or CronetClient for HTTP/2 and the system proxy, or a MockClient in tests.
Off the caller's isolate
large answers are inflated, decoded and parsed on a background isolate, so the UI keeps its frames.
Cancellation and retries
withCancelToken() makes every call of a view cancellable, and a JellyfinRetryPolicy resends idempotent requests after transient failures.
Streaming downloads
downloadToFile() writes a body to disk as it arrives, with progress, and never leaves a partial file.

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',
    ),
  );

  // Sign in.
  final auth = await jellyfin.user.authenticateByName(
    username: 'me',
    password: 'hunter2',
  );
  jellyfin.setSession(token: auth.accessToken, userId: auth.user.id);

  // Find the music library and list its albums.
  final views = await jellyfin.library.userViews();
  final music = views.firstWhere((v) => v.isMusic);
  final albums = await jellyfin.items.list(
    parentId: music.id,
    includeItemTypes: const [JellyfinItemKind.musicAlbum],
    sortBy: const [JellyfinSortField.sortName],
    limit: 50,
  );

  // List the first album's tracks and build a stream URL for one.
  final tracks = await jellyfin.items.list(
    parentId: albums.items.first.id,
    includeItemTypes: const [JellyfinItemKind.audio],
  );
  final url = jellyfin.audio.universalStreamUrl(
    itemId: tracks.items.first.id,
    maxStreamingBitrate: 320000,
    audioCodec: 'aac',
    playSessionId: 'my-session-uuid',
  );
  print(url); // hand it to your audio engine

  // Release the HTTP client when the app is done with the server.
  jellyfin.close();
}

Guide #

JellyfinClient holds the identity of your app, the server, the token and the user. Each part of the Jellyfin API is a sub-API on it (jellyfin.items, jellyfin.playlists, jellyfin.audio and so on), and every call goes through the same connection. Ids, names and keys are always named parameters.

Architecture: your code calls JellyfinClient; JellyfinConnection sends through an http.Client and parses large answers on a background isolate into typed models

1. Initialization and lifecycle #

Client lifecycle: JellyfinClient, sign in with setSession, sub-APIs, withCancelToken, close

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',
  ),
);

Only credentials is required. baseUrl can come later through connect(). The other parameters:

Parameter Default Description
httpClient a dart:io or browser client The package:http client to send through (see 1.4)
connectTimeout 15 s Bounds the handshake of the default client and, with receiveTimeout, the wait for the response headers
receiveTimeout 30 s Also bounds every pause inside a response body
jsonIsolateThreshold 50 KB Bodies this size or larger are decoded, and parsed into models, on a background isolate
retryPolicy JellyfinRetryPolicy.none Resends idempotent requests after a transient failure (see 26.4)

With the default dart:io client, a gzip body is inflated as part of that decode, so above the threshold it leaves the caller's isolate too. The web has no isolates, and the work runs in place.

JellyfinClient and the sub-API classes are not final, so your tests can implement or mock them. The models are final data classes.

1.2 Credentials and the Authorization header

JellyfinCredentials says who the client is. Every request carries:

Authorization: MediaBrowser Client="MyApp", Device="iPhone", DeviceId="…uuid…", Version="1.0.0"

Once a session is set, the same header ends with , Token="…". This is the only authorization header the client sends, and the one Jellyfin 12 accepts by default.

deviceId must be a stable UUID for each installation: Jellyfin tracks sessions and transcodes by it. Generate it once, store it (SharedPreferences, Keychain, Android Keystore) and reuse it.

JellyfinAuthHeader.build() returns the header value, for a player that sends its own requests:

final header = JellyfinAuthHeader.build(credentials, token: jellyfin.token);

1.3 Server and session

jellyfin.connect('https://jellyfin.example.com'); // a trailing slash is dropped

jellyfin.setSession(token: savedToken, userId: savedUserId);
jellyfin.clearSession(); // drop the token and user id, keep the server
jellyfin.disconnect();   // drop the server too

connect() switches the client to another server and keeps the session: call clearSession() first when the new server has other accounts. token, userId, baseUrl and isAuthenticated are read-only getters, so you can store the session and restore it with setSession().

1.4 Choosing the HTTP stack

The transport is package:http, so any http.Client works. On Apple platforms and Android the native stacks bring HTTP/2, the system proxy and the platform's certificate store:

// CupertinoClient.defaultSessionConfiguration() (package:cupertino_http),
// CronetClient.defaultCronetEngine() (package:cronet_http), or any other
// http.Client.
final http.Client httpClient = http.Client();

final jellyfin = JellyfinClient(
  baseUrl: 'https://jellyfin.example.com',
  credentials: credentials,
  httpClient: httpClient,
);

// Later: a client you pass in is yours to close.
httpClient.close();

In tests, pass a MockClient from package:http/testing.dart. A client you pass in handles gzip itself; the native stacks do it on their own threads.

1.5 Closing the client

jellyfin.close();

close() closes the HTTP client the JellyfinClient created. Later requests, through it or any withCancelToken() view, fail with JellyfinErrorType.state. A client you passed as httpClient stays open.


2. Authentication #

Sign-in: authenticateByName, or Quick Connect with initiate, the approved code, state until authenticated and authenticateWithQuickConnect; then setSession

2.1 Username and password

final auth = await jellyfin.user.authenticateByName(
  username: 'me',
  password: 'hunter2',
);
jellyfin.setSession(token: auth.accessToken, userId: auth.user.id);

authenticateByName() returns a JellyfinAuthResult with accessToken, serverId and the JellyfinUser. Wrong credentials throw a JellyfinException of type auth.

2.2 Quick Connect

Quick Connect signs in a new device without a password: the user approves a 6-character code on a device that is already signed in.

// Is Quick Connect on for this server?
if (!await jellyfin.quickConnect.enabled()) return;

// Start the flow on the new device and show the code.
final init = await jellyfin.quickConnect.initiate();
print('Enter ${init.code} in Jellyfin, under Settings, Quick Connect');

// Poll until the user approves it.
while (!(await jellyfin.quickConnect.state(secret: init.secret)).authenticated) {
  await Future<void>.delayed(const Duration(seconds: 2));
}

// Exchange the secret for a token.
final auth = await jellyfin.user.authenticateWithQuickConnect(secret: init.secret);
jellyfin.setSession(token: auth.accessToken, userId: auth.user.id);

To approve a code from the device that is already signed in:

await jellyfin.quickConnect.authorize(code: 'ABC123');

2.3 Users

final me = await jellyfin.user.currentUser();
print('${me.name} (${me.id})');

// No token needed: the users the server shows on its sign-in screen.
final users = await jellyfin.user.publicUsers();
for (final u in users) {
  print('${u.name} (primaryImageTag=${u.primaryImageTag})');
}

With an admin token, user also manages accounts: list(), byId(), create(), updateProfile(), updatePolicy(), updateConfiguration(), updatePassword() and delete(). forgotPassword() and forgotPasswordPin() run the password reset.


3. System #

3.1 Server info

// No token needed.
final public = await jellyfin.system.publicInfo();
print('${public.serverName} ${public.version} (${public.id})');

// With a token: adds the operating system, and admin fields for an admin.
final info = await jellyfin.system.info();

// True when the server answers; never throws.
if (await jellyfin.system.ping()) {
  print('reachable');
}

Call publicInfo() before asking for credentials, to check that the URL points at a Jellyfin server. ping() calls the same route and turns any failure into false.

3.2 Counts, endpoint and clock

final counts = await jellyfin.library.counts();
print('${counts.albumCount} albums, ${counts.songCount} tracks');

final endpoint = await jellyfin.system.endpointInfo(); // isLocal, isInNetwork
final clock = await jellyfin.system.utcTime();        // for clock-skew correction

These return JellyfinItemCounts, JellyfinEndpointInfo and JellyfinUtcTime. Like every model, each keeps the decoded body in raw.

3.3 Server administration

These calls need an admin token.

final tasks = await jellyfin.scheduledTasks.list();
final devices = await jellyfin.devices.list();
final log = await jellyfin.activityLog.entries(limit: 50);

final scan = tasks.firstWhere((t) => t.key == 'RefreshLibrary');
await jellyfin.scheduledTasks.start(taskId: scan.id);

They return JellyfinScheduledTask, JellyfinDevice and a JellyfinQueryResult<JellyfinActivityLogEntry>. The "Audio Normalization" task fills JellyfinItem.normalizationGain in the libraries that have EnableLUFSScan on.

The rest of the administration API answers maps:

Sub-API Covers
system storage(), logs(), logFile(), restart(), shutdown()
configuration Server configuration, named sections, branding, default metadata options
libraryStructure Virtual folders: add, remove, rename, media paths, library options
library refresh(), mediaFolders(), physicalPaths(), deleteItem(), the notify…() hooks for external managers
items refresh(), updateMetadata(), updateContentType(), metadataEditorInfo()
itemLookup Remote metadata search for movies, series, albums, artists and more, and applySearchResult()
remoteImage Remote artwork providers, search and download
plugins, packages Installed plugins, their configuration, the catalog and repositories
startup The first-run wizard
environment Directory browsing and path validation for the library setup
branding Login disclaimer and custom CSS
apiKey, backup, dashboard, clientLog API keys, backups, plugin configuration pages, client log upload
liveTv Tuners, listing providers and channel mappings

4. Library browsing #

4.1 Libraries

final views = await jellyfin.library.userViews();
for (final v in views) {
  print('${v.collectionType ?? '?'}: ${v.name}');
}
final music = views.firstWhere((v) => v.isMusic);

A view's id is the parentId that scopes the calls below to one library. collectionType is one of music, movies, tvshows, musicvideos, photos, books, livetv, homevideos, boxsets, playlists or folders; isMusic, isMovies, isTvShows and isPhotos test the common ones.

userViews.list() reads the same views with options: hidden libraries, channel and plugin content, and preset views. groupingOptions() lists the ways the server offers to group them.

final all = await jellyfin.userViews.list(
  includeHidden: true,
  includeExternalContent: false,
  presetViews: const ['music', 'tvshows'],
);
final options = await jellyfin.userViews.groupingOptions();

4.2 Listing items

items.list() is the general browse call. It takes the /Items parameters and returns a JellyfinQueryResult<JellyfinItem> with items, totalRecordCount and startIndex:

final page = await jellyfin.items.list(
  parentId: music.id,
  includeItemTypes: const [JellyfinItemKind.musicAlbum],
  sortBy: const [JellyfinSortField.productionYear, JellyfinSortField.sortName],
  descending: true,
  startIndex: 50, // the second page
  limit: 50,
  searchTerm: 'pink',
  filters: const [JellyfinItemFilter.isFavorite],
  genreIds: [genreId],
  fields: JellyfinItemsApi.browseFields,
);
print('${page.items.length} of ${page.totalRecordCount}');

Every filter that takes several values is a list: ids, excludeItemIds, genreIds, artistIds, albumArtistIds, contributingArtistIds, albumIds, studioIds, personIds, tags, officialRatings, the name-based genres, artists, albums and studios, and years (a List<int>). Pass the list as it is: each value travels as its own query parameter, so a genre or tag that holds a comma or a pipe arrives intact. Prefer the id filters to the name filters: ids come from filter.facets() and survive a rename.

A few rules of the route:

  • descending applies only together with sortBy.
  • parentId scopes albums and tracks to a library, but not artists: with JellyfinItemKind.musicArtist and a library's id, /Items answers the artists of every library. List one library's artists with artists.
  • enableTotalRecordCount (on by default) makes the server count the whole result. Pass false when you page until a short page and never show a total; totalRecordCount then counts only the page.

4.3 Item kinds, sort keys and filters

includeItemTypes, excludeItemTypes, fields, sortBy, mediaTypes and filters take typed values instead of strings:

Type Parameter Examples
JellyfinItemKind includeItemTypes, excludeItemTypes audio, musicAlbum, musicArtist, playlist, movie, series, episode, boxSet
JellyfinItemField fields genres, people, mediaSources, overview, recursiveItemCount
JellyfinSortField sortBy sortName, productionYear, dateCreated, datePlayed, random
JellyfinMediaType mediaTypes audio, video, photo, book
JellyfinItemFilter filters isFavorite, isPlayed, isUnplayed, isResumable

The server ignores a value it does not know and answers a wider result, so a typo in a string would list everything. With the typed values a typo does not compile. Each type holds one constant per value of the Jellyfin API document, and reads as the wire string. A value the package does not list yet is one constructor away:

final items = await jellyfin.items.list(
  includeItemTypes: const [JellyfinItemKind('NewKind')],
);

4.4 Fields

fields defaults to empty, the server's own selection. Two presets cover the common cases:

Preset Fields Use for
JellyfinItemsApi.browseFields Genres, DateCreated, ChildCount, SortName, PrimaryImageAspectRatio Grids and lists of albums, artists, playlists, genres
JellyfinItemsApi.musicFields Overview, Genres, MediaSources, MediaStreams, ProviderIds, PrimaryImageAspectRatio, SortName, DateCreated, ChildCount, ParentId, Path, OriginalTitle A queue or a track info sheet, where media sources and streams are read

MediaSources and MediaStreams make most of a track's size, so ask for musicFields only where you read them. Neither preset is a default, because /Items also serves video and photo libraries.

Image tags are not fields: the server sends them on every item, so covers work with either preset. enableImages: false drops ImageTags.

Some typed members of JellyfinItem need their field:

Member Field Holds
genreItems genres Each genre's id and name (JellyfinNameGuidPair), the ids genreIds and instantMix.fromItem() take
people people JellyfinItemPersons: name, id, role, a type such as 'Composer', image tag
recursiveItemCount recursiveItemCount Items below a folder, at any depth
albumCount, songCount, artistCount, movieCount, seriesCount, episodeCount, … itemCounts Counts of an artist, genre or person

Others come without a field: albumArtistItems (album artists with ids), playlistItemId, normalizationGain and albumNormalizationGain (in dB, from the "Audio Normalization" task), artworkItemId and artworkVersion. The last two give the id whose cover to show, a track's own or its album's, and the image tag to put in a cache key:

final coverId = track.artworkItemId;
if (coverId != null) {
  final url = jellyfin.images.url(
    itemId: coverId,
    tag: track.artworkVersion, // changes when the cover does
    maxWidth: 512,
    maxHeight: 512,
  );
}

4.5 Counting items

count() takes the narrowing parameters of list(), without paging, sorting and fields, and returns the total without the items:

final albumCount = await jellyfin.items.count(
  parentId: music.id,
  includeItemTypes: const [JellyfinItemKind.musicAlbum],
  genreIds: [genreId],
);
final item = await jellyfin.items.byId(itemId: '12345');
if (item != null) {
  print('${item.name} ${item.mediaSources.firstOrNull?.bitrate}');
}

// Continue listening or watching, and recently added.
final resume = await jellyfin.items.resume(
  mediaTypes: const [JellyfinMediaType.audio],
  limit: 10,
);
final latest = await jellyfin.items.latest(
  parentId: music.id,
  includeItemTypes: const [JellyfinItemKind.musicAlbum],
  limit: 10,
);

// Related items.
final similar = await jellyfin.library.similarAlbums(itemId: album.id, limit: 12);
final path = await jellyfin.library.ancestors(itemId: track.id);

byId() returns null when the server answers 404. resume() returns a JellyfinQueryResult<JellyfinItem>, latest() a plain List<JellyfinItem>.

library has similarItems(), similarAlbums(), similarArtists(), similarMovies(), similarShows() and similarTrailers(), plus themeSongs(), themeVideos() and themeMedia(). items has specialFeatures(), localTrailers() and intros(), and downloadUrl() and fileUrl() for the original file.

4.7 Artists

artists lists each artist once, however many tracks credit it, and honors parentId. Use it for an Artists tab:

// Artists credited on at least one album.
final albumArtists = await jellyfin.artists.albumArtists(
  parentId: musicLibraryId,
  sortBy: const [JellyfinSortField.sortName],
  limit: 100,
);

// Every artist, track artists included.
final all = await jellyfin.artists.list(
  parentId: musicLibraryId,
  searchTerm: 'beat',
  limit: 50,
);

final aphex = await jellyfin.artists.byName(name: 'Aphex Twin'); // null on 404

Artists are also people on Jellyfin 12: persons.list(personTypes: ['Artist']) answers them, without the sort and genre filters of artists.

4.8 Genres

Jellyfin keeps two items for each genre name: a generic Genre and, for music, a MusicGenre, with different ids. Both ids filter items.list(genreIds:), but only the music-genre id seeds an instant mix.

Scope genres.list() with the music library's parentId: those ids are the ones an instant mix accepts. Without parentId it answers the generic genres, whatever includeItemTypes says.

final musicGenres = await jellyfin.genres.list(
  parentId: musicLibraryId,
  sortBy: const [JellyfinSortField.sortName],
);

// Exact name lookups: the music genre, and the generic one.
final ambient = await jellyfin.musicGenres.byName(name: 'Ambient');
final action = await jellyfin.genres.byName(name: 'Action');

musicGenres.byName() is the one route that finds a music genre by its exact name. genres.byName() takes no parentId and answers the generic genre.

4.9 People, studios and years

persons, studios and years list one kind of entity each, as JellyfinItems, and look one up by name or year:

final actors = await jellyfin.persons.list(
  personTypes: const ['Actor'],
  searchTerm: 'cumberbatch',
  limit: 20,
);
final one = await jellyfin.persons.byName(name: 'Benedict Cumberbatch');

final studios = await jellyfin.studios.list(parentId: movieLibraryId);
final pixar = await jellyfin.studios.byName(name: 'Pixar');

final years = await jellyfin.years.list(
  parentId: musicLibraryId,
  includeItemTypes: const [JellyfinItemKind.musicAlbum],
  sortBy: const [JellyfinSortField.productionYear],
  descending: true,
);
final y1999 = await jellyfin.years.byYear(year: 1999);

persons.list(appearsInItemId:) lists the people credited on one item, such as a movie's cast. nameStartsWith, nameStartsWithOrGreater and nameLessThan drive an alphabet picker on persons, studios and genres.

4.10 Filter facets

Ask the server which values exist in a library, to fill filter chips:

final facets = await jellyfin.filter.facets(
  parentId: musicLibraryId,
  includeItemTypes: const [JellyfinItemKind.musicAlbum],
);
for (final g in facets.genres) {
  print('${g.id} ${g.name}');
}

final legacy = await jellyfin.filter.legacy(
  parentId: movieLibraryId,
  includeItemTypes: const [JellyfinItemKind.movie],
);
print(legacy.years);           // [1999, 2001, 2024, ...]
print(legacy.officialRatings); // ['PG-13', 'R', ...]

facets() answers genres with ids and tags. Pass the chosen ids to items.list(genreIds:). legacy() answers genres and tags by name, plus the years and official ratings that facets() leaves out.

4.11 Collections

Collections are BoxSet items, so items.list(includeItemTypes: [JellyfinItemKind.boxSet]) lists them. collection edits them:

final created = await jellyfin.collection.create(
  name: 'Best of 2024',
  ids: [albumA, albumB],
);
final collectionId = created['Id'] as String;

await jellyfin.collection.addItems(collectionId: collectionId, ids: [albumC]);
await jellyfin.collection.removeItems(collectionId: collectionId, ids: [albumA]);

// Every collection an item belongs to, for an "Included in" row.
final includedIn = await jellyfin.library.collections(itemId: album.id);

Removing an item from a collection leaves the item in the library.


5. Playlists #

5.1 Creating a playlist

final playlist = await jellyfin.playlists.create(
  name: 'My Mix',
  mediaType: 'Audio', // 'Audio', 'Video', 'Photo' or 'Book'
  itemIds: const ['12345', '12346'],
  isPublic: true, // private by default
);

5.2 Listing entries

final entries = await jellyfin.playlists.items(
  playlistId: playlist.id,
  fields: JellyfinItemsApi.musicFields,
  limit: 100,
);
final same = await jellyfin.playlists.byId(playlistId: playlist.id);

fields defaults to the server's own selection. Playlists hold video as readily as audio, so pass musicFields when you read media sources.

5.3 Adding, removing and moving entries

await jellyfin.playlists.addItems(
  playlistId: playlist.id,
  itemIds: const ['99999', '99998'],
);

// Remove and move by entry id, read from playlists.items().
final entries = await jellyfin.playlists.items(playlistId: playlist.id);
final entryIds = [
  for (final item in entries.items)
    if (item.playlistItemId case final id?) id,
];
await jellyfin.playlists.removeItems(
  playlistId: playlist.id,
  entryIds: entryIds.take(2).toList(),
);
await jellyfin.playlists.moveItem(
  playlistId: playlist.id,
  playlistItemId: entryIds.last,
  newIndex: 0,
);

An entry id is a JellyfinPlaylistEntryId, so an item id does not fit where an entry id belongs. It reads as a String; wrap a stored one with JellyfinPlaylistEntryId(id).

The server gives each entry the id of its item. With a track listed twice, removing its entry removes every copy and moving it moves the first: the server has no way to address one copy of a duplicate.

5.4 Renaming, updating and deleting

await jellyfin.playlists.rename(playlistId: playlist.id, name: 'New name');
await jellyfin.playlists.update(playlistId: playlist.id, isPublic: false);
await jellyfin.playlists.delete(playlistId: playlist.id);

update() also takes name and ids, the whole new list of entries.

The server saves an entry change a moment after it answers. A rename sent in that moment is lost, though the server reports success, and a delete leaves the playlist file on disk for the next library scan. So rename(), update() and delete() wait until JellyfinPlaylistsApi.renameSettle (1 second) has passed since this client last changed that playlist's entries. With no recent entry write they are sent at once. Writes from another app or another JellyfinClient are not tracked.

5.5 Sharing

final access = await jellyfin.playlists.users(playlistId: playlist.id);
for (final a in access) {
  print('${a.userId} canEdit=${a.canEdit}');
}

await jellyfin.playlists.setUserAccess(
  playlistId: playlist.id,
  userId: friendId,
  canEdit: true,
);
await jellyfin.playlists.removeUserAccess(playlistId: playlist.id, userId: friendId);

6. Search, suggestions and instant mix #

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}');
}

hints() returns a JellyfinQueryResult<JellyfinSearchHint>, a flat list with itemId, name, matchedTerm, type, mediaType, run time and a primaryImageTag for images.url(). For search with the full item filters, use items.list(searchTerm:).

6.2 Suggestions

suggestions.list() answers a flat feed of items the server picks for the user, of any kind:

final picks = await jellyfin.suggestions.list(
  mediaType: const [JellyfinMediaType.audio],
  type: const [JellyfinItemKind.musicAlbum, JellyfinItemKind.audio],
  limit: 30,
);

For movie rows grouped by reason, see Movies.

6.3 Instant mix

The server builds a list of related tracks from a seed: a song, an album, an artist, a playlist or a music genre.

final mix = await jellyfin.instantMix.fromItem(itemId: track.id, limit: 50);
for (final item in mix.items) {
  print('${item.name} - ${item.albumArtist}');
}

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);

// A genre seeds a mix by its music-genre id.
final rock = await jellyfin.musicGenres.byName(name: 'Rock');
final fromGenre = await jellyfin.instantMix.fromItem(itemId: rock!.id);

Each call returns a JellyfinQueryResult<JellyfinItem>. Take a genre's id from a track's genreItems, from genres.list(parentId: musicLibraryId) or from musicGenres.byName() (see 4.8): a generic genre id answers an empty mix.


7. Lyrics #

7.1 Reading lyrics

final lyrics = await jellyfin.audio.lyrics(itemId: track.id);
if (lyrics == null) {
  // no lyrics
} else if (lyrics.hasCues) {
  print(lyrics.toLrc(enhanced: true)); // enhanced LRC, a time for each word
} else if (lyrics.isSynced) {
  print(lyrics.toLrc()); // LRC, a time for each line
} else {
  print(lyrics.toPlainText());
}

jellyfin.lyrics.forItem(itemId:) is the same call. JellyfinLyrics holds the lines, each with startTicks (in 100-nanosecond units), start as a Duration, text and cues: word-level JellyfinLyricCues with their own start and end. metadata holds the file's tags (JellyfinLyricMetadata: artist, album, title, offset) when the server sends them. toLrc(enhanced: true) writes the lines that have cues in enhanced LRC and the others as plain LRC.

lyrics() returns null on a 404, which the server answers both for a track without lyrics and for an id that names no item.

No route serves the original .lrc or .txt file: the lyrics route answers the parsed lyrics as JSON. Rebuild a file with toLrc() or toPlainText().

7.2 Uploading and deleting

final bytes = await File('track.lrc').readAsBytes();
final uploaded = await jellyfin.lyrics.upload(
  itemId: track.id,
  fileName: 'track.lrc',
  body: bytes,
);

await jellyfin.lyrics.delete(itemId: track.id);

The server picks the parser from the file extension, and upload() returns the lyrics as it parsed them.

7.3 Remote search and download

With a lyrics provider plugin on the server:

final hits = await jellyfin.lyrics.searchRemote(itemId: track.id);
for (final hit in hits) {
  print('${hit.providerName}: ${hit.id} (${hit.lyrics?.lines.length} lines)');
}

final preview = await jellyfin.lyrics.previewRemote(lyricId: hits.first.id);
final attached = await jellyfin.lyrics.downloadRemote(
  itemId: track.id,
  lyricId: hits.first.id,
);

Each hit is a JellyfinRemoteLyric with id, providerName and the lyrics as JellyfinLyrics when the provider sent them. previewRemote() fetches a result without attaching it; downloadRemote() attaches it to the track.


8. Audio streaming #

These methods build URLs and fetch nothing. Hand the URL to your audio engine (mpv, AVPlayer, ExoPlayer). The token travels in the URL as ApiKey, so segment requests need no extra headers.

8.1 Universal stream URL

The server decides between direct play and transcoding from what the client accepts:

final url = jellyfin.audio.universalStreamUrl(
  itemId: track.id,
  containers: const ['mp3', 'aac', 'flac', 'ogg', 'opus'],
  maxStreamingBitrate: 320000,
  audioCodec: 'aac',
  transcodingProtocol: 'hls', // or 'http'
  transcodingContainer: 'ts',
  playSessionId: 'my-uuid',
);

Other parameters: mediaSourceId, audioBitRate, maxAudioChannels, transcodingAudioChannels, maxAudioSampleRate, maxAudioBitDepth, startTimeTicks.

8.2 Direct stream URL

The file as it is stored, with no transcoding:

final (url, ext) = jellyfin.audio.directStreamUrl(
  itemId: track.id,
  container: 'flac',
  playSessionId: 'my-uuid',
);

isStatic (on by default) serves the stored file. ext echoes container, for naming a file.

8.3 Transcoded file URL

A progressive transcode: one seekable file at a set bitrate, not HLS segments. Use it to download a smaller copy:

final (url, ext) = jellyfin.audio.transcodedStreamUrl(
  itemId: track.id,
  audioCodec: 'aac',
  container: 'm4a',
  audioBitRate: 192000,
  playSessionId: 'download-${track.id}',
);

audioBitRate sets the bitrate, in bits per second. The container must hold the codec. The server reuses a running transcode of the same item for the same device and play session, whatever the other parameters say, so give each download its own playSessionId.


9. HLS playlists #

HLS: the master playlist lists the variant playlist, which lists the segments; DeviceId and PlaySessionId ride every URL

Audio and video each have a master.m3u8, with every bandwidth variant, and a main.m3u8 with one. Use the master when the player picks the quality, the variant when you already did.

The playlist URLs carry the token as ApiKey and, as DeviceId, the device id of the credentials. The server files the transcode under that device and playSessionId, and the playback reports reach it through the same pair.

9.1 Audio master and variant

final masterUrl = jellyfin.hls.audioMasterUrl(
  itemId: track.id,
  maxStreamingBitrate: 320000,
  audioCodec: 'aac',
  playSessionId: 'my-uuid',
);
final mainUrl = jellyfin.hls.audioVariantUrl(
  itemId: track.id,
  audioCodec: 'aac',
  audioBitRate: 256000,
  segmentContainer: 'mp4', // 'ts' or 'mp4'
  segmentLength: 6,        // seconds
  minSegments: 1,          // segments ready before the server answers
  enableAutoStreamCopy: false, // both false: always transcode
  allowAudioStreamCopy: false,
  playSessionId: 'my-uuid',
);

Also typed: audioSampleRate, maxAudioBitDepth, audioChannels, maxAudioChannels, startTimeTicks, breakOnNonKeyFrames and context (Streaming or Static). params adds any other query parameter as it is.

9.2 Video master and variant

final masterUrl = jellyfin.hls.videoMasterUrl(
  itemId: movie.id,
  videoCodec: 'h264',
  audioCodec: 'aac',
  videoBitRate: 8000000,
  audioBitRate: 192000,
  maxWidth: 1920,
  maxHeight: 1080,
  subtitleStreamIndex: 3,
  subtitleMethod: 'Hls',
);

videoVariantUrl() takes the same parameters. Cap a video transcode with videoBitRate and audioBitRate: the video routes do not read maxStreamingBitrate.

9.3 Segments, live and stopping a transcode

The player walks the playlist, so segment URLs are rarely needed. They are there for cache warming or debugging:

final segmentUrl = jellyfin.hls.videoSegmentUrl(
  itemId: movie.id,
  playlistId: 'main',
  segmentId: 0,
  container: 'ts',
);

For a live TV channel the server keeps the playlist growing. Use the live variant:

final liveUrl = jellyfin.hls.videoLiveUrl(
  itemId: channelId,
  videoCodec: 'h264',
  audioCodec: 'aac',
);

stopEncoding(playSessionId:) stops a transcode at once. The API document hides that route; playback.stopped() is the supported way to end a transcode.


10. Media info and playback negotiation #

/Items/{itemId}/PlaybackInfo takes a DeviceProfile, what the client can decode, and answers a JellyfinPlaybackInfo whose mediaSources say whether each source plays directly (supportsDirectPlay, supportsDirectStream, supportsTranscoding) and, when it does not, carry a transcodingUrl to use as it is.

10.1 Playback info

final info = await jellyfin.mediaInfo.info(itemId: track.id);

The GET form sends no device profile, so the server assumes generic defaults. That suits audio; for video, post a profile.

10.2 Posting a device profile

const profile = JellyfinDeviceProfile(
  name: 'MyApp',
  maxStreamingBitrate: 8000000,
  musicStreamingTranscodingBitrate: 320000,
  directPlayProfiles: [
    JellyfinDirectPlayProfile.audio(container: 'mp3,aac,flac'),
    JellyfinDirectPlayProfile.video(container: 'mp4,mkv,webm'),
  ],
);

final info = await jellyfin.mediaInfo.postedInfo(
  itemId: movie.id,
  deviceProfile: profile,
  maxStreamingBitrate: 8000000,
  subtitleStreamIndex: 3,
);
final source = info.mediaSources.first;
print('directPlay=${source.supportsDirectPlay} transcoding=${source.transcodingUrl}');

Each direct play profile names its media kind (Audio or Video), so an audio source matches an audio profile. transcodingProfiles, codecProfiles, containerProfiles and subtitleProfiles take the finer rules as maps, and extra adds any other top-level field of the profile.

10.3 Live streams

A source with RequiresOpening in its raw map, such as a live TV channel, needs an explicit open and close:

final opened = await jellyfin.mediaInfo.openLiveStream(
  openToken: source.raw['OpenToken'] as String,
  itemId: movie.id,
  deviceProfile: profile,
);
final liveStreamId = opened.mediaSources.first.raw['LiveStreamId'] as String;
// play, then:
await jellyfin.mediaInfo.closeLiveStream(liveStreamId: liveStreamId);

10.4 Bitrate probe

The server sends size bytes; the time they take gives a starting maxStreamingBitrate:

final stopwatch = Stopwatch()..start();
final size = await jellyfin.mediaInfo.bitrateTestBytesLength(size: 1000000);
stopwatch.stop();
final bitsPerSecond = (size ?? 0) * 8 * 1000 ~/ stopwatch.elapsedMilliseconds;

11. Playback reporting #

Playback reports: start, progress with isPaused, stopped, with one play session id; ping keeps an HLS job alive

Reports tell the server what plays: they fill "Continue listening", play counts and the dashboard, and keep a transcode alive. Positions are Durations; the library converts them to Jellyfin's ticks (100 nanoseconds).

11.1 Start

await jellyfin.playback.start(
  itemId: track.id,
  playSessionId: 'my-uuid',
  mediaSourceId: track.mediaSources.first.id,
  method: JellyfinPlayMethod.transcode, // directPlay, directStream or transcode
);

11.2 Progress

Send it about every 10 seconds and on every change of state:

await jellyfin.playback.progress(
  itemId: track.id,
  position: const Duration(seconds: 42),
  isPaused: false,
  volumeLevel: 80,
  playSessionId: 'my-uuid',
  method: JellyfinPlayMethod.transcode,
  order: JellyfinPlaybackOrder.shuffle, // or defaultOrder
  repeat: JellyfinRepeatMode.all,       // none, all or one
);

11.3 Stopped

await jellyfin.playback.stopped(
  itemId: track.id,
  position: const Duration(seconds: 240),
  playSessionId: 'my-uuid',
);

stopped() ends the play session and its transcode.

11.4 Ping

Tells the server the transcode of a play session is still wanted:

await jellyfin.playback.ping(playSessionId: 'my-uuid');

A player that sends progress() every 10 seconds needs no ping. Ping covers stretches with nothing to report, such as buffering before the first start().


12. Sessions and remote control #

sessions lists the clients connected to the server, registers this client as a cast target and sends commands to other sessions. Commands sent to this client arrive as notifications.

12.1 Listing sessions

final sessions = await jellyfin.sessions.list();
for (final s in sessions) {
  print('${s.userName} on ${s.deviceName} (${s.client}): ${s.nowPlayingItem?.name}');
}

// Sessions the current user may control, active in the last 30 seconds.
final controllable = await jellyfin.sessions.list(
  controllableByUserId: jellyfin.userId,
  activeWithinSeconds: 30,
);

12.2 Registering this client as a cast target

Once this client posts its capabilities, other Jellyfin clients list it as a cast target:

await jellyfin.sessions.postCapabilities(
  playableMediaTypes: const ['Audio', 'Video'],
  supportedCommands: const [
    'Play', 'PlayState', // accept play and playstate commands
    JellyfinGeneralCommand.setVolume,
    JellyfinGeneralCommand.mute,
    JellyfinGeneralCommand.unmute,
    JellyfinGeneralCommand.toggleMute,
    JellyfinGeneralCommand.setAudioStreamIndex,
    JellyfinGeneralCommand.setSubtitleStreamIndex,
  ],
  supportsMediaControl: true,
);

supportedCommands takes GeneralCommandType values. Pause, stop, seek and track skips are not among them: they arrive as playstate commands, which the PlayState capability turns on. postFullCapabilities() posts the whole body as a map.

When the user signs out, call sessions.reportSessionEnded() so the server drops the session.

12.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: JellyfinPlayCommand.playShuffle,
);

playCommand defaults to JellyfinPlayCommand.playNow, which replaces the queue. The others are playNext, playLast, playInstantMix and playShuffle.

12.4 Playstate commands

await jellyfin.sessions.sendPlaystateCommand(
  sessionId: target.id,
  command: JellyfinPlaystateCommand.pause,
);
await jellyfin.sessions.sendPlaystateCommand(
  sessionId: target.id,
  command: JellyfinPlaystateCommand.seek,
  seekPositionTicks: 1200000000, // 2 minutes
);

JellyfinPlaystateCommand has stop, pause, unpause, playPause, nextTrack, previousTrack, seek, rewind and fastForward.

12.5 General and system commands

// A command without arguments.
await jellyfin.sessions.sendCommand(
  sessionId: target.id,
  command: JellyfinGeneralCommand.volumeUp,
);

// A command with arguments.
await jellyfin.sessions.sendFullCommand(
  sessionId: target.id,
  name: JellyfinGeneralCommand.setVolume,
  arguments: {'Volume': '80'},
);

// Navigation: GoHome, GoToSettings, GoToSearch.
await jellyfin.sessions.sendSystemCommand(
  sessionId: target.id,
  command: 'GoHome',
);

12.6 Messages and content

// A message on the remote device.
await jellyfin.sessions.sendMessage(
  sessionId: target.id,
  text: 'Now playing on the kitchen TV.',
  header: 'Cast',
  timeoutMs: 4000,
);

// Open an item's page on the remote device, without playing it.
await jellyfin.sessions.displayContent(
  sessionId: target.id,
  itemId: album.id,
  itemType: 'MusicAlbum',
  itemName: album.name,
);

reportViewing(itemId:) tells the server what this client shows, and addUser() and removeUser() add a second user to a session.


13. User data #

User data is what the server keeps for each user and item: favorite, played, play count, resume position and rating. Each call below returns the new JellyfinUserData.

13.1 Favorites

await jellyfin.userData.markFavorite(itemId: track.id);
await jellyfin.userData.unmarkFavorite(itemId: track.id);

// For a toggle:
await jellyfin.userData.setFavorite(itemId: track.id, isFavorite: true);

13.2 Played state and ratings

await jellyfin.userData.markPlayed(itemId: track.id);
await jellyfin.userData.markUnplayed(itemId: track.id);

await jellyfin.userData.rate(itemId: track.id, likes: true);
await jellyfin.userData.clearRating(itemId: track.id);

13.3 Reading and writing the record

final data = await jellyfin.userData.get(itemId: track.id);
print('position=${data.playbackPositionTicks} count=${data.playCount}');

// Reset the resume position, keep the rest.
await jellyfin.userData.update(
  itemId: track.id,
  userData: JellyfinUserData(
    playCount: data.playCount,
    isFavorite: data.isFavorite,
    played: data.played,
    playbackPositionTicks: 0,
  ),
);

Every call in this chapter acts for the session's user; pass userId to act for another.


14. Images #

Image URLs follow a pattern, /Items/{id}/Images/{type}, with a tag from JellyfinItem.imageTags that changes when the image does. The server resizes, so the client needs no scaling.

With tag as the only parameter, the server sends the stored file as it is. Any processing parameter (sizing, quality, blur, percentPlayed, unplayedCount, backgroundColor) makes it re-encode. Without format, it then picks the output from the request's Accept header: WebP when that lists image/webp, as browsers and most image widgets do, JPEG otherwise.

14.1 Building an image URL

final url = jellyfin.images.url(
  itemId: track.artworkItemId ?? track.id,
  tag: track.artworkVersion,
  maxWidth: 512,
  maxHeight: 512,
  format: JellyfinImagesApi.formatJpg,
  quality: 85,
);

type defaults to JellyfinImagesApi.typePrimary. The sizing parameters:

Parameters Effect
maxWidth, maxHeight Fit inside the box, aspect kept: the pair for a cover cache
fillWidth, fillHeight Cover the box, aspect kept
width, height Exactly those dimensions, stretched

format picks the encoding (formatJpg, formatPng, formatWebp, formatGif, formatBmp, formatSvg) and quality (0 to 100) applies to the lossy ones. percentPlayed and unplayedCount draw a progress bar and a badge into the image, and blur takes a radius. backgroundColor fills transparency and must be a hex RGB, ARGB, RRGGBB or AARRGGBB, with an optional #. Any other value throws an ArgumentError, because the server would drop the sizing too and send the full original.

14.2 Fetching bytes

final Uint8List? bytes = await jellyfin.images.fetch(
  itemId: track.artworkItemId ?? track.id,
  tag: track.artworkVersion,
  maxWidth: 512,
  maxHeight: 512,
  format: JellyfinImagesApi.formatJpg,
  cancelToken: token, // drop it when the cell scrolls away
);

fetch() takes the sizing and encoding parameters of url() and a cancelToken. It returns null on a 404 and throws a JellyfinException on a transient failure, so "no image" and "try again later" stay apart.

14.3 Image types and other images

Constant Wire value Holds
typePrimary Primary Album cover, movie poster, track art
typeArt Art Clear art
typeBackdrop Backdrop Backdrop
typeBanner Banner Wide banner
typeLogo Logo Title logo
typeThumb Thumb Wide thumbnail
typeDisc Disc Disc art

Artists, genres, music genres, studios and people have images by name:

final artistUrl = jellyfin.images.artistImageUrl(
  artistName: 'Aphex Twin',
  maxWidth: 300,
  maxHeight: 300,
);
final images = await jellyfin.images.listItemImages(itemId: album.id);

genreImageUrl(), musicGenreImageUrl(), studioImageUrl() and personImageUrl() work the same way. listItemImages() returns each image of an item as JellyfinImageInfo (type, index, tag, size, dimensions). setItemImage(), deleteItemImage() and reorderItemImage() edit an item's images; userImageUrl(), uploadUserImage() and deleteUserImage() the user's own picture; and splashscreenUrl() the login screen's background.


15. Subtitles #

Subtitles are the entries of mediaSources[…].mediaStreams whose type is Subtitle. The index below is that stream's index, the same value as subtitleStreamIndex in videos.streamUrl().

15.1 Subtitle stream URL

final url = jellyfin.subtitles.streamUrl(
  itemId: movie.id,
  mediaSourceId: mediaSourceId,
  index: 3,
  format: JellyfinSubtitlesApi.formatVtt,
);

// A slice, for seeking through transcoded subtitles.
final slice = jellyfin.subtitles.streamWithTicksUrl(
  itemId: movie.id,
  mediaSourceId: mediaSourceId,
  index: 3,
  startPositionTicks: 6000000000, // 10 minutes
  endPositionTicks: 6600000000,   // 11 minutes
);

// Subtitles delivered over HLS, with subtitleMethod: 'Hls' on the video URL.
final playlistUrl = jellyfin.subtitles.playlistUrl(
  itemId: movie.id,
  mediaSourceId: mediaSourceId,
  index: 3,
  segmentLength: 10,
);

format defaults to vtt. The constants are formatSrt, formatVtt, formatAss, formatSsa and formatSub.

15.2 Fetching subtitle text

For a player that draws subtitles itself:

final body = await jellyfin.subtitles.fetch(
  itemId: movie.id,
  mediaSourceId: mediaSourceId,
  index: 3,
  format: JellyfinSubtitlesApi.formatSrt,
);
if (body == null) {
  // 404: the subtitle stream is gone
}

fetchFromPosition(startPosition:) fetches from a position on. The text is decoded by its byte order mark, then the declared charset, then UTF-8 with a Latin-1 fallback.

15.3 Upload and delete

These calls need an admin token.

await jellyfin.subtitles.upload(
  itemId: movie.id,
  language: 'eng',
  format: 'srt',
  data: base64Subtitle, // the file, base64-encoded
  isForced: true,       // isHearingImpaired is the other flag
);

await jellyfin.subtitles.delete(itemId: movie.id, index: 3);

15.4 Remote search and download

With a subtitle provider plugin on the server, such as OpenSubtitles:

final hits = await jellyfin.subtitles.searchRemote(
  itemId: movie.id,
  language: 'eng',
);
for (final h in hits) {
  print('${h['ProviderName']} - ${h['Name']} (${h['Format']})');
}

await jellyfin.subtitles.downloadRemote(
  itemId: movie.id,
  subtitleId: hits.first['Id'] as String,
);

getRemote(id:) fetches a result without attaching it.

15.5 Fallback fonts

For .ass and .ssa subtitles drawn with libass, the server hosts fallback fonts:

final fonts = await jellyfin.subtitles.fallbackFonts();
final fontUrl = jellyfin.subtitles.fallbackFontUrl(
  name: fonts.first['Name'] as String,
);

16. Trickplay #

Trickplay images are the thumbnails shown while scrubbing. Each video item lists its trickplay widths, and each width's Interval, in JellyfinItem.raw['Trickplay']. Pick a width the server generated, then pick the tile from the position.

16.1 Tile URL

final url = jellyfin.trickplay.tileUrl(
  itemId: movie.id,
  width: 320,
  index: tileIndex, // from the position and the width's Interval
);

16.2 HLS tile playlist

For a player that loads the tiles from a playlist:

final playlistUrl = jellyfin.trickplay.hlsPlaylistUrl(
  itemId: movie.id,
  mediaSourceId: mediaSourceId,
  width: 320,
);

17. Media segments #

Media segments mark time ranges of an item (intro, recap, outro, commercial, preview). Plugins such as Intro Skipper create them, and a player uses them to show a skip button.

17.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}');
}

JellyfinMediaSegmentType has unknown, commercial, preview, recap, outro and intro. start and end are Durations.


18. Video streaming #

Like audio, videos builds URLs and fetches nothing; the token travels as ApiKey.

18.1 Stream URL

final (url, ext) = jellyfin.videos.streamUrl(
  itemId: movie.id,
  mediaSourceId: movie.mediaSources.first.id,
  videoCodec: 'h264',
  audioCodec: 'aac',
  videoBitRate: 8000000,
  audioBitRate: 192000,
  audioStreamIndex: 1,
  subtitleStreamIndex: 3,
  maxAudioChannels: 2,
  maxWidth: 1920,
  maxHeight: 1080,
  playSessionId: 'my-uuid',
);

Cap a transcode with videoBitRate and audioBitRate. isStatic: true serves the original file with no remuxing. params replays a transcode the server chose, from the query string of a transcodingUrl.

18.2 Choosing between direct play and transcoding

Ask the server first, with a device profile:

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 case final path?) {
  play('${jellyfin.baseUrl}$path'); // the server built it: use it as it is
}

18.3 Parts, versions and attachments

// A movie split across files (CD1, CD2).
final parts = await jellyfin.videos.additionalParts(itemId: movie.id);
for (final p in parts) {
  print('${p['Name']} (${p['Id']})');
}

// An attachment of a source, such as a font in an MKV.
final fontUrl = jellyfin.videos.attachmentUrl(
  videoId: movie.id,
  mediaSourceId: movie.mediaSources.first.id,
  index: 0,
);

With an admin token, mergeVersions(ids:) groups items as versions of one movie, and deleteAlternateSources() splits them again.


19. TV shows #

These calls return a JellyfinQueryResult<JellyfinItem>, like items.list().

19.1 Seasons and episodes

final seasons = await jellyfin.tvShows.seasons(
  seriesId: seriesId,
  isSpecialSeason: false,
);
for (final s in seasons.items) {
  print('${s.indexNumber} ${s.name}');
}

final episodes = await jellyfin.tvShows.episodes(
  seriesId: seriesId,
  season: 2,
  fields: const [JellyfinItemField.overview, JellyfinItemField.primaryImageAspectRatio],
  sortBy: JellyfinSortField.premiereDate,
);

seasonId scopes the episodes by the season's item id instead of its number. episodes() takes a single sortBy key and no sort order.

19.2 Next up and upcoming

final nextUp = await jellyfin.tvShows.nextUp(
  limit: 12,
  enableResumable: true,
  enableRewatching: false,
);
final upcoming = await jellyfin.tvShows.upcoming(limit: 20);

nextUp(nextUpDateCutoff:) keeps the series last watched on or after a date (an ISO 8601 string). upcoming() lists the episodes whose premiere date is in the future.


20. Movies #

20.1 Recommendations

The server groups recommended movies in rows by reason, such as "Because you watched":

final rows = await jellyfin.movies.recommendations(
  parentId: movieLibraryId,
  categoryLimit: 6,
  itemLimit: 12,
);
for (final row in rows) {
  print('${row.recommendationType} ${row.baselineItemName}: ${row.items.length} items');
}

Each JellyfinMovieRecommendation has categoryId, recommendationType (such as SimilarToRecentlyPlayed), baselineItemName and its items.

20.2 Trailers

Trailers are items of kind Trailer:

final trailers = await jellyfin.items.list(
  includeItemTypes: const [JellyfinItemKind.trailer],
  sortBy: const [JellyfinSortField.dateCreated],
  descending: true,
  limit: 20,
);

21. Live TV #

21.1 Channels

final channels = await jellyfin.liveTv.channels(
  type: 'TV', // or 'Radio'
  sortBy: const [JellyfinSortField.defaultOrder],
  limit: 50,
);
for (final ch in channels.items) {
  print('${ch.indexNumber} ${ch.name}');
}

final channel = await jellyfin.liveTv.channel(channelId: channelId);
// channel?.raw['CurrentProgram'] holds what is on now.

The channels route has no search: filter the answer by name.

21.2 Programs and guide

final now = DateTime.now().toUtc();
final guide = await jellyfin.liveTv.programs(
  channelIds: const ['ch1', 'ch2', 'ch3'],
  minStartDate: now.toIso8601String(),
  maxStartDate: now.add(const Duration(hours: 6)).toIso8601String(),
);

final recommended = await jellyfin.liveTv.recommendedPrograms(
  isAiring: true,
  isMovie: true,
  limit: 20,
);

21.3 Recordings

final recordings = await jellyfin.liveTv.recordings(isInProgress: false, limit: 100);
final detail = await jellyfin.liveTv.recording(recordingId: recordingId);
await jellyfin.liveTv.deleteRecording(recordingId: recordingId);

final streamUrl = jellyfin.liveTv.liveRecordingStreamUrl(recordingId: recordingId);

21.4 Timers

A timer records one airing, a series timer 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: timerId);

final rules = await jellyfin.liveTv.seriesTimers();
await jellyfin.liveTv.createSeriesTimer(
  body: {
    'ProgramId': 'pgm-1234',
    'RecordAnyTime': false,
    'RecordNewOnly': true,
  },
);

Tuners, listing providers and channel mappings are on liveTv too, for an admin token.


22. SyncPlay #

SyncPlay plays the same queue on several clients at once. One client creates a group, the others join, and pause, seek and track changes reach every member. Each member calls ready() once it has buffered, so the server resumes the group together.

22.1 Listing and joining groups

final groups = await jellyfin.syncPlay.list();
for (final g in groups) {
  print('${g.name} (${g.participants.length} participants, ${g.state})');
}

await jellyfin.syncPlay.createGroup(groupName: 'Friday movie night');
await jellyfin.syncPlay.joinGroup(groupId: groups.first.id);
await jellyfin.syncPlay.leaveGroup();

list() returns JellyfinSyncPlayGroups (id, name, state, participants, lastUpdatedAt). group(id:) fetches one, or null.

22.2 Group playback control

await jellyfin.syncPlay.pause();
await jellyfin.syncPlay.unpause();
await jellyfin.syncPlay.seek(positionTicks: 1200000000); // 2 minutes
await jellyfin.syncPlay.stop();

await jellyfin.syncPlay.nextItem(playlistItemId: entryId);
await jellyfin.syncPlay.previousItem(playlistItemId: entryId);
await jellyfin.syncPlay.setPlaylistItem(playlistItemId: entryId);

await jellyfin.syncPlay.setRepeatMode(mode: 'RepeatAll');
await jellyfin.syncPlay.setShuffleMode(mode: 'Shuffle');

22.3 Group queue

// Replace the queue.
await jellyfin.syncPlay.setNewQueue(
  playingQueue: const ['12345', '12346', '12347'],
  playingItemPosition: 0,
);

// Append ('Queue', the default) or insert after the current item.
await jellyfin.syncPlay.queue(itemIds: const ['12348'], mode: 'QueueNext');

// Reorder and remove.
await jellyfin.syncPlay.movePlaylistItem(playlistItemId: entryId, newIndex: 0);
await jellyfin.syncPlay.removeFromPlaylist(playlistItemIds: const ['entry-1']);

// While buffering, and once ready.
await jellyfin.syncPlay.buffering(playlistItemId: entryId, positionTicks: 0, isPlaying: false);
await jellyfin.syncPlay.ready(playlistItemId: entryId, positionTicks: 0, isPlaying: false);

23. Channels #

Channels are content sources that plugins provide, such as podcasts or online radio; for TV channels, see Live TV. Each channel holds a tree of items, browsed like a library.

23.1 Listing channels

final channels = await jellyfin.channels.list(supportsLatestItems: true);

23.2 Items, latest and features

final episodes = await jellyfin.channels.items(
  channelId: channelId,
  sortBy: const [JellyfinSortField.dateCreated],
  descending: true,
  limit: 50,
);

final latest = await jellyfin.channels.latest(channelIds: [channelA, channelB], limit: 20);

final features = await jellyfin.channels.features(channelId: channelId);
print(features['SupportsContentDownloading']);
final allFeatures = await jellyfin.channels.allFeatures();

items(folderId:) opens a folder of the channel. allFeatures() answers the features of every channel in one call.


24. Display preferences #

Display preferences are UI state the server stores for each user and client: view type, sort order, scroll direction, and a free customPrefs map. They follow the user across devices.

displayPreferencesId names the document, such as usersettings or a library's id; client keeps one app's documents apart from another's.

24.1 Reading and writing

final prefs = await jellyfin.displayPreferences.get(
  displayPreferencesId: 'usersettings',
  client: 'my_app',
);
print('${prefs.viewType} sorted by ${prefs.sortBy}');

await jellyfin.displayPreferences.update(
  displayPreferencesId: 'usersettings',
  client: 'my_app',
  preferences: const JellyfinDisplayPreferences(
    id: 'usersettings',
    client: 'my_app',
    viewType: 'Poster',
    sortBy: 'SortName',
    sortOrder: 'Ascending',
    rememberSorting: true,
  ),
);

24.2 Custom preferences

customPrefs is a Map<String, String> for your app's own state; the server stores it without reading it:

await jellyfin.displayPreferences.update(
  displayPreferencesId: 'usersettings',
  client: 'my_app',
  preferences: const JellyfinDisplayPreferences(
    client: 'my_app',
    customPrefs: {
      'home.layout': 'compact',
      'home.rails': 'continueWatching,nextUp,recentlyAdded',
    },
  ),
);

25. Localization #

25.1 Countries, cultures and ratings

The server's lists for pickers. Read them instead of hard-coding values the server may not accept:

final countries = await jellyfin.localization.countries();
final cultures = await jellyfin.localization.cultures();
final options = await jellyfin.localization.options();

final ratings = await jellyfin.localization.parentalRatings();
for (final r in ratings) {
  print('${r['Value']}: ${r['Name']}');
}

Each entry is a map. Countries carry Name, DisplayName and TwoLetterISORegionName; cultures the two- and three-letter ISO codes and display names. A rating's Value is what the server stores, its Name the label, such as PG-13. options() lists the metadata languages the server offers.


26. Errors, cancellation, retries and paging #

26.1 Exceptions

Every call throws a JellyfinException on failure:

try {
  await jellyfin.items.list(parentId: musicLibraryId);
} on JellyfinException catch (e) {
  print('${e.type} ${e.statusCode} ${e.message}');
}

JellyfinErrorType is one of connection, timeout, auth, notFound, badRequest, serverError, parse, state, cancelled and unknown. The exception also carries statusCode, path, cause, stackTrace and retryAfter, the server's Retry-After on a 429 or 503.

Invalid arguments are programming errors and throw ArgumentError instead, such as a malformed backgroundColor in images.url().

26.2 Classifying a failure

try {
  await jellyfin.items.list(parentId: musicLibraryId);
} on JellyfinException catch (e) {
  if (e.isCancelled) {
    // the caller cancelled: not a failure
  } else if (e.isTransient) {
    scheduleRetry(); // network failure, 5xx or 429
  } else if (e.isAuthError) {
    await signInAgain(); // 401 or 403: the token was rejected
  } else if (e.isNotFound) {
    showError('Gone from the server');
  } else {
    showError(e.message);
  }
}
Getter True for
isNetworkFailure No answer at all: the connection failed or timed out
isTransient May pass later: a network failure, a 5xx or a 429
isNotFound The server answered 404
isCancelled The request was cancelled through a cancel token
isAuthError 401 or 403

The library never fetches a new token on its own. isAuthError is the signal to run authenticateByName() or Quick Connect again.

26.3 Cancellation

withCancelToken() returns a view of the client whose every sub-API call is cancelled by one JellyfinCancelToken. The view shares the connection pool, the server and the session, and costs nothing to create, so make one for each page, filter or search term:

JellyfinCancelToken? pageToken;

Future<void> showAlbums(String libraryId) async {
  pageToken?.cancel(); // drop the previous page's requests
  final token = pageToken = JellyfinCancelToken();
  try {
    final albums = await jellyfin.withCancelToken(token).items.list(
          parentId: libraryId,
          includeItemTypes: const [JellyfinItemKind.musicAlbum],
          limit: 50,
        );
    print(albums.items.length);
  } on JellyfinException catch (e) {
    if (e.isCancelled) return; // not a failure
    rethrow;
  }
}

A cancelled call throws a JellyfinException of type cancelled. A token cancels once; make a new one for the next page. request(), requestBytes(), requestStream(), downloadToFile() and images.fetch() also take a cancelToken directly. The notifications socket is not a request, and a token does not close it.

26.4 Retries

Retries are off by default. Pass a JellyfinRetryPolicy to resend idempotent requests after a transient failure:

final jellyfin = JellyfinClient(
  credentials: credentials,
  retryPolicy: const JellyfinRetryPolicy(), // up to 3 attempts in all
);

// Or tune it:
const patient = JellyfinRetryPolicy(
  maxAttempts: 5,
  initialDelay: Duration(milliseconds: 500),
  maxDelay: Duration(seconds: 10),
);

The defaults are maxAttempts: 3 (the first attempt included), initialDelay: 300 ms and maxDelay: 5 s.

Only GET and HEAD are resent, and only after no answer at all, a 502, 503, 504 or 429. A 500 is not retried. The wait grows exponentially with full jitter, a Retry-After header (in seconds) is honored up to maxDelay, and a cancel token also cancels the wait. Opening a stream is retried too; a body already flowing is not replayed.

26.5 Paging

JellyfinPaging.items() and JellyfinPaging.pages() walk any paged call as a stream, one request per page, nothing fetched ahead:

final tracks = JellyfinPaging.items(
  (start, size) async => (await jellyfin.items.list(
    parentId: music.id,
    includeItemTypes: const [JellyfinItemKind.audio],
    sortBy: const [JellyfinSortField.sortName],
    startIndex: start,
    limit: size,
    enableTotalRecordCount: false,
  ))
      .items,
  pageSize: 200,
);
await for (final track in tracks) {
  print(track.name);
}

Paging stops at the first page shorter than pageSize, or after limit items when you pass one. Cancelling the subscription stops it before the next request; pair it with a cancel token to drop the request in flight too.


27. Notifications and typed events #

Socket frames become a JellyfinNotification, and events() decodes them into the sealed JellyfinEvent subtypes

The server pushes changes over a WebSocket: user data, library changes, sessions, scheduled tasks and remote-control commands for this client.

27.1 Connecting

final socket = await jellyfin.notifications.connect();

final subscription = socket.events().listen((event) {
  switch (event) {
    case JellyfinUserDataChangedEvent(:final changes):
      for (final c in changes) {
        print('${c.itemId} favorite=${c.isFavorite} played=${c.played}');
      }
    case JellyfinLibraryChangedEvent(:final itemsUpdated, :final itemsRemoved):
      print('re-read $itemsUpdated, drop $itemsRemoved');
    case JellyfinPlaystateEvent(:final command, :final seekPosition):
      print('remote asks: $command $seekPosition');
    case _:
  }
});

// Later:
await subscription.cancel();
await jellyfin.notifications.close();

connect() needs the server and the session, and returns a broadcast stream of JellyfinNotification frames (messageType, data, raw). events() on the stream, or event on one frame, decodes them into the sealed JellyfinEvent type. The client sends a keep-alive every keepAliveInterval (30 seconds by default). Call close() before connecting again.

27.2 Typed events

Event MessageType Holds
JellyfinUserDataChangedEvent UserDataChanged The user id and a JellyfinUserDataChange for each item (favorite, played, play count, position, last played); a track's change also carries its album
JellyfinLibraryChangedEvent LibraryChanged Items added, updated and removed, folders added to and removed from, sent in batches some seconds after the change
JellyfinSessionsEvent Sessions The JellyfinSession list, after startSessions()
JellyfinPlayEvent, JellyfinPlaystateEvent, JellyfinGeneralCommandEvent Play, Playstate, GeneralCommand Remote-control commands sent to this session
JellyfinRefreshProgressEvent RefreshProgress A library refresh's item id and progress
JellyfinScheduledTasksInfoEvent, JellyfinScheduledTaskEndedEvent ScheduledTasksInfo, ScheduledTaskEnded The JellyfinScheduledTask list after startScheduledTasks(), and a finished run
JellyfinKeepAliveEvent KeepAlive, ForceKeepAlive Keep-alive; ForceKeepAlive carries the server's timeout
JellyfinUnknownEvent Anything else messageType and the whole frame in raw

Decoding never throws: a frame whose Data has an unexpected shape becomes a JellyfinUnknownEvent too. Remote-control commands reach the socket of the session that owns the token, the device that signed in.

27.3 Subscriptions

Sessions and scheduled tasks are sent only on request:

jellyfin.notifications.startSessions(); // every 2 seconds by default
jellyfin.notifications.startScheduledTasks(interval: const Duration(seconds: 5)); // admin

socket.events().listen((event) {
  if (event case JellyfinSessionsEvent(:final sessions)) {
    print('${sessions.where((s) => s.isPlaying).length} playing');
  }
});

jellyfin.notifications.stopSessions();
jellyfin.notifications.stopScheduledTasks();

send(messageType:, data:) sends any other message the server accepts.


28. Downloads #

28.1 Downloading to a file

downloadToFile() is an extension in package:dart_jellyfin/io.dart, kept out of the main library so that one stays usable on the web:

import 'package:dart_jellyfin/dart_jellyfin.dart';
import 'package:dart_jellyfin/io.dart';

Future<void> saveTrack(JellyfinClient jellyfin, JellyfinItem track) async {
  final token = JellyfinCancelToken();
  final (url, _) = jellyfin.audio.directStreamUrl(itemId: track.id);
  final written = await jellyfin.downloadToFile(
    url,
    '/music/${track.id}', // the original file, as stored on the server
    cancelToken: token,   // token.cancel() stops it and removes the file
    onProgress: (received, total) => print('$received / ${total ?? '?'}'),
  );
  print('$written bytes');
}

The body goes to <path>.part as it arrives, at the pace the disk takes, and is renamed to the target only once complete. The target holds either the whole file or what was there before: a failure, a stall past receiveTimeout or a cancellation removes the partial file and throws a JellyfinException. onProgress gets the total when the server sent a Content-Length. For a smaller copy, download a transcoded file URL.


29. Escape hatch #

For a route the sub-APIs do not cover, call it directly. These calls use the same HTTP client, headers, retry policy and JellyfinException translation as the sub-APIs.

29.1 Raw request

final response = await jellyfin.request<Map<String, Object?>>(
  '/Items',
  queryParameters: {
    'userId': jellyfin.userId,
    'includeItemTypes': 'Audio',
    'recursive': true,
    'limit': 0,
  },
);
final total = response.data?['TotalRecordCount'];

request() returns a JellyfinResponse<T> with data, statusCode, headers and uri. It takes method ('POST', 'DELETE'), extraHeaders, data, absoluteUrl and cancelToken; a List in queryParameters repeats the key once for each value. responseType picks the decoding: JellyfinResponseType.json (the default: JSON when the server says so, text otherwise), plain (UTF-8 text) or bytes (a Uint8List). get(), post() and delete() are shorthands.

final m3u8 = await jellyfin.request<String>(
  '/Audio/${track.id}/main.m3u8',
  responseType: JellyfinResponseType.plain,
);
print(m3u8.data);

29.2 Raw bytes

final response = await jellyfin.requestBytes(
  '/Items/${item.id}/Download',
  absoluteUrl: false,
);
final bytes = response.data;

requestBytes() holds the whole body in memory, which suits artwork and small files. Its url is absolute by default, for a URL a builder made; with absoluteUrl: false it is a path on the server. For media, stream the body (29.3) or write it to disk (28).

29.3 Streamed body

final (url, _) = jellyfin.audio.directStreamUrl(itemId: track.id);
final res = await jellyfin.requestStream(url);
print('${res.contentLength} bytes, ${res.contentType}');
await for (final chunk in res.stream) {
  sink.add(chunk);
}

requestStream() returns a JellyfinStreamedResponse as soon as the headers arrive. Listen to stream once; cancelling the subscription closes the connection. A pause in the body longer than receiveTimeout fails the stream, unless you pass stallTimeout: false.


Project background #

I wrote the typed models, the sub-APIs and the transport with Claude Code.

Jellyfin is a trademark of the Jellyfin project. dart_jellyfin is not affiliated with it.


Developed by Alessandro Di Ronza

5
likes
160
points
435
downloads
screenshot

Documentation

API reference

Publisher

verified publisherales-drnz.com

Weekly Downloads

Dart client for Jellyfin. Supports authentication, library browsing, playlists, streaming, search and more. Targets macOS, Windows, Linux, iOS and Android.

Repository (GitHub)
View/report issues

Topics

#jellyfin #media-server #music #streaming #api-client

License

BSD-3-Clause (license)

Dependencies

http, meta, web_socket_channel

More

Packages that depend on dart_jellyfin