dart_plex 0.0.2 copy "dart_plex: ^0.0.2" to clipboard
dart_plex: ^0.0.2 copied to clipboard

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

dart_plex #

Plex Media Server client for Flutter and Dart.

logo dart_plex is a Dart client for Plex Media Server v1.43.x. It covers libraries, hubs, playlists, audio streaming, playback reporting, search and play queues through typed Dart objects, and handles plex.tv sign-in and server discovery.

Installation #

Add dart_plex to your pubspec.yaml:

dependencies:
  dart_plex: ^0.0.2

Contents #


Features #

Pure Dart
no native plugin, no Flutter dependency. Runs on every Dart-supported platform.
Typed DTOs
PlexMetadata, PlexMedia, PlexPart, PlexStream, PlexLibrarySection, PlexHub, PlexResource, PlexUser, PlexPin, each with factory fromJson and a .raw map for forward compatibility.
Both auth flows
signInWithPassword() for legacy credentials, createPin() + pollPin() for the official 4-character link flow at plex.tv/link.
Server discovery
account.fetchResources() returns owned and shared servers with connection candidates; bestConnection() picks local first, then https, then relay.
Stateful façade
PlexClient holds the X-Plex-* headers, the active server URL and the token; sub-APIs reuse the same Dio internally.
Audio streaming
streaming.universalAudioUrl() builds .m3u8 (HLS) or .mpd (DASH) manifest URLs; directFileUrl() gives the zero-transcode original.
Semantic errors
one PlexException hierarchy with PlexErrorType enum (auth, notFound, connection, timeout, serverError, parse, …), never raw Dio exceptions in your code.
Escape hatch
client.request<T>() and client.requestBytes() for endpoints not yet covered by the typed sub-APIs.

Quick start #

import 'package:dart_plex/dart_plex.dart';

Future<void> main() async {
  final plex = PlexClient(
    credentials: const PlexCredentials(
      clientIdentifier: 'PUT-YOUR-UUID-HERE', // stable per install
      product: 'MyApp',
      version: '1.0.0',
      device: 'iPhone',
      deviceName: "My iPhone",
      platform: 'iOS',
    ),
  );

  // 1. Authenticate against plex.tv.
  final user = await plex.account.signInWithPassword(
    username: 'me@example.com',
    password: 'hunter2',
  );
  plex.setToken(user.authToken);

  // 2. Find the user's servers and connect to one.
  final servers = await plex.account.fetchResources();
  final server = servers.firstWhere((s) => s.owned);
  final connection = server.bestConnection()!;
  plex.connect(connection.uri, accessToken: server.accessToken);

  // 3. Browse the music library.
  final sections = await plex.library.sections();
  final musicLib = sections.firstWhere((s) => s.type == PlexLibraryType.music);
  final albums = await plex.library.allByType(
    sectionId: musicLib.id,
    type: PlexMetadataType.album,
    sort: 'titleSort:asc',
    size: 50,
  );

  // 4. Play a track.
  final track = albums.items.first;
  final url = plex.streaming.universalAudioUrl(
    ratingKey: track.ratingKey,
    protocol: 'hls',
    audioCodec: 'aac',
    maxAudioBitrate: 320,
  );
  // hand `url` to your audio engine
}

Guide #

1. Initialization and lifecycle #

1.1 Creating a client

final plex = PlexClient(
  credentials: const PlexCredentials(
    clientIdentifier: '2f5b-…-uuid',
    product: 'MyApp',
    version: '1.0.0',
    device: 'iPhone',
    deviceName: "My iPhone",
    platform: 'iOS',
  ),
);

The constructor optionally accepts a custom dio: Dio() instance plus connectTimeout / receiveTimeout. By default it owns its own Dio.

1.2 Credentials

PlexCredentials describes who the client is. Every request carries the corresponding X-Plex-* headers; the most important is clientIdentifier, a stable per-installation UUID. Generate it once, persist it (SharedPreferences, Keychain, Android Keystore…), and reuse it forever. Changing it invalidates every previously-issued token.

Optional fields:

  • platformVersion is sent as X-Plex-Platform-Version.
  • clientProfileExtra is sent as X-Plex-Client-Profile-Extra. Used to declare extra transcode targets the server should support for this client, e.g. 'add-transcode-target(type=musicProfile&context=streaming&protocol=hls&container=mpegts&audioCodec=aac,mp3)'.

1.3 Disposing

plex.disconnect(); // clears baseUrl + token

PlexClient 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 if it owns native HTTP connections.


2. Authentication #

2.1 Legacy username and password

final user = await plex.account.signInWithPassword(
  username: 'me@example.com',
  password: 'hunter2',
);
plex.setToken(user.authToken);

Returns a PlexUser with authToken, profile fields and Plex Pass status. The token is not automatically applied to the client; call plex.setToken(user.authToken) after a successful sign-in. Throws PlexException(type: PlexErrorType.auth) on bad credentials.

Plex officially recommends the PIN flow for new integrations because accounts with 2FA enabled cannot use this path. Keep this flow only for migrations from older clients.

2.2 PIN flow

// 1. Generate a PIN.
final pin = await plex.account.createPin(strong: true);
print('Open https://plex.tv/link and enter ${pin.code}');

// 2. Poll until the user authorises the device.
while (true) {
  await Future<void>.delayed(const Duration(seconds: 2));
  final fresh = await plex.account.pollPin(pin.id);
  if (fresh.isAuthenticated) {
    plex.setToken(fresh.authToken!);
    break;
  }
  if (fresh.isExpired) throw StateError('PIN expired');
}

strong: true opts into JWT-grade tokens (recommended). The pin expires after ~15 minutes.

2.3 Account info

final me = await plex.account.currentUser();
print('${me.username} (${me.email})');

Returns the same PlexUser shape as signInWithPassword but built from /api/v2/user. Requires a valid token.

2.4 Sign out

await plex.account.signOut();
plex.setToken(null);

Invalidates the token on plex.tv's side. After this, subsequent PMS calls would throw PlexException(type: PlexErrorType.auth).


3. Server discovery and connection #

3.1 Fetch resources

final servers = await plex.account.fetchResources(
  includeHttps: true,
  includeRelay: true,
  includeIPv6: true,
  serverOnly: true,
);

serverOnly: true (default) filters resources to those whose provides includes "server", dropping player and client resources.

Each PlexResource carries:

  • name, clientIdentifier, platform, productVersion, owned, home, relay, presence, httpsRequired
  • accessToken, the per-server token (use this when connecting, not the account-level token)
  • connections, a list of PlexServerConnection candidates

3.2 Picking the best connection

final connection = server.bestConnection();

Picks in priority order:

  1. Local, non-relay
  2. HTTPS, non-relay
  3. Any non-relay
  4. Relay (last resort)

Each PlexServerConnection has protocol, address, port, uri, local, relay, ipv6. You can iterate server.connections and implement your own selection (e.g. parallel-race the candidates with a 2-second timeout).

3.3 Connecting to a PMS

plex.connect(connection.uri, accessToken: server.accessToken);

accessToken overrides the current token. For shared servers it's mandatory, the account-level token won't be authorised on someone else's server.

3.4 Switching servers

PlexClient is single-server-at-a-time. To switch, call connect() with the new URI and token. To keep two servers alive simultaneously, instantiate two PlexClients with the same PlexCredentials.


4. Server info #

4.1 Identity (no auth)

final id = await plex.server.identity();
// {machineIdentifier: …, version: …, apiVersion: …}

/identity doesn't require a token. Useful for reachability checks before re-authenticating.

4.2 Full info

final info = await plex.server.info();
// machineIdentifier, version, platform, transcoderAudio, myPlex, …

Same as identity() but with the full root MediaContainer.

4.3 Ping

if (await plex.server.ping()) { /* server reachable */ }

Swallows all transport errors. Use identity() directly when you need to inspect failures.


5. Library browsing #

5.1 Sections

final sections = await plex.library.sections();
for (final s in sections) {
  print('${s.type.name}: ${s.title} (key=${s.id})');
}

PlexLibraryType is one of music, movie, show, photo, unknown. The id field is the section's key, what every /library/sections/{id}/... call expects.

5.2 Items by type

final page = await plex.library.allByType(
  sectionId: musicLib.id,
  type: PlexMetadataType.album,
  start: 0,
  size: 50,
  sort: 'titleSort:asc',
);
print('Got ${page.items.length} of ${page.totalSize}');

PlexMetadataType integers mirror Plex's wire values:

Type Value
movie 1
show 2
season 3
episode 4
artist 8 music
album 9 music
track 10 music
photoAlbum 13
photo 14
playlist 15
collection 18

Sort examples: 'titleSort:asc', 'addedAt:desc', 'lastViewedAt:desc', 'originallyAvailableAt:desc', 'random:<seed>'.

filter accepts the raw Plex filter expression ('genre=Rock', 'year>=2010', 'userRating>>=8').

5.3 Counting items

final count = await plex.library.countByType(
  sectionId: musicLib.id,
  type: PlexMetadataType.album,
);

Issues a request with X-Plex-Container-Size=0 so the server answers with totalSize without streaming items.

5.4 Single item metadata

final metadata = await plex.library.item('12345');
if (metadata != null) {
  print(metadata.title);
  print(metadata.media.first.audioCodec); // e.g. 'flac'
}

Returns null on 404. The DTO carries media, genres, moods, styles, plus raw for fields not promoted to typed properties.

5.5 Children and leaves

final tracks = await plex.library.children('albumId');
final allTracksInArtist = await plex.library.allLeaves('artistId');

children() returns direct descendants (album to tracks, artist to albums, season to episodes). allLeaves() returns every leaf-level item recursively, useful for "play artist" or "play show".

5.6 Genres

final genres = await plex.library.genres(
  sectionId: musicLib.id,
  type: PlexMetadataType.album,
);

Each entry is a PlexMetadata whose key is the genre id; pass it back as filter: 'genre=<id>' to filter allByType.

5.7 Filters, albums, folders, categories

filters() returns the list of facets the server can sort or filter on. Each Directory carries a Pivot array (in its raw map) with pre-built sub-views like the A-Z index and folder view.

final facets = await plex.library.filters(sectionId: musicLib.id);
for (final f in facets) {
  print('${f.title} key=${f.key} type=${f.type}');
}

albums(), folderLocations(), and categories() are cheap sub-bucket endpoints when the consumer does not need the full sort/filter machinery of allByType:

final paged = await plex.library.albums(
  sectionId: musicLib.id,
  start: 0,
  size: 100,
);
final folders = await plex.library.folderLocations(sectionId: musicLib.id);
final cats = await plex.library.categories(sectionId: photoLib.id);

6. Playlists #

6.1 Listing and counting

final audio = await plex.playlists.list(type: 'audio', start: 0, size: 50);
final count = await plex.playlists.count(type: 'audio');

type is 'audio' | 'video' | 'photo'. Pass smart: true to filter to smart playlists. Each entry is a PlexMetadata of PlexMetadataType.playlist.

6.2 Creating a playlist

final identity = await plex.server.identity();
final machineId = identity['machineIdentifier'] as String;

final playlist = await plex.playlists.create(
  title: 'My Mix',
  type: 'audio',
  machineIdentifier: machineId,
  itemRatingKeys: const ['12345', '12346', '12347'],
);

Plex requires the server's machineIdentifier to build the playlist URI. Pass an empty itemRatingKeys to create an empty playlist.

6.3 Adding and removing items

await plex.playlists.addItems(
  playlistId: playlist.ratingKey,
  machineIdentifier: machineId,
  itemRatingKeys: const ['99999'],
);

// To remove, find the playlistItemID from .raw on each entry returned
// by .items().
final items = await plex.playlists.items(playlist.ratingKey);
final entryId = items.first.raw['playlistItemID'].toString();
await plex.playlists.removeItem(
  playlistId: playlist.ratingKey,
  playlistItemId: entryId,
);

⚠️ removeItem expects the playlistItemID, not the underlying rating key. Reading it from items.raw[…] is currently the only way; a typed field will be added when promoted out of .raw.

6.4 Renaming and deleting

await plex.playlists.rename(playlistId: '123', title: 'New name');
await plex.playlists.delete('123');

final hubs = await plex.search.hubs(
  query: 'pink floyd',
  sectionId: musicLib.id, // optional, scope to a single library
  limit: 10,
);

for (final hub in hubs) {
  print('${hub.title} (${hub.type})');
  for (final hit in hub.items) {
    print('  - ${hit.title}');
  }
}

Modern type-as-you-go search; returns one PlexHub per result category (artists, albums, tracks, …).

final results = await plex.search.flat(query: 'pink floyd', limit: 30);

Older endpoint; returns a flat List<PlexMetadata> mixing all categories. Prefer hubs() when available.


8. Playback reporting #

8.1 Timeline heartbeat

await plex.playback.timeline(
  ratingKey: track.ratingKey,
  state: PlexPlaybackApi.statePlaying, // 'playing' | 'paused' | 'stopped' | 'buffering'
  timeMs: 42_000,
  durationMs: 240_000,
  playQueueItemId: 'optional-pq-id',
  continuing: false,
);

Plex recommends sending a timeline every 10 s on LAN, 20 s on cellular, plus one on every state change.

continuing: true tells the server "I'm about to start a different track in the same playback flow", so Plex won't reap the current transcode session, which matters during gapless prefetch.

8.2 Scrobble and unscrobble

await plex.playback.scrobble(ratingKey);   // mark as watched or listened
await plex.playback.unscrobble(ratingKey); // mark as unwatched

8.3 Rate and favourites

await plex.playback.rate(ratingKey: ratingKey, rating: 7.5); // 0..10
await plex.playback.setFavorite(ratingKey: ratingKey, isFavorite: true);

Plex does not have a separate "is favourite" flag; favourites are encoded as userRating == 10. setFavorite is sugar around rate.


9. 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 X-Plex-Token is appended as a query parameter so segment requests work without custom headers.

9.1 Universal transcode URL

final url = plex.streaming.universalAudioUrl(
  ratingKey: track.ratingKey,
  protocol: 'hls',          // 'hls' | 'dash' | 'http'
  container: 'mpegts',      // for HLS use 'mpegts'; for direct mp3 use 'mp3'
  audioCodec: 'aac',
  maxAudioBitrate: 320,     // kbps; pass null to let the server pick
  audioChannels: 2,
  session: 'my-uuid',       // stable per playback session
  directPlay: false,
  directStream: true,
);

The returned URL extension is derived from protocol: .m3u8 for HLS, .mpd for DASH, the supplied container for HTTP.

9.2 Direct file URL

final (url, ext) = plex.streaming.directFileUrl(
  partId: track.media.first.parts.first.id!.toString(),
  container: 'flac',
  download: true,
);

Best-quality, zero-server-CPU URL. Use for downloads and for clients that can decode whatever the file contains.

9.3 Transcode session lifecycle

await plex.streaming.pingUniversal(sessionId);  // keep alive
await plex.streaming.stopUniversal(sessionId);  // teardown

Plex reaps inactive sessions after ~2 minutes. Ping every 30 s while a player is paused but the session must stay warm.

9.4 Lyrics

// streamKey is the .key of a track's Stream entry with streamType == 4.
final lyrics = await plex.streaming.lyrics(streamKey: streamKey);

Returns the raw lyrics body (LRC or plain text) or null if not available. Parsing into typed LyricLine is left to the consumer.


10. Video streaming and decision #

For video, the universal endpoint alone is not enough. Plex wants a /decision round-trip first: the server inspects the source and your client profile and answers with a numeric code telling you whether to direct-play (1xxx) or transcode (2xxx).

10.1 Decision call

final decision = await plex.streaming.decisionUniversal(
  params: {
    'path': '/library/metadata/$ratingKey',
    'mediaIndex': 0,
    'partIndex': 0,
    'protocol': 'hls',
    'directPlay': 0,
    'directStream': 1,
    'videoResolution': '1920x1080',
    'maxVideoBitrate': 8000,
    'videoCodec': 'h264',
    'audioCodec': 'aac',
    'session': 'my-uuid',
  },
  extraHeaders: const {
    // Pin the music or video profile per-call without touching the
    // global PlexCredentials.clientProfileExtra.
    'X-Plex-Client-Profile-Extra':
        'add-transcode-target(type=videoProfile&context=streaming'
        '&protocol=hls&container=mpegts&videoCodec=h264&audioCodec=aac)',
  },
);

if (decision.isDirect) {
  // direct-play / direct-stream — start.{m3u8|mpd|mp4} will work as-is
} else if (decision.isTranscode) {
  // server agreed to transcode — fetch the manifest
} else {
  // server refused: codec mismatch, unauthorised, ...
}

10.2 Universal video URL

final url = plex.streaming.universalVideoUrl(
  ratingKey: ratingKey,
  protocol: 'hls',
  container: 'mpegts',
  videoResolution: '1920x1080',
  videoBitrate: 8000,
  audioBitrate: 256,
  videoCodec: 'h264',
  audioCodec: 'aac',
  subtitleSize: 100,
  session: 'my-uuid',
);

The URL extension is derived from protocol: .m3u8 for HLS, .mpd for DASH, the container otherwise. Bandwidth is in kbps.

10.3 Session lifecycle

Same stopUniversal() / pingUniversal() calls as audio — Plex uses one universal endpoint family for both. Ping every 30 s while the player is paused to keep the session warm; call stopUniversal() when the user navigates away.


11. Sessions #

"What's playing on the server right now" plus historical scrobble data. Useful for multi-room awareness, dashboards, or to avoid starting a new transcode when the user is already streaming.

11.1 Active sessions

final sessions = await plex.sessions.active();
for (final s in sessions) {
  print('${s.user?.title} → ${s.metadata.title}'
        ' (${s.player?.state}, ${s.viewOffsetMs} ms,'
        ' ${s.transcodeSession == null ? 'direct' : 'transcode'})');
}

Each [PlexSession] carries the full [PlexMetadata] of the item being played plus user, player, transcodeSession and a sessionId you can pass to 11.3.

11.2 Playback history

final history = await plex.sessions.history(
  accountId: 1,
  mindate: DateTime.now()
      .subtract(const Duration(days: 30))
      .millisecondsSinceEpoch ~/ 1000,
  size: 100,
  sort: 'viewedAt:desc',
);

Returns raw maps (viewedAt, ratingKey, accountID, deviceID) so callers stay flexible. Promote a typed DTO when usage justifies it.

11.3 Terminating a session

await plex.sessions.terminate(
  sessionId: session.sessionId!,
  reason: 'You started watching elsewhere',
);

The reason is shown to the user whose session was killed.


12. Images #

12.1 Building a transcoded URL

final url = plex.images.transcodeUrl(
  sourcePath: metadata.thumb!,  // e.g. '/library/metadata/123/thumb/1700000000'
  width: 500,
  height: 500,
  minSize: 1,
  upscale: false,
);

Goes through /photo/:/transcode so the server delivers a pre-resized JPEG (~50-150KB at 500×500).

12.2 Fetching bytes

final Uint8List? bytes = await plex.images.fetch(
  sourcePath: metadata.thumb!,
  width: 500,
  height: 500,
);

Returns null on 404. Throws PlexException on transient failures (5xx, network down) so the caller can distinguish "no artwork ever" from "try again later".


13. Hubs #

Hubs power Plex's "Home" screen — Recently Added Music, Continue Listening, More from <artist>, On Deck. Each hub is a typed row of items; render them as horizontal carousels.

13.1 Global and per-section hubs

// Global hubs (all libraries).
final globalHubs = await plex.hubs.global(count: 16);

// Hubs scoped to one library section.
final musicHubs = await plex.hubs.forSection(
  sectionId: musicLib.id,
  count: 16,
);

for (final hub in musicHubs) {
  print('${hub.title}: ${hub.items.length} items');
}

Each [PlexHub] carries a small preview of items in hub.items plus a more flag indicating whether the rail has more entries behind a Show all action.

13.2 Promoted and on-deck

final promoted = await plex.hubs.promoted();
final onDeck   = await plex.hubs.continueWatching();
final perLibrary = await plex.hubs.sectionOnDeck(
  sectionId: musicLib.id,
);

continueWatching() and sectionOnDeck() return [List<PlexMetadata>] directly (not wrapped in a hub) since the data is already a flat list of "Up Next" entries.

13.3 Drilling into a hub

When hub.more == true and the user taps "Show all", fetch the rest:

final all = await plex.hubs.drill(
  hubKeyOrIdentifier: hub.hubKey, // or hub.hubIdentifier
  start: 0,
  size: 100,
);

14. Play queues #

Plex's canonical queue model. A play queue holds the currently-playing item, what's next, and any shuffled order. Casting to a Plex TV client reuses the same queue, and the queue ID is what drives the cross-device "Resume" behaviour.

You can skip queues for local-only playback (just use library + streaming). Reach for them when you want cast-friendly state, persisted "Up Next", or party-mode add-to-queue from another device.

14.1 Creating a queue

final identity = await plex.server.identity();
final machineId = identity['machineIdentifier'] as String;

final uri = PlexPlayQueuesApi.seedFromItems(
  machineIdentifier: machineId,
  ratingKeys: const ['12345', '12346', '12347'],
);
final queue = await plex.playQueues.create(
  type: 'audio',
  uri: uri,
  shuffle: false,
  continuous: true,
);
final queueId = int.parse(queue.raw['playQueueID'].toString());

14.2 Reading and editing

// Read the queue contents (optionally centred on the currently
// playing entry).
final items = await plex.playQueues.items(
  playQueueId: queueId,
  center: currentPlayQueueItemId,
  window: 50,
);

// Append more items.
await plex.playQueues.addItems(
  playQueueId: queueId,
  uri: PlexPlayQueuesApi.seedFromItems(
    machineIdentifier: machineId,
    ratingKeys: const ['99999'],
  ),
);

// "Play next" — splice right after the current item.
await plex.playQueues.addItems(
  playQueueId: queueId,
  uri: PlexPlayQueuesApi.seedFromItems(
    machineIdentifier: machineId,
    ratingKeys: const ['99998'],
  ),
  playNext: true,
);

// Move an entry. Pass `afterPlaylistItemId: 0` to move to the start.
await plex.playQueues.moveItem(
  playQueueId: queueId,
  playQueueItemId: entryId,
  afterPlaylistItemId: 0,
);

// Remove an entry.
await plex.playQueues.removeItem(
  playQueueId: queueId,
  playQueueItemId: entryId,
);

14.3 Shuffle, unshuffle, reset, clear

await plex.playQueues.shuffle(queueId);
await plex.playQueues.unshuffle(queueId);

// Rewind the queue so the first item is current again.
await plex.playQueues.reset(queueId);

// Drop every item. The queue id stays valid.
await plex.playQueues.clear(queueId);

14.4 Adding a Plex playlist

await plex.playQueues.addItems(
  playQueueId: queueId,
  playlistId: playlist.ratingKey,
);

uri and playlistId are mutually exclusive on the upstream endpoint. Use playlistId to splice every track of a Plex playlist into the queue.


15. Live TV and DVR #

Plex Live TV streams broadcast/cable channels through a tuner + listings provider; DVR records scheduled programs to disk. This sub-API covers the consumer-facing slice — current sessions, DVR backends, recording subscriptions. Tuner provisioning and EPG listings provider setup stay on the escape hatch (admin-only).

15.1 Active sessions

final liveSessions = await plex.liveTv.sessions();
for (final entry in liveSessions) {
  print('${entry['title']} on ${entry['channelCallSign']}'
        ' (${entry['Player']?['title']})');
}

// Past live-TV viewing history:
final history = await plex.liveTv.history(start: 0, size: 50);

15.2 DVR backends

final dvrs = await plex.liveTv.dvrs();
for (final dvr in dvrs) {
  print('${dvr['title']} (${dvr['lineup']?['name']})');
}

// Drill into the channels grouped under one DVR:
final channels = await plex.liveTv.dvrChannels(dvrs.first['key'] as String);

15.3 Subscriptions

final subs = await plex.liveTv.subscriptions();
for (final sub in subs) {
  print('${sub['title']} — ${sub['subscriptionType']}');
}

// Cancel a subscription.
await plex.liveTv.cancelSubscription(subs.first['key'] as String);

Live TV results are returned as raw maps. Typed DTOs can be promoted later if the consumer pressure justifies it; the upstream shapes (DVR, MediaSubscription) are deeply nested and frequently change between PMS releases.


16. Similar items and sonic radio #

Three endpoints for "what should I play next". The first returns the server's general similar-items judgment (album to album, movie to movie). The other two read the music sonic-analysis index that PMS maintains for every track to surface sonically nearest tracks.

16.1 Similar items

final picks = await plex.library.similar(
  ratingKey: album.ratingKey,
  count: 12,
);

Works for albums, artists, movies, and shows (any metadata item the server has tagged with similarity).

16.2 Sonically nearest tracks

final nearest = await plex.library.nearestToTrack(
  ratingKey: track.ratingKey,
  limit: 25,
  maxDistance: 0.25,
);

Use excludeParentID and excludeGrandparentID to keep the album or artist of the seed out of the result (useful when the seed track dominates the radio).

16.3 Nearest tracks in a section

final radio = await plex.library.nearestInSection(
  sectionId: musicSection.key,
  values: track.musicAnalysisValues,
  limit: 50,
);

Seeds the search from a music-analysis vector instead of a single track. Average several vectors together to build a "mood" radio.


17. UltraBlur #

Plex servers can extract a four-corner colour palette from any image and render that palette into a smooth gradient. Saves the client the cost of running k-means locally, and the rendered image can be cached just like any other transcoded artwork.

17.1 Extracting colours

final palettes = await plex.ultraBlur.colors(
  sourceUrl: album.thumb!,
);
final palette = palettes.first;
print('${palette.topLeft} ${palette.topRight} ${palette.bottomLeft} ${palette.bottomRight}');

The container shape returns an array; pick the first element for the common case. sourceUrl accepts a relative PMS path (most common) or an absolute URL.

17.2 Server-rendered backdrop

final url = plex.ultraBlur.imageUrl(
  topLeft: palette.topLeft,
  topRight: palette.topRight,
  bottomLeft: palette.bottomLeft,
  bottomRight: palette.bottomRight,
  width: 1920,
  height: 1080,
  noise: 1,
);

Pass noise: 1 when the image will be used behind text so the server adds a small amount of dither to reduce gradient banding.

17.3 Fetching the rendered image

final bytes = await plex.ultraBlur.fetchImage(
  topLeft: palette.topLeft,
  topRight: palette.topRight,
  bottomLeft: palette.bottomLeft,
  bottomRight: palette.bottomRight,
  width: 1280,
  height: 720,
);

Convenience wrapper around imageUrl plus plex.requestBytes for when you want to cache the PNG yourself.


18. Error handling #

18.1 PlexException and PlexErrorType

Every public call throws PlexException on failure:

try {
  await plex.library.sections();
} on PlexException catch (e) {
  print('${e.type} → ${e.statusCode} → ${e.message}');
}

PlexErrorType values: connection, timeout, auth, notFound, badRequest, serverError, parse, state, unknown.

18.2 Retriable vs terminal

} on PlexException catch (e) {
  if (e.isRetriable) {       // connection or timeout
    scheduleRetry();
  } else if (e.isAuthError) { // 401 or 403, token rejected
    await reAuthenticate();
  } else {
    surfaceError(e.message);
  }
}

18.3 Auth invalidation

PlexException.isAuthError is the signal to re-run the PIN flow or the legacy sign-in. The library will not automatically re-fetch a token; that's an app-level policy decision.


19. Escape hatch #

When the typed sub-APIs don't yet cover an endpoint, drop down to:

19.1 Raw request

final response = await plex.request<Map<String, dynamic>>(
  '/library/sections/$id/all',
  queryParameters: {'type': 9, 'X-Plex-Container-Size': 50},
);
final container = response.data?['MediaContainer'];

Same Dio, same headers, same PlexException translation as the typed sub-APIs. Pass method: 'POST'/'PUT'/'DELETE', extraHeaders, data, absoluteUrl: true as needed.

19.2 Raw bytes

final response = await plex.requestBytes(
  '${plex.baseUrl}/library/parts/123/file.flac?X-Plex-Token=${plex.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

4
likes
160
points
102
downloads
screenshot

Documentation

API reference

Publisher

verified publisherales-drnz.com

Weekly Downloads

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

Repository (GitHub)
View/report issues

Topics

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

License

BSD-3-Clause (license)

Dependencies

dio, meta, web_socket_channel

More

Packages that depend on dart_plex