Commit Graph
5 Commits
Author SHA1 Message Date
edde746 cb55134cef fix(startup): move optional work off the launch gate
Cold start on the target TV box reaches its first frame 21 ms after `dart_main`,
so nothing Dart-side gates the splash. Everything below is on the path to the
first *useful* frame, which is a strictly serial chain and where the viewer
actually waits.

- `monoTheme` is a pure function of two bools that builds a full `ColorScheme`, an
  applied-and-copied 15-style `TextTheme`, ~14 sub-themes and then clones the whole
  `ThemeData` again. It was rebuilt five times per cold start, two of them before
  `runApp`, and twice more per app-shell rebuild. It is memoized now, keyed by
  palette plus `TargetPlatform` -- the platform matters because `ThemeData()`
  derives tap target size, visual density and typography from
  `defaultTargetPlatform`, so a palette-only key would be wrong under a debug or
  test platform override.
- `initializeDateFormatting` ignores its locale argument and builds CLDR symbols
  and patterns for all 121 locales synchronously. It blocked the gate ahead of the
  database open for data that only content screens use.
- `DownloadStorageService.initialize` ended in a `path_provider` round trip plus
  mkdir at the tail of the gate, contradicting the comment above it that already
  explained offline artwork is not a launch requirement.
- `recoverInterruptedDownloads()` and `TrackerCoordinator.initialize()` ran from
  `initState` of the widget whose first build produces the first app frame, and
  the RSS watchdog installed a periodic timer there whose first useful sample is
  15 s away regardless.
- `CredentialVault` decrypted every token with pure-Dart AES-GCM on the main
  isolate, uncached, on every registry read and on every Drift re-emit -- and the
  binder writes tokens during the startup sweep, so writes re-triggered reads.
  Decryption is memoized by ciphertext, with `invalidateCache()` wired into the
  preference-store repair path so a repaired install cannot serve stale plaintext.
- `reloadFromStorage` now coalesces in-flight callers. The two serial awaits around
  the legacy migration are deliberately not merged; only genuinely concurrent
  callers share a snapshot.
- `_sameConnections` ran two `jsonEncode` calls per connection on every Drift emit
  purely to compare, allocating two maps and two strings each time.
- The splash rendered one `CircularProgressIndicator` per pending server on top of
  the aggregate one, so N+1 tickers scheduled a frame every vsync for the whole of
  `awaitBindingSettle` -- competing with the startup work they were reporting on.

Measured on device in a settled dexopt state: time to `main_screen` 1495 -> 1400 ms,
`credentials_loaded` 1238 -> 1128 ms, `database_ready` 501 -> 443 ms. First frame is
unchanged at ~18 ms, as expected.
2026-08-23 18:13:39 +02:00
edde746 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.
2026-08-05 06:09:26 +02:00
edde746 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.
2026-07-31 21:45:32 +02:00
edde746 61f7d33fdf fix(profiles): treat vault decrypt failures as lost credentials
A failed MAC check (key/ciphertext divergence: restored backup,
clobbered prefs, racing key generation across isolates) threw from
CredentialVault.reveal on the startup profile-settings path and
crash-looped the app until data was wiped — one device logged 31
fatals in 16 minutes on 2.8.0.

Decrypt failure now means the credential is lost, never a crash:
reveal() returns null, ProfileConnectionRegistry maps it onto the
existing empty-token lazy-fetch sentinel and heals the row so later
boots re-acquire the token instead of re-failing, and
revealConnectionConfig degrades tokens to empty strings without
marking them migrated. Key init also reloads prefs before deciding to
generate and re-reads after writing, adopting whatever landed so all
isolates converge on a single key instead of orphaning ciphertext.
2026-07-05 14:16:04 +02:00
edde746 31d2d9dc98 feat: jellyfin 2026-05-01 01:20:36 +02:00