A 2-letter preferred language (es, ar, ru, ...) prefix-matched unrelated 3-letter track codes (est, arm, rum), so profile-based audio/subtitle selection could pick a wrong-language track whenever it preceded the wanted one in container order. Route the matcher through languageMatches, which compares exact codes, region variants, and ISO-639 variations.
plex_client.selectStreams' allParts=false branch, download_manager's both-branches-true conditional and unscoped getAllPinnedMetadata tail, playback_source_resolver.preferOffline, and update_service's unreachable catches had no reachable path; PlaybackSession.result/streamHeaders, PlaybackContext.clientScopeId, FileInfoStreams.audioStream, PrefsRepairOutcome.settingsReset/sessionsSalvaged, and JellyfinLiveSessionTracker.playSessionId were write-only; PlexConnection.directUrl, PlexServer.isOnline plus its parse-and-persist presence field, SafStorageService.createDirectory, FullscreenStateManager.stopMonitoring/dispose, the play_queue_launcher re-export shim, track-selection's constant params, and PlexApiCache.unpinForOffline/isPinnedRatingKey (matching the earlier Jellyfin removal) were dead API.
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars load with the media behind a 10s open guard, so a
subtitle URL the server is slow to serve — Jellyfin extracting an
embedded stream while its transcoder spins up — tripped the guard: stop,
reopen without subtitles, "Selected subtitles could not be loaded"
snackbar, and an emptied subtitle menu. Since 2b3853a88 every embedded
Plex subtitle was handed to the player as a sidecar whose URL is the
original container, so a transcode also range-read and demuxed the
source over HTTP — for a 40 GB remux, purely to find a subtitle track —
which is also why PGS never appeared: the client was handed a container
to demux rather than a rendition to play.
Delivery is the server's job again, backported from the AVPlayer branch
(42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted
to main's mpv backend:
Plex burns every embedded track (subtitles=burn); only a real external
file with a /library/streams key stays a client-fetched sidecar. A burn
is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400
to directPlay=1 with burn — and the burn is aimed by selecting the
stream on the part first via the selectStreams PUT, because the decision
endpoint ignores subtitleStreamID alongside subtitles=burn. An
unaimable or undeliverable burn (dvb_teletext) refuses the transcode and
falls back to warned direct play rather than welding the wrong language
in or silently dropping the caption. Main's per-preset
directPlay/directStream pinning is kept; verified against a live PMS
that burn works under directStream=0.
Jellyfin never offers image formats as External, so bitmaps fall through
to Encode and are burned; text External is withheld per request when the
effective selection — including the server's DefaultSubtitleStreamIndex —
is embedded, and offered when it is a real file, so a file is delivered
as a file and never fetched twice. The burned row is excluded from the
sidecars; remaining text rows stay extractable, which is how a secondary
track still renders over a transcode. Sidecar URLs now use the format
extension the endpoint expects instead of the reported codec name.
The controls and selection layers learn what burning means: burn
eligibility is the codec's property, so burned rows stay selectable in
the menu; any change away from a burned selection renegotiates with the
server instead of pretending a local switch worked; the visibility
shortcut explains itself instead of doing nothing; and the track manager
is told when the primary is server-rendered so it stops waiting out a
thirty-second deadline for a native track that is already pixels.
Verified: analyzer parity, clean_translations --check --strict, full
flutter test (5749), and decision-level runs against live Plex and
Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn
400, External file delivery, an unchanged no-burn baseline, and a real
burn session serving its playlist. The pre-commit aggregate was bypassed
for pre-existing main-state findings outside this diff: 21 format-drifted
files and three unused test seams in lib/main.dart.
close#1738
Refs #1815, #1622.
Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
Episode advance carried live player state, so the viewer's choice only
survived while every episode could serve it: one episode without the
picked audio or subtitle fell back, and the fallback became the carry for
the rest of the session. The screen now keeps the last explicit audio,
subtitle, and secondary-subtitle choices for its lifetime; automatic
outcomes never overwrite them, so the choice retries on every following
episode and reattaches as soon as a catalog can serve it again.
Audio catches up with the subtitle carry from #1785. The old matcher
required raw language equality (a 'sv' pick never found a 'swe' row) and
otherwise took the first same-language track, flipping a commentary or
alternate-mix pick back to the main mix on every episode. Audio now uses
the same evidence bands as subtitles: bridged language parity is
authoritative, a unique title match vouches for untagged tracks, codec
and channel-count parity only break ties, and an ambiguous catalog
declines to the server's own choice instead of guessing. The synthesized
source descriptor also prefers the row's own title over the display title
that collapses to the bare language.
Episode advance previously sent no audio hint to negotiation at all, so a
transcode baked in the server's default audio no matter what was playing.
Both backends now resolve the carried semantics against the new episode's
streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the
transcode decision, an explicit per-part stream id always wins, and a
failed match falls back to the server's pick.
close#1785
The cross-item subtitle intent required declared languages on both sides,
and a null on either side counted as a contradiction. Any untagged track -
common when a title like "Swedish" is the only signal - declined on every
episode advance, fell to the server's per-item priority, and turned the
viewer's subtitles off (a 2.11.0 regression from the #1716/#1717 hard
gates).
A unique real title match now vouches for a row when language evidence is
missing on either side. Declared languages that disagree still decline no
matter what the title says, forced-class parity is untouched, codec and
external parity only break ties within the title-matched set, and a
residual tie declines rather than guesses, so the wrong-track class of
#1716 stays closed.
A decline is also no longer laundered into a viewer decision: the resolver
keeps the unserved preference on the selection, the open flow hands it to
the track manager instead of a navigation-priority off (late native tracks
may carry the container tags the server rows lack), the next episode
boundary re-carries it instead of hardening it into an explicit off, and
progress reports withhold the -1 subtitle index that would otherwise come
back as the item's server-side default forever.
A pick the screen could not map to a source row (no subtitle catalog, or
an identity-matcher miss) previously never reached the committed session
selection at all, so the next episode carried the stale off while the
picked track was visibly on screen. Such picks now commit the raw native
track without source ids and demote to a semantic intent at the boundary.
close#1785
Plex treats a subtitle stream as forced when its title says "Forced" even
with the API flag unset. Every forced comparison now uses that effective
forced-ness on both sides: the match scorer, the low-metadata hard gate,
the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation.
Carrying a track choice into the next episode no longer reuses the
same-item identity matchers. A sealed SubtitlePreference (off / track
reference / semantic intent) replaces the id-'navigation' pseudo-track
through the whole preference channel, and cross-item intents hard-require
language and forced-class parity. When the next episode has no track of
the same class, the intent declines and selection falls through to the
server's own per-episode choice instead of latching onto a full track by
position and persisting that mistake back to the server.
Intents wait for pending native tracks under the same catalog-completeness
rule as source ids, so an early decline cannot retire the selection
listener before the real track arrives.
Ref #1716
Plezy's device profile declares every subtitle format with
`Method: External`, so Jellyfin answers PlaybackInfo with
`DeliveryMethod: External` and a `DeliveryUrl` even for streams embedded
in a direct-played container. Direct play never fetches those URLs, but
the rows kept the delivery URL as `MediaSubtitleTrack.key`, and keyed
rows only match a native track loaded from the same URL. No embedded
track could satisfy that, so `selectSubtitleTrack` reported "still
pending" forever: playback started with subtitles off and logged the
five- and thirty-second waits, and the server's default subtitle had to
be picked by hand on every item.
Restrict sidecar identity to the rows an open actually fetched as
sidecars. A row that stays in the container loses `key` and
`usesExternalDelivery` and matches on metadata again; genuine
`IsExternal` files keep theirs, and remuxed or transcoded renditions
still resolve their sidecars by URL.
Also declare every subtitle format Embed-first so a direct-played
container reports embedded delivery in the first place, and make the
pending contract match its purpose on every backend. The
complete-catalog escape is no longer Plex-only, so a Jellyfin row the
native player has not produced keeps the pass pending instead of
committing an unrelated default and retiring the listener that was
waiting for the real track. A source id absent from the catalog no
longer defers a decision that can never change, and the thirty-second
deadline resolves from what has arrived instead of re-deriving the same
deferral and applying nothing.
close#1696
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.
Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
Several pairs of near-identical code paths differ in one load-bearing
line. Each site now carries a comment naming the invariant that forces it
apart, backed by a characterization test so a future deduplication fails
loudly instead of silently changing behaviour.
Pinned: focusable wrapper vs. chip D-pad activation policy, profile
connection cleanup's raw-id vs. ServerId-typed server projections, live TV
tab loaders, video player display matching and playback service wiring,
track selection container ordering, tracker HTTP client status ladder, and
the MediaServerHttpClient shutdown/cancellation contract versus
ManagedHttpClient's closing guard.
New tests:
test/focus/dpad_activation_policy_test.dart
test/services/track_selection_container_ordinal_test.dart
test/services/trackers/tracker_status_ladder_test.dart
test/utils/media_server_http_client_shutdown_test.dart
- Extract track lifecycle logic from VideoPlayerScreen (3248→2928 lines) into TrackManager
- Parse isDefault/isForced for subtitle tracks in parseTrackList
- Emit forced flag from ExoPlayer's emitTrackList
- Fix detectSubtitleMimeType failing on URLs with query params
- Add missing external_ ID prefix in ExoPlayer addSubtitleTrack
- Replace polling loop with stream in selectAndApplyTracks
- Handle play() failure in resumeAfterSubtitleLoad
- Handle addExternalSubtitles failure in onBackendSwitched
The Plex server already applies all preference levels (account,
show/season, per-item) into stream `selected` flags. Remove redundant
client-side subtitleMode/account prefs logic and show/season metadata
fetching (2 fewer API calls per episode).