Status: design only. This page captures the agreed design for a read-only Plex music provider so a contributor can build it in small, reviewable PRs. No Plex code ships with this document. It is the first PR in the sequence described in issue #178 and the Suggested PR steps below; it does not touch Jellyfin, Navidrome/Subsonic, or Local music, and it does not register a provider, change playback, or bump the version.
Plex Media Server (PMS) is a self-hosted media server
many people already run for their own music. A Plex MusicSource lets those
users play their library in Linthra the same way Jellyfin and Navidrome/Subsonic
users already can. Plex Media Server is the user's own server, not a closed
streaming service, so it fits Linthra's "music you own or host yourself" stance
(see providers.md → Non-goals).
This design slots behind the existing MusicSource seam
(lib/core/services/music_source.dart) and the capability model
(lib/core/sources/music_provider.dart) described in
architecture.md and providers.md, without
changing any existing provider path.
Phase 1 is a read-only Plex provider, mirroring how Subsonic shipped streaming first and deferred the rest:
- Connect to a Plex Media Server.
- Token-based authentication — the plex.tv PIN sign-in ("Connect with Plex", shipped after the phase-1 polish) as the primary flow, with the original manual server URL + manual token kept as the advanced fallback.
- Discover and let the user select which music libraries (sections) to use.
- List artists, albums, and tracks.
- Stream tracks — direct play only (no server-side transcoding).
- Load cover art.
- No writes to the server.
Declared unsupported in the capability model so their actions stay hidden/disabled rather than failing — exactly how Subsonic deferred favorites/etc:
- Offline downloads / caching.
- Cache management.
- Playlist sync.
- Favorites sync.
- Android Auto-specific artwork changes.
- Server-side transcoding.
- Any change to existing providers (Jellyfin, Subsonic/Navidrome, Local).
PMS exposes an HTTP API. Default responses are XML; pass
Accept: application/json to get JSON (the first big difference from
Jellyfin/Subsonic, which are JSON-native — see Risks).
Plex uses three numeric metadata types for music: 8 = artist, 9 = album,
10 = track. Every item carries a stable per-server ratingKey.
All endpoints are relative to the chosen server base URL.
| Purpose | Endpoint | Notes |
|---|---|---|
| Server identity / reachability | GET /identity (or /) |
machineIdentifier, version. Mirrors Jellyfin /System/Info/Public and Subsonic ping. |
| List libraries (sections) | GET /library/sections |
Directory entries; keep those with type == "artist" (music). Each has key, title, uuid. |
| Artists | GET /library/sections/{key}/all?type=8 |
|
| Albums | GET /library/sections/{key}/all?type=9 |
Album carries parentRatingKey → artist. |
| Tracks | GET /library/sections/{key}/all?type=10 |
Track carries grandparentRatingKey → artist, parentRatingKey → album. |
| Drill-down (alternative to flat lists) | GET /library/metadata/{ratingKey}/children |
An artist's albums or an album's tracks. |
| Single item (needed at play time) | GET /library/metadata/{ratingKey} |
Media[].Part[].key is the actual stream path. |
| Stream (direct play) | GET {server}{Part.key}?X-Plex-Token=… |
e.g. /library/parts/12345/…/file.flac. The Part key is not the ratingKey. |
| Cover art | GET {server}{thumb}?X-Plex-Token=… |
Items carry a thumb path, e.g. /library/metadata/123/thumb/…. |
- Pagination: large libraries page via
X-Plex-Container-Start/X-Plex-Container-Size(header or query);MediaContainer.totalSizereports the total. Reuse the paged-walk shape Subsonic already uses for album lists. - Direct play only: the transcoder (
/music/:/transcode/universal) is explicitly out of scope for phase 1. A sizing photo transcoder (/photo/:/transcode?url=…&width=…&height=…) exists for art but is optional. - Two-step play resolution: because the Part
keydiffers from theratingKey, a track's playable URL needs aGET /library/metadata/{ratingKey}lookup at play time (see MusicSource mapping).
Plex auth centers on the X-Plex-Token, sent either as the X-Plex-Token
header (API calls) or a query param (unavoidable for stream/art URLs handed to
the audio/image layers). Every request also needs client-identity headers:
X-Plex-Client-Identifier (a stable per-install UUID), X-Plex-Product,
X-Plex-Version, X-Plex-Platform, X-Plex-Device. This is analogous to
Jellyfin's device id + Authorization client header.
- PIN sign-in via plex.tv — the primary flow (shipped). "Connect with
Plex" in Settings:
POST https://plex.tv/api/v2/pins?strong=true→{id, code}; the browser openshttps://app.plex.tv/auth#?clientID=…&code=…&context[device][product]=…(parameters in the fragment, bound to the sameX-Plex-Client-Identifierthat minted the PIN); Linthra pollsGET https://plex.tv/api/v2/pins/{id}every 2s untilauthTokenis populated (tolerating transient failures while the user is away in the browser; a 404 means the PIN lapsed). With the account token,GET https://plex.tv/api/v2/resources?includeHttps=1&includeRelay=1returns the user's servers, each with its own per-serveraccessTokenand connection URIs. One server connects directly; several show a picker (owned servers first); none shows a clean empty state. The picked server's connections are probed in order (relay last) againstGET /identity, and only then is a session persisted. The account token lives only in memory for the duration of the flow and is released the moment the flow ends — connected, cancelled, or failed. Implementation:plex_tv_endpoints.dart/plex_tv_api.dart/plex_tv_client.dart/http_plex_tv_client.dart/plex_pin_auth.dart. - Manual token + server URL — the advanced fallback (kept). The user
pastes their
X-Plex-Tokenand server URL under "Manual setup (advanced)"; Linthra verifies it against/identity. Closest to the existing Jellyfin/Subsonic "URL + credential, verify against the server" flow, with no browser handoff — useful for dev setups and tokens scoped by hand.
A Plex account can host a Plex Home — several profiles (the owner plus
managed users like a partner or a kids profile) sharing one account. After the
PIN grants the account token, Linthra lists the Home users
(GET https://plex.tv/api/v2/home/users) and, when there is more than one,
shows a user picker before the server step and any sync. Picking a profile
is what keeps onboarding fast and scoped — only that profile's library is
synced:
- Owner/admin — already who the account token belongs to, so no switch is made; the flow continues with the account token.
- A managed profile — Linthra switches into it
(
POST https://plex.tv/api/v2/home/users/{uuid}/switch, plus the profile's PIN when it is protected) to mint that profile's own token, then lists servers and builds the session with it. A restricted profile's token only sees the libraries the owner shared, so the synced catalog mirrors exactly what that person may play.
The picker is an enhancement, never a gate: an account without Plex Home (one user), or a plex.tv hiccup while listing profiles, simply skips it and connects as the owner — identical to the pre-picker behaviour. As before, nothing syncs automatically on connect (a fresh sign-in starts with an empty selection and clears the Plex catalog slice rather than kicking a full library walk); the existing library picker + Sync path is unchanged.
Implementation: the listing/switch live in plex_tv_endpoints.dart /
plex_tv_api.dart (PlexHomeUser) / plex_tv_client.dart /
http_plex_tv_client.dart; the flow seam (fetchHomeUsers / switchToUser)
in plex_pin_auth.dart; and the new UI states (loadingUsers / pickingUser,
PlexUserChoice) in the settings controller/section. Token safety is the same
as the rest of the flow: the per-profile token is minted by the switch, lives
only in the controller's in-memory flow state until the session is persisted
(encrypted), and never reaches state, a log, or an exception. The switch URL
carries the profile PIN (a short, low-entropy local PIN — not the
X-Plex-Token) as a query param; the account token always rides in the header.
Prefer the per-server accessToken from the resources endpoint over the
account token: the account token grants access to the whole Plex account,
not just one server — a far bigger blast radius if it ever leaks. The design
defaults to the narrowest token that works. The shipped PIN flow follows
this: PlexPinAuth.connectToServer persists the picked server's
accessToken and falls back to the account token only when plex.tv didn't
provide one; the account token itself is never persisted anywhere.
These follow the existing non-negotiables documented in providers.md and enforced for Jellyfin/Subsonic — Plex adds one extra concern (the token rides in query params for stream/art URLs).
- Store only the token (prefer the server-scoped
accessToken), encrypted viaflutter_secure_storage, in asecure_plex_session_store.dartunder a single versioned key (e.g.plex_session_v1), with anInMemoryPlexSessionStorefor tests. - Never persist a password. The PIN flow never sees one (the user signs in to plex.tv in their own browser); only the resulting token is kept. The account token granted by the PIN exists only in controller memory while the flow runs (poll → server pick) and is released when the flow ends.
- plex.tv calls keep the token in the header. The pins/resources URLs are
token-free; the only token-bearing wire DTO (
PlexResource.accessToken) redacts it intoString, and the settings state exposes display-safePlexServerChoices instead of resources. - No token in
Track.uriorTrack.artworkUri(or the DB). Keep the opaque, credential-freeplex:<ratingKey>andplex-thumb:<…>references; mint credentialed URLs only at play/render time and discard them. - No token in logs, diagnostics, cache filenames, or errors. Never log the
token (header or query param), never surface it in a UI error, never put
it in a cache filename.
PlexSession.toString()must redact the token. - Guard URL logging centrally. Because the token rides in stream/art
query params, a single leaked URL log line exposes the whole token.
Redact
X-Plex-Token=…centrally in the HTTP layer. - Diagnostics are secret-free by construction — reduce the server address to its host only and assert in tests that no token reaches any sink.
The MusicSource contract is small (id, displayName,
fetchTracks/Albums/Artists, resolvePlayableUri). Proposed mapping, mirroring
JellyfinTrackMapper / SubsonicTrackMapper:
id→plex;displayName→Plex · <server name>.- URI scheme →
plex:(opaque, credential-free), registered inMusicProviders.forTrackUri(lib/core/sources/music_provider.dart) next to the existingjellyfin:/subsonic:prefixes. This is the only shared- code edit phase 1 makes (plus a new provider-matrix row in docs). - Type mapping → Plex 8 →
Artist, 9 →Album, 10 →Track, usingparentRatingKey/grandparentRatingKeyto fill artist/album names, mirroring Jellyfin's mapper, with missing-field fallbacks. A track'sindex→Track.trackNumber(album order),parentTitle→albumName, andgrandparentTitle(the album artist) →artistName. Two fallbacks mirror the other providers so a Plex track carries the same canonical fields: the artist falls back to the track's own creditedoriginalTitlewhen PMS didn't denormalise the album-artist link (Jellyfin doesalbumArtist ?? artist), and a track with nothumbfalls back to its album cover (parentThumb) so it shows art the way a Subsonic track'scoverArtalways does. The sharedTrackmodel carries no disc number, so Plex'sparentIndexis left unmapped — matching Jellyfin and Subsonic, which carry no disc field either. - Track URIs →
plex:<ratingKey>. TheratingKeyis the stable per-server id; it is not the Part key, so it carries no credential and never names a file path. - Artwork references →
plex-thumb:<…>stored inTrack.artworkUri(mirroring Subsonic'ssubsonic-cover:), with the token woven in only at render time. resolvePlayableUri→ resolve at play time:GET /library/metadata/{ratingKey}→ readMedia[0].Part[0].key→ build{baseUrl}{partKey}?X-Plex-Token=…. This keepsTrack.uriopaque and mints the credentialed URL on demand, exactly like Jellyfin/Subsonic. (Design check: confirm the extra round trip is acceptable, or probe likeJellyfinStreamProbe.)- Library selection → unlike Jellyfin/Subsonic (which sync the whole
server), Plex asks the user to pick which music sections to include. The
selected section keys live in the Plex session and scope
fetchArtists/Albums/Tracks.
MusicProviderCapabilities for Plex declares stream + lyrics:
| Capability | Phase 1 |
|---|---|
canStream |
✅ |
canCache |
❌ |
canFavoriteTracks / canReadFavoriteState / canSyncFavorites |
❌ |
canListPlaylists / canSyncPlaylists (and create/edit/delete) |
❌ |
canLyrics |
✅ |
canCast |
❌ |
Cast is a natural later add (the stream URL is network-reachable) but stays off in phase 1 to keep the credential-in-URL surface small.
Lyrics are fetched on demand, off the playback path by
PlexLyricsProvider (registered for plex: tracks in the shared
LyricsResolver, exactly like the Jellyfin/Subsonic/Local providers — no
provider-architecture change). PlexClient.fetchLyrics does the two-step
lookup:
GET /library/metadata/{ratingKey}— the track's full metadata carries itsMedia → Part → Streamlist; a lyric stream is Plex'sstreamType=4, whosekey(e.g./library/streams/12345) andformat(lrc/txt) name the content.GET {key}— the stream body, parsed into the sharedLyricsmodel. Plex serves either its structured JSON (MediaContainer.Lyrics[].Line[].Span[]with millisecondstartOffsets, for agent / LyricFind lyrics) or the raw.lrc/.txtbytes of a local sidecar — both handled, with.lrc-style timed content rendering synced and plain content static, via the sameLyricsTextParserthe local reader uses.
A track with no lyric stream (or missing content) resolves to null → the
calm "No lyrics available" state; a transport/auth failure throws a token-free
PlexException → "couldn't load". Like every API call the token rides in the
X-Plex-Token header, so both lyric URLs are token-free and safe to log.
Like every source, Plex syncs into the local SQLite catalog the UI reads
from (MusicLibraryRepository); the browse screens never hit the server, so the
library is instant and survives restarts without re-fetching. A sync writes
tracks only — albums and artists are derived from them at display time
(core/catalog/library_grouping.dart), so they're cached implicitly with no
extra library walks.
Re-syncing an unchanged library is cheap, even across launches. After each
successful sync the controller stores a credential-free content signature —
the selected sections plus a one-way hash of every track's identity/display
fields — through PlexSyncCacheStore (plain shared_preferences, scoped by the
server's machineIdentifier). On the next sync — a manual Sync tap, a
same-server reconnect, or the auto-sync after a selection change — a scan that
produces the same signature skips the catalog rebuild and the Library
refresh instead of rewriting rows that didn't change. Previously that
signature lived only in memory, so the first sync after a restart always rebuilt.
Invalidation is conservative on purpose. The signature is computed after
the scan, so a sync still lists the library every time and can never miss a real
change — the fast path only avoids redundant writes, never the fetch. Any
change to the selection, or to a track's id / title / artist / album / duration /
track number / artwork, changes the signature and forces a rebuild. Disconnecting
or connecting to a different server clears the stored signature (and the
machineIdentifier scope means another server's fingerprint never matches), so a
freshly-emptied catalog is never mistaken for "already in sync".
Artwork. Covers stay credential-free plex-thumb: references in the catalog
(see Artwork references above), resolved to a tokened URL only at render time.
The platform media session (lock screen / Android Auto) additionally caches the
fetched cover bytes on disk through MediaArtworkCache, keyed by the reference
hash, so a cover fetched once is reused across launches without re-downloading.
Plex feeds that shared cache exactly like Subsonic: the in-app render fetches the
full-size cover, while the media session asks for a small, fast-to-decode one
(kMediaSessionArtworkSize) — the Subsonic side via getCoverArt?size=…, the
Plex side via PMS's photo transcoder (/photo/:/transcode?width=…&height=…&url=…).
The size lives only in the fetched URL, never in the cache key (still the
size-free plex-thumb: reference hash), so the two render paths share one cache
entry per cover and a Plex cover is cached at the same modest size as a Subsonic
one — no full-resolution media-session bitmap, no provider-specific cache logic.
Security. Nothing cached here carries a credential: the catalog stores opaque
plex:/plex-thumb:references, and the durable signature is a one-way hash plus the non-secret server id — never a token, URL, or title.
Phase 1 above is read-only. The one deliberate exception added since:
timeline reporting, so a Plex Media Server shows Linthra as an active
player in its Now Playing dashboard while a plex: track plays. It is a
benign, ephemeral write (PMS updates its session list; nothing in the library
is modified) and is best-effort by contract — a failed report is silently
dropped and can never stall, stop, or alter playback.
Note — this is reporting only, not remote control. Timeline reporting is a one-way push; it lists the session but does not open the channel a Plex controller uses to drive it, so Linthra appears in the dashboard but does not yet react to remote play/pause/skip commands. Receiving those commands is the separate Plex Companion protocol, designed in docs/plex-remote-control.md.
A client reports its playback to GET /:/timeline (verified against
python-plexapi's updateTimeline and the community API docs):
| Param | Value |
|---|---|
ratingKey |
the playing item's id (from the opaque plex:<ratingKey> uri) |
key |
/library/metadata/{ratingKey} |
identifier |
com.plexapp.plugins.library (fixed protocol constant) |
state |
playing / paused / stopped (buffering exists; unused) |
time |
playback position, milliseconds |
duration |
item length, milliseconds — omitted when unknown |
The token rides in the X-Plex-Token header (like every API call), so a
timeline URL is token-free and safe to log. The X-Plex-* identity headers
name the player: X-Plex-Product / X-Plex-Device / X-Plex-Device-Name
are Linthra, and X-Plex-Client-Identifier is the stable per-install id
persisted with the session — PMS keys the session on it, so pause/resume
update one player entry instead of spawning new ones. state=stopped clears
the entry.
ServerPlaybackReporter(core/services/server_playback_reporter.dart) — the neutral contract:onPlaybackStarted/Progress/Paused/Resumed/StoppedonTrackChanged, with aNoOpServerPlaybackReporterfor providers without reporting.
RoutingServerPlaybackReporter— selects the reporter whosehandlesclaims the track's uri;plex:routes to Plex,jellyfin:to the Jellyfin reporter,subsonic:to the Subsonic one, and local files report nowhere, so one provider's playback can never trigger another provider's call.onTrackChangedis forwarded to the owners of both sides, so a Plex session closes even when the next track belongs to another provider.PlaybackReportingService(core/services/playback_reporting_service.dart) — listens to the unifiedPlaybackStatestream and derives the lifecycle: first play → started, pause/resume → immediate, idle/completed/error → stopped, queue move → track change. Progress is throttled (one report per 10s of steady play) so position ticks never spam the server; reports dispatch strictly in order, off the playback path, with every failure swallowed.loading/bufferingare not transitions (no pause/resume flap on a re-buffer; a track that never starts is never reported).PlexPlaybackReporter(core/sources/plex/plex_playback_reporter.dart) — every Plex-specific detail (state mapping, ratingKey extraction, ms units) stays here, behind the neutral interface. It reads the live session and client lazily — signed out means silent no-op — exactly like the playable-uri resolver.
The timeline path adds no new token surface: the token goes only to the
PlexClient (header), never into the URL, a log, an error, or diagnostics;
the reporter never throws, so no failure can carry anything out; timeline
URLs are minted per report and discarded — nothing about reporting is ever
persisted. Tests prove the URL builder is token-free, the HTTP errors are
token-free, and the reporter swallows every failure kind.
- XML-first API. PMS defaults to XML; we rely on
Accept: application/json, and some endpoints/older servers may still return XML. → Decide JSON-only with a clear error, or tolerate XML. - Token blast radius. A Plex account token is far more powerful than a
Jellyfin access token or a Subsonic salt+token (both server-scoped). → Prefer
the per-server
accessToken. - Token in URL query params. Stream/art URLs must carry the token as a query
param (the image/audio layers can't easily set headers), a bigger leak surface
than Jellyfin's
api_keyparam or Subsonic's salt+token. → Centralize URL redaction. - Auth-flow complexity. Plex's modern path is a plex.tv PIN/browser handoff, unlike a single username/password POST. → Phase 1 started with manual token paste; the PIN flow has since shipped on top of it (manual stays as the advanced fallback).
- Two-step play resolution.
ratingKey≠ Partkey, so playback needs an extra metadata round trip. Jellyfin/Subsonic build the stream URL straight from the id. - Connectivity /
.plex.directTLS & relay. Plex servers are often reached via plex.tv-discovered connections (relay,*.plex.directcerts) rather than a plain typed URL. → The PIN flow now probes the plex.tv-advertised connections in order (relay kept as the last resort, since it is bandwidth-capped); the manual flow still takes a typed URL. - Direct-play codec fit. Without the transcoder, a Part may be a codec the device can't decode. → Phase 1 is direct-play only; transcoding is a later capability.
- Library selection (new UX). Jellyfin/Subsonic sync the whole server; Plex needs a section-picker, a small new surface to design and test.
lib/core/sources/plex/
plex_api.dart # PMS DTOs (MediaContainer / Metadata / Part)
plex_endpoints.dart # pure PMS URL builders
plex_client.dart # PMS interface (HTTP behind this seam)
http_plex_client.dart # package:http impl, JSON via Accept header
plex_authenticator.dart # manual token verify (advanced fallback)
plex_tv_api.dart # plex.tv DTOs (PlexPin / PlexResource)
plex_tv_endpoints.dart # pure plex.tv URL builders (pins/resources/auth)
plex_tv_client.dart # plex.tv interface
http_plex_tv_client.dart # package:http impl for plex.tv
plex_pin_auth.dart # the PIN sign-in flow (begin/poll/servers/connect)
plex_track_mapper.dart # type 8/9/10 -> Artist/Album/Track, plex: scheme
plex_music_source.dart # implements MusicSource (+ PlexStreamSource)
plex_stream_source.dart # narrow stream seam
lib/core/models/plex_session.dart
lib/core/repositories/plex_session_store.dart
lib/data/repositories/secure_plex_session_store.dart
lib/data/repositories/in_memory_plex_session_store.dart
lib/features/settings/plex/ # section + controller + state + providers
docs/plex.md
Tests use hand-written Fake* clients (no mocking library) and Riverpod
overrides.
plex_endpoints_test.dart— pure URL builders (sections, items by type, metadata, part stream, thumb), incl. token placement and pagination params.plex_track_mapper_test.dart— type 8/9/10 → Artist/Album/Track, parent/grandparent wiring,plex:scheme,plex-thumb:artwork reference, missing-field fallbacks.http_plex_client_test.dart— JSON parsing viaAccept: application/json, error →PlexExceptionmapping, token never in exception/log, paging.plex_authenticator_test.dart— token-paste verify against/identity.plex_tv_endpoints_test.dart— pins/resources/auth-app builders (fragment shape, encoding, token-free URLs).plex_tv_api_test.dart— PIN/resource parsing,providesServer,PlexResource.toStringredacts the accessToken.http_plex_tv_client_test.dart— pin create/poll/expiry, resources array, token in header only, every failure token-free.plex_pin_auth_test.dart— poll pacing/cancel/expiry/transient tolerance, server filtering (owned first), per-server vs account token selection, connection probe order (relay last; unauthorized aborts), session building.fake_plex_client.dart/fake_plex_tv_client.dart— reusable canned-response/error fakes.plex_music_source_test.dart— fetch +resolvePlayableUri(part-key lookup), library-selection scoping.plex_settings_controller_test.dart— connect/select-libraries/sign-out, state holds no token, password/token cleared after use; the sign-in flow's linking/picker/cancel/error states, server selection, and a full-flow token sweep over every state field.- Capability-matrix test — Plex declares stream-only in phase 1.
- A guard test that
MusicProviders.forTrackUristill routes existingjellyfin:/subsonic:/ local URIs unchanged (no regression).
Small, incremental, each independently reviewable:
-
Design doc — this
docs/plex.md(endpoints, auth choice, token-scope rule, capability matrix). No code wiring. ← this PR -
DTOs + endpoints —
plex_api.dart,plex_endpoints.dart, fully unit-tested, no UI, no wiring. -
Client —
plex_client.dart+http_plex_client.dart+fake_plex_client.dart(identity, sections, items, metadata) + tests. No UI. -
Auth + session + secure store — token-paste verify (PIN flow optional / behind a follow-up),
plex_session.dart, secure + in-memory stores + tests. -
Mapper + source —
plex_track_mapper.dart,plex_music_source.dart/plex_stream_source.dart, registerplex:inMusicProviderswith a stream-only capability set + tests (incl. the no-regression routing guard). -
Library selection + settings UI — section discovery/picker,
plex_settings_section/controller/state, Riverpod providers + tests. -
Cover art —
plex-thumb:reference resolver at render time; update the providers.md provider matrix to add the Plex row. -
Real-device hardening & phase-1 polish — the catalog sync (
plex_sync_controller: a Sync Plex library action plus an automatic, coalesced sync after every committed library-selection change; the sync replaces the catalog's Plex slice even when empty, so the catalog always mirrors the selection), explicit loading/empty/error + retry states in the library picker, selection pruning when a section vanishes server-side, same-server reconnects keeping the selection, startup-restore race guards and a friendly restore-failure message, and disconnect also removing the synced (now unplayable) Plex rows — with tests for each state and for token redaction on every new message path. -
Playback & artwork final polish (the last phase-1 PR before real-device testing) — precise playback errors: a track whose metadata resolves but carries no playable Part says so (instead of a generic "couldn't stream"), a
plex:uri with no ratingKey fails typed without a junk request, and a malformed Part key fails typed instead of escaping as an untyped error or splicing into the server URL. Artwork hardening:plex-thumb:references round-trip query-carrying (sizing-transcoder) thumb paths byte-for-byte — including aurl=value PMS itself percent-encoded — the minted stream/art URLs merge the token into an existing query rather than replacing it, splicing the existing pairs through raw (and dropping any token-named param a stored path might smuggle, however encoded), and the render-time resolver never throws — a degenerate session, a non-absolute thumb path, or an unparseable reference all degrade to the row's placeholder. Tests sweep every failure kind for token/URL-free messages and re-prove the Jellyfin/Subsonic/local artwork chain is untouched. -
plex.tv PIN sign-in ("Connect with Plex") — the browser/PIN flow as the primary connection path (this section's Authentication describes it): mint a strong PIN, open
app.plex.tv/authin the browser, poll for approval (cancellable, transient-failure tolerant), list the account's servers with per-server tokens, pick one (auto when there is exactly one; clean empty state when there are none), probe its plex.tv-advertised connections (relay last), and persist the server-scoped session. Manual URL + token stays available under "Manual setup (advanced)", and a rejected/expired session offers "Reconnect with Plex" right at the error. -
Scan performance & reliability — make a large-library sync (≈1000+ tracks) non-blocking and incremental, fixing the UI freeze / "app not responding" reports. Four changes, all Plex-scoped:
- Off-isolate decode. A library page's
jsonDecode+MediaContainerparse — the heaviest synchronous step — runs on a background isolate (HttpPlexClientdecodes bodies over a size threshold viacompute); small replies stay inline. - Tracks only. The sync reads just tracks; albums/artists are derived
from tracks by the library screen (
library_browse_providers.dart) and were never persisted, so listing them was two extra full library walks of wasted work. - Batched, progressive writes. Results are written in chunks via a new
optional
IncrementalCatalogWritercapability (implemented by the Drift/in-memory/recording repositories), refreshing after the first chunk so the library fills as it goes instead of after one monolithic write. - Skip unchanged. A credential-free content signature of the last successful sync lets a re-sync that finds the same library skip the database rebuild entirely (the durable catalog already lives in SQLite, so launch never re-scans — this only avoids redundant re-syncs).
Playback is untouched — a
plex:track resolves its stream URL lazily at play time, so music keeps playing during a scan — and the sync status gains explicitscanning/syncing(writing) /donephases. - Off-isolate decode. A library page's
- This design must not change existing providers; the only edits to shared
code (in later PRs) are the new
plex:branch inMusicProviders.forTrackUriand a new row in the provider matrix/docs. - Reuse, don't reinvent:
package:http,flutter_secure_storage,crypto, the capability model, and theFake*-client test style are all already in place. - Credentials follow the same non-negotiables as every other provider: never logged, encrypted at rest, never woven into a persisted URI.