e9a213807fc9c6d3ee1b8e526bb145fe080cf5c8
354
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bcd6fe9906 |
feat(linux): HDR video on a native Wayland plane
Video on Linux went through a Flutter texture: 8-bit sRGB, which cannot carry HDR at all, and which forced a whole-window Flutter recomposite for every video frame. This moves it onto a wl_subsurface stacked below the Flutter surface, with mpv rendering into an EGL window surface on it through the libmpv render API. The subsurface is desynchronized, so video and UI now present independently. With the plane in place HDR follows: the surface is described to the compositor through wp_color_manager_v1 as the source's own curve and gamut - PQ or HLG, BT.2020 - carrying whatever HDR10 static metadata the stream actually declares. The description and the buffer it describes land on the same commit, staged and validated before mpv is switched, so a PQ frame is never presented labelled sRGB. A five-second watchdog bounds the one wait a compositor could otherwise leave hanging. A session that cannot host the plane - X11, or a compositor without wl_subcompositor - fails initialize with VIDEO_PLANE_UNSUPPORTED naming the reason: the texture path is gone, and refusing by name beats degrading to something the user cannot see. An SDR output, a missing capability or an 8-bit config keep the plane and simply leave it undescribed. The output's colour state is trusted only when it has been earned. Every landed property step records itself as it lands; a reset or sequence that cannot finish downgrades its result to unknown and marks the applied-output cache untrusted until a clean apply earns it back. A plane whose output state cannot be named is quarantined - hidden, its description withdrawn - and the quarantine is recorded state: an unrelated visibility change cannot put a mislabelled plane back on screen, and only a commit that resolves to a nameable outcome lifts it. A rect collapsing to zero detaches the buffer exactly as hiding does, a refused setVideoRect drops the Dart-side sent-rect cache so the next layout pass retries for free, and a refused tone-mapping pick tells the user instead of dying in a log. NVIDIA's Wayland EGL (through at least 610.xx) offers no 10-bit unorm window configs, so the plane takes half-float as the tier between 10-bit unorm and 8-bit, declares the whole surface opaque so the compositor never reads the alpha those configs carry, and states GL_RGBA16F rather than a 10-bit lie. Whether the output is in HDR is read from luminance headroom above its own reference white rather than from the preferred transfer function, which current KWin no longer answers PQ for; the margin is half a stop, because KWin reports an undimmed maximum over a software-dimmed SDR white. Validated on an RTX 4090 (driver 610.57.04) under KWin 6.7.4 with locked-exposure photographs. Who tone-maps is a user choice. The default is the compositor: photographed on a 400-nit HDR output against a PQ chart it keeps 400 -> 1000 nits monotonic and separated where the player leg flattens them, because the player path drives mpv's legacy vo_gpu, whose own standalone output scores the same. The gap is the renderer, not the wiring. The decision itself - what the source carries, what the output supports, what to tell mpv and what to tell the compositor - lives in hdr_metadata.h, free of Wayland and GTK so its luminance validation can be tested without a display server. Sending an incoherent luminance set is a protocol error that disconnects the client, so the rules are worth a unit test. The deb, rpm and pacman packages now declare wayland-client, wayland-egl and EGL: the plane links them directly and bundle-libs.sh deliberately never bundles them, since they are coupled to the running compositor and GPU driver. lib/dev/harness_main.dart is a second entrypoint for measuring this on hardware - it drives one clip with scripted mpv properties and reports the colour state mpv actually settled on. Nothing imports it, so it is tree-shaken out of the app. Verified on a Steam Deck against an external 400-nit HDR display: the compositor reports PQ / BT.2020, the connector carries HDR_OUTPUT_METADATA, and against mpv vo=gpu-next on the same frame the shipped build sits 4.90 counts away overall - closer to the reference HDR player than to its own SDR fallback. |
||
|
|
f5665df43f |
fix(music): report a gaplessly advanced track's first timeline at its own start
When a gapless advance was announced, the new track's tracker sent its initial report from live player state, which still carried the finished track's position and duration - telling Plex the new track was already at ~100%. PMS recorded a play (and a Last.fm scrobble) at track start on top of the one from the real playthrough, and the tracker latched the new track watched locally the moment it began. The music bind now pins the initial report to the track's own start (position zero, metadata duration); timer ticks keep reading live state. close #1849 |
||
|
|
df0570b861 | style: apply dart format to 21 drifted test files | ||
|
|
716fe9b51b |
feat(watchlist): add watchlist toggle to library context menus
Watchlist membership was only reachable from Explore cards and the detail screen's action row, which drops the bookmark first on narrow screens with no fallback in the overflow menu. Add an entry to MediaContextMenu for movies and shows whenever a connected catalog source can hold the item, covering card long-press everywhere and the detail screen's overflow. External-id resolution is session-cached per item on CatalogSourcesProvider and shared with the detail screen. A cold cache labels the entry "Add to Watchlist" and always adds (idempotent), so a press can never turn into a surprise removal; "Remove" is offered once cached membership proves it. Several capable sources open the same per-source chooser the detail screen uses. close #1822 |
||
|
|
8e5279a487 |
feat(subtitles): optionally anchor text subtitles to the screen bottom
Adds an "Anchor to Screen" toggle under Subtitle Styling (Android + ExoPlayer only, default off). When enabled, the text SubtitleView is sized to the full container instead of the letterboxed video rect, so SRT/VTT/mov_text cues render in the black bars below widescreen video and font size and the position setting become relative to the physical screen height. Bitmap (PGS/VOB) and ASS/libass rendering are unchanged; mpv already places plaintext subtitles in the margins by default. close #1730 |
||
|
|
de76c0a515 |
feat(tv): refresh the Watch Next row without the app open
The Watch Next row previously only updated while the app was in the foreground, so it drifted stale until the next launch. A WorkManager periodic job (6h, network-connected, KEEP) now runs a headless Flutter engine executing `systemShelfBackgroundMain`, which mirrors the cold-start profile bind from cached tokens (never prompting for a PIN), fetches Continue Watching through the existing multi-server aggregation, and republishes the shelf through the normal Watch Next pipeline. The job is armed by a committed foreground sync, cancelled when the shelf is cleared, and re-armed after boot or app update only when persisted shelf state exists. It skips entirely while a foreground engine holds the shelf lifecycle lease, both to defer to the live app and to avoid two engines sharing the database in one process. The Dart isolate always reports completion over `backgroundSyncComplete`; the worker hard-caps the run at 90 seconds and destroys the engine on the main thread. |
||
|
|
291a22a4a4 |
feat(tvos): fetch Top Shelf content live and show poster art
The Top Shelf extension now fetches Continue Watching directly from Plex/Jellyfin/Emby instead of replaying a cache the app wrote on its last foreground Discover pass. The app publishes per-profile server descriptors on every shelf sync (`updateSources`): token-free metadata in the app group, tokens in an app-group-shared keychain item, both wiped by `clear`. On success the extension rewrites the cached payload as the offline fallback; any fetch failure falls back to the previous cache-replay behavior. Poster images are passed as remote URLs, so the extension no longer depends on app-side artwork downloads. Episodes now render season/series poster art (2:3, `.poster` shape) instead of 16:9 episode stills, and labels lead with the S/E marker so long titles no longer hide it behind the focused-item marquee. Shelf schema v3 (Dart, Android, tvOS envelopes bumped together) discards stale wide-art caches instead of letterboxing them into poster slots. close #1474 close #1835 |
||
|
|
9d51f5aadd |
feat(tvos): scale Siri Remote swipe distance to the focused item
A focus step cost a fixed 180pt of pan travel regardless of what was focused, so small controls felt sluggish and large cards hair-triggered compared with the native focus engine, which prices a step by on-screen geometry. Derive per-axis thresholds from the focused control's rect (gain 1.1, clamped 100-360pt) and normalize axis resolution by them, so a wide-flat tile steps vertically once the finger covers its height. Focus scopes, the player's screen-sized catch-all surfaces, and nodes without layout fall back to the fixed threshold, keeping player chrome behavior unchanged. |
||
|
|
fe79817e76 |
fix(tvos): stop a single Siri Remote flick moving focus two steps
Touch travel banked during the swipe repeat cooldown was released as a second focus step by the first post-cooldown move frame, even when the finger had stopped or was lifting. Re-anchor the swipe delta on every frame inside the cooldown so a discrete flick emits exactly one step while a sustained drag keeps repeating. close #1756 |
||
|
|
f4ce60611b |
fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Since |
||
|
|
8740a19f36 |
feat(player): start Plex transcodes at the resume position (#1817)
A Plex transcode session always starts producing at zero: the decision request never sent offset=, so any non-zero open - resuming a transcoded title, or switching from Direct Play to a transcoded quality mid-playback - opened a session whose produced window begins at the start of the file and seeked it. mpv immediately requests a segment the transcoder has not produced, PMS answers 404 for it and every subsequent segment, and playback buffers forever. Send offset=<seconds> (6dp) with the decision and start request - the view offset on initial open, the resolved resume position on every in-place reload - so the session begins producing at the position the player consumes first. The playlist timeline is unchanged: an offset session's media playlist still covers the full title from segment zero, so the player keeps opening with start: at the resume position and in-stream seeks work as before. Before a native player opens an offset playlist, waitForTranscodeReady walks the master playlist, the media playlist, and the segment containing the offset, because PMS can publish a manifest before that segment is fetchable and mpv treats the 404 as an HLS error. The probe is best-effort: it never fails an open, hands off immediately on HTTP 500 (on the response and exception paths alike) so the server-limit dialog stays prompt, stops on cancellation, skips itself when the playlist durations never reach the offset, and stays out of the endpoint-failover cascade. In-place reloads resolve the replacement source only after the old stop report has gone out, so Plex cannot use that stop to terminate the replacement transcode. close #1840 |
||
|
|
7437b43207 |
fix(plex): validate a failover candidate before switching the live endpoint
A transient GET failure on a healthy endpoint could park the client on an unreachable fallback (e.g. the server host's Docker bridge gateway, which plex.tv advertises as a local connection) for a full connect timeout, failing every request in flight during that window (log bbr90). The cascade now probes each candidate with an unauthenticated /identity request under the discovery-race budget and only switches when it answers as the expected server, mirroring the Jellyfin trust gate. Unreachable-looking private IPv4 candidates stay in the list — a client on the server host can legitimately reach them, so reachability is probed, not inferred. |
||
|
|
636fd48f40 |
fix(player): settle the watched patch the backend recorded itself
Watching an episode to the end left it stuck as watched for the rest of the session. Unmarking it on another device and refreshing did nothing; only a restart cleared it. Unlike #1829 this needs no second device to cause -- a normal watch-through is enough, and the second device only makes it visible. A threshold crossing writes an unacknowledged overlay patch, deliberately: reporting success proves the backend received the report, not that it classified the item as played, so the patch stays owed until something settles it. _settleServerMark has three settled outcomes and only one of them did. The explicit-mark branch promoted; the two branches that skip the mark because the backend already recorded the watch itself -- Jellyfin from /Sessions/Playing/Stopped, Plex from a timeline crossing past LibraryVideoPlayedThreshold -- returned without promoting. Those are the common paths, so nearly every completed playback stranded a patch that the store then refused to suppress, because an unacknowledged entry is never retired by an authoritative read. Both now promote, through one idempotent helper that clears the id so the delivery callback and the settle paths cannot promote twice. Promotion has to follow delivery rather than the settle decision. A marks-on-stop backend settles when the crossing latches, which happens before the stop is sent, and until that stop lands the watch really is still owed -- promoting there would let a refresh retire a patch the server had never heard about. MediaBrowser also drops a stop for a session it never opened, in which case the watch it would have recorded never happens at all. So the stop path promotes only once the report reached a session able to act on it, which is the same condition that already governs whether the stop persists its position; that condition is now named rather than recomputed, and reset with its siblings when a session re-arms. The crossing branch needs no such gate: it is assembled from two delivered reports, so delivery is already proven. Verified against a live Jellyfin server driving the real client and tracker: before, the server reported the item unwatched after a second device cleared it while the overlay still rendered watched; after, the overlay follows the server. The optimistic mark still appears immediately during playback -- it now yields to a later authoritative read instead of outliving one. The #1287 and #1740 contracts are unchanged: neither branch issues an explicit mark, and the tests assert that alongside the promotion. |
||
|
|
5f397a99d9 |
fix(discover): let a refreshed row override a stale local watch patch
Pausing an episode on one device, finishing it on another and pressing Refresh left the first device showing the old "minutes left". Restarting the app showed the right value. Two independent defects produce that, and either alone reproduces the report. The first is the watch-state overlay. Every local watch event lands in WatchStateStore as a patch, and WatchStateSnapshot.apply overwrites viewOffsetMs unconditionally; isNewerThan only ever orders one patch against another, never against the server row underneath. Nothing expires a patch and nothing clears the map except a profile switch, so the Mac's own paused position kept winning over every subsequent fetch until the process died. A patch exists to bridge the gap between a local action and the next server read of that item, so it should stop applying once that read happens. The store now records the watermark at which a successful authoritative response returned each key, and suppresses an acknowledged session patch at or below it. Only a watermark is stored, never the observed state: WatchStateSnapshot cannot hold a container's leaf counts, and keeping max() per key makes the order two concurrent responses complete irrelevant. Suppression is a read-time predicate, so nothing mutates during build. The barrier covers the parentChain too. patchForItem picks the newest of the item's own entry and its ancestors', so retiring only the item's entry would let an older season mark win and render watched/0 -- worse than either the stale value or the fresh one. An authoritative read of a child already reflects any container mark that preceded it, so the child's observation judges its ancestors as well; a newer container action still wins. Provenance decides what may be suppressed at all. WatchStateEvent now carries serverAcknowledged, defaulting to false so an unclassified emit site degrades to today's behaviour rather than silently becoming retireable. An offline write is owed to the server and a read must never retire it, so it stays until a WatchPatchPromotionNotifier promotion says the queue replayed it. That channel is deliberately not a WatchStateEvent: OfflineWatchSyncService reacts to watched/unwatched by purging queued progress, so replaying one there would delete a newer rewatch. Promotion matches an exact WatchPatchId -- session minted for live crossings, derived from the persisted (profile, row, revision) for queued ones so it still joins after a restart. Report acceptance is not delivery: PlaybackReportSession resolves true for a same-state startup heartbeat it drops, so acknowledgement now keys on onDelivered. A MediaBrowser Started saves play count and last-played date but not the position, so it cannot acknowledge an offset. No report-derived watched crossing is acknowledged on any backend -- Jellyfin hard-codes its threshold and Plex never loads the server pref that would tell it the real one -- so only an awaited explicit markWatched settles one. The second defect is that a failed Refresh reported success. Plex _fetchHubs and the Jellyfin hub legs both degrade a failure to an empty list, and the library prefetch discarded its failures, so a server whose every hub request failed was recorded as succeeded; DiscoverProvider then kept the previous rows, set loaded and surfaced nothing. Worse, the background Continue Watching refresh wiped the row outright on zero success. Hub legs now report what they degraded through a HubFetchDiagnostics sink, which keeps partial rows alongside the failure and leaves every existing caller untouched. Failures ride through the aggregation results, a leg that could not run because discovery failed contributes that failure rather than a successful no-op, and loaded-server ids became succeeded - failed - cancelled so one bad leg no longer caches a server as covered and blocks its retry. The toolbar awaits a DiscoverRefreshOutcome and shows the existing unableToLoad snackbar on failure while the retained rows stay on screen. Rollback after a mid-pass exception is version-guarded, refilters against the current hidden libraries and no longer publishes a system shelf the pass never committed. Observations are staged with the pass and flushed only once the same disposal, generation and exception checks that authorise committing those rows have passed, so a discarded or rolled-back response can never suppress a patch. Also fixes a live data-loss race the promotion work would have built on: upsertProgressAction stamped a millisecond timestamp and updated the row in place, so a rewatch queued during an in-flight replay was deleted by id. Revisions are now strictly monotonic per row, replay deletes and retry updates compare against them, and the upsert resets the retry fields because a new revision is a new logical action. close #1829 |
||
|
|
4816e3928f |
fix(player): skip relative to the position a jump landed on
A coalesced key-repeat skip pins its target so a slow backend cannot make the next press rebase off a position the seek has not reached yet. Nothing retired that pin when something else moved the playhead, so for the ten seconds it survived, a skip taken after a timeline tap, a chapter jump, an OS media control or a peer sync resumed from the superseded target and threw the user back across their own jump. Publish every playhead movement on the player and retire the pin whenever the announced destination is not the accumulator's own commit. Overlapping seeks and backend-chosen relocations arbitrate by which operation the backend accepted, so a request that was merely asked for cannot speak for where the playhead ended up. close #1819 |
||
|
|
f63d0fe49e |
fix(music): shuffle the head of a shuffled queue too
Starting a music playlist, album, or artist on shuffle always opened on the list's first track: MusicQueueController.load anchored _order[cursor] and shuffled only the rest, and _startQueue collapsed "no start track" into startIndex 0, so the anchor was always the head. Anchoring is right for the two callers that do have a track which must play first -- the now-playing shuffle toggle, and a load with an explicit start track -- so make "no explicit start" representable instead of inferring it from the index: load takes int? startIndex and shuffles the whole list, head included, when it is null. A start track the list turns out not to contain now drops the anchor rather than falling back to 0. Video playback was never affected: Plex shuffles server-side via /playQueues and Jellyfin already shuffles its full local list. The queue's Random is injectable so the service-level regression is deterministic without depending on the SDK's seeded-PRNG sequence. Close #1811 |
||
|
|
db4f7a643b | test: prune low-value coverage | ||
|
|
094be1fa3e |
fix(continue-watching): clear the resume position when an item is marked watched
Marking a movie or episode watched left it sitting in Continue Watching with a checkmark, and the only way to shift it was to play it and skip to the end. Continue Watching membership on a MediaBrowser server is derived from UserData.PlaybackPositionTicks alone; Played is never consulted. Marking played normally zeroes that position as a side effect, so the row usually disappears and nothing ever checked that it had. When something writes a position back afterwards the item is left played *and* resumable, which the resume route happily keeps returning forever. markWatched now reads the UserItemDataDto the mark already returns and clears the bookmark itself when the server left one behind, so the postcondition holds however the item got into that state. The follow-up write costs a request only when the invariant is actually broken. The writer putting items there is our own offline queue. insertWatchAction already drops queued progress for an item when the mark is itself queued, but the online mark writes straight to the server and queues nothing, so a progress row recorded earlier survived and replayed afterwards — pending actions go out oldest first — restoring the very position the mark had cleared. The sync service now listens for watch-state events and discards queued progress for that item as the mark lands. Progress recorded after a mark is a rewatch and is queued later, so it is untouched. Plex never showed this because it forwards the recorded-at timestamp and lets the server discard a stale replay; the MediaBrowser stop report has nowhere to put one. Continue Watching also drops the row locally now instead of waiting a round trip for the refetch to confirm it, matching what removal events already did, and marking a season or show takes its on-deck episode with it. Watched items are deliberately still not filtered out of the shelf: Jellyfin keeps Played set when new progress arrives, so a rewatch in progress is indistinguishable from a stuck row, and filtering would hide it. close #1812 |
||
|
|
309a107912 |
feat(downloads): let Android move the app and its downloads to adoptable storage
Declare android:installLocation="auto" so the app becomes eligible for the Settings "change storage" flow and pm move-package. Adoptable storage relocates the private data directory with the APK, so downloads follow the app onto a USB drive adopted by an Android TV. Moving the app changes the private data directory, which invalidated any download task already enqueued: those pinned BaseDirectory.root plus an absolute directory that background_downloader persists verbatim, so a queued or paused download resumed writing to a volume the app no longer owns. Enqueue app-storage targets against the base directory the downloader re-resolves from the live app context instead, and drop the tasks and records a previous location left behind so the download restarts under the current one. That sweep runs before the downloader is wired up, because initialization delivers statuses accumulated while suspended — which can mark the row failed, and a failed row is deliberately not restarted — and because rescheduleKilledTasks re-enqueues every killed record it finds, stale absolute directory included. Compare paths by containment rather than by string prefix while making a stored path relative. A custom download root that merely starts with the base directory's name is a sibling the app does not own, and stripping it re-rooted the download inside app storage. close #1794 |
||
|
|
3a56218a12 |
fix(automotive): keep music playing while a car is parked, and silence it while driving
Music ran under a foreground service whose lifecycle observer was registered for App TV, so backgrounding the app on a head unit never paused it and driving never stopped it. Both halves were wrong for a car: parked audio must survive the app going to the background, and DD-2 requires it to stop when the vehicle starts moving. The vehicle now owns exactly the pause it caused. It is claimed when a restriction arrives and discharged on the event that proves the resume, so a track the user paused during a drive stays paused when the car parks. A restriction landing while the next source is still resolving silences the native player as well as the session, because the previous track is still coming out of it, and a pause that throws ends the session rather than leaving audio running in a moving car. |
||
|
|
7ce5a443fd |
feat(automotive): read the vehicle's driver-distraction state
Android Automotive tells an app when the car requires distraction optimization, and Plezy never asked. A monitor now watches CarUxRestrictions and publishes the verdict over the existing platform channel, where a single Dart gate answers whether playback may start. The car service is reached through the lifecycle-listener overload rather than Car.createCar(Context). That overload blocks its caller for up to five seconds polling ServiceManager, and on car-service death it reaches killClient(), which kills the hosting process for any context that is not an Activity or a Service - a crash in a system component would take the app down with it. Head units on Android 9 and 10 predate the listener, so a legacy ServiceConnection is used there, with the same identity guard on reconnect. A vehicle that has not answered yet counts as restricted, and one deadline is spent resolving it rather than one per request, so a wedged car service delays playback once instead of on every open. |
||
|
|
eaa1736c4e |
feat(mdblist): sync watched history, scrobbles and ratings with MDBList
Connects MDBList through its OAuth device-code grant, registered as a Device Code app so no client secret or redirect URI ships in the binary and TV, mobile and desktop all use the same flow. MDBList omits `verification_uri_complete`, but its device page seeds the code field from a `user_code` query parameter and the sign-in redirect preserves the query string, so the activation link is built locally and the dialog's open button lands on a filled-in form instead of an empty one. A server-supplied complete URL still wins if one ever appears. Poll state is read from the response body rather than the status code: `authorization_pending` and `slow_down` both arrive as HTTP 400, and a missing grant answers 404 `device_not_found`. Writes go out as real-time `/scrobble/*` reports plus `/sync/watched` for the marks that never pass through the player, with ratings on `/sync/ratings`. Matching uses IMDb and TMDb only — MDBList's id block has no `tvdb` field, so a TVDB-only item is skipped rather than written under an empty id block. |
||
|
|
8879941d29 |
fix(player): keep app-owned fullscreen when Escape leaves the player (#1791)
Physical Escape inside the player resolved to exitFullscreenIfActive on Windows and Linux whenever HTPC-style player navigation was off, so it dropped the window out of fullscreen regardless of who put it there. For anyone running with "start in fullscreen" (or who had toggled fullscreen from the browse UI), backing out of a movie left the app windowed, with "exit fullscreen on player close" switched off. Track fullscreen ownership instead: FullscreenStateManager now exposes a scope that the player opens in initState and closes in dispose, and setFullscreen — the single funnel every desktop platform reports through (window_manager on Linux, the Win32 runner callback on Windows, NSWindowDelegate on macOS) — records whether the fullscreen currently active was entered inside that scope. Escape only exits fullscreen the player itself entered; otherwise it is plain Back. The scope is depth-counted so the next-episode swap, where the incoming screen's initState runs before the outgoing screen's dispose, carries ownership across rather than resetting it. Nothing changes for a user who fullscreens from inside the player: Escape still exits fullscreen first, then acts as Back. The fullscreen toggle button and its shortcut are untouched, as is exitFullscreenOnPlayerClose. Fixes #1624. |
||
|
|
05fd622968 |
feat(emby): add Emby as a MediaBrowser backend alongside Jellyfin
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.
|
||
|
|
58d5d3c4ef |
fix(plex): read external ids from legacy agent and HAMA AniDB guids
Plex only builds the `Guid` array for the Plex Movie / Plex TV Series agents. A library still on a legacy agent answers with the scalar `guid` alone, so `fetchExternalIds` returned nothing for it and every consumer went quiet: trackers logged "no external IDs" and skipped the write, manual ratings showed "Not available", the detail screen dropped its watchlist button, and Continue Watching stopped collapsing duplicate copies. The reverse lookup already read that scalar; only the forward path ignored it. Read both shapes from the one request the method already makes, with the array winning per field and the scalar filling the rest. HAMA identifies anime by AniDB id and nothing else, which no id set could carry. AniDB is the Fribb mapping's own primary key, so it now travels on `ExternalIds` and indexes those rows directly — 7177 of them expose no tvdb/tmdb/imdb at all and were unreachable by any other path. Only plain `anidb-` maps: `anidb2`..`anidb9` group several AniDB entries under one TVDB-numbered show, so the guid names the root entry only. Two guards keep the new id where it means something. It is trusted for season 1, because that mode puts the anime there and its specials in season 0, while a higher season means the library is numbered by TVDB instead. And it resolves nothing for Trakt and Simkl, which never map anime and cannot address an AniDB id, so they keep reporting no ids rather than failing silently further down. `hasCatalogIds` marks the callers that can only speak IMDb/TMDB/TVDB. close #1788 |
||
|
|
4872adcde3 |
feat(player): keep the session's explicit track choices across episodes
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 |
||
|
|
61ae314c94 |
fix(player): tell same-language subtitle rows apart across episodes
The committed track kept the server display title, which collapses to the
bare language ("English") and is identical for every same-language row: a
carried signs/songs choice tied with the full dialogue track on the next
episode and latched onto whichever row sorted first. The row's own title
is preferred now, so the carried intent names the exact row again and the
native pass can match the right container track by title instead of
language order.
Reproduced against a live library where both English ASS rows differ only
by Title ("Styled Subtitles" vs "Signs/OP/ED").
close #1785
|
||
|
|
fdd4c661fe |
fix(player): carry a picked subtitle language across episodes with sparse tags
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 |
||
|
|
a759e8b3c6 |
perf(jellyfin): fetch a detail item once when several callers want it at once
Opening a detail screen issued two identical full-detail GETs for the same id, concurrently: `_loadFullMetadata` calls `fetchItemWithOnDeck`, and `_initWatchlistState` calls `fetchExternalIds`, which fetches the same item purely to read `ProviderIds`. Playback start adds three more for its own id. Each of those makes the server rebuild the entire dto — `People`, `Chapters` and `MediaSources` cost a database query apiece and `Trickplay` costs several plus a filesystem stat — so the duplicate is expensive on both ends. `fetchItem` now shares an in-flight request per item id. Single-flight only: once a request settles the next caller re-fetches, so nothing can serve a stale item. Measured on a remote Jellyfin server, 12 interleaved show-detail opens per version: requests 4 -> 3, payload 28.4 KB -> 18.6 KB. Median wall time is unchanged (1394ms -> 1386ms) because the duplicate ran alongside the first rather than behind it; this removes duplicated work, not latency. Two things were tried and rejected because measurement did not support them: starting `/Shows/NextUp` in parallel with the detail fetch (the requests contend rather than overlap — NextUp went from 380ms alone to 1395ms beside it — and it costs a wasted request per movie), and dropping `Trickplay` / `Chapters` from the detail field set (no measurable effect; both are real data the playback path reads). Refs #1784 |
||
|
|
74d3af3ae1 |
perf(home): load the home screen once instead of twice per cold start
The Discover tab fanned out its whole request set twice on every cold start and replayed slow rows on a shrinking timeout ladder, so a healthy remote server produced anywhere from 4s to 15s of loading. Measured against a remote Jellyfin server with four libraries, 24 interleaved cold-start samples per side: requests 19 -> 9 payload 219 KB -> 94 KB settled 5231ms -> 2502ms median, 13222ms -> 5927ms p95 Four independent causes: - Retry policy. `Client.send` resolves on response headers, so the connect budget covers the server's think time and a slow-but-alive query raises `connectionTimeout`. Replaying it made the server re-run the query with a shorter budget than the one it just missed; the `[10s, 8s, 5s]` ladder turned an 11s answer into an empty row after 23s. Hub surfaces now get one whole-request deadline, retry only immediate connection errors, and the deadline bounds the whole call including the request still in flight. - Request shape. `/Items/Latest` groups a TV library by series, so its rows are Series folder dtos and `RecursiveItemCount`/`ChildCount` cost a DB count each, per row. Hub rows now ask for `Overview` only; watch state survives because Jellyfin derives `UserData.Played` from `UnplayedItemCount` when the count fields are absent. `/Shows/NextUp` sends `NextUpDateCutoff` to bound the server's series-key scan, and `Thumb` leaves `EnableImageTypes` since nothing reads it. `UserData` and `PremiereDate` leave the browse set: neither is an `ItemFields` member, so the server dropped them anyway. - Fan-out. Per-library hubs ran in batches of three separated by a barrier, so one slow library stalled every library behind it. A sliding window keeps the same peak concurrency without head-of-line blocking. Concurrent `fetchLibraries` calls now share one `/Views` instead of racing two identical round trips, Plex's global and music hub legs start together, and Jellyfin gets Plex's pool tuning. - Duplicate pass. `DiscoverScreen.initState` starts a load and the online-entry hook asked for a full refresh on top of it, which `CoalescedLoadCoordinator` correctly queued as a trailing pass. The hook now calls `primeRefresh`, which rides along with a load already in flight; profile switches still go through `fullRefresh`. Refs #1784 |
||
|
|
860ce1e11a |
feat(profiles): show the first linked connection's user picture
A local profile had no picture of its own and always fell back to
initials. It now borrows the user picture of the connection it was
linked to first — oldest Connection.createdAt, ties broken by
connection id, since the join table carries no creation time.
Jellyfin links resolve to /Users/{id}/Images/Primary, keyed by the
PrimaryImageTag now captured at authentication and refreshed from the
/Users/Me body checkHealth already fetches. That endpoint is anonymous
on every Jellyfin release, so the URL carries no api_key and the access
token stays out of the image cache key. Plex links resolve the Home
user the link points at against PlexHomeService's live cache, so no
account-level lookup is needed and the picture tracks Plex's own
refresh.
The picture is derived per snapshot and never written back onto a
Profile: ProfileDetailScreen upserts the model it holds, so a
persisted URL would go stale and outlive the connection it came from.
Plex Home profiles are untouched, including one whose Plex avatar is
unset — it keeps its initials rather than borrowing a lent connection's
picture.
close #1667
|
||
|
|
2b4875d389 |
fix(player): keep hidden and cycled subtitles off in the next episode
Episode navigation carries the subtitle choice this screen has committed, so a way of turning subtitles off that the screen never sees is undone by the next episode. ExoPlayer has no renderer-level visibility switch, so the player's hide toggle is emulated by deselecting the track. That emulation lasted until the next selection: the automatic pass after an episode change put subtitles straight back on screen while the toggle still read "hidden", and un-hiding then restored a track id belonging to the episode that had already ended. Hiding is now sticky across media opens the way mpv's global sub-visibility is, selections made while hidden become what un-hiding restores, and the toggle no longer refuses to restore because the hidden track reads as Off. Cycling subtitles over the native track list — downloads, and items whose server exposes no subtitle rows — went straight to the track manager, which owns the player selection and the server write-back but not the committed choice. The screen records the cycled track now. |
||
|
|
e7aa1e4782 |
fix(jellyfin): stop overriding a server subtitle mode of None
Jellyfin answers PlaybackInfo with a null DefaultSubtitleStreamIndex when the user's SubtitleMode is None: the index is the server's whole answer, and null means it picked no subtitle. The mapper read null as "the server did not say" and promoted the container's default/forced flags to a server selection instead, which outranks the profile subtitle mode in the selection ladder. A viewer who had turned subtitles off for their Jellyfin user got them switched back on by every item that carried a default or forced row. Only the row the server names is selected now. A stream the viewer picks, and an explicit off, still survive per item because Plezy reports the index through playback progress and the server hands it back as that index or -1. close #1779 |
||
|
|
0f5e5c8b6e |
feat(music): offer File Info on tracks and other file-backed items
The context menu only offered File Info for movies and episodes, so a track's path, container, and audio stream detail were unreachable even though both backends already answer getFileInfo for them. Gate the entry on the new MediaKind.hasFileInfo instead of a literal kind list: movies, episodes, tracks, and clips are leaf items with real files, while shows, seasons, artists, albums, collections, playlists, and folders carry no Media/MediaSources and would only ever produce the "not available" snackbar. Also fix the Plex stream classifier, which mapped streamType 4 to an embedded image although PlexStreamType.lyrics is 4. Only music tracks carry that type, so a track's lyric stream rendered under "Embedded Images" with the video field block. Type 5 was invented outright and is now unknown. close #1747 |
||
|
|
fb0613e3db |
feat(player): toggle playback on a two-finger tap without raising the chrome
A touch viewer had to raise the chrome to pause, which dims the picture and covers the subtitle line they were trying to finish reading. A two-finger tap now toggles playback with the chrome left down, so the frame that pauses is the frame that was on screen. It fires the moment the chord resolves, in every player state. The two-finger double tap no longer resets the video zoom. Keeping it would mean holding this toggle back for the double-tap window before acting, and pausing late is pausing on the wrong frame. Zoom reset stays in the video settings sheet, its presets and the keyboard shortcut, and pinching back to 100% now snaps exactly within three percent so touch has a one-gesture path too. Both chord actions share _mobileTouchGesturesAllowed, so the chord is inert under screen lock, in PiP and while the content strip is open; the zoom reset previously fired straight through a locked screen. close #1505 |
||
|
|
9c08c78f6d |
fix(plex): stop recording a second play when the server already logged one
Plezy reported a completed playback twice: the /:/timeline heartbeats let the server mark the item played on its own, and the in-player auto-scrobble then sent an explicit /:/scrobble for the same watch. On PMS 1.30 that adds a second Play History row; on 1.43 the row is suppressed but viewCount still lands on 2 for one playback. Measured against PMS 1.43 to find what the server acts on: a watched-threshold crossing observed inside one session. Consecutive above-threshold reports mark nothing, a resume point left by an earlier session does not arm a new one, and a report at position zero is inert while one at a single second is enough. So the explicit mark now goes out only for sessions that gave the server no crossing to observe. That decision cannot be made while the session is live. A session beginning past the threshold has no crossing yet, but rewinding and playing forward creates one, and the server records it — marking eagerly and then hitting that path leaves viewCount at 2 again. The mark is therefore deferred to the terminal stop, and rides its future so callers that await the stop before tearing the player down do not drop it. Deferring also covers a crossing coalesced away during startup and a seek back below the threshold before stopping. Crossing state is tracked from reports the backend actually received rather than from PlaybackReportSession.report()'s bool, which resolves true for a same-state snapshot dropped during startup. The same-file sibling hook (#1500) still runs exactly once, on the transition to a settled mark rather than at the local crossing, so sibling episodes are never marked watched while the episode actually played is not. Local watched state and Continue Watching removal still happen on the observed crossing, so the only behaviour that moves is the redundant server call. close #1740 |
||
|
|
95b013e155 |
fix(seerr): show worldwide popular titles in Explore again
Overseerr and Jellyseerr bind the `language` query parameter of `/discover/movies` and `/discover/tv` to `originalLanguage`, which becomes TMDB's `with_original_language`. Sending the app locale there collapsed both shelves to titles originally made in that language, so a Portuguese UI saw only Portuguese films. Those two routes take their display language from the instance/user locale, which already wins over the query value, so the parameter was pure filtering with no localization to show for it. Drop it from the two paged discover routes. Trending, both upcoming rows, search, details and recommendations keep it: Seerr treats it as the display language everywhere else. close #1763 |
||
|
|
d83d0790ba |
fix(exoplayer): match side-loaded subtitles after media3 rewrites track ids
Plex sidecar subtitles are attached as MediaItem.SubtitleConfiguration and tagged `external_<n>`, then recovered from the Format id the track selector reports. Since media3 1.3.0, DefaultMediaSourceFactory always merges side-loaded subtitles with the primary source and MergingMediaPeriod rewrites every child format id to "<periodIndex>:<originalId>", so the tag arrives as "1:external_0" - measured on device - or "0:1:external_0" behind the container-sidecar merge. The prefix test therefore never matched and every sidecar reached Dart as an embedded track with no URI. A Plex sidecar's only identity is its stream key, which the app carries in that URI, so both matchers failed on it: a server-selected sidecar could never resolve and left subtitle selection pending, and a manually chosen one could not be mapped back to a stream id to write to the server. The already-attached branch of addSubtitleTrack compared the raw id too, so re-selecting a loaded sidecar silently did nothing. Route every write and readback of the tag through ExternalSubtitleIds, which matches the final id segment, and cover it with an instrumentation test that side-loads a subtitle through the real media3 media-source factory. Also stop claiming a saved track selection when no server stream was identified - there is no local store, so that path silently dropped the user's choice. close #1713 |
||
|
|
35061f9f68 |
fix(jellyfin): scope global search to visible libraries
Jellyfin search rows do not expose their owning collection, so hidden libraries cannot be filtered after the response. Scope searches to visible libraries, stamp the returned rows, reuse the latest loaded views, propagate cancellation, and fail closed when views cannot be loaded. Keep full candidate budgets and split music libraries into parallel album, audio, and artist requests. Album requests disable UserData and use the existing album field set to avoid recursive per-album work from #1552; audio requests retain cheap leaf play state. close #1770 |
||
|
|
1d9ffb7427 |
fix(search): exclude hidden libraries from global search results
searchAcrossServers was the only aggregation entry point without a hiddenLibraryKeys parameter, so libraries hidden from home hubs, Continue Watching and the library rail still surfaced their contents in the Search tab. Thread the profile's hidden keys from SearchScreen through to the aggregation, and drop matching items between the fan-out and the ranking pass so hidden hits cannot spend the result limit and shrink what is shown. Items the backend cannot attribute to a library, such as Plex shared and external media, are kept. The screen re-runs the visible query when a library is hidden or unhidden while results are on screen. Its listener is attached only after the provider has hydrated, so the initial load notification cannot race the first query into running twice. Plex search rows now go through the library-aware tagger, so a response that names its section only via librarySectionKey or targetLibrarySectionID is still attributable, and therefore filterable. Jellyfin search results carry no library id at all: the mapper's ParentLibraryId is not a Jellyfin field, and ParentId resolves to a season or physical folder rather than a CollectionFolder. Filtering there needs server-side ParentId scoping and is left for a follow-up. close #1770 |
||
|
|
2a7e5f4f9c |
fix(jellyfin): ask the server who may delete before offering it
Jellyfin never consults IsAdministrator when authorizing a library delete: BaseItem.IsAuthorizedToDelete looks at EnableContentDeletion and the per-library grant, and only the first user a server creates gets the former for free. Gating the "Delete from server" entry on the admin bit therefore offered a destructive action that answers 401 to later administrators, and hid it from plain users who do hold the grant. Ask the server per item instead, through the new MediaDeletionPermissionClient capability: BaseItemDto.CanDelete already folds the global grant, the per-library grant, and item state such as missing files or an in-progress recording. The probe runs when a menu opens on a deletable kind, costs ~0.5 KB, carries a whole-request deadline because the client's own budget covers connect and receive separately, and fails closed on anything unknown. Plex keeps its account-level owner/admin gate; it has no per-item permission on the wire. close #1749 |
||
|
|
bc0d14a749 |
fix(explore): list every library copy of a title, not one per server
`MediaServerClient.findByExternalIds` returned `MediaItem?`, so the Explore "In these libraries" chooser could never show more than one copy per server. A movie held by both a 4K library and an HD library on one Plex server therefore resolved to whichever copy came back first, with no way to reach the other. Return every id-verified match instead. `/library/all` is already server-wide and each `Metadata` entry carries its own `librarySectionID`, so both copies come back labelled with no extra request; Plex was simply taking `Metadata[0]` and the title ladder was returning on its first hit. An exact-guid hit no longer short-circuits the title search either — a library still on a legacy agent has a different primary guid and is invisible to the `guid=` filter. Copies are deduped by global key and ordered best-first, and each row now states its resolution, since library names need not mention it. Resolution passes merge rather than replace: the cross-server fan-out logs and skips per-server failures, so a later pass can come back short a server that answered an earlier one, and a failed pass no longer claims the title left the library. Duplicate keys fold field by field, because Jellyfin's library stamp is a best-effort ancestors lookup that returns the item bare when it fails and an unstamped row is indistinguishable from its sibling. Focus nodes are keyed by copy and reclaimed after a merge re-sorts the rows, so a dpad user is not thrown to a different copy. close #1754 |
||
|
|
957711a650 |
fix(startup): lead the damaged-store screen with the repair, not retry
A reporter on #1732 ran three successive builds against a preference store of 10336 bytes, every one of them zero, and reported each as "still failing". The gate classified it correctly every time and the consented repair would have cleared it in-process, but nothing on the failure screen said so: Retry was first, styled `FilledButton`, and autofocused, while `Repair storage` sat beside it as a tonal afterthought. Retry re-reads the same document, so for a corrupt-store failure it is an action that cannot succeed however many times it is pressed — and it was the one the screen recommended. Repair now takes the primary styling, the focus node and first position whenever it is offered, and the body text says plainly that retrying will not help. Retry keeps its place for every other failure, where a locked database or a denied directory really can change between attempts. The consent dialog was also promising an outcome it could not always deliver. Servers and profiles survive a repair only because their tokens are ciphertext in the database and the key that decrypts them lives in the store, so a store the key cannot be read out of signs the user out of everything — exactly the all-zero case. `PrefsRecovery.previewSalvage` reads the damaged file without touching it, and the dialog now names the real cost from that. The retained copy is labelled as holding credentials unless the bytes prove otherwise: what the salvage recovered says nothing about what the file still contains, because a store truncated mid-value keeps most of a vault key in plaintext while the salvage pattern — which needs the value's closing quote — matches nothing at all. Only an all-zero file drops the warning, so the one case that cries wolf is the one that provably holds no secret. `describe()` finally carries whether a repair was on offer. That line is the difference between a report a maintainer can act on and two days of guessing whether the button was even on screen. close #1732 |
||
|
|
2cb2c3eb95 |
feat(ratings): show every rating source the server already sent
Plezy rendered exactly one score per item. MediaRatingBadge._ratingDataFor took `rating` and fell back to `audienceRating` only when it was null, so a Plex movie carrying four attributed scores surfaced one, and which one was whatever the server happened to put in the scalar slot. #1755 asked for a setting to choose the source; showing all of them answers it without one. The data was already on the wire and being thrown away. `/library/metadata/ {id}` returns a `Rating[]` child array — IMDb, both Rotten Tomatoes panels, TMDB — with no extra query parameter, but PlexMetadataDto declared no field for it, so json_serializable dropped the key. The identical parse already existed in plex_catalog_source for the Explore tab and had simply never been wired to library items. Model the scores as a list rather than widening the scalar pair. The neutral MediaItem gains `ratings`; PlexMediaItem loses audienceRating, ratingImage and audienceRatingImage, which the list subsumes — Plex sends those images on listings too, so the same field covers both response shapes and no caller narrows to a backend type to read a score any more. CatalogRatingSource is promoted to lib/media as MediaRatingSource instead of growing a second near-identical type beside it, and plex_catalog_source's _ratingsFor becomes the shared plexRatingSources so one implementation serves both paths. There is no persistence to migrate: MediaItem.toJson has no production caller, the offline path re-parses raw Plex JSON through the same mapper, and Plex's audienceRating sort is server-supplied data, not a model read. Cards and the dashboard still show fewer scores than detail screens, and that part is a real Plex limit rather than a shortcut. Section listings send only the scalar pair; includeRatings, includeElements=Rating, includeFields=Rating, includeChildren and includeExtras were each probed against a live server and none surfaced the array, while includeGuids=1 demonstrably does add Guid[] — the probe works, the parameter does not exist. Hydrating every card would be one request per row, so listings render whatever their own response carried, which is one or two attributed scores rather than the single one they showed before. Jellyfin has no per-source array at all: the server collapses whatever its fetchers found into CommunityRating and CriticRating. CommunityRating's provenance is unknowable from the DTO — TMDB vote_average, IMDb via OMDb or a local NFO, last writer wins — so it stays the generic `audience` source with no brand mark. CriticRating is the Rotten Tomatoes Tomatometer as a 0-100 percent and is divided by ten explicitly rather than folded by magnitude, because a Tomatometer of 9 means 9% and range-sniffing would have promoted a rotten score to fresh. Photo rows are skipped, since Jellyfin reuses CommunityRating for the EXIF 0-5 star. The badges share one slot on every surface. On the phone hero the scores go in a single pill because that chip row is a height-clipped Wrap and a chip per source would push year, certification and runtime out of the visible band on short heroes; on the TV detail line and the dashboard spotlight the group occupies the one metadata slot so bullet separators do not multiply. The group announces itself as a single semantics node naming each source, because a bare row of four percentages tells a screen reader nothing about which score is which. rating_utils drops parseRatingImage and isRottenTomatoes — the URI vocabulary now lives only in the Plex mapper — and the source-key resolver and label map, previously private to the Explore detail screen, become the shared pair both screens use. The label strings move from explore.ratingSource to common.ratingSource accordingly, which costs no translations because every non-English value was empty; running clean_translations also scaffolds startup.quitPlezy and startup.restartRequiredBody, which were already drifted. Verified against the live server the probes came from: a detail response now yields TMDB 83%, IMDb 8.3 and Rotten Tomatoes audience 96% through the production mapper and badge resolver, and the listing response for the same title yields TMDB 83% alone. Both payloads are pinned verbatim as fixtures. Coverage adds mapper ordering, dedupe against the array's repeat of the scalar, out-of-range rejection, the Jellyfin scale and photo guard, the CatalogItem conversion that feeds Explore's dashboard hubs, and the three render surfaces including the semantics announcement. close #1755 |
||
|
|
3f49bcabf8 |
fix(prefs): replace the desktop preference store atomically
Upstream shared_preferences_windows and _linux write the whole preference document with a bare `writeAsStringSync`. That opens with the default `FileMode.write`, which truncates the live file before writing it, so every single preference write has a window in which the only copy on disk is empty or half-written. A crash, power loss, forced reboot or antivirus interception inside that window leaves a document that fails to parse on every subsequent launch — and the store holds the credential-vault key, so the loss is not recoverable by rewriting it. This is the corruption class behind #1732; the recovery path already landed is a band-aid over it. Vendor both packages under packages/ — the convention saf_util and wakelock_plus already follow — and stage, flush, then rename over the target. The flush has to precede the rename or it could publish contents that were never committed, the same corruption by another route. Staging uses one fixed sibling name rather than a stamped one, because the file is a plaintext copy of the vault key, tracker refresh tokens and Seerr cookies; it is created in the target's own directory so rename stays on one volume and the mode matches what the canonical file would have had, and a stale one is swept once the canonical document has been read cleanly. Both deltas are marked in-source and in provenance.json with the refresh contract. Atomicity is proven, not asserted. A hard link to the store observes the old document after a write, which only holds when the directory entry was replaced — truncate-in-place would have rewritten the shared inode, and that test does fail against unpatched upstream. Upstream's own suites still pass unchanged in both packages and now run in CI, so the patch keeps the contract it inherited. Windows `MoveFileExW` replacement semantics cannot be proven on a POSIX runner or a memory file system, so they get their own test on the existing windows-latest job, including replacement while a reader holds the file open — antivirus and Search Indexer both do. |
||
|
|
3ae7aa554b |
fix(prefs): recover a preference store whose bytes are not valid UTF-8
`File.readAsString` reports a UTF-8 decode failure as a FileSystemException, not a FormatException, so three guards written for that case never ran. The preflight's `on FormatException` branch was unreachable and its `on FileSystemException` sibling waved the document through; the plugin then threw the same FileSystemException, which failed the FormatException/TypeError test that decides repairability; and quarantine's lossy-decode fallback sat dead behind a rethrow. A store with one bad high byte — a UTF-16 BOM, a stray 0x80 — therefore reached the user as a failure screen with no Repair button and no way forward at all. Read bytes and decode explicitly instead, at both sites. Classification moves into describeStoreDamage, so a failure that surfaces after the preflight passed is judged by re-reading the file rather than by the error's type: a denied or locked store is indistinguishable from a decode failure by type or message, and offering a destructive repair for a permissions problem would reset every setting and risk the vault key over something a chmod fixes. isCorruptStoreError went with it, having no remaining callers. A repair that quarantines the store and then cannot reopen it no longer strands the process either. The repaired future was built straight from the cache loader, bypassing the self-healing reset sharedCache installs, so a failed reopen parked a rejected future in _cacheFuture and every later attempt replayed that stale error — with the damaged file already moved aside, so a restart would have booted cleanly. CorruptPreferenceStoreException now carries reopenSafe and a derived, content-free shape: byte length, whether it decoded, whether every byte is zero. #1732 arrived as "FormatException at offset 0" and nothing else, which cannot separate an all-zero file from a non-JSON first character from bytes that are not UTF-8; these can, and never quote the document. Cover the loop against the real desktop backend rather than a fake. shared_preferences_linux is pure Dart, byte-identical to the Windows implementation, and exposes fs/pathProvider, so pointing it at a temp directory exercises the genuine read, parse, cache and write path on any host — the join between preflight, classification and reopen where every one of these defects lived, and which had no coverage at all. |
||
|
|
56ad48824b |
fix(jellyfin): pin MediaSourceId on every static stream URL
Jellyfin has no DirectStreamUrl field — MediaSourceInfo carries only
TranscodingUrl, and a DirectPlay decision returns no URL at all, leaving the
client to build /Videos/{id}/stream itself. The branch reading
DirectStreamUrl was therefore dead against every Jellyfin version, along with
the 'DirectStream' play method and the doc comment promising both.
The static URL also dropped MediaSourceId whenever the item had a single
source whose Id equalled the item id — an ordinary episode. The streaming
endpoint resolves a blank MediaSourceId to its own first sorted source
(VideoFile first, then widest video), so the omission silently streamed a
different file as soon as the item gained an alternate version. Forward the
id the negotiation settled on, as jellyfin-web, Findroid, and Streamyfin all
do unconditionally.
Every "pinned" fixture used a source id that differed from the item id, so no
test exercised the shape that dropped the param; add one that does.
|
||
|
|
6f9edd3e93 |
fix(startup): make the deferred crash report survive its races
The persist-then-flush model had four ways to lose or corrupt the record it exists to protect. A no-op hub — which is what a failed or timed-out crash-reporting init leaves behind, because that phase is best effort — accepts an event and returns an empty id without throwing. "Did not throw" was treated as delivery, so the record was marked reported and suppressed forever. Delivery now requires a non-empty Sentry id, and init completion is tracked explicitly rather than assumed. Opting out, and building without a DSN, are deliberate suppression rather than delivery failure: both mark the record resolved so it is not rediscovered every launch. Everything else stays pending, and consumption no longer deletes an unreported record — deleting it ended the only retry there was, which made "the next launch tries again" false. The write path is now a queue. Record writes were launched unawaited from the failure path, so a fast retry could flush before the file existed, consume before a late write landed, or run two writers against one file and let the older one finish last. markReported joins the same queue and compares record identity before rewriting, because reading and writing outside it let a concurrent record land in between and be overwritten by the record it had just superseded. Records carry an id so that comparison is meaningful. Consumption also waits on a registered flush, so the success path cannot delete the file mid-send. Also routes the tvOS recovery marker through the tolerant read. reconcile() runs inside AppDatabase.open, a fatal gate step, so a wrong-typed marker vetoed the launch outright on a first-class TV target. Both new guards have regression tests verified to fail without the fix. |
||
|
|
9555937873 |
fix(startup): defer crash reports until the reporter exists
Reporting the failure inline was wrong for the phase that matters most. The gate opens preferences before SentryFlutter.init, so a corrupt or unreadable store — the likeliest cause of #1732 — was captured by a NoOpHub and silently discarded, which is exactly the telemetry gap the previous commit claimed to close. Initialising the reporter earlier is not an option either: `_beforeSend` reads the crash-reporting opt-out from settings, so events raised before settings load would bypass a user's choice. Every failure is now persisted first and flushed once the reporter is up with settings loaded, which in practice is the user's own retry seconds later in the same process. Records carry a `reported` flag so a send happens exactly once, and a failed send leaves the flag clear so the next launch tries again. The flush reads without consuming, so the record still reaches Settings > Logs. Also routes the tvOS recovery marker through the tolerant read: `reconcile()` runs inside `AppDatabase.open`, a fatal gate step, so a wrong-typed marker vetoed startup outright on a first-class TV target despite the new default-instead-of-veto behaviour. An unreadable marker tells us nothing, which is the same position as an absent one. |
||
|
|
66549e3a67 |
fix(prefs): route every credential read through the tolerant path
The wrong-type recovery only covered reads that went through a BaseSharedPreferencesService instance. The three stores that hold credentials read the shared cache directly, so a mistyped value there still threw a raw TypeError or, for Seerr, was swallowed by a catch-all and reported as "no session" — the registry documented protection it did not actually provide. readPreferenceTolerantly now takes the cache, so CredentialVault, TrackerAccountStore and SeerrSessionStore get the same classification as the settings layer. CredentialVault's post-write re-read moves outside its catch: a wrong-typed value written by another isolate was swallowed there, and the process then returned a key that never durably landed, making every ciphertext written under it unreadable on the next launch. Those stores are consulted long after startup, where a throw is an unhandled provider error rather than a repair prompt, so SettingsService initialization now walks the cached key set once and reads every sensitive key. That puts the failure inside a fatal gate step while the store is still open and a surgical single-key repair is possible. The remaining direct reads in settings and storage are routed too; the only ones left are the library-density dual-type migration, which probes both types deliberately, and an untyped switch that is type-safe by construction. |