Skip to content

chore(sync): merge upstream silo-android main (2026-09-28) - #30

Merged
JonahMMay merged 293 commits into
mainfrom
sync/upstream-2026-09-28
Sep 28, 2026
Merged

JonahMMay merged 293 commits into
mainfrom
sync/upstream-2026-09-28

Conversation

@JonahMMay

Copy link
Copy Markdown

Sync with upstream Silo-Server/silo-android main (280 commits). CI in progress; body will be updated.

🤖 Generated with Claude Code

Quick104 and others added 30 commits September 4, 2026 20:51
RegistryPairingAuthPort refreshed the contract verdict only in the
rollback branch. After a successful replaceAccountSession the newly
paired server stayed at UNKNOWN (or a stale UPDATE_REQUIRED saved by an
older build), so gated startup consumers acted on a verdict that was
never established for it.

Pairing is a server switch like every other path: after the session
commits and before the port reports SignedIn, run refreshServerContract()
for the now-active server. The rollback call is kept. The probe never
throws on a failed request, so this cannot undo a committed session.
The override now declares `: Unit` so the added tail expression does not
change the port's return type.

Test: RegistryPairingAuthPortTest.successfulPairingProbesTheNewServerExactlyOnce

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…aunch

The launch probe in resolveStartDestination was fire-and-forget, so gated
startup consumers (ProfileSelectionViewModel's admin lookup, and anything
else behind ApiV2Gate) could run against a stale UPDATE_REQUIRED verdict
saved before the server was upgraded, and fail without a request.

Only UPDATE_REQUIRED is harmful: UNKNOWN and V2 both pass the gate. So
resolveStartDestination (already a suspend fun inside a LaunchedEffect on
both phone and TV) now calls the new
AuthRepository.awaitContractRefreshIfUpdateRequired(), which probes and
waits at most 3 s only when the stored verdict is UPDATE_REQUIRED, and
passes the result to refreshActiveServerName(knownContract) so the
background identity refresh does not probe twice. No runBlocking on the
main thread; the wait runs under Dispatchers.IO.

Consumers are made robust as well: AuthRepository exposes
activeServerContractFlow (from registry.activeEntry), and
ProfileSelectionViewModel collects it once in init and re-runs
loadProfiles() when the contract moves from UPDATE_REQUIRED to V2.

Tests:
- AuthRepositoryContractTest: awaitContractRefreshIfUpdateRequired
  replaces a stale UPDATE_REQUIRED verdict / does not probe when the gate
  would pass
- ProfileSelectionViewModelContractTest.v2VerdictArrivingAfterUpdateRequiredRetriggersTheGatedLoad

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Removing the ACTIVE server makes the registry promote the next-MRU entry,
but neither server-list view model probed it: TV only called
tokenManager.switchActiveServer(promotedId), and the phone did nothing
after remove(). The promoted server kept whatever contract verdict an
older build had stored (or UNKNOWN), and its fetched name was never
refreshed.

TV: replace the bare token switch with authRepository.switchToServer
(registry switch + token switch + refreshActiveServerName). It is
idempotent when the registry already points at promotedId —
AndroidServerRegistry.switchTo just bumps lastUsedAt and re-applies the
same active id — and the navigation logic after it is unchanged.

Phone: mirror the TV wasActive/promoted logic and call
authRepository.refreshActiveServerName() when the active server was
removed and another one was promoted.

Test: AuthRepositoryContractTest `switchToServer on the already-active id
still probes` (no ServerList view-model tests exist).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…test

ProfileSelectionViewModelContractTest asserted on a plain list the mock
engine appended to from its own thread, so under the full unit-test run
the first assertion could observe an empty list before the v1 request
had landed. Requests now go through a channel and each expected request
is awaited on a real clock (bounded), so the test observes network
arrivals instead of assuming them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server pilot branch documented updateProfile's real statuses and media
type (contract digest moved) and switched listProgress to keyset cursors
(list_progress_ok.json next_cursor changed). SOURCE now pins
74fe2b4ac97349167d1bb85892d7f0566bd43d49.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The probe mapped any 404 with a text/plain Content-Type to UpdateServer,
so a reverse proxy's own plain-text "Not Found" would tell the user to
update a server that may not be a Silo at all.

Decide on the body instead: only Go's http.NotFound text
("404 page not found", tolerating the single trailing newline it writes)
is UpdateServer. Leading whitespace, extra newlines, or any other body
stays Failure(UNEXPECTED_STATUS, 404). This matches the Apple client's
isLegacyNotFound rule so both clients reach the same verdict.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…efresh

Both server-list view models await AuthRepository.switchToServer behind a
spinner. It ran refreshActiveServerName, which awaits the contract probe
plus the branding and health requests, so a saved server that accepts
the TCP connection but never answers held the spinner for the full
client timeouts (about 72 s).

switchToServer now switches the registry and token scope, awaits only
the contract probe bounded by the same 3 s used on the launch path
(recording V2/UPDATE_REQUIRED, leaving UNKNOWN alone), and launches the
display-name refresh on an injected process-lifetime backgroundScope
without awaiting it. The scope is optional; without one the name
refresh runs inline after the probe (single-server hosts, tests). The
active-id guard still drops a late name for a server that is no longer
active.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
listProgress promises the full progress list, but the cursor walk
stopped at PROGRESS_MAX_PAGES and returned ApiResult.Success with only
the prefix, so continue-watching consumers treated older entries as
absent without any signal.

The page size stays at the server maximum (200). PROGRESS_MAX_PAGES is
now a runaway guard only (100 pages, 20,000 entries): reaching it with
has_more still true returns ApiResult.Error(code = 0,
error = "progress_incomplete") instead of Success. The function now
documents that the result is complete or an error.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
185d1e0 made the probe accept only Go's exact "404 page not found" text, so
the two pairing tests that mocked a bare "not found" started recording
UNKNOWN instead of UPDATE_REQUIRED. Mock the real legacy body.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server pilot branch constrained the profile PIN schema (contract digest
moved) and bound listProgress cursors to the viewer's access policy
(list_progress_ok.json carries a new next_cursor). SOURCE now pins
c9e5a4e376f919a90e76aa8408892ba925d5661e.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… lifts

When an upgraded server still holds a stored UPDATE_REQUIRED verdict and
the launch probe outlasts its 3 s bound, MainTvActivity routes on the
stale verdict and the background refresh records V2 afterwards. The TV
profile picker called getCurrentUser() exactly once in loadProfiles(),
so the gated admin lookup failed locally and the admin lost the
profile-management controls for the ViewModel's lifetime.

Mirror the phone ProfileSelectionViewModel: collect
activeServerContractFlow in init and re-run loadProfiles() only when the
verdict moves from UPDATE_REQUIRED to V2. The initial emission is
skipped, so a fresh V2 or UNKNOWN start does not double-load.

TvProfileSelectionViewModelContractTest proves a V2 verdict arriving
after an UPDATE_REQUIRED first load re-triggers the load and lets the
v2 admin lookup reach the network.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…time

After a successful replaceAccountSession the pairing port awaited
refreshServerContract() unbounded. The client request and socket
timeouts are ~60 s while the companion waits only 30 s for
PairingMessage.ServerResult, so a stalled /api/v2/system/info made the
phone report failure after the credentials were already committed.

Add AuthRepository.refreshServerContractBounded(timeoutMs = 3_000L) as
the single home for the probe bound, and route switchToServer,
awaitContractRefreshIfUpdateRequired, and both PairingAuthPort probe
sites (success and rollback) through it. A timeout leaves the verdict
UNKNOWN, which passes the gate and is re-probed on the next switch or
launch.

RegistryPairingAuthPortTest gains
successfulPairingReportsSignedInWhenTheProbeNeverAnswers: a MockEngine
that suspends until cancelled still lets the port return within the
bound on runTest's virtual clock, with the session intact and no verdict
recorded. The two existing probe-counting tests now run the port on a
real-clock dispatcher, because under the test scheduler the new
virtual-time bound fires before the engine thread can answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…imes out

switchToServer bounds the contract probe to SWITCH_PROBE_TIMEOUT_MS so the
switch spinner stays responsive. When the target entry held a stale
UPDATE_REQUIRED verdict and /api/v2/system/info answered after that bound,
withTimeoutOrNull cancelled the only contract refresh; the background task
afterwards refreshed just the display name, so the gate kept rejecting v2
calls for the switched session until the next launch even though the
upgraded server would have answered moments later.

Keep the switch bounded, but when the bounded probe returns null and a
backgroundScope is wired in, launch a replacement unbounded probe (the
client's own timeouts still apply) pinned to the target serverId. The
existing active-id guard in recordServerContract drops the result if the
user switched again meanwhile. Without a scope (tests, single-server
hosts) behavior is unchanged. The launch path in MainActivity and
MainTvActivity already follows awaitContractRefreshIfUpdateRequired with
a fire-and-forget refreshActiveServerName(knownContract = null) that runs
a full unbounded probe, so it needs no replacement.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…o-Server#293)

* fix(player): select exact native subtitles and align Cast timing

* fix(cast): keep stream authentication before URL fragments

* fix(subtitles): settle failed native mounts before recovery

* fix(subtitles): preserve native remounts and phone playback

* fix(tv): confirm subtitles on the replacement media item

* fix(playback): clear failed native subtitle restoration

* fix(playback): preserve video when subtitle recovery fails

* fix(playback): confirm phone subtitles on the current media mount

* fix(playback): retain subtitle mount evidence across screen recreation
refreshServerContractBounded mapped a transient probe Failure (connection
error, 5xx) to the non-null ServerContract.UNKNOWN. refreshServerContract
correctly recorded nothing for it, but both callers read any non-null
result as "verdict obtained": the launch path passed knownContract =
UNKNOWN into refreshActiveServerName, which then skipped its own probe,
and switchToServer skipped its background replacement probe. With a
stored UPDATE_REQUIRED and a server briefly unreachable at launch or
switch, the gate kept rejecting pilot calls for the whole session.

Make "no verdict" uniform: the bounded probe now returns null unless the
probe produced a real V2 or UPDATE_REQUIRED verdict, so a failure is
handled exactly like a timeout. The launch path (MainActivity,
MainTvActivity) already falls back to refreshActiveServerName(null),
which re-probes unbounded on lifecycleScope; switchToServer now launches
its background replacement for failures too. recordServerContract keeps
ignoring UNKNOWN. PairingAuthPort discards the return value and needs no
change.

Tests cover a 503 answer returning null and switchToServer's replacement
recording V2 once the server recovers, a connection failure on the launch
guard returning null, and real V2 / UPDATE_REQUIRED answers still
returning their verdict without a replacement probe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…talls

Both pairing probe sites (the newly paired server and the rollback
restore) awaited refreshServerContractBounded() and discarded the
result, so a timeout or transient failure while re-pairing a server
that carried a stale UPDATE_REQUIRED left the session gated with no
replacement probe — unlike switchToServer, which already handed the
retry to the background scope.

Extract that "bounded probe, then background replacement on no
verdict" step into AuthRepository.refreshServerContractWithFallback
(serverId) and use it from switchToServer and both pairing sites, so
every path that activates a server shares one fallback.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…failed

The connect path mapped a transient candidate-probe Failure through
toServerContract() into a non-null UNKNOWN and passed that to
setServerUrl once /api/v2/system/setup succeeded. refreshActiveServerName
neither records UNKNOWN nor re-probes when handed a known contract, so a
stale UPDATE_REQUIRED on the registry entry survived a successful v2
connection and kept gating the session.

A successful v2 setup answer is proof of v2 on its own, so both setup
view models now pass CONTRACT_PROVEN_BY_V2_RESPONSE (ServerContract.V2)
on the success path and keep the probe only for the UpdateServer early
return. The shared value is documented in AuthRepository.kt so the
reasoning lives next to toServerContract().

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
probeServerContract ran the candidate probe unbounded, so a server that
accepted the socket but stalled held the setup spinner for the client's
~60 s timeout per candidate (HTTPS then HTTP for a bare host) before
the setup call waited again.

Bound it with withTimeoutOrNull(SWITCH_PROBE_TIMEOUT_MS) behind a
timeoutMs parameter. A timeout returns null, which the connect path
already treats as "no verdict"; getSetupStatus carries the client's own
timeout and decides reachability, so no second bound is added there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On the first launch after upgrading the app, restored server entries
carry ServerContract.UNKNOWN, which passes ApiV2Gate. Authenticated
startup consumers (getCurrentUser and friends) then raced the background
probe and could receive raw v2 404s from a saved v1-only server before
UPDATE_REQUIRED was recorded; those one-shot loads do not retry.

Generalize awaitContractRefreshIfUpdateRequired into
awaitContractRefreshIfUnsettled: only a stored V2 skips the bounded
wait; UNKNOWN and UPDATE_REQUIRED both probe before routing. Both
activities keep passing the result as knownContract to the
fire-and-forget refreshActiveServerName, so a null (V2, timeout, or
failure) still re-probes off the critical path. No other callers used
the old name, so it is renamed rather than aliased.

Tests: UNKNOWN awaits and records UPDATE_REQUIRED from a v1-only
server; UNKNOWN awaits and records V2; V2 returns null with zero
requests; existing UPDATE_REQUIRED cases unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Removing the ACTIVE server on the phone promotes the next-MRU entry
inside the registry, and ServerListViewModel.onRemove re-probed it via
refreshActiveServerName in viewModelScope. Popping the server list
cancels that scope, so the promoted entry could keep UNKNOWN or a stale
UPDATE_REQUIRED for the rest of the session.

Route the promotion through AuthRepository.switchToServer(promotedId)
like the TV client already does. The registry already points at the
promoted id, so the switch is idempotent there:
EncryptedTokenManagerImpl.switchActiveServer returns early when the id
is unchanged (no credential-epoch bump, no cache reload), and the
bounded probe hands its replacement to the repository's process-lifetime
backgroundScope with the display-name refresh off the critical path.

Test: promoted server re-probe survives cancellation of the calling
scope (AuthRepositoryContractTest).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The server pilot branch paginated listAdminUsers, declared the statuses the
listener really emits, relaxed the profile quality/subtitle enums, and bound
listProgress cursors to the library-restriction flag (contract digest and
list_progress_ok.json next_cursor moved). SOURCE now pins
7bd3af1430962a8b6662510caffe49e2954c4055.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
listProgress let every page's client.get resolve the active auth scope
afresh, so a server or profile switch mid-walk sent later pages under a
different identity while `entries` kept the earlier pages. The server
binds the cursor to user + profile and answers 400 invalid_cursor, so
mixed results could not surface today, but the walk relied on that
server behavior and reported a confusing cursor error.

The walk now captures one AuthScopeSnapshot up front (or takes an
explicit `scope`, mirroring addFavorite/syncProgress) and pins every
page request with authScope(), so SiloAuthPlugin sends the same server,
profile, and credential slot on each page. Before each later page the
captured scope is compared with the live one via the new
AuthScopeSnapshot.isSameIdentityAs (extracted from
ProfileRepository.identityScopeUnchanged); a generation or epoch change
aborts with ApiResult.Error(error = "identity_changed") instead of
continuing. PersonalDataApi now takes the TokenManager from Koin; the
default null keeps single-scope test constructions unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ncel it

refreshServerContractWithFallback awaited the bounded probe in the caller's
coroutine and only then launched the unbounded replacement on
backgroundScope. A server-list viewModelScope cancelled by Back during the
bound propagated CancellationException out of the bounded call, so the
replacement never started and the switched-to server stayed UNKNOWN or a
stale UPDATE_REQUIRED for the session.

With a backgroundScope and probe wired in, the whole bounded-then-unbounded
sequence now runs in a job launched on backgroundScope before the caller
waits; the caller only awaits the bounded verdict through a
CompletableDeferred under its own withTimeoutOrNull. Cancelling the caller
cancels that await, not the probe. The active-id guard stays inside the
background job, and recordServerContract still drops verdicts for a server
that is no longer active. Without a scope the inline behavior is unchanged.

Tests cover a caller cancelled mid-bound whose late answer is still
recorded, and a caller cancelled with the server switched meanwhile that
records nothing. Three existing tests moved their background scope to a
real-clock dispatcher because the probe no longer runs in the caller.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CI run 33942933256 failed `switchToServer re-probes in the background
when the bounded probe fails` at the runTest line. 51fea49 moved the
bounded probe and its fallback onto backgroundScope before the caller
awaits, but this test still built the background scope on a
StandardTestDispatcher(testScheduler) while the caller waited under
Dispatchers.Default. runTest kept advancing the shared scheduler while
the body was suspended, so the 503 -> null -> replacement-probe chain
raced the caller's real-clock 3 s bound and the later joinChildren() and
request-count assertions; it passed locally and failed in CI.

Put the background scope on Dispatchers.Default, matching the three
sibling switch tests already converted in 51fea49, and keep the
CompletableDeferred gate so only the background replacement can record
V2. The remaining StandardTestDispatcher tests (timeout re-probe, the
cancellation tests, stale-result drop) run the caller on the virtual
clock too and need it to elapse the bound deterministically, so they
stay as they are.

Verified: 10 consecutive `:shared:testDebugUnitTest --tests
"*AuthRepositoryContractTest*" --rerun` runs pass; full
`:shared:testDebugUnitTest` and `*RegistryPairingAuthPort*` pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
switchToServer ran registry.switchTo and tokenManager.switchActiveServer
in the caller's coroutine and only handed the probe to backgroundScope
afterwards. A server-list viewModelScope cancelled while either
persistence call was in flight could leave the new server persisted as
active with no probe handoff, so its entry stayed UNKNOWN or stale
UPDATE_REQUIRED for the session.

With a backgroundScope wired in, the whole activation (registry switch,
token switch, bounded probe, replacement probe, display-name refresh)
now runs as one job there, started before the caller waits. The caller
awaits a CompletableDeferred completed after the bounded verdict, so it
still returns within SWITCH_PROBE_TIMEOUT_MS when the probe hangs and
never awaits the display-name refresh; cancelling the caller cancels
only that await, and a scope torn down mid-activation releases it. The
no-scope path is unchanged. The probe-then-fallback sequence moves into
probeContractWithFallback so switchToServer and the pairing path's
refreshServerContractWithFallback share it.

Adds a real-clock test that gates the registry fake's switchTo, cancels
the caller while suspended there, releases the gate, and asserts the
token switch, probe, and V2 record still complete.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
feat(apiv2): add the v2 contract harness, probe, and pilot operations [api-v2 phase 3]
…client-transform

fix(tv): direct-play Dolby Vision Profile 7 on HDR10 outputs so TrueHD passthrough survives
Quick104 and others added 15 commits September 22, 2026 10:43
Call silo-server's reusable Discord commit feed workflow on pushes to
main. Commits go to this repository's post in the commit-feed forum
channel, set by the DISCORD_COMMITS_THREAD_ID variable and the
DISCORD_COMMITS_WEBHOOK_URL secret.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ass-media 0.4.0's OpenGL overlay, used on Android 10 and later, kept the
height its surface had when it was created. The TV player creates the
overlay full-screen and then shrinks it to the title-safe canvas, so
every ASS event was drawn about a tenth of the screen too high and
top-aligned lines were pushed off screen. ass-media 0.5.1 carries the
upstream fix (peerless2012/libass-android 57ae102).

0.5 turned the Matroska extractor's flags into a Kotlin default
parameter that Java cannot omit, so the bridge passes 0, the value the
old two-argument constructor used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Discord commit feed calls the shared silo-server reusable workflow at
@main, the same as silo-apple. The supply-chain check required every
`uses:` ref to be a commit SHA, so the Unit tests job failed before tests
ran on every main push since the feed landed.

Exempt Silo-Server-owned refs so the client repos share one version of
the workflow. Third-party actions still need SHA pins.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
loginForTokens compared the account-session expectation captured before
the login request with a fresh capture using data-class equality. That
equality includes the installationAllowed lambda, and D8 builds a new
default lambda for every AccountSessionExpectation, so the two never
matched on device. Every TV password attempt, right or wrong, ended with
"The account or server changed" and hid the server's answer.

Compare only the generation, server id and server URL instead.

Fixes Silo-Server#364

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ilo-Server#367)

Home overlays local watched state and queued playback progress by looking
up every distinct item on the page in one Room IN (...) query. SQLite on
Android 11 and older rejects a statement with more than 999 bind
variables, so a customized Home with more than ~997 distinct items threw
"too many SQL variables" and crashed the app on launch (reported on an
NVIDIA Shield, SILO-VKCBLPM8).

Split both lookups into batches of 900 IDs. Batches are by content ID, so
every row for an item stays in one batch and the per-item grouping is
unchanged.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* feat(playback): use profile-wide seek intervals

Adopt the settings contract revision 9 keys for video and audiobook skip
intervals (silo-server#994). On a server that supports them, every relative
seek uses the active profile's values: phone and TV player buttons and
gestures, TV D-pad quick skips, MediaSession seek back/forward for video and
audiobooks, picture-in-picture actions, Google Cast controls including the
Cast notification, and the audiobook players. Audiobook session seeks go
through the player view model so they cross part files.

Older servers, and a server whose support is not known yet, keep the
previous fixed intervals and device-local audiobook values, and nothing is
written to them. Device-local audiobook intervals become profile-wide only
through an explicit import that reports each direction separately.

Phone and TV playback settings gain Video and Audiobooks groups. Changes
apply to the next skip without restarting playback; changes from another
device apply at the next foreground, reconnect, player open, or profile
switch, since the app has no settings change stream.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(playback): settle seek interval writes against confirmed values

Review follow-up for the profile seek interval store:

- A write now settles in the store's scope, so leaving the screen while it
  is in flight no longer strands an unsaved interval on screen.
- A failed write restores the last server-confirmed value instead of an
  earlier choice that was never confirmed, and only the newest request for
  a key changes what is shown. The cache stores confirmed values only.
- A choice made while a legacy import is running stays on screen and is
  saved; the import updates only directions nobody changed meanwhile.
- Re-selecting the same profile after a switch reloads the intervals on
  phone and TV instead of waiting for the next foreground.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Show remote trailers and local extras on phone detail pages
- Share trailer metadata and YouTube launching with TV
* feat(tv-settings): opt-in Force HDR Passthrough toggle

DisplayHdrProbe (Android's Display.HdrCapabilities) can under-report a
panel's real HDR support. HLG in particular carries no static metadata
block, unlike HDR10, so a TV/AVR can render it correctly without ever
declaring HDR_TYPE_HLG. When that happens the codec/display intersection
in PlaybackCapabilityDetector.detect() comes back false for a type the
device actually handles, and the server has no way to tell that apart
from a real lack of support — it plans a tone-mapped SDR transcode for
a file that could have played natively.

There's no general fix on the Android side (no "trust the codec, not the
panel" platform API), so this adds an explicit, off-by-default, user-
acknowledged override: DisplayHdrProbe.probeDetailed() gains a
forcePassthrough parameter that skips the EDID-derived probe and reports
every HDR type as supported, letting the codec probe alone decide
eligibility (intersect() still runs — this never claims decode support
the hardware doesn't have, only removes the panel as the limiting
factor). Threaded through PlaybackCapabilityDetector.detect() and wired
from a new local-only, per-profile setting (player.force_hdr_passthrough,
default false, excluded from DeviceSettings since panel capability is a
device fact, not a server preference).

Surfaced as a single toggle in TV Settings > Streaming, paired with a
footer warning that an unsupported type can render as a black or
incorrectly-colored picture. The system-forced-SDR check keeps priority
over this override, since that describes what the compositor will
actually do regardless.

Real-world repro (titles/network details redacted): an HLG-encoded
1080p WEBRip file played correctly in HDR on the same TV through a
different Jellyfin-compatible client (direct play), but Silo always
tone-mapped it to SDR. Server logs showed the client capability report
sent for that file with hdr_details.hlg=false, both in the codec-decode
block and the display block. `adb shell dumpsys display` on the Shield
confirmed Android's own HdrCapabilities reported
mSupportedHdrTypes=[1, 2] (Dolby Vision, HDR10 — no HLG) for that
output, matching what the client sent. Other HDR10-tagged titles on the
same device direct-played correctly, isolating the gap to HLG
specifically under-reported by Display.HdrCapabilities rather than a
general playback bug.

AI disclosure: implemented with Claude (Anthropic), following this
repo's existing dolbyVisionEnabled/pictureInPictureEnabled setting
pattern end to end. Not build-verified locally (no JDK/Android SDK in
this environment) — reviewed by hand against every call site of
PlaybackCapabilityDetector.detect() and every implementer of
PlayerSettingsStore in the repo; please run the normal CI build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(tv): honor HDR override during playback preflight

---------

Co-authored-by: tellur1an <claude.ai.antarctic284@passmail.net>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
- Record the server each identity generation commits and assert requests against their own generation instead of the live scope
- Drive switches through a single `switchIdentity` helper so the server is recorded before it is published
- Mark the fake token manager's server and profile fields `@Volatile`
…Silo-Server#370)

* feat(diagnostics): log seeks and player state in playback diagnostics

Diagnostics reports had no seek, buffering, or play/pause lines, so a
report sent after a freeze following rapid fast-forward presses could not
show where playback got stuck. The analytics listener only pushed those
events to the in-process flow, and the view models' seek routing went to
logcat only.

Log seek starts, player state, and play/pause changes from the shared
analytics listener, and seek commit routes, settle or settle-deadline,
recovery queueing, rollback, and failure from the phone and TV player
view models. Each line's meaning is in its fixed message text because the
hosted collector strips position_ms and reason; no attribute keys are
added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(diagnostics): keep playback reason codes in hosted reports

The hosted collector strips the playback reason attribute, so hosted
reports could not tell a user pause from an audio-focus or end-of-item
pause, and lost the seek reanchor and player-error reasons. Append the
fixed reason code to the message instead.

The message "seek committed server reanchor" was also replaced with
[redacted_private_id] by hosted text scrubbing, which reads the word after
"server" as a possible host name. Rename it to "seek committed reanchor"
and pin every new playback state and seek message, with each reason code,
against hosted scrubbing in a test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(diagnostics): drive the playback logger in the hosted-message test

The hosted-message test built its JSON lines by hand, so it would still
pass if the logger stopped appending reason codes. Capture the lines the
real DiagnosticsPlaybackLogger emits through SiloLog.installSink and feed
those to the hosted bundle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rver#391)

* fix(playback): refresh expired tokens on progress heartbeats

Sequenced progress was sent pinned and single-attempt, so the auth plugin
neither refreshed ahead of expiry nor refreshed and resent after a 401.
When the access token expired mid-playback, the next heartbeat failed and
the player showed "Playback progress is pending" until unrelated traffic
refreshed the token.

Let progress use the pinned-scope refresh-and-resend path, matching the
Apple client. The resend carries the same sequenced sample. Stop, replan,
route events and start stay single-attempt. The lifecycle now warns only
after three consecutive failed ticks, since the journal resends the
pending sample on the next tick anyway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(auth): stop re-refreshing a rejected pinned refresh token

With progress now refreshing on 401, a saved-account refresh token that
the server definitively rejected would be retried on every heartbeat:
the pinned path records dead temporary generations but nothing for
persistent credentials, and it must not invalidate the scope.

Remember the rejected refresh token and skip the refresh while it is
still the scope's token. A new sign-in or rotation installs a different
token, so the block clears on its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ver#393)

Re-vendor with scripts/sync-apiv2-fixtures.sh from silo-server 83ac7a9
(#1445). The system-info body carries the new contract digest and the
identity link; the user, setup-status, profile, and progress bodies pick
up fields the server added since 0416528.

Closes Silo-Server#392

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…rver#400)

* fix(search): show actor and director results on phone and TV

Search on Android phone and TV only ran the title search, so an actor or
director could not be found by name. Both screens now show a People row
above the titles, and selecting a person opens their person page.

People search runs only when the server advertises people_media_scope,
so an older server never answers with people outside the viewer's
library access. Each filter maps to a media scope. TV never searches
book or manga credits.

Closes Silo-Server#357

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(search): ask for people only after the server supports it

Read the people_media_scope capability before sending any people
request, so an older server is never asked for unscoped people. On TV,
a query that matches only people no longer shows "No matches" under the
People row; the status line says "No matching titles" instead. The
post-search focus handoff now waits at most 600 ms for people before
moving to the titles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(search): hold phone "No results" until people search answers

Titles can answer before the capability and people requests do. The
phone now tracks whether the current query's people lookup is pending
and keeps the searching state up until both have answered, so a
person-only match does not flash "No results" first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(search): show phone title errors while people are still loading

A failed title search set the error, but a pending people lookup kept
the "Searching…" placeholder up and hid the error and its Retry. The
placeholder now waits on people only when there is no title error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…09-28)

Merges 280 upstream commits (through c38fb93) with a real merge so
upstream stays in ancestry. Upstream's headline change is the API v2
client migration: every HTTP call now targets /api/v2 via ApiV2Gate,
SequencedPlayback and the per-family *V2Api classes.

Rebrand re-applied to everything new from upstream: org.siloserver.silo
-> org.prairieserver.prairie packages/paths, Silo* -> Prairie* identifiers,
X-Silo-* request headers -> X-Prairie-*, user-facing "Silo" strings, and a
Prairie CONTRIBUTING.md. Upstream's discord-commits workflow is dropped.

Prairie-only features kept: Live TV (LiveTvApi/Repository/ViewModels and
screens), quality ladder + trickplay, LAN discovery and its /api/v1/health
probe, app update checker, image-format negotiation header.

PlaybackApi is trimmed to the Prairie-only transcode/quality-ladder routes
(upstream deleted the dead v1 playback transport); PlaybackRepository takes
it as an optional second dependency.

Also fixes pre-existing main breakage from the previous sync + PR #26:
missing TranscodeStartRequest/Response models and leftover
TvSiloCastReceiver / TvSiloCastStandbyView / siloCardDefaults references.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 28, 2026

Copy link
Copy Markdown

Walkthrough

Warning

Review details and warnings were omitted to fit the comment limit.

JonahMMay and others added 12 commits September 28, 2026 14:41
…workflows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Upstream removed the v1 playback/session transport. The Prairie transcode
fallback in PlaybackSessionManager had no callers and prairie-server has no
/playback/transcode/start route, so it is removed along with its models.
Prairie-only coverage tests written against the deleted v1 API surfaces
(sessions, v1 settings/ebooks/onboarding/etc.) are removed; upstream's v2
tests cover those paths now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…DI, Setup nav branch)

Several of these were already broken on main after PR #26: Spacer(Modifier = ...)
named-arg typos, missing PlaybackQuality import, ServerList VMs gained a
LanDiscovery dependency not wired in Koin, and the new Setup switch
destination was not handled in either nav graph. Also drops Prairie-only
v1-API coverage tests that no longer compile against the v2 clients.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tale v1 VM tests

- prairie-subtitle / prairie-downloaded-subtitle track-id prefixes everywhere
  (merge had left source and tests disagreeing).
- Test hosts silo.example/.test/.lan -> prairie.* so setup and assertions agree.
- LiveTv player screens use androidx.annotation.OptIn for Media3 UnstableApi.
- Remove Prairie-only Home/Recommendations VM tests written against v1 mocks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…Test

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The upstream sync replaced the v1 clients and models, and the Prairie-only
coverage tests written against them were dropped, leaving the :shared Kover
gate at 86% (floor 95%). Add a JVM round-trip test that discovers every
non-generic @serializable type and decodes it from descriptor-derived
minimal and fully populated bodies, then checks the re-encoding is stable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…and overlay values

Replaces the Prairie coverage tests that were dropped with the v1 clients:
route/method assertions and problem mapping for the v2 transports, editor
ETag handling, and overlay registry value/icon formatting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…le identity routing

Behaviour tests for upstream code that arrived without tests: trailer rail
selection, the TokenManager contract (store/clear/server-scoped tokens),
overlay enum labels and raw-value parsing, and routing of each subtitle row
shape to its SubtitleIdentity. :shared line coverage 92.81% -> 94.33%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…downloads, LAN CIDRs, diagnostics status, Kindle conversion

Behaviour tests for upstream rules that landed untested:
- card presentation labels/raw parsing and decode-or-default fallbacks
- seek interval pair/state edits, legacy import filtering and outcome text
- subtitle appearance sanitizing (hex/opacity) and tolerant JSON decode
- intro skip mode wire/legacy mapping (legacy can never yield NEVER)
- download media type / subscription enum fallbacks and persistence
- /32, /31, /30 host expansion and default LAN candidate list
- diagnostics status validation, one failing path per field
- Kindle-family EPUB promotion and single-page progress
:shared line coverage 94.33% -> 95.15%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dels

- PlaybackDecisionResponseV3.validateForMedia3: terminal without body,
  missing outcome/plan/session identity, unsupported plan version, empty
  stream URL, server transformation on original delivery
- executable client DV recipes and the DV8 base-layer claim
- LibraryCollectionTabV2 / CatalogFiltersV2 / search diagnostics and
  capabilities decoding, including optional and defaulted fields
:shared line coverage 95.15% -> 95.37%; koverVerify passes locally.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The 2026-08-27 upstream merge (4db4d4a) took Silo's re-rendered launcher
icons, adaptive foregrounds, wordmarks, TV banner and assets/icon.png over
Prairie's, so the phone and TV apps shipped the Silo mark. Restore the
Prairie artwork from 4db4d4a^1. This is what TvLauncherIconAssetsTest
guards, and it has failed on main since that merge.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Prairie-only Live TV screen still wrapped its first-channel focus claim
in runCatching { requestFocus() }, which hides an unattached node instead of
retrying, and trips TvSilentFocusClaimSourceTest (baseline 0). Use the shared
observed-focus policy, keyed on the channel list's hasFocus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JonahMMay
JonahMMay merged commit f98a52a into main Sep 28, 2026
4 checks passed
@JonahMMay
JonahMMay deleted the sync/upstream-2026-09-28 branch September 28, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants