dart_plex 0.0.2
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.
![]() |
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 DTOsPlexMetadata, PlexMedia, PlexPart, PlexStream, PlexLibrarySection, PlexHub, PlexResource, PlexUser, PlexPin, each with factory fromJson and a .raw map for forward compatibility. |
||
Both auth flowssignInWithPassword() for legacy credentials, createPin() + pollPin() for the official 4-character link flow at plex.tv/link. |
Server discoveryaccount.fetchResources() returns owned and shared servers with connection candidates; bestConnection() picks local first, then https, then relay. |
||
Stateful façadePlexClient holds the X-Plex-* headers, the active server URL and the token; sub-APIs reuse the same Dio internally. |
Audio streamingstreaming.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 hatchclient.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:
platformVersionis sent asX-Plex-Platform-Version.clientProfileExtrais sent asX-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,httpsRequiredaccessToken, the per-server token (use this when connecting, not the account-level token)connections, a list ofPlexServerConnectioncandidates
3.2 Picking the best connection
final connection = server.bestConnection();
Picks in priority order:
- Local, non-relay
- HTTPS, non-relay
- Any non-relay
- 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,
);
⚠️
removeItemexpects the playlistItemID, not the underlying rating key. Reading it fromitems.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');
7. Search #
7.1 Hub search
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, …).
7.2 Legacy flat search
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

