Skip to content

Latest commit

 

History

History
574 lines (505 loc) · 33.1 KB

File metadata and controls

574 lines (505 loc) · 33.1 KB

Plex (self-hosted music) — design

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.

Scope — phase 1 (read-only)

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.

Out of scope for phase 1

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

Plex API endpoints (music libraries)

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.totalSize reports 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 key differs from the ratingKey, a track's playable URL needs a GET /library/metadata/{ratingKey} lookup at play time (see MusicSource mapping).

Authentication

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.

Two ways to obtain a token

  • 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 opens https://app.plex.tv/auth#?clientID=…&code=…&context[device][product]=… (parameters in the fragment, bound to the same X-Plex-Client-Identifier that minted the PIN); Linthra polls GET https://plex.tv/api/v2/pins/{id} every 2s until authToken is 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=1 returns the user's servers, each with its own per-server accessToken and 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) against GET /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-Token and 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.

Plex Home user selection (whose library to sync)

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.

Token scope (key safety decision)

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.

Token safety rules

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 via flutter_secure_storage, in a secure_plex_session_store.dart under a single versioned key (e.g. plex_session_v1), with an InMemoryPlexSessionStore for 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 in toString, and the settings state exposes display-safe PlexServerChoices instead of resources.
  • No token in Track.uri or Track.artworkUri (or the DB). Keep the opaque, credential-free plex:<ratingKey> and plex-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.

MusicSource mapping

The MusicSource contract is small (id, displayName, fetchTracks/Albums/Artists, resolvePlayableUri). Proposed mapping, mirroring JellyfinTrackMapper / SubsonicTrackMapper:

  • idplex; displayNamePlex · <server name>.
  • URI schemeplex: (opaque, credential-free), registered in MusicProviders.forTrackUri (lib/core/sources/music_provider.dart) next to the existing jellyfin: / 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, using parentRatingKey / grandparentRatingKey to fill artist/album names, mirroring Jellyfin's mapper, with missing-field fallbacks. A track's indexTrack.trackNumber (album order), parentTitlealbumName, and grandparentTitle (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 credited originalTitle when PMS didn't denormalise the album-artist link (Jellyfin does albumArtist ?? artist), and a track with no thumb falls back to its album cover (parentThumb) so it shows art the way a Subsonic track's coverArt always does. The shared Track model carries no disc number, so Plex's parentIndex is left unmapped — matching Jellyfin and Subsonic, which carry no disc field either.
  • Track URIsplex:<ratingKey>. The ratingKey is the stable per-server id; it is not the Part key, so it carries no credential and never names a file path.
  • Artwork referencesplex-thumb:<…> stored in Track.artworkUri (mirroring Subsonic's subsonic-cover:), with the token woven in only at render time.
  • resolvePlayableUri → resolve at play time: GET /library/metadata/{ratingKey} → read Media[0].Part[0].key → build {baseUrl}{partKey}?X-Plex-Token=…. This keeps Track.uri opaque and mints the credentialed URL on demand, exactly like Jellyfin/Subsonic. (Design check: confirm the extra round trip is acceptable, or probe like JellyfinStreamProbe.)
  • 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.

Capabilities (phase 1)

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

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:

  1. GET /library/metadata/{ratingKey} — the track's full metadata carries its Media → Part → Stream list; a lyric stream is Plex's streamType=4, whose key (e.g. /library/streams/12345) and format (lrc/txt) name the content.
  2. GET {key} — the stream body, parsed into the shared Lyrics model. Plex serves either its structured JSON (MediaContainer.Lyrics[].Line[].Span[] with millisecond startOffsets, for agent / LyricFind lyrics) or the raw .lrc/.txt bytes of a local sidecar — both handled, with .lrc-style timed content rendering synced and plain content static, via the same LyricsTextParser the 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.

Local cache & re-sync

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.

Playback reporting / Now Playing (shipped after phase 1)

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.

How Plex sees a player

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.

Architecture (provider-neutral seam)

  • ServerPlaybackReporter (core/services/server_playback_reporter.dart) — the neutral contract: onPlaybackStarted/Progress/Paused/Resumed/Stopped
    • onTrackChanged, with a NoOpServerPlaybackReporter for providers without reporting.
  • RoutingServerPlaybackReporter — selects the reporter whose handles claims 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. onTrackChanged is 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 unified PlaybackState stream 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/buffering are 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.

Token safety (same non-negotiables)

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.

Risks vs Jellyfin and Navidrome

  • 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_key param 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 ≠ Part key, so playback needs an extra metadata round trip. Jellyfin/Subsonic build the stream URL straight from the id.
  • Connectivity / .plex.direct TLS & relay. Plex servers are often reached via plex.tv-discovered connections (relay, *.plex.direct certs) 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.

Proposed file layout (for the eventual implementation)

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 to add (mirrors the Jellyfin/Subsonic layout)

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 via Accept: application/json, error → PlexException mapping, 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.toString redacts 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.forTrackUri still routes existing jellyfin: / subsonic: / local URIs unchanged (no regression).

Suggested PR steps

Small, incremental, each independently reviewable:

  1. Design doc — this docs/plex.md (endpoints, auth choice, token-scope rule, capability matrix). No code wiring.this PR

  2. DTOs + endpointsplex_api.dart, plex_endpoints.dart, fully unit-tested, no UI, no wiring.

  3. Clientplex_client.dart + http_plex_client.dart + fake_plex_client.dart (identity, sections, items, metadata) + tests. No UI.

  4. Auth + session + secure store — token-paste verify (PIN flow optional / behind a follow-up), plex_session.dart, secure + in-memory stores + tests.

  5. Mapper + sourceplex_track_mapper.dart, plex_music_source.dart / plex_stream_source.dart, register plex: in MusicProviders with a stream-only capability set + tests (incl. the no-regression routing guard).

  6. Library selection + settings UI — section discovery/picker, plex_settings_section/controller/state, Riverpod providers + tests.

  7. Cover artplex-thumb: reference resolver at render time; update the providers.md provider matrix to add the Plex row.

  8. 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.

  9. 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 a url= 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.

  10. 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/auth in 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.

  11. 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 + MediaContainer parse — the heaviest synchronous step — runs on a background isolate (HttpPlexClient decodes bodies over a size threshold via compute); 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 IncrementalCatalogWriter capability (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 explicit scanning / syncing (writing) / done phases.

Notes

  • This design must not change existing providers; the only edits to shared code (in later PRs) are the new plex: branch in MusicProviders.forTrackUri and a new row in the provider matrix/docs.
  • Reuse, don't reinvent: package:http, flutter_secure_storage, crypto, the capability model, and the Fake*-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.