Add dart_plex to your pubspec.yaml:
dependencies:
dart_plex: ^0.1.0import '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
}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 and receiveTimeout. By default it owns its own Dio.
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)'.
plex.disconnect(); // clears baseUrl + tokenPlexClient 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.
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.
// 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.
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.
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).
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
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).
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.
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.
final id = await plex.server.identity();
// {machineIdentifier: …, version: …, apiVersion: …}/identity doesn't require a token. Useful for reachability checks
before re-authenticating.
final info = await plex.server.info();
// machineIdentifier, version, platform, transcoderAudio, myPlex, …Same as identity() but with the full root MediaContainer.
if (await plex.server.ping()) { /* server reachable */ }Swallows all transport errors. Use identity() directly when you need
to inspect failures.
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.
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').
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.
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.
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".
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.
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 and 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);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.
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.
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.
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.
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.
await plex.playback.scrobble(ratingKey); // mark as watched or listened
await plex.playback.unscrobble(ratingKey); // mark as unwatchedawait 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.
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.
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.
final (url, ext) = plex.streaming.directFileUrl(
partKey: track.media.first.parts.first.key!,
download: true,
);Best-quality, zero-server-CPU URL. Use for downloads and for clients that can decode whatever the file contains.
await plex.streaming.pingUniversal(sessionId); // keep alive
await plex.streaming.stopUniversal(sessionId); // teardownPlex reaps inactive sessions after ~2 minutes. Ping every 30 s while a player is paused but the session must stay warm.
// 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.
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 generalDecisionCode.
A code in the 1xxx band means playback can succeed (whether by
direct-play, direct-stream, or transcode) while 2xxx, 3xxx and 4xxx are
error bands (general, direct-play, and transcode errors respectively).
Whether a playable decision is direct or transcode is read from the
sibling directPlayDecisionCode and transcodeDecisionCode fields.
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) {
// playable, directPlayDecisionCode in 1xxx, start.{m3u8|mpd|mp4}
// will work as-is
} else if (decision.isTranscode) {
// playable, transcodeDecisionCode in 1xxx, fetch the manifest
} else {
// not playable (generalDecisionCode 2xxx/3xxx/4xxx): bandwidth,
// codec mismatch, unauthorised, ...
}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.
Same stopUniversal() and 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.
"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.
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.
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.
await plex.sessions.terminate(
sessionId: session.sessionId!,
reason: 'You started watching elsewhere',
);The reason is shown to the user whose session was killed.
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).
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".
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.
// 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.
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.
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,
);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.
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());// 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. Omit `afterPlaylistItemId` (leave it null) to move to
// the start.
await plex.playQueues.moveItem(
playQueueId: queueId,
playQueueItemId: entryId,
);
// Remove an entry.
await plex.playQueues.removeItem(
playQueueId: queueId,
playQueueItemId: entryId,
);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);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.
Plex Live TV streams broadcast and 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).
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 is not exposed by
plex.liveTv; useplex.sessions.history(). Live TV plays are reported into the same playback-history store as on-demand items.
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);DVR recording subscriptions live on their own sub-API,
plex.subscriptions (PlexSubscriptionsApi), not on plex.liveTv:
// List every recording rule. Returns the raw `MediaContainer` map.
final container = await plex.subscriptions.list();
final subs = (container['MediaContainer']?['MediaSubscription'] as List?)
?.cast<Map<String, dynamic>>() ?? const [];
for (final sub in subs) {
print('${sub['title']} - ${sub['type']}');
}
// Delete a subscription by its id.
await plex.subscriptions.delete(subs.first['id'].toString());Live TV (sessions, DVRs) results come back as raw maps. They could get typed wrappers later, but the underlying shapes are deeply nested and change often between Plex releases, so the raw map is the safer bet for now.
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.
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).
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).
// The sonic vector lives in the raw envelope under `musicAnalysis`
// (a track has one once the server has analysed it).
final seed = (track.raw['musicAnalysis'] as List).cast<num>();
final radio = await plex.library.nearestInSection(
sectionId: musicSection.key,
values: seed,
limit: 50,
);Seeds the search from a raw music-analysis vector instead of a single track. Average several such vectors together to build a "mood" radio.
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.
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.
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.
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.
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.
} 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);
}
}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.
When the typed sub-APIs don't yet cover an endpoint, drop down to:
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' or 'DELETE',
extraHeaders, data, absoluteUrl: true as needed.
final response = await plex.requestBytes(
'${plex.baseUrl}/library/parts/123/file.flac?X-Plex-Token=${plex.token}',
);
final bytes = response.data;All the typed DTOs, sub-APIs, and architectural patterns were implemented through the use of Claude Code.
Developed by Alessandro Di Ronza
