Skip to content

Sync upstream Silo-Server/silo-server (181 commits) - #241

Merged
JonahMMay merged 197 commits into
mainfrom
sync/upstream-2026-10-18
Oct 19, 2026
Merged

JonahMMay merged 197 commits into
mainfrom
sync/upstream-2026-10-18

Conversation

@JonahMMay

Copy link
Copy Markdown

Rebrand-aware merge of Silo-Server/silo-server at a434bbc2f2 (181 upstream commits).

Land with "Create a merge commit". Never squash. The second parent is the real upstream commit; that is what keeps the next sync small.

82 files auto-resolved (hunks: theirs 88, ours 2, same 0, superset 5, import-union 8; delete-over-noise 5).

Generated files taken from upstream (the regen job rebuilds them on this branch):

  • go.sum
  • web/pnpm-lock.yaml
  • web/src/api/v2/schema.ts

Upstream deleted files Prairie had modified. The delete was applied. Check that Prairie's change isn't a feature that needs re-homing:

  • +4 -3 internal/sections/seasonal_query_test.go
  • +2 -2 web/src/components/WatchTonightDialog.tsx
  • +11 -4 web/src/components/watchtonight/CardStack.tsx
  • +5 -2 web/src/components/watchtonight/SwipeCard.tsx
  • +52 -2 web/src/lib/cssSanitizer.ts
  • +145 -25 web/src/lib/themeTokens.ts
  • +31 -6 web/src/pages/admin-settings/logRetentionPolicy.ts
  • +12 -2 web/src/pages/admin-settings/overviewCards.tsx

The audit comment below lists likely compile breaks and lost Prairie lines. CI must be green, including Prairie invariants; a failing test after a sync usually means Prairie code was lost, not that the test is stale.

Quick104 and others added 30 commits October 12, 2026 20:37
…ilo-Server#2363)

Every Apple and FCM relay send now carries server_id and profile_id, so a device signed in to several servers or profiles fetches each push's display text from the one it came from. A relay that predates the fields gets a one-time resend without them, remembered for an hour per relay. A retry the relay refuses with 422 idempotency_key_reuse is resent once with the other choice, and a failed identity lookup makes that 422 retryable. Apple and Android push registration receipts gain server_id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ures (Silo-Server#2077)

* fix(api): stop counting and logging canceled requests as server failures

A client that leaves mid-request (the web app refetching, a viewer
moving on) produced 500 responses in the access log and metrics, and
WARN or ERROR lines from every lookup the request had in flight, all
saying "context canceled".

- ctxerr.IsCanceled matches context.Canceled and the gRPC Canceled
  status plugin calls return; ctxerr.LogLevel drops a log line to Debug
  only when the request itself was canceled and the error is that
  cancellation.
- v2 no longer sends a 5xx once the request context is canceled, so the
  request is recorded as abandoned. Problems below 500 are still sent,
  since net/http also cancels the context on a body-read timeout.
- v1 writeAPIError writes nothing for context.Canceled, as
  writeCatalogError already does, and ListLibraries returns the
  cancellation instead of logging ERROR and wrapping it in a 500.
- The lookups seen failing this way in production log at Debug when
  their request was canceled, and the scanner's folder watch stops
  quietly when the scan ends during its check.

* fix(api): record abandoned requests as 499 instead of leaving v1 silent

Writing nothing from writeAPIError on a plain context.Canceled could
send an empty 200 to a client that is still connected, for example a
handler waiting on app-context work while the server drains, and the
shared observers then recorded abandoned requests as 200: the activity
log for v1 and v2, and the v1 request log and HTTP metrics.

Put writeAPIError back as it was. v1 keeps writing its 500 to the dead
connection, which is harmless. activitylog.RecordedStatus records a
request whose client went away with no response or a server error as
499, and the activity log, the v1 request log and the v1 metrics use
it. The two "fetching section items" ERROR lines drop to Debug when
their request was canceled.

* fix(ctxerr): keep a real failure joined with a cancellation at its level

Abandoned took any error that wrapped a cancellation as the caller going
away, so errors.Join(queryErr, context.Canceled) on a cancelled request
was logged at Debug and the query failure was hidden.

Abandoned now needs every error in the tree to be a cancellation. A plain,
wrapped or gRPC cancellation, or several joined together, still counts;
anything joined with a real failure keeps its level. IsCanceled is
unchanged.

* fix(apiv2): still send a server error to a client whose context was canceled

net/http also cancels the request context when an HTTP/1.1 client only
half-closes its side, and that client is still reading. Dropping the 5xx in
flush let net/http answer it with an empty 200 OK, so a failed request
looked like a success.

flush keeps main's rule again: only a success body is dropped once the
context ends. observe records a server error whose client went away as
abandoned, using activitylog.RecordedStatus, so v2's log, metric and trace
classify it the same way the activity log records 499. This matches v1,
which already writes its 500 to the closed connection.

TestHalfClosedClientGetsServerError half-closes a real socket after a
complete request and expects the 500 problem; it gets an empty 200 with the
previous flush.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…Silo-Server#1981)

* feat(metadata): offer local sidecar artwork in the admin image picker

The v2 Edit Metadata image list for a movie or series now offers the
item's local sidecar artwork, found by Silo's own sidecar discovery, with
a small WebP preview. Apply caches a file:// choice only when that
discovery offers it for that image type, reading it under the library-root
confinement the image cache processor uses, which now opens each root as
an os.Root before resolving the path.

This squashes the branch's nine commits onto main. Conflicts with main's
local artwork rework (Silo-Server#2300, Silo-Server#2310, Silo-Server#2321) are resolved here: the shared
reader takes the bare path and the root list, so the processor keeps its
storage deferral, and the picker strips the size+mtime fingerprint from a
file:// source and treats only file:// sources as local files.

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(metadata): offer only applicable local sidecar files in the picker

Discovery on main also returns banner, thumb, clear art and disc art, and
image URLs an NFO names. The picker cannot apply the first group or read
the second, so it now offers only poster, backdrop and logo sidecar files.
Discovery also reports a failure to read the item's files instead of
treating it as an item without sidecars.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(metadata): keep a library root stat failure retryable

A root that opened but could not be stat'ed after the image path resolved
fell through to the stable "outside library roots" failure, so an NFS
hiccup parked the job for the long deferral and hid the picker choice. It
is now a retryable read failure, like a root that failed to open. The
open-failure and pinned-root lookups are one helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(metadata): document local picker limits and share its provider key

The local provider key in the picker's provider_errors now comes from one
exported metadata constant. The docs cover the chain-failure 200, the slots
and sources the picker offers, fingerprinted sources on apply, and the read
permission os.Root needs on a library root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…locked (Silo-Server#2081)

* fix(playback): keep the requested version when only its subtitle is blocked

* fix(playback): return to the requested edition and say why the subtitle is off

* fix(playback): keep a playable sibling and the requested edition in the subtitle fallback

Plan the requested file without its subtitle through one helper that
escalates a refused progressive remux before the result replaces a
sibling that already plays. Without it, a worker-only server with
header-authenticated media could end the start or replan in
local_transcode_disabled where main played the sibling.

When the active alternate was kept only for its subtitle, hold back the
requested edition ahead of other versions that drop the subtitle, so it
still wins when the item has more than two versions.

Describe the same-file fallback and the wider subtitle_track_unavailable
meaning in the protocol doc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…e required (Silo-Server#2357)

* Fix h.264 transcode fallback

* Fix Ref frames issue

* Implemented Changes

* Fixes pt2

* fix(jellycompat): keep transcodes for optional H.264 profile and level rules

Stating an H.264 encode's profile and level turned optional rules on them
into refusals. A client that sends an optional VideoLevel <= 42 with a
120 Mbps limit had its transcode stated at level 5.1 and lost every route
to an HEVC source. Treat optional profile and level rules on an H.264
encode as encode targets, as Jellyfin applies them; required rules are
still checked against the stated values.

The 4.1 floor on the stated level used this package's max, which returns
any positive first argument, so a 720p encode was stated at 3.1 while
libx264 writes 4.1. Apply the floor with a comparison.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ilo-Server#2388)

* feat(notifications): name the server and profile in every relay push

A device signed into several servers or profiles cannot tell which one a
push belongs to, so it fetches display data from whichever is active and
shows generic text for the rest.

Every Apple and FCM relay send, administrator test pushes included, now
carries server_id (the identity getServerIdentity reports) and profile_id
(the push device's profile). They are sent together or not at all, and only
when both are 1-128 characters, because the relay rejects an empty field.

Relays that predate the fields reject unknown keys with 400
unexpected_field before recording the idempotency key. The sender resends
that attempt once without them under the same key and leaves them out of
later sends to that relay for an hour. Only that answer drops them, because
they take part in the relay's idempotency hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(api): return server_id in push registration receipts

Clients key per-server push credentials by server and profile, and need
the server's identity from the registration that issues them.

registerApplePushDevice and registerPushDevice receipts gain server_id, the
identity getServerIdentity reports. It is read best effort, as device
login already does, and omitted when the identity cannot be read, so a
committed registration never fails over it. New generated fixtures
apple_push_registration_ok and android_push_registration_ok show both
receipts; they are appended last so earlier fixture request IDs stay put.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(notifications): retry a push with the target choice the relay recorded

server_id and profile_id are part of the relay's idempotency hash, and a
retry keeps its attempt's key. Nothing records whether the first send
carried them: an attempt from the previous server version, one sent while
this process remembered the relay as lacking them, or one another replica
sent can be retried with the other choice. The relay refuses that retry
with 422 idempotency_key_reuse, and the sender treated it as final, so the
push was lost.

On that answer the sender now resends the attempt once with the other
choice, which the relay replays or resumes instead of delivering again.
If that resend reaches a relay that refuses the fields, the original 422
is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(notifications): retry a push whose identity lookup failed instead of dropping it

When the server identity can't be read, a push goes out without
server_id and profile_id. If the relay recorded that attempt with them,
from an earlier send or another replica, it answers 422
idempotency_key_reuse. With no target to offer, the sender kept that 422
as final and the push was lost, even though the next lookup could
succeed.

That case is now a retryable 503 server_identity_unavailable, so a later
retry can name the target the relay recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(notifications): name the relay's idempotency_key_reuse code

goconst flags the third use of the string now that the sender and its
tests check for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(notifications): send a real notification as the admin test push

An administrator test push named its own attempt as the delivery, so the
device's display fetch returned 404 and the phone could only show the
relay's generic "New notification available" text. The test proved the
transport but never what a real push looks like.

A test push now carries the newest notification of the selected device's
profile, matched by account too because profile IDs repeat across accounts.
The device fetches that notification's title, body and tap target exactly
as for an ordinary push, and the v2 test response names it in a new
optional delivery_id. A profile with no notification keeps the old bare
test, so the button still verifies transport on a fresh install.

Linking a test attempt to a real delivery collided with the at-most-once
key on (device, delivery, attempt number): the delivery's own first
attempt, or an earlier test, already held attempt number 1, so the test
could not be finalized. A migration scopes that key to delivery attempts,
the only rows fanout's enqueue dedupes. Its Down detaches test attempts
from their deliveries before restoring the wider key.

The test reuses the notification's collapse ID, so it replaces that
notification on the device if it is still shown. It changes neither the
notification's read state nor the inbox. It adds one test attempt to the
delivery's attempt history; nothing counts attempts as deliveries, and
pruning the notification removes the attempt with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(notifications): keep a test push out of its notification's dispatch

An administrator test push now carries a real notification, so its pending
attempt names that delivery. Ordinary dispatch claimed every pending
attempt for the delivery, test included. If that notification's dispatch
ran between the test's enqueue and the request's own claim, the request
found nothing to claim and answered 500 while the worker still sent the
test.

The delivery claim now takes only the delivery's own attempts. The test
stays with the request that enqueued it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(database): make the push attempt key migration safe to retry

goose records a migration only after its last statement. The SQL version of
20261012230946 started by dropping any copy of the new key, so a run that
had already dropped the old key but was not recorded would, on retry, drop
the valid replacement too. The table would then have no unique key while
it was rebuilt, and duplicates written meanwhile would make the rebuild
fail. Its Down had the same flaw for the restored key.

The migration is now a Go migration with the same version. Each direction
builds its key through ensureIndexConcurrently, which drops only an invalid
copy left by an interrupted build and keeps a valid one, before dropping
the other key. That check is shared with the invalid index rebuild, which
already worked this way. A deployment that applied the SQL file has the
version recorded and skips it.

DEVELOPMENT.md now notes the pattern for migrations that replace an index.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…Silo-Server#2338)

* fix(nodes): sign worker requests instead of sending the shared secret

API servers and proxies authenticated every call to a transcode node or
proxy by sending the auth.jwt_secret setting itself as a bearer token.
That secret also signs login, profile, and stream tokens, so anyone
watching a worker link without TLS could copy it.

Senders now sign each request with an HMAC under a key derived from the
secret with HKDF (label silo/node-request-auth/v1). The MAC covers the
method, escaped path, raw query, a timestamp, and the body hash. Workers
check the MAC before reading any body, refuse timestamps more than 30
seconds off, compare with hmac.Equal, and log throttled warnings for bad
signatures, clock skew, and mismatched bodies.

For one release, workers also accept the previous bearer (constant-time)
so a rolling upgrade can go transcode nodes, then proxies, then API
servers. docs/architecture/worker-http-protocol.md describes the scheme
and the upgrade path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(nodes): sign the worker path relative to the node URL

A worker registered with a URL path prefix that its reverse proxy strips
received the route path while senders signed the full path, so every call
to it failed. Senders now sign the path relative to the node URL, which is
what a worker serves; unprefixed URLs sign the same path as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(nodes): scope the no-secret-on-the-wire claims to signed requests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(nodes): throttle node auth warnings on the monotonic clock

Concurrent requests that sampled the time out of order looked like a
backward clock step, so the throttle logged again and moved its last
time backwards, letting junk requests bypass the ten-minute limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(downloads): sign prepared-download requests against the URL they use

HTTPPreparer built each request from a trimmed, re-parsed copy of the node
URL but signed it with the raw string. For a node registered with an escaped
path prefix or surrounding whitespace behind a prefix-stripping reverse proxy,
the signed path kept the prefix the worker never sees, so every
prepared-download call got 401. Sign against the same normalized base the
request is built from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…st (Silo-Server#2393)

The v2 admin image list now returns local sidecar choices ahead of provider
choices, in discovery order, so a title's own poster.jpg no longer sinks
below every rated provider image. The Edit Metadata Images tab adds filter
chips (All, then one per provider with its count) when a tab has choices
from more than one source.

Closes Silo-Server#2391

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…er#2361)

* fix(plugins): time out stalled catalog and package fetches

The catalog service and installer fell back to http.DefaultClient, which
has no timeouts, so a catalog or download host that accepted the
connection and never answered held the fetch open forever. During an
auto-update the plugin is already stopped at that point and stayed down.

The fallback client now bounds the dial (10s), TLS handshake (10s) and
wait for response headers (30s), and every fetch fails when one read of
the body waits more than 60s for data. There is no whole-request
deadline, so slow but progressing downloads of large packages still
finish. A timeout reports the host as unreachable or the download as
interrupted, both transient, like other fetch failures; a canceled
caller context still returns its own error.

* fix(plugins): cap each package body read so steady chunked downloads don't time out

An HTTP/1.1 chunked body keeps reading from the connection inside one Read
until the caller's buffer is full, and io.ReadAll's buffer grows to
megabytes. A large package sent as one chunk at a steady rate could then
wait more than the idle timeout inside a single Read and fail as stalled.
Read at most 32 KiB at a time, so a body counts as stalled only when it
sends less than that within the idle timeout.

* docs(plugins): describe the package read stall rule as a minimum rate
…te items (Silo-Server#2360)

* fix(metadata): match the personal collection key when merging duplicate items

Migration 156 added sub_item_id to the primary key of
user_personal_collection_items, but the merge step still named the old
three-column conflict target. Postgres rejected the statement at plan
time, so every duplicate-item merge rolled back, even when the source item
had no collection rows.

Carry sub_item_id through the merge so whole-item entries and episode
entries for the same collection collapse onto the canonical item
separately. Add database tests that run the full merge against a migrated
schema.

Closes Silo-Server#2299

* fix(metadata): keep provider IDs and ebook annotations when merging duplicates

With the merge running again, two of its steps lost data. The provider-ID
step inserted a copy of each source ID for the canonical item with ON
CONFLICT DO NOTHING, but the source row itself holds the unique
(provider, provider_id, item_type) key, so every copy was skipped and the
next step deleted the source IDs. Move the rows for providers the
canonical item has no ID for instead.

Ebook annotations were never moved, so deleting the source item cascaded
and removed its highlights, notes, and bookmarks. Move them to the
canonical item.
…ilo-Server#2362)

Continue Watching switched to portrait posters whenever every item was a
movie, audiobook or ebook, so the row changed shape as episodes came and
went. The iPhone, Apple TV and Android apps keep it landscape.

The row now uses the wide 16:9 cards whenever it holds any video. Only a
row made up entirely of audiobooks and ebooks keeps the poster variant,
where audiobook covers render square and ebook covers upright.

Closes Silo-Server#2347
Silo-Server#2402)

* fix(playback): keep web subtitles in sync after a seek on offset files

Files whose container starts after timestamp zero (MPEG-TS recordings and
their remuxes) put web subtitles out of sync by the container start time.
Seeked subtitle windows kept the source's absolute timestamps while a
window at position 0 came back container-relative, and Chrome's direct-play
clock keeps the container start while the HLS routes do not.

Rebase seeked subtitle extracts to the container start (-start_at_zero, not
for a cached .sup) so every window shares one timeline. In the web player,
read the direct-play engine's clock origin at loadedmetadata and fold it into
the timeline offset before seeking to the start position; hold seeks made
before then. Progress capture and the exit position no longer read the
element's clock before it has metadata.

Closes Silo-Server#2400

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): keep startup skips and held seeks on direct play's target

While an original file's clock origin is unknown, relative skips and the
exit position use where playback is heading instead of the element's
unloaded clock, and a load's reset timeupdate or seeked no longer settles
a held seek before the metadata handler applies it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): show the target position while direct play's origin is pending

A load's reset timeupdate during that window now leaves the shown position
on the held seek or the start position instead of the unloaded clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): publish direct play's duration on the media timeline

The element's duration runs on the engine's clock, which can start at the
container's start time. Subtract the origin like positions, and apply it
once the origin is known, since durationchange arrives before
loadedmetadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(player): judge room catch-up from direct play's target before metadata

A room command that runs while direct play's clock origin is unknown now
measures drift from where playback is heading, so it seeks to the room
position instead of deciding the unloaded element is already there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…-Server#692) (Silo-Server#860)

* fix(mdblist): prevent API key leakage in transport errors (fixes Silo-Server#692)

* test(jellycompat): return catalog.ErrSeasonNotFound in fake season repo

* test(jellycompat): import catalog package in parent_browse_test

* test(jellycompat): remove unused errors import

* fix(mdblist): redact the API key inside wrapped transport errors

url.Error.Error prints its wrapped Err as well as the URL, so a transport
error that quotes the request URL still carried the key. Both sanitizers
now redact Err too, keeping errors.Is/As and timeout classification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mdblist): redact the key a request sent, not the current setting

fetchLists read the API key setting again to redact a failed request's
error. The config watcher can replace or clear that setting while a
request is in flight, so redaction then masked a different key, or
nothing, and the key the request sent could reach the v1/v2 error
responses. Redaction now uses the key the request carried.

Reported by Quick104 on Silo-Server#860.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mdblist): mask the errors errors.As hands out

redactedError.As delegated to its cause, so errors.As could still pull
out the original *url.Error or net.Error, whose own message quoted the
API key, around the masked top-level text. As now hands out masked
copies: a *url.Error with its URL and cause masked, and a net.Error
whose message is masked and which still reports Timeout. The test
helper checks what errors.As returns as well as the Unwrap chain.

Reported by silo-kody on Silo-Server#860.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(mdblist): keep a masked url.Error's timeout classification

A *url.Error asks its Err directly whether it timed out, and the masked
cause answered neither Timeout nor Temporary. Masking a net.Error now
keeps both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(mdblist): note which errors.As targets come back masked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ilo-Server#803) (Silo-Server#856)

* fix(catalog): add system media control artwork to WatchDetail (fixes Silo-Server#803)

* style: apply gofmt to detail.go and test file

* fix(catalog): retrigger CI with clean workspace

* style: format handlers_items_test.go for gofmt CI check

* fix(jellycompat): return catalog.ErrSeasonNotFound in test helper for 404 test assertion

* style(jellycompat): remove unused errors import for golangci-lint

* test(catalog): cover watch detail artwork through GetWatchDetail

The artwork test built a WatchDetail itself, so it could not catch a
regression in GetWatchDetail. Drive GetWatchDetail against the versions
fixture's PostgreSQL rows instead: a movie's own poster and backdrop, an
episode's still with its series' backdrop, an extra's parent artwork, and
empty fields for an item without artwork, each compared with the
resolver's URL for the expected source path. List it as a DB pin so CI
runs it against a migrated database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(apiv2): return watch detail artwork from the v2 watch response

GET /api/v2/watch/{id} runs the same service as v1, but its transport
dropped the poster and backdrop the service now resolves. Add the four
optional fields to the v2 WatchDetail, map them, regenerate the OpenAPI
contract, fixtures and web types, and cover them in the HTTP test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(catalog): isolate watch artwork from legacy and room reads

* fix(apiv2): skip artwork when authorizing seek previews

* docs(api): clarify parent artwork for extras

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
…o-Server#865)

* chore(hooks): catch gofmt and golangci-lint issues in pre-commit

PR Silo-Server#864 needed two follow-up pushes for a stale gofmt issue and a
goconst lint failure that only surfaced in CI. Run gofmt on staged Go
files and golangci-lint (new issues vs main, matching CI's check) in
the pre-commit hook so these are caught before pushing. golangci-lint
is optional locally — the hook warns instead of failing when it's not
installed, since gofmt already covers formatting.

* style(jellycompat): gofmt handlers_items_test.go; harden lint hook

Also make check-go-lint.sh treat a golangci-lint run whose only
reported category is typecheck as a warning rather than a commit
blocker -- it means the package graph couldn't be fully resolved
(e.g. a source file transiently unreadable), not a real lint finding.
Discovered when this exact hook blocked committing the one-line
gofmt fix above. CI runs from a clean checkout and still catches a
genuine compile error.

* ci: retrigger checks

internal/jellycompat's Go test run failed on TestHandleEpisodes_UnknownSeasonIDReturnsNotFound,
which passed cleanly on the same base commit in another PR's run 10 hours earlier -- flaky, not
caused by this PR's hook/tooling-only diff. Retriggering CI.

* test(jellycompat): return catalog.ErrSeasonNotFound in fake season repo

* test(jellycompat): import catalog package in parent_browse_test

* test(jellycompat): remove unused errors import

* fix(hooks): name a missing lint base ref instead of failing obscurely

lint-changed.sh diffs against BASE_REF (default origin/main). In a fresh
clone or worktree without that ref the lint step failed with unrelated
output. Check the ref first and say how to fix it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(hooks): read literal paths, run on Bash 3.2, lint deletions

- mapfile needs Bash 4, but macOS's default bash is 3.2, so the hook
  blocked every commit there. A read loop fills the arrays instead, and
  empty arrays are expanded the way Bash before 4.4 accepts under set -u.
- Paths are read NUL-delimited (git diff -z), so a name git would quote,
  such as café.go, reaches git show literally. A staged blob that can't
  be read now fails the check instead of passing as empty input.
- A commit that only deletes Go files still runs the lint gate, which
  catches code left broken by the deletion; deleted files aren't
  format-checked.

Reported by Quick104 on Silo-Server#865.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(hooks): guard the whole touched packages, read lint paths literally

- The unstaged-change guard looked only at added and modified staged
  files. A staged deletion with the file restored on disk let
  golangci-lint, which reads the working tree, pass a commit that is
  broken as staged. The guard now refuses when any Go file in a package
  the commit touches differs from the index: an unstaged edit, a
  restored deletion, or an untracked file.
- lint-changed.sh read quoted paths from git diff, so a package in a
  directory git quotes (café) never matched go list and escaped lint. It
  reads NUL-delimited literal paths, as check-go-lint.sh does.

Reported by silo-kody on Silo-Server#865.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(hooks): count a staged rename's source package as touched

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(hooks): validate staged Go inputs without worktree bypasses

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
…urces (Silo-Server#1436)

* perf(subtitles): reuse cached embedded extracts for AI translation sources

AI translation of an embedded text subtitle ran a whole-file ffmpeg demux
on every job, including repeat translations of the same track into
another language. The node already keeps complete text extracts in
playback.SubtitleCache for subtitle delivery.

The AI service now takes that cache and loads embedded sources through
SubtitleCache.ExtractText: a complete cached SRT for the track is reused,
and a miss extracts once and publishes the result for later jobs and
playback. Without a cache the service extracts as before.

Fixes Silo-Server#1099

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): share an in-flight subtitle extract with concurrent requests

SubtitleCache.ExtractText extracted on its own whenever another request
held the fill for the same track, so two AI translations of one uncached
track both demuxed the whole source. When the fill is busy, wait for it
and use its result; if that fill failed, retry as the filler. The batch
path already waited this way.

Also cover the AI source cache on a miss: a first translation extracts
once and publishes the extract, and a later one, even from another
service sharing the cache, reads it without extracting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(subtitles): reuse playback's cached WebVTT for AI sources

Native playback caches a SubRip extract under the vtt key; the AI loader
looked only for srt, so it demuxed the source again. It now reads that
rendition first. ExtractText also re-checks the cache after reserving a
fill, so a fill committed between lookup and reservation is reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(subtitles): recheck the cache when another fill finishes first

If another request's fill committed after ExtractText found it in
progress but before ExtractText waited for it, the wait reported
nothing to wait for and ExtractText demuxed the source again despite
the fresh entry. ExtractText now goes round lookup, reserve and wait a
bounded number of times, so that fill is found by the next lookup. A
cache that can't take a fill (disabled, unwritable) still falls
through to extracting directly.

Reported by silo-kody and CodeRabbit on Silo-Server#1436.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(subtitles): release the first fill only once the second finds it busy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(subtitles): reuse a fill that commits during the last wait

The lookup after the reservation loop ran only when a fill was reserved,
so a fill committing during the final round's wait left no reservation
and the source was demuxed again. The lookup now runs either way.

The in-flight test now releases the first extraction once the second
request holds the fill's done channel, via a hook in waitForTextFill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): scope realtime query invalidation to itemId to prevent buffering (fixes Silo-Server#796)

* trigger ci

* fix(web): retrigger CI with clean workspace

* style: format handlers_items_test.go for gofmt CI check

* fix(jellycompat): return catalog.ErrSeasonNotFound in test helper for 404 test assertion

* style(jellycompat): remove unused errors import for golangci-lint

* fix(web): keep explicitly watched keys in an item-scoped refresh

The itemId narrowing ran before the watchedKeys allowlist, so keys a
caller names on purpose (a watched episode's season or series detail,
from getCachedWatchedInvalidationKeys) resolved to another item ID and
were dropped. Exempt watched keys from the narrowing only; library
scoping is unchanged. Covered by a new test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web): keep the ratings-list check independent of watchedKeys

The ratings-list test fed ratingKeys.all as a watched key and expected
another item's rating to be skipped. Watched keys are now exempt from the
itemId narrowing by design, so that half contradicted the new rule. The
regression itself, "list" not being read as an item ID, is asserted
directly on getQueryKeyItemId.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(web): note that watched keys match their whole subtree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web): cover catalog-shape itemId narrowing on media refresh

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep related items' queries in an item-scoped refresh

Narrowing a refresh to the event's item ID dropped caches derived from
it: an episode event left its season grid and series detail stale, a
season mark left its episodes' details stale, and a watched key for the
default library missed the same item cached under a library ID.

The refresh now treats the item's related IDs as its own, as the cache
shows them: parents from its detail's series and from cached lists that
hold it, and children from its own cached lists. A watched key also
matches the same item in any library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(web): index related items in one pass over the query cache

relatedItemIds rescanned every cached query for each item it found, so
a series with many cached seasons and episodes cost one full scan per
item. It now indexes parent and child links in one pass and walks them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): refresh a directly opened season when its series changes

A cached detail's series_id was indexed only upward. A season page
opened by URL has no cached season list, so a change event for its
series couldn't reach the season's detail or episodes, which show the
series' title, backdrop and credits. The index now links the series down
to the details that name it.

Reported by Quick104 on Silo-Server#857.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): refresh lists for unknown items, narrow series-keyed queries

- A catalog event for an item no cached query knows, such as an episode
  a scan just added, couldn't be traced to the season grid that should
  list it, so narrowing left the grid stale. Such an event now refreshes
  as if unscoped by item, as before narrowing. Calls that name the keys
  they changed (watchedKeys) stay scoped.
- ['catalog', 'series', id, ...] queries (season lists, season detail
  and episodes) weren't narrowed by item, so every unrelated event
  refetched them. Their series id now takes part in the narrowing, and
  related-item and watched-key exemptions still apply.

Reported by silo-kody on Silo-Server#857.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): limit the unknown-item refresh to new catalog items' lists

The fallback for an item the cache doesn't name dropped item scoping for
every caller, so a progress, favorite or watchlist event about an item
open on another client refetched the player's watch detail and episode
lists here. It is now opt-in (itemMayBeNew), set only for catalog events,
and refreshes only series and season lists, keeping other items' detail
narrowed away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): trace seasons and watch details in related items

- A season detail cached under its series now links the series to the
  season named in its data, so a season's progress reaches it.
- A cached watch detail's series_id is indexed like an item detail's, so a
  playing episode's progress reaches its series' watched summary.
- The new-item fallback also refreshes the detail of each item whose
  cached list it refreshes, keeping a season's episode count in step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* build(web): raise the eager bundle budget for this change

main's eager launch bundle already measures 320971 B brotli in CI, 1119 B
over its 319852 B budget. This PR's query-invalidation changes are on the
launch path and add about 280 B, for 321254 B, which the budget now
matches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): preserve dependent caches in scoped realtime refreshes

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
…ilo-Server#1760)

* feat(playback): read Matroska keyframes and plan copy HLS segments

A complete VOD playlist for copied video needs the segment boundaries
before FFmpeg starts (Silo-Server#1466). This reads the video keyframe times from a
Matroska file's Cues, following the seek head to Cues stored after the
clusters and never reading the clusters, and plans segment starts with
the rule FFmpeg's HLS muxer uses for copied video: cut at the first
keyframe at or after the target, then move the target on one segment.

Tests check the reader against ffprobe and the plan against the
segments FFmpeg writes, on a file with irregular keyframes; they skip
without ffmpeg.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(playback): serve copy remux from a complete keyframe playlist

Copy-remux HLS served FFmpeg's growing playlist, and Safari's native
player can fall back to a partial copy of it and stop mid-episode
(Silo-Server#1466). Behind the new playback.copy_keyframe_playlist setting (off by
default), a copy stream that starts at the beginning is served as one
complete VOD playlist, planned from the source's Matroska Cues.

From the start of the file FFmpeg's HLS muxer cuts copied video at the
planned keyframes, so its seg_NNNNN files are the planned segments. A
seek restart would cut elsewhere, so until mid-stream restarts land a
planned session waits for segments ahead of FFmpeg and restarts from
the beginning when one is gone. The flag is frozen into recipe cards,
stream tokens, transcode-node requests and Jellycompat's play-session
source so rebuilt sessions keep the playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(playback): name the restarting reason and check cache assertions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(apiv2): regenerate the contract for the keyframe playlist field

The transcode-node start request gained keyframe_playlist; the system
info fixture carries the contract digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): check cue coverage and node support for keyframe playlists

Review fixes for the keyframe playlist:

- Matroska recommends but doesn't require a cue for every video keyframe,
  and a plan missing one would cut segments where FFmpeg doesn't. The
  reader now checks a few clusters spread through the file, and the
  cluster after each, reading only block headers, and reports
  ErrIncompleteIndex (an ErrNoIndex) if any video keyframe has no cue.
- A transcode node attests that it understood a requested keyframe
  playlist; both remote start paths reject a node that doesn't, as they
  do for the throttle and copy recipes.
- Jellycompat records the flag only for copy streams, after subtitle
  delivery can turn a copy into a transcode, and compares it only for
  copy streams, so encoded sessions still match their recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): use a keyframe index only after checking every cluster

Sampling a few clusters can't prove the Cues list every video keyframe:
an uncued keyframe in an unsampled cluster would still let the plan cut
where FFmpeg doesn't. VerifyMatroska now walks every cluster's block
headers and requires a cue for each video keyframe and a keyframe for
each video cue; a cluster too large to read completely fails the check.

Because that reads every block header, Load runs it once per file in the
background: the first playback gets ErrUnverified (an ErrNoIndex) and
FFmpeg's playlist, and later playbacks use the verified index from the
cache. LoadVerified runs the check itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): bound keyframe checks and request the flag only for copy

- Track in-flight full checks apart from the evictable result cache and
  cap the queue; a file that finds it full is checked on a later
  playback, so cycling through many files can't pile up goroutines.
- Cache malformed files as having no index; only errors reading the
  file are retried, so a damaged MKV isn't rescanned on every playback.
- The v3 paths request a keyframe playlist only for copied video, so an
  older transcode node isn't rejected for an encoded job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): hand a finished keyframe check to the cache atomically

The background check cleared its in-flight marker before storing its
result, and admission didn't look at stored results, so a Load in
between could queue the same file again. The result and the marker now
change under one lock, and a file with a stored result isn't queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): return a keyframe check that finished during a load

A load that missed the cache, then found the check already done when it
tried to queue one, still reported ErrUnverified. It now looks again
and returns the stored result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): count a planned stream's end from its first keyframe

The plan took the probed duration as the stream's end on the
container's timeline, but the duration counts from the file's start and
container timestamps can start later than zero. For such a file the
last segment came out negative and the plan was dropped. The end is now
the first keyframe plus the duration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): end a planned stream's last segment with its video

The plan ended the last segment at the catalog's runtime. That is the
container's duration, which can include an audio or subtitle track
running past the video. FFmpeg ends its last segment with the copied
video even when the selected audio is longer (checked with FFmpeg 8.0.1
and jellyfin-ffmpeg 8.1.3), so the plan advertised media that doesn't
exist.

VerifyMatroska already reads every block, so it now records when the
video's last frame ends: its timestamp plus the track's default frame
duration, or the gap to the frame before. The plan ends there, and
falls back to FFmpeg's playlist when that is unknown. The FFmpeg
comparison test now has an unselected audio track that runs 20s past
the video, and checks the last segment too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep a planned session's playlist and honor block durations

- Record whether a session serves a keyframe playlist, with its plan, in
  the session's output directory. A session rebuilt from its recipe
  follows the record instead of deciding again from this process's
  keyframe checks. It can't switch to a planned playlist once the
  file's index has been verified, or lose a plan the index cache no
  longer has. An abandoned start records nothing.
- End the video at its last frame's BlockDuration when the block gives
  one. This takes precedence over the track's default frame duration
  and the gap to the frame before.
- The FFmpeg HLS comparison test plans from the verified index's video
  end, as a session does, and checks the last segment too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): record the plan decision for every launched session

StartTranscode launches FFmpeg even after its caller has gone, so
skipping the record for an abandoned start left a running session
without one. A rebuilt session could then switch it to a keyframe
playlist. The decision is now recorded whenever it is made. The FFmpeg
comparison tests also check the playlist's total against FFmpeg's, so
per-segment tolerances can't hide drift that adds up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): keep the longest final block, skip scaled video tracks

- When several video blocks share the last timestamp, the video ends
  with the longest BlockDuration, whatever their order.
- A video track with a TrackTimestampScale other than 1 isn't planned
  from. The reader's times don't apply that deprecated extra scale, so
  the session keeps FFmpeg's playlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(apiv2): drop the breaking approvals Silo-Server#1788 merged with

The invitation approvals came in with Silo-Server#1788 and are now on main, so
against main they match no change and fail the contract gate. The
contract docs say the next pull request removes merged approvals; their
record is the file's history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): cut at exact keyframe targets, read Info through the seek head

- With a nonzero origin, float error put a target just past a keyframe
  that lands exactly on it (2.2800000000000002 against 2.28), so the
  planner skipped the cut FFmpeg makes. Targets are now measured from
  the first keyframe with a microsecond of slack. The FFmpeg comparison
  adds regular 2s keyframes starting at 0.28s, which planned [4, 2, 2, 2]
  against FFmpeg's five 2s segments before this.
- Info can come after the clusters, found through the seek head. The
  reader didn't follow it and fell back to the default timestamp scale,
  returning wrong keyframe times without an error. It now reads Info
  through the seek head, and rejects an index without it.

Reported by Quick104 on Silo-Server#1756.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(keyframes): read late Tracks and reject malformed cues

Tracks after the clusters is now found through the seek head, like Info.
A malformed cue point rejects the index instead of silently ending the
cue list early and dropping later keyframes.

The FFmpeg HLS comparison now plans to the selected video's end, adds a
longer unselected audio track, and checks every segment and the total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(keyframes): keep children's doc comment on children

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(keyframes): reject a cue point with a malformed track position

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(keyframes): reject an explicit zero TimestampScale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(apiv2): clear breaking approvals merged in from main

main's approvals are already applied there; on this branch they match no
change, and the contract check rejects them as stale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): replan a rebuilt keyframe-playlist session from the verified index

A clean shutdown removes a session's output directory and the plan record
in it, and a cold process hasn't checked the source's keyframe index yet,
so a rebuilt session fell back to FFmpeg's growing playlist while its
player held the planned one. A rebuild now waits for the full keyframe
check and plans again: the plan follows from the verified index alone, so
it is the same as the first start's. A first start still doesn't wait.

Reported by Codex on Silo-Server#1760.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(playback): let a canceled rebuild stop its keyframe check

A rebuilt keyframe-playlist session runs the full keyframe check itself,
which read every cluster without watching the request. The check now
stops when its context is done and isn't cached, and the rebuild records
no plan decision and fails, so the next request rebuilds and plans
instead of following a fallback.

Reported by Codex on Silo-Server#1770.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…Silo-Server#2399)

Access groups, account overrides and invitations store library IDs in
integer arrays that no foreign key covers, so deleting a library left its
ID behind. Admin pages then counted it and listed it as "#<id>", and saving
or accepting kept it.

A migration adds an AFTER DELETE trigger on media_folders that prunes the
deleted IDs, a BEFORE INSERT/UPDATE trigger that locks the named libraries
and keeps only existing ones, and a one-time repair. An emptied list stays
[] and never widens to NULL. v2 access group and invitation writes now
refuse unknown library IDs with 422, as account and profile writes do.

Closes Silo-Server#2395

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ooks (Silo-Server#2410)

* fix(autoscan): accept Sonarr SeriesDelete and Radarr MovieDelete webhooks

Sonarr's SeriesDelete and Radarr's MovieDelete payloads send deletedFiles
as a bool (whether the folder was deleted). The webhook parser declared the
key as the list of files an upgrade replaces, so decoding failed, the
delivery was answered 400 as malformed, and the endpoint recorded an error.
An admin who ticked "On Series Delete" or "On Movie Delete" in arr saw the
webhook fail on every delete.

deletedFiles is now decoded only when it is a list, so these events parse
as the no-ops they already were meant to be. A list that does not hold
files is still malformed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(autoscan): ignore only a bool deletedFiles, reject other non-lists

The previous change ignored every deletedFiles value that was not a list,
so a Download payload carrying an object or a string there was accepted
with its replaced files dropped instead of being rejected as malformed.
Only the bool that SeriesDelete and MovieDelete send is now ignored; null
and lists decode as before, and anything else is malformed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(autoscan): accept a bool deletedFiles only on folder-delete events

deletedFiles was decoded before the parser knew the event type, so a
Download payload with deletedFiles set to a bool parsed and dropped the
files its upgrade replaced. It is now decoded after the event type is
read: a bool is accepted only for SeriesDelete and MovieDelete, and on any
other event a value that is not a list of files is malformed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…o-Server#2411)

The Sonarr/Radarr requests plugin adds a Metadata language setting for
Sonarr v5 connections, stored as Sonarr's numeric language id. A rule that
overrides it showed that id ("Metadata language: 8") in the routing list
and the preview, because those load a server's options only for the
override keys listed as server-named.

metadata_language is now one of them, so the summary names the language
once the server's options load, as it does for quality profiles and tags.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ssed cells (Silo-Server#2412)

* fix(trickplay): correct HDR previews, decode on NVIDIA, and repair missed cells

Seek-preview sheets now tone map HDR in software with the chain playback uses
for the source's base signal, on GPU attempts too: GPUs decode and scale,
then download 10-bit frames. Dolby Vision Profile 5 no longer comes out green
on VAAPI/QSV, HLG keeps its transfer, and every decoder gives the same colors.

NVENC hosts decode sheets on CUDA. GPU frames are scaled to four tiles' size
and area-scaled in software to avoid aliasing. A list run's missing cells
(VC-1 in Matroska, HEVC duplicate-POC drops) and repeated cells (GOPs longer
than the interval) are read again within a time box. A filter-graph reinit
no longer discards a hardware attempt, and a GPU that cannot decode a codec
is classified as unsupported.

Nodes advertise trickplay_extract_v2 and servers use only those nodes. A
migration requeues sheets VAAPI/QSV made for HDR sources and sheets with
filled cells, and retries backed-off files now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(trickplay): repair empty lists, recheck repeats, and bound run memory

Repairs now run for a list that decoded nothing, since a container without
keyframe flags can miss every short entry. Repeated cells are recomputed
after the missing-cell repair, so a recovered cell that shows its neighbor's
keyframe is decoded at its own time too, and a cell repaired twice counts
once. A Sheets request whose tiles exceed 64 million pixels is invalid,
because a run now holds every tile until its repairs finish.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(trickplay): place repaired frames by their clamped inpoint

A container that starts before zero clamps an early sample's list inpoint
at zero. Frame times are now placed back in the input from that clamped
inpoint and the container start, so the same keyframe gives two samples the
same time and a repeat is detected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(trickplay): check a probed tile height against the run limit after probing

With UseInputAspect the probe replaces the requested tile height, and
trickplay sizes later chunks from the decoded height. Request validation now
refuses only runs too large at any height, and the check with the probed
height runs before anything is decoded, so a rotated file whose catalog
height exceeds its decoded height is not refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ilo-Server#2409)

NotificationItem.reason_flags reused the settings JSONValue type, so the
v2 contract described it as "the setting's value_schema in the settings
contract". Give it its own NotificationReasonFlags type: an open object
whose keys depend on the notification type. Settings fields keep the
JSONValue description.

Closes Silo-Server#2381

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ver#2405)

The admin log stream skipped its 250 ms connect debounce whenever the
reconnect nonce was non-zero. Nothing reset the nonce, so after one
Reconnect click every filter keystroke minted a ticket and opened a
socket immediately. Skip the debounce only for the attempt that a new
reconnect() started.

Closes Silo-Server#2389

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…rver#1658)

* fix(opslog): store logged errors as their text instead of {}

Fixes Silo-Server#1605.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* opslog: sanitize stored error text and guard typed-nil errors

Error attrs now pass through logredact.SanitizeURLError before being
stored, so requested URLs lose credentials and query strings. A nil
pointer implementing error is recorded as a placeholder, and a
panicking Error method is contained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(opslog): redact every requested URL and secret pairs in error text

Stored error text could still carry credentials in three shapes:
a second *url.Error under a wrapped errors.Join, a wrapper that repeats
the requested URL in its own text, and a plain-text api_key=... pair
such as a plugin's gRPC status description. SanitizeURLError now
replaces the raw URL of every *url.Error in the tree wherever it appears
in the message, and opslog masks secret key=value pairs in the error
text with the new logredact.RedactSecretAssignments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uj5ANjmwiWfx36d1AEHjKH

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Daniel Shafer <dan@danielshafer.com>
)

* Initial Feature

* Revert "Initial Feature"

This reverts commit eec3f37.

* Initial Feature

* fixed drop when narrow

* fix(web): let the egress Peak label wrap in a narrow card

Holding Peak on one line pushed the range picker past the card's right
edge once the Egress widget was resized to its minimum span. The
container query already hides Total before the header runs out of room,
so Peak can wrap again as it does on main and in the Concurrent streams
widget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…en tab (Silo-Server#1870)

* fix(web): block saving users while a limit field is invalid on a hidden tab

The Edit User dialog kept the last valid Max Streams or Max Transcodes value
when the box was cleared, and the native required check only ran while the
Limits tab was mounted. Saving from another tab sent the stale value.

Track invalid limit fields in the form, refuse Save while any remain, and
switch to the Limits tab so the browser reports the field. A remounted field
shows the pending empty box instead of the old value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep invalid limit text when the Limits tab unmounts

UserForm already tracked which limit fields were invalid, so Save stayed
blocked after a tab switch, but the field reopened with an empty box and
lost what the admin typed (for example 1.5). Keep the invalid text in
UserForm instead of a flag and reopen the field with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uj5ANjmwiWfx36d1AEHjKH

* fix(web): point the browser at the invalid limit after a refused save

The refused save asked the browser to report the field from an effect that
ran before the Limits tab mounted its fields, so nothing was reported and
focus stayed on Save. Render the tab with flushSync, then report.

The tests now override Max Streams to 3 before clearing it, so they cover the
stale value Silo-Server#1867 describes, and check that the field is reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Daniel Shafer <dan@danielshafer.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Silo-Server#1667)

* fix(collections): reject unknown visibility on admin create and update

An unknown visibility value reached the library_collections CHECK
constraint and surfaced as a 500. Validate it in the handler and return a
visibility field error instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(lint): use a constant for the hidden collection visibility

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(collections): normalize an explicit empty visibility on update to visible

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…example config (Silo-Server#1662)

* fix(observability): scrape the main server's metrics listener in the example config

The example Prometheus config scraped silo-api:8080, the application
listener, which answers /metrics with 404. Point it at the
SILO_METRICS_LISTEN listener and document the reachability caveat.
Reword the proxy and transcode node comments to compare with the
dedicated metrics listener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: retrigger (unrelated librarymonitor flake)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(transcodenode): compare the test's /metrics posture with the metrics listener

The application listener has no /metrics route; only the dedicated
SILO_METRICS_LISTEN listener serves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
Quick104 and others added 12 commits October 17, 2026 20:13
…lo-Server#2656)

- person_reindex_db_test: wait on the write's done channel with a short
  timer instead of time.Sleep (forbidigo, from Silo-Server#2554).
- recommendations: use strconv.Itoa for hnsw.ef_search (perfsprint, from
  Silo-Server#2553).
- TestPersonRefreshWorkerPacesLookupsAcrossBatches: take lookup starts
  from the worker's pacing clock. The hook ran after the worker set
  nextLookup, so jitter could make a correctly paced gap read 29.7ms
  against a 30ms floor.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…nto Manage (Silo-Server#2657)

* feat(web): group the detail page's More menu and move admin actions behind Manage

The detail page's More menu had grown to up to 19 flat rows for admins,
with playback, library, party, and management actions mixed together.

Group the menu into playback, library, and Watch Together sections, and
move metadata, marker, preview, and info actions into a Manage panel that
opens in place (Back, ArrowLeft, or Escape return to the main menu). A
user with a single management action sees it inline instead of a
one-item submenu.

Drop Play from Beginning when the Play button already offers it, drop
the Watch Together header, and move subtitle search into the Subs picker
as a pinned "Search for Subtitles…" row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(web): open the detail page's Manage actions in a menu beside the first

Manage replaced the More menu's contents with its own panel. Open it as
a second menu next to the first instead, on whichever side has room, so
the rest of the menu stays visible.

Hovering Manage opens it; a click, Enter, or ArrowRight opens it and
moves focus in; ArrowLeft or Escape closes it and returns focus to
Manage. Hovering or focusing another item closes it. On a phone with no
room beside the menu, it cascades over the first menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep Manage beside its trigger when the More menu scrolls

The scroll handler ignored scrolls inside the main menu, so with Manage
open, scrolling a tall main menu left Manage at its old position. Count
those scrolls while Manage is open; placing the main menu again places
Manage after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…r#2658)

The link shared a row with the Password label, above the input, so Tab
went from username to the link before the password field. It now sits on
a right-aligned line under the input, with the same styling and the same
visibility conditions.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ilo-Server#2606)

* feat(database): name the code site and SQL shape behind query cost

Postgres metrics and spans say only which pool role and statement type
ran, so a slow request or a hot query cannot be traced back to the code
that issued it. The new queryattr package, fed by the pgx tracer,
attributes each query, batch, and copy to a site, the first Silo
function and line outside internal/database, and to a shape, a hash of
the SQL after github.com/DataDog/go-sqllexer normalizes it.

When SILO_QUERY_ATTRIBUTION_ENABLED is true:

- The api, apiv2, and jellycompat request lines gain db_* fields: query
  count, query and pool-wait time, and the slowest and most frequent
  site and shape.
- Slow or large operations emit a rate-limited Info event.
- Each node logs a periodic and a final summary of its top sites and
  shapes.
- Full nodes export per-site query counters, and recording dependency
  spans gain silo.db.site and silo.db.shape.

Background work launched from a request detaches from its attribution,
so it does not count on that request's line.

Attribution is off by default and read once at startup. Off, it adds
no request fields, events, summaries, metrics, or span attributes. SQL
is hashed and dropped inside queryattr: no output carries SQL text,
table or column names, argument values, or error text, and per-site
memory is capped.

Two changes apply with the flag off. The "silo starting" line now
records query_attribution and a query_run ID. A batch that pgx ends
twice after an early error now ends its dependency span and counter
once instead of twice. make test-go also runs queryattr under -race.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>
Signed-off-by: Anagh Kumar Baranwal <6824881+darthShadow@users.noreply.github.com>

* perf(database): walk eight frames before the full stack for query sites

A 25-second CPU profile of a sandbox under API load spent 10.3 µs per
query in runtime.Callers, which unwound up to 32 frames to find a site
that is usually four or five frames above the tracer. Walking eight
frames first and falling back to the full walk cut that to 5.7 µs and
attribution's total to 8.6 µs per query, with no unknown sites.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Anagh Kumar Baranwal <6824881+darthShadow@users.noreply.github.com>

* fix(queryattr): requests that ran no queries no longer carry db_* fields

Request lines are stored in operational_logs at the default capture
level, and every request writes one, including image and stream-segment
requests. A request that ran no queries still carried nine db_* fields
with constant content: four "none" strings and five zeros. With
attribution on by default, that is stored on every asset request for no
information.

Request.LogAttrs now returns nil when the request saw no operation and
no acquire wait. The first call still closes the request and caches the
result, so later completions count only in site totals. A request that
waited on a pool acquire but ran no query still gets all nine fields,
with four "none" strings.

observability.md states the new rule in place of "An idle request gives
four none strings and zero numbers". The tests that expected the idle
fields now expect them absent, and new tests cover the nil snapshot and
the acquire-only case.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>
Signed-off-by: Anagh Kumar Baranwal <6824881+darthShadow@users.noreply.github.com>

---------

Signed-off-by: Anagh Kumar Baranwal <6824881+darthShadow@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex (gpt-6.1-sol) <noreply@openai.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
…ilo-Server#2667)

* feat(sections): curated holiday lists with vibes for Seasonal Picks

Seasonal Picks rows now show curated public MDBList lists ranked by IMDb
popularity instead of genre rules sorted by date added. Each holiday offers
one or more vibes (for example Halloween favorites, family or horror), rows
can move a holiday's dates or add holidays of their own, and rows the list
cannot fill are topped up with the library's closest matches by
recommendation embedding unless set to "the lists only".

Lists are downloaded in the background into seasonal_list_snapshots, up to
two weeks before a holiday, and on section save. The editor reads the holiday
catalog from GET /api/v2/sections/seasonal-themes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web): move the seasonal editor tests into their own file

AddRowDialog.test.tsx crossed the 800-line size limit. The vibe, holiday
dates and custom holiday tests now render the holiday fields directly and
check the save rule with canSaveDraft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sections): scope the seasonal fill to the row's libraries

- The recommendation fill searched every library the viewer can see, so a
  library page's fill could be spent on titles the item fetch then dropped.
  It now searches only the row's libraries.
- List lookups send each external ID once, and a movie falls back to its
  TVDB ID after TMDB and IMDb.
- The editor treats a row with only custom holidays as the Holidays variant,
  so its fields show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): refuse MDBList links the server refuses in custom holidays

The editor accepted list links with a username, password or a port other
than 80 or 443, which the server's ValidateMDBListURL rejects, so a holiday
could look complete and then fail to save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): refuse MDBList links with empty userinfo in custom holidays

The browser's URL parser reports "https://@mdblist.com/..." as having no
username, but the server's ValidateMDBListURL refuses any userinfo, empty
included. The editor now also looks for "@" in the link's authority as
typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…#2670)

* feat(web): keep playback speed and pitch with the browser decoder

The browser decoder's audio worklet played other speeds by resampling,
which shifted pitch, so the player held the decoder at 1x and hid Speed.
The worklet now builds its output with a WSOLA time stretcher when the
rate is not 1, keeping the clock and buffering accounting it already had.

The clock reads anchors at the audible context time with the rate they
were rendered at, so a speed change no longer jumps the picture. A new
decoder session starts at the element's default rate, and due pictures
are released as they arrive while the tab is hidden, so fast playback
keeps the decoder supplied with frame credits. Gaps in the audio stay
silent above 2x.

Closes Silo-Server#2665

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): centre stretched audio on the decoder clock

Code review of the speed change found the stretched audio trailing the
clock by up to 64 ms at 3x, a clock report delayed after a rate change,
anchor history too short for high sample rates, and an underrun latch
that a speed change during a dry spell could leave set. Each sequence
above 1x now starts half its skip past the head, the worklet reports a
rate change at once, the clock keeps one second of anchors, and audio
flowing again clears the latch. The architecture doc and the room sync
comments no longer say the decoder shifts pitch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): play the end of the stream unstretched below a sequence

At a slowed speed, the last sequence before the end of the stream was
padded with silence, so up to half of the final audio's media time
played as silence. Once the stream has ended and less than a sequence
is queued, the worklet now fades from the stretcher into unstretched
playback for the rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep an audio gap that starts after the queue ran dry

Above 1x the worklet's head can step past the end of the last chunk.
That overshoot became gap silence only when the next chunk was already
queued; after an underrun, a chunk arriving later after a gap was
played at once and the clock jumped across the gap. The same carry now
applies when audio refills an empty queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…-Server#2668)

GET /Items?Ids=<list> without Limit paged the list with the 24-row
default for item browsing while TotalRecordCount counted every item, so
Jellyfin clients that look items up by ID (CrossWatch, 100 at a time)
silently lost all but 24. Jellyfin applies no default page size there.

handleSpecificItems now skips the default on both of its paths: the
per-item lookup returns every resolved item, and a request that also
filters, searches, or sorts asks the catalog browse for one row per
named ID, still bounded by the 1,000-row compat browse cap. An explicit
Limit pages as before.

Closes Silo-Server#2662

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
181 upstream commits since f1a1ee4. Rebrand-aware merge by
Prairie-Server/.github upstream-sync/prairie_sync.py (base=rebrand(merge-base), theirs=rebrand(upstream)).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicts resolved by hand:
	.golangci.yml
	cmd/prairie/main.go
	contracts/api/v2/fixtures/get_image_capabilities_ok.json
	contracts/api/v2/openapi.json
	docs/architecture/playback-protocol-v3.md
	docs/playback-api.md
	go.mod
	internal/access/profile_token.go
	internal/api/handlers/admin_images.go
	internal/api/handlers/admin_marker_providers.go
	internal/api/handlers/admin_settings_checks.go
	internal/api/handlers/favorites_image_size_test.go
	internal/api/handlers/image_size_test.go
	internal/api/handlers/images_capability_test.go
	internal/api/handlers/session_ws_test.go
	internal/api/router.go
	internal/apiv2/document.go
	internal/artworkkey/artworkkey.go
	internal/artworkkey/artworkkey_test.go
	internal/audiobooks/config.go
	internal/autoscan/probe.go
	internal/autoscan/suppress.go
	internal/catalog/detail.go
	internal/catalog/detail_audiobook_test.go
	internal/catalog/image_size_test.go
	internal/catalog/item_cards.go
	internal/catalog/last_air_date_denorm_test.go
	internal/catalog/library_collection_service.go
	internal/catalog/provider_id_repo.go
	internal/catalogseed/service.go
	internal/catalogseed/service_db_test.go
	internal/collections/templates/validate.go
	internal/config/admin_settings.go
	internal/contractledger/ledger_test.go
	internal/database/postgres_metrics_test.go
	internal/diagnostics/bundle.go
	internal/diagnostics/repo.go
	internal/diagnostics/service.go
	internal/ebookconvert/cache.go
	internal/ebookconvert/converter.go
	internal/historyimport/emby_client.go
	internal/imagecache/imagecache.go
	internal/imagecache/imagecache_test.go
	internal/imagesize/imagesize_test.go
	internal/jellycompat/handlers_userdata.go
	internal/jellycompat/login.go
	internal/jellycompat/person_image_tags_test.go
	internal/jellycompat/streams.go
	internal/logredact/url_test.go
	internal/markers/contribution_repo_test.go
	internal/metadata/image_cache_processor.go
	internal/metadata/image_ladder_backfill_test.go
	internal/metadata/image_ladder_fallback.go
	internal/metadata/image_ladder_fallback_test.go
	internal/metadata/local_person_photos.go
	internal/metadata/queue.go
	internal/metadata/service.go
	internal/metadata/tmdb/client.go
	internal/metadata/worker.go
	internal/notifications/inbox_pages_test.go
	internal/opslog/cleanup.go
	internal/playback/copy_seek_anchor.go
	internal/playback/protocol_v3.go
	internal/playback/transcode.go
	internal/pluginhost/client.go
	internal/plugins/archive_cache.go
	internal/plugins/catalog_settings.go
	internal/plugins/installer.go
	internal/plugins/service.go
	internal/ratelimit/memory.go
	internal/recommendations/cowatch.go
	internal/recommendations/taste.go
	internal/requests/service.go
	internal/s3client/metrics_test.go
	internal/s3client/transport.go
	internal/sections/recipes/seasonal.go
	internal/transcodenode/server.go
	internal/uploads/session_test.go
	internal/usercollections/sync.go
	internal/userstore/pgstore/conformance_test.go
	internal/userstore/pgstore/section_overrides.go
	internal/watchsync/service.go
	internal/watchtogether/room_token.go
	internal/watchtogether/service.go
	web/package.json
	web/src/App.tsx
	web/src/app.css
	web/src/components/CastCarousel.tsx
	web/src/components/ImageSelectorTab.tsx
	web/src/components/admin/libraries/LibraryFormSections.tsx
	web/src/fonts.css
	web/src/hooks/admin/useSettingsHealth.test.ts
	web/src/lib/adminNavigation.ts
	web/src/lib/adminSettingsSearch.ts
	web/src/lib/documentTitle.ts
	web/src/lib/recommendation-provider-presets.test.ts
	web/src/lib/recommendation-provider-presets.ts
	web/src/pages/AdminLogs.tsx
	web/src/pages/AdminPlaybackHistory.tsx
	web/src/pages/AdminRecommendations.tsx
	web/src/pages/AdminRequests.tsx
	web/src/pages/ItemDetail/SeriesContent.test.tsx
	web/src/pages/ItemDetail/components/ActionBar.tsx
	web/src/pages/Login.tsx
	web/src/pages/Notifications.tsx
	web/src/pages/Profiles.tsx
	web/src/pages/SettingsLayout.tsx
	web/src/pages/TasteSeed.tsx
	web/src/pages/admin-settings/SettingField.tsx
	web/src/pages/settings/ConnectAppsSettings.tsx
	web/src/pages/watchtogether/room/picker/usePickerData.ts
	web/src/player/components/PlaybackInfoOverlay.tsx
	web/src/player/components/VideoPlayer.tsx
	web/src/player/hooks/useSubtitleTracks.ts
	web/src/player/playback-info.ts
	web/src/player/protocol-v3.ts
	web/tsconfig.app.json
… check fake

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

JonahMMay commented Oct 18, 2026 •

Copy link
Copy Markdown
Author

Upstream sync audit

check count
conflict markers 0
likely compile breaks: names used but declared nowhere 0
likely compile breaks: names declared twice 0
files with Prairie lines no longer present 163
Silo tokens added by the sync 61

Prairie lines no longer present. Each is either intended (upstream rewrote that code) or a lost Prairie change:

cmd/prairie/main.go (1)
slog.Info("silo starting", "mode", mode, "listen", cfg.Server.Listen, "log_level", cfg.Server.LogLevel, "node_id", nodeID)
contracts/api/v2/fixtures/admin_account_delete_break_glass_required.json (1)
"instance": "urn:silo:request:0000000000000000000002d0"
contracts/api/v2/fixtures/admin_account_profile_sections_not_found.json (1)
"instance": "urn:silo:request:00000000000000000000021d"
contracts/api/v2/fixtures/admin_download_revoke_mixed.json (1)
"instance": "urn:silo:request:0000000000000000000002f2"
contracts/api/v2/fixtures/admin_download_storage_delete_invalid_id.json (1)
"instance": "urn:silo:request:0000000000000000000002eb"
contracts/api/v2/fixtures/admin_item_markers_redetect_unknown_kind.json (1)
"instance": "urn:silo:request:000000000000000000000247",
contracts/api/v2/fixtures/admin_login_session_forbidden.json (1)
"instance": "urn:silo:request:0000000000000000000002e6"
contracts/api/v2/fixtures/admin_network_access_status_not_found.json (1)
"instance": "urn:silo:request:00000000000000000000028f"
contracts/api/v2/fixtures/admin_network_access_unknown_host.json (1)
"instance": "urn:silo:request:000000000000000000000292",
contracts/api/v2/fixtures/admin_subtitle_metadata_precondition_required.json (1)
"instance": "urn:silo:request:000000000000000000000265"
contracts/api/v2/fixtures/approve_device_login_api_key_forbidden.json (1)
"instance": "urn:silo:request:0000000000000000000002cc"
contracts/api/v2/fixtures/cancel_admin_download_preparations_invalid_id.json (1)
"instance": "urn:silo:request:0000000000000000000002d7"
contracts/api/v2/fixtures/cancel_device_login_not_found.json (1)
"instance": "urn:silo:request:0000000000000000000002b5"
contracts/api/v2/fixtures/collection_order_foreign_id.json (1)
"instance": "urn:silo:request:0000000000000000000002d8",
contracts/api/v2/fixtures/complete_account_identity_link_invalid_grant.json (1)
"instance": "urn:silo:request:0000000000000000000002c7"
contracts/api/v2/fixtures/complete_oauth_login_invalid_grant.json (1)
"instance": "urn:silo:request:0000000000000000000002c1"
contracts/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json (1)
"instance": "urn:silo:request:0000000000000000000002c3",
contracts/api/v2/fixtures/create_admin_user_identity_linked_elsewhere.json (1)
"instance": "urn:silo:request:0000000000000000000002bc"
contracts/api/v2/fixtures/delete_account_identity_last_sign_in_method.json (1)
"instance": "urn:silo:request:0000000000000000000002b8"
contracts/api/v2/fixtures/ebook_annotation_delete_missing_guard.json (1)
"instance": "urn:silo:request:000000000000000000000234"
contracts/api/v2/fixtures/ebook_annotation_patch_missing_guard.json (1)
"instance": "urn:silo:request:000000000000000000000233"
contracts/api/v2/fixtures/ebook_config_missing_guard.json (1)
"instance": "urn:silo:request:00000000000000000000022f"
contracts/api/v2/fixtures/ebook_progress_missing_event_time.json (1)
"instance": "urn:silo:request:00000000000000000000022d",
contracts/api/v2/fixtures/get_home_layout_include_items_out_of_range.json (1)
"instance": "urn:silo:request:0000000000000000000002f6",
contracts/api/v2/fixtures/get_image_capabilities_ok.json (1)
"revision": "0ebdd7fa6c49a596cc6371358390a174e111965b73e938af57b825b2b2200118",
contracts/api/v2/fixtures/get_ratings_capability_authentication_required.json (1)
"instance": "urn:silo:request:0000000000000000000002ab"
contracts/api/v2/fixtures/get_watch_trickplay_not_found.json (1)
"instance": "urn:silo:request:0000000000000000000002ae"
contracts/api/v2/fixtures/link_account_identity_with_credentials_directory_refused.json (1)
"instance": "urn:silo:request:0000000000000000000002cb",
contracts/api/v2/fixtures/list_series_seasons_invalid_artwork.json (1)
"instance": "urn:silo:request:000000000000000000000284",
contracts/api/v2/fixtures/login_account_required.json (1)
"instance": "urn:silo:request:0000000000000000000002cf"
contracts/api/v2/fixtures/login_provider_null.json (1)
"instance": "urn:silo:request:00000000000000000000028a",
contracts/api/v2/fixtures/playback_installation_changed.json (1)
"instance": "urn:silo:request:00000000000000000000027e"
contracts/api/v2/fixtures/playback_invalid_protocol.json (1)
"instance": "urn:silo:request:00000000000000000000027f",
contracts/api/v2/fixtures/playback_progress_conflict.json (1)
"instance": "urn:silo:request:00000000000000000000027d"
contracts/api/v2/fixtures/refresh_session_provider_unavailable.json (1)
"instance": "urn:silo:request:0000000000000000000002c9"
contracts/api/v2/fixtures/regenerate_admin_item_trickplay_disabled.json (1)
"instance": "urn:silo:request:0000000000000000000002b1"
contracts/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json (1)
"instance": "urn:silo:request:0000000000000000000002d2"
contracts/api/v2/fixtures/start_account_identity_link_unknown_ticket.json (1)
"instance": "urn:silo:request:0000000000000000000002c5"
contracts/api/v2/fixtures/start_device_login_temporary_null.json (1)
"instance": "urn:silo:request:00000000000000000000028b",
contracts/api/v2/fixtures/theme_songs_playback_not_acceptable.json (1)
"instance": "urn:silo:request:000000000000000000000298"
contracts/api/v2/fixtures/theme_songs_playback_unauthorized.json (1)
"instance": "urn:silo:request:000000000000000000000299"
contracts/api/v2/fixtures/token_refresh_required.json (1)
"instance": "urn:silo:request:0000000000000000000002ac"
contracts/api/v2/openapi.json (20)
"summary": "Dispatch one test push notification and report its current delivery outcome.",
"summary": "Dispatch one test push notification and report its current delivery outcome.",
"summary": "Read persisted counts and this process's running flags.",
"description": "Artwork variant to presign",
"description": "Artwork variant to presign",
"summary": "Queue a translation of the item's descriptions into the language the detail document reported missing; answers 202 with the job.",
"/api/v2/collections/import/trakt": {
"operationId": "importTraktCollection",
"$ref": "#/components/schemas/TraktCollectionImport"
"summary": "Import a Trakt preset as a synced collection. Creates the collection and runs its first sync. Not idempotent: a retry after a lost response creates a second collection.",
"description": "Artwork variant to presign; absent picks each surface's default",
"description": "Artwork variant to presign; absent picks each surface's default",
"summary": "Remove the item from the acting profile's favorites; an absent entry succeeds, but automatic retries can repeat provider and refresh effects.",
"summary": "Add the item to the acting profile's favorites. Automatic retries are unsafe because provider and refresh effects are not change-gated.",
"description": "Artwork variant to presign; absent picks each surface's default",
"description": "Artwork variant to presign; absent picks each surface's default",
"description": "Artwork variant to presign for included_sections, as on getHomeSectionItems; absent picks each surface's default",
"description": "Artwork variant to presign for included_sections, as on getHomeSectionItems; absent picks each surface's default",
"description": "Artwork variant to presign; absent picks each surface's default",
"description": "Artwork variant to presign; absent picks each surface's default",
deploy/observability/prometheus.yml (1)
- targets: [silo-api:8080]
docs/architecture/admin-settings-ux.md (1)
owns the feature: Library & Metadata decides *whether* Silo looks for intro and
docs/architecture/dropped-shows.md (3)
Provider reads can lag or omit Silo's writes. Trakt serves its GET responses from a cache
for up to an hour, and its dropped list leaves out drops that apps make, so a drop Silo
which Silo cannot see. An undrop of a drop a read had confirmed keeps its agreed row
docs/architecture/playback-protocol-v3.md (2)
| Media | `/stream/{session_id}`, `/playback/transcode/{session_id}/master.m3u8` and its segments | `seek` — the progressive-remux start offset in seconds, present only when it is non-zero; `hls_vars=1` on the HLS manifest of an attempt that advertised `hls_variable_substitution_v1` (below) |
| Media on a designated origin | `{proxy}/stream/v3/{session_id}`, `{proxy}/stream/v3/{session_id}/master.m3u8` and its `segment/{name}` children (§4.1) | `seek` and `hls_vars`, with the same meanings; these routes never accept a credential parameter of any kind |
go.mod (1)
github.com/prairie-server/prairie-plugin-sdk v0.12.1-0.20261012211536-3ea8c11a9099
internal/access/profile_token.go (1)
return nil, fmt.Errorf("%w: %w", ErrProfileUnverified, err)
internal/api/handlers/admin.go (13)
// ensureRedisReachableBeforeSave rejects persisting a non-empty redis.url that
// cannot be pinged (Prairie: an unreachable URL used to restart-loop the server
// under Docker). Clearing Redis (empty URL) is always allowed. Env-managed
// Redis is never written through these endpoints.
func (h *AdminHandler) ensureRedisReachableBeforeSave(ctx context.Context, changed map[string]string) error {
raw, ok := changed["redis.url"]
normalized, err := config.NormalizeRedisURL(raw)
if err := probeRedisConfig(ctx, config.RedisConfig{URL: normalized}); err != nil {
return fmt.Errorf("redis.url is unreachable; fix the URL or leave Redis disabled: %w", err)
if err := h.ensureRedisReachableBeforeSave(ctx, normalized); err != nil {
return AdminSettingsUpdateResult{}, &APIError{Status: http.StatusBadRequest, Code: "invalid_settings", Message: err.Error()}
if err := h.ensureRedisReachableBeforeSave(ctx, map[string]string{key: req.Value}); err != nil {
return AdminSettingUpdateResult{}, &APIError{Status: http.StatusBadRequest, Code: "invalid_settings", Message: err.Error()}
internal/api/handlers/admin_images.go (2)
case itemTypeSeason:
case itemTypeEpisode:
internal/api/handlers/admin_marker_providers.go (1)
return MarkerProviderValidationView{Error: err.Error()}, nil //nolint:nilerr // the provider failure is the validation result
internal/api/handlers/admin_settings_checks.go (9)
// redisSettingsCheckClient is the Prairie seam probeRedisConfig pings through,
if err := probeRedisConfig(ctx, cfg.Redis); err != nil {
// probeRedisConfig pings Redis so unreachable URLs cannot be persisted and later
// brick startup under a process supervisor.
func probeRedisConfig(ctx context.Context, redisCfg config.RedisConfig) error {
if strings.TrimSpace(redisCfg.URL) == "" {
client, err := newAdminRedisSettingsCheckClient(redisCfg)
defer func() { _ = client.Close() }()
return client.Ping(checkCtx)
internal/api/handlers/admin_settings_checks_test.go (19)
func TestAdminSettingsRejectUnreachableRedisURL(t *testing.T) {
return &fakeRedisSettingsCheckClient{
ping: func(context.Context) error {
return errors.New("dial tcp: connection refused")
}, nil
name:   "batch",
target: "/admin/settings",
body:   `{"values":{"redis.url":"redis://unreachable.example.invalid:6379"}}`,
name:   "single",
target: "/admin/settings/redis.url",
body:   `{"value":"redis://unreachable.example.invalid:6379"}`,
single: true,
t.Fatalf("unreachable redis.url was persisted: SetMany=%d Set=%d", settings.setManyCalls, settings.setCalls)
if !strings.Contains(rec.Body.String(), "unreachable") {
t.Fatalf("body = %q, want unreachable message", rec.Body.String())
func TestAdminSettingsAcceptReachableRedisURL(t *testing.T) {
strings.NewReader(`{"values":{"redis.url":"redis://cache.example:6379"}}`),
if settings.values["redis.url"] != "redis://cache.example:6379" {
t.Fatalf("values=%#v, want redis.url persisted", settings.values)
internal/api/handlers/favorites_image_size_test.go (2)
{"large", ":large:", "/poster/w500.", "/backdrop/w1920."},
{"large", imagesize.Large, ":large:tmdb/movies/550/logo/w500.png"}, // Prairie: logos top out at w500
internal/api/handlers/image_size_test.go (6)
// Continue Watching w1280 backdrop. Widths follow Prairie's TV-tuned ladder
// (posters w500/w300/w200, stills w500/w300; no w780 rung).
if got := sizedCardPath(poster, "poster", imagesize.Large); got != "tmdb/movies/550/poster/w500.abc123.webp" {
if got := sizedBackdropPath(still, imagesize.Large); got != "tvdb/series/73141/seasons/22/episodes/9/still/w500.webp" {
{imagesize.Large, "still/w500.webp", "backdrop/w1920.abc123.webp"},
{imagesize.Large, "tmdb/movies/550/logo/w500.png"},
internal/api/handlers/images_capability_test.go (5)
// Prairie's TV-tuned ladder: posters w500/w300/w200, a single w500 logo.
if w := got.Widths["poster"]; w.Large != 500 || w.Small != 200 {
t.Errorf("poster widths = %+v, want large 500 / small 200", w)
if got.Widths["logo"].Large != 500 {
t.Errorf("logo large width = %d, want 500", got.Widths["logo"].Large)
internal/api/handlers/playback_v3.go (1)
// card to another Silo process through (*noderecipe.Store). One instance owns
internal/api/handlers/recommendations.go (1)
return h.buildSectionItems(r.Context(), items, enrichment, nil)
internal/api/handlers/recommendations_similar_test.go (2)
func (s *stubSimilarEngine) SimilarItems(context.Context, string, int) ([]recommendations.ScoredItem, error) {
func (s *stubSimilarEngine) BecauseYouWatched(context.Context, int, string, string, int) ([]recommendations.ScoredItem, error) {
internal/api/handlers/session_ws_test.go (2)
if resp != nil {
defer resp.Body.Close()
internal/api/handlers/transcode_segment_head_test.go (1)
<file deleted>
internal/artworkkey/artworkkey.go (15)
// the newly-added rungs. Adding a type does not need a bump.
case "backdrop":
case "logo":
return []int{500}
// No w200 rung. An episode still renders ~140 CSS-px in the browser
// (EpisodeRow) and ~358 px on a UHD television, so w300 covers both
// while w200 would be upscaled on the larger one. Nothing asks for a
// w200 still: cachedImageVariantKeyFor keeps stills at w500 for TV
// clients, the smart-TV client pins STILL_WIDTH to 500, and no caller
// passes size="small" for a still. Generating it cost one encode plus
// an AVIF sibling per episode for an object only a sweeper would read.
default: // poster, profile
// w200 is the TV rung: poster cards render ~155 CSS-px, so w300 decodes
// ~4x the pixels shown. w200 roughly halves decoded surface (and bytes)
// for the smart-TV clients without visibly softening the card.
internal/artworkkey/artworkkey_test.go (2)
// Backdrops have their own ladder (1920/1280/300).
if len(got) != 2 || got[0] != "tmdb/movies/550/poster/w300.webp" {
internal/artworkkey/coverage_gate_test.go (1)
if len(VariantWidths("backdrop")) != 3 || len(VariantWidths("logo")) != 1 || len(VariantWidths("poster")) != 3 {
internal/artworkkey/retired_test.go (7)
if got := VariantWidths("still"); !reflect.DeepEqual(got, []int{500, 300}) {
t.Errorf("VariantWidths(still) = %v, want [500 300]", got)
if got := VariantWidths("backdrop"); !reflect.DeepEqual(got, []int{1920, 1280, 300}) {
t.Errorf("VariantWidths(backdrop) = %v, want [1920 1280 300]", got)
if got := VariantWidths("logo"); !reflect.DeepEqual(got, []int{500}) {
t.Errorf("VariantWidths(logo) = %v, want [500]", got)
if got := VariantNames("still"); !reflect.DeepEqual(got, []string{OriginalVariant, "w500", "w300"}) {
internal/audiobooks/config.go (3)
if err == nil {
// Fail open when settings are unavailable.
return true, nil
internal/auth/initial_setup_atomic_test.go (1)
<file deleted>
internal/autoscan/probe.go (3)
if err == nil {
if err == nil {
return ConnectionTestResult{OK: false, Err: err.Error()}, nil
internal/autoscan/suppress.go (1)
return true, nil //nolint:nilerr // fail open: a Redis hiccup should not block scanning
internal/catalog/detail.go (2)
path = cachedImageVariantPath(ctx, path, artworkkey.ImageProfile, string(filter.ImageSize))
variant := cachedImageVariantKeyFor(ctx, imageType, size)
internal/catalog/detail_audiobook_test.go (1)
extension := (&DetailService{}).buildEbookExtension(context.TODO(), item, crew, AccessFilter{})
internal/catalog/image_size_test.go (3)
// "large" must reach the widest cached rung short of the original. Prairie's
// ladder has no w780/w1280 rung, so posters, stills and logos stop at w500.
"large":    "tmdb/movies/550/poster/w500.abc123.webp",
internal/catalog/item_cards.go (5)
normalized := cachedImageVariantPath(ctx, path, imageTypeOrDefault(path, artworkkey.ImagePoster), size)
backdrop: addTyped(ctx, add, item.BackdropPath, artworkkey.ImageBackdrop, size),
logo:     addTyped(ctx, add, item.LogoPath, artworkkey.ImageLogo, size),
func addTyped(ctx context.Context, add func(string) string, path, imageType, size string) string {
return add(cachedImageVariantPath(ctx, path, imageTypeOrDefault(path, imageType), size))
internal/catalog/last_air_date_denorm_test.go (2)
return err
if readErr == nil && deletePattern.Match(data) {
internal/catalog/library_collection_service.go (11)
return nil, fmt.Errorf("trakt preset sync requires configured Trakt access")
return nil, fmt.Errorf("trakt list sync requires configured Trakt access")
return "", "", fmt.Errorf("trakt list sync: url is required")
return "", "", fmt.Errorf("trakt list sync: expected a URL like https://trakt.tv/users/{user}/lists/{list}, got %q", raw)
return "", "", fmt.Errorf("trakt list sync: invalid url %q", raw)
itemType := itemTypeMovie
itemType = itemTypeSeries
itemType := itemTypeMovie
itemType = itemTypeSeries
if entry.TVDBID > 0 && itemType == itemTypeSeries {
if itemType == itemTypeSeries && entry.TVDBID > 0 {
internal/catalog/provider_id_repo.go (1)
case !strings.EqualFold(entries[i].Provider, entries[j].Provider):
internal/catalogseed/service.go (3)
return nil, fmt.Errorf("%w: opening catalog seed bundle: %w", ErrInvalidBundle, err)
return nil, fmt.Errorf("%w: reading catalog seed bundle: %w", ErrInvalidBundle, err)
return nil, fmt.Errorf("%w: decoding catalog seed bundle: %w", ErrInvalidBundle, err)
internal/diagnostics/bundle.go (3)
return BundleInfo{}, fmt.Errorf("%w: open gzip: %w", ErrInvalidBundle, err)
return fmt.Errorf("%w: %w", ErrInvalidBundle, err)
return fmt.Errorf("%w: %w", ErrInvalidBundle, err)
internal/diagnostics/repo.go (3)
return time.Time{}, "", fmt.Errorf("%w: decode: %w", ErrInvalidCursor, err)
return time.Time{}, "", fmt.Errorf("%w: timestamp: %w", ErrInvalidCursor, err)
return time.Time{}, "", fmt.Errorf("%w: id: %w", ErrInvalidCursor, err)

Silo tokens added by this sync. Rebrand them, or add them to the rules' protect_tokens if intentional:

  • urn:silo:request:0000000000000000000002d1: contracts/api/v2/fixtures/admin_account_delete_break_glass_required.json
  • urn:silo:request:00000000000000000000021e: contracts/api/v2/fixtures/admin_account_profile_sections_not_found.json
  • urn:silo:request:0000000000000000000002f3: contracts/api/v2/fixtures/admin_download_revoke_mixed.json
  • urn:silo:request:0000000000000000000002ec: contracts/api/v2/fixtures/admin_download_storage_delete_invalid_id.json
  • urn:silo:request:000000000000000000000248: contracts/api/v2/fixtures/admin_item_markers_redetect_unknown_kind.json
  • urn:silo:request:0000000000000000000002e7: contracts/api/v2/fixtures/admin_login_session_forbidden.json
  • urn:silo:request:000000000000000000000290: contracts/api/v2/fixtures/admin_network_access_status_not_found.json
  • urn:silo:request:000000000000000000000293: contracts/api/v2/fixtures/admin_network_access_unknown_host.json
  • urn:silo:request:000000000000000000000266: contracts/api/v2/fixtures/admin_subtitle_metadata_precondition_required.json
  • urn:silo:request:0000000000000000000002cd: contracts/api/v2/fixtures/approve_device_login_api_key_forbidden.json
  • urn:silo:request:0000000000000000000002d8: contracts/api/v2/fixtures/cancel_admin_download_preparations_invalid_id.json
  • urn:silo:request:0000000000000000000002b6: contracts/api/v2/fixtures/cancel_device_login_not_found.json
  • urn:silo:request:0000000000000000000002fb: contracts/api/v2/fixtures/clear_resume_position_not_found.json
  • urn:silo:request:0000000000000000000002fa: contracts/api/v2/fixtures/clear_resume_position_not_leaf.json
  • urn:silo:request:0000000000000000000002d9: contracts/api/v2/fixtures/collection_order_foreign_id.json
  • urn:silo:request:0000000000000000000002c8: contracts/api/v2/fixtures/complete_account_identity_link_invalid_grant.json
  • urn:silo:request:0000000000000000000002c2: contracts/api/v2/fixtures/complete_oauth_login_invalid_grant.json
  • urn:silo:request:0000000000000000000002c4: contracts/api/v2/fixtures/create_account_identity_link_ticket_wrong_password.json
  • urn:silo:request:0000000000000000000002bd: contracts/api/v2/fixtures/create_admin_user_identity_linked_elsewhere.json
  • urn:silo:request:0000000000000000000002b9: contracts/api/v2/fixtures/delete_account_identity_last_sign_in_method.json
  • urn:silo:request:000000000000000000000235: contracts/api/v2/fixtures/ebook_annotation_delete_missing_guard.json
  • urn:silo:request:000000000000000000000234: contracts/api/v2/fixtures/ebook_annotation_patch_missing_guard.json
  • urn:silo:request:000000000000000000000230: contracts/api/v2/fixtures/ebook_config_missing_guard.json
  • urn:silo:request:00000000000000000000022e: contracts/api/v2/fixtures/ebook_progress_missing_event_time.json
  • urn:silo:request:0000000000000000000002f7: contracts/api/v2/fixtures/get_home_layout_include_items_out_of_range.json
  • urn:silo:request:000000000000000000000306: contracts/api/v2/fixtures/get_not_interested_not_found.json
  • urn:silo:request:0000000000000000000002ac: contracts/api/v2/fixtures/get_ratings_capability_authentication_required.json
  • urn:silo:request:0000000000000000000002af: contracts/api/v2/fixtures/get_watch_trickplay_not_found.json
  • urn:silo:request:000000000000000000000302: contracts/api/v2/fixtures/ignore_unmatched_item_not_found.json
  • urn:silo:request:0000000000000000000002cc: contracts/api/v2/fixtures/link_account_identity_with_credentials_directory_refused.json
  • urn:silo:request:000000000000000000000285: contracts/api/v2/fixtures/list_series_seasons_invalid_artwork.json
  • urn:silo:request:0000000000000000000002d0: contracts/api/v2/fixtures/login_account_required.json
  • urn:silo:request:00000000000000000000028b: contracts/api/v2/fixtures/login_provider_null.json
  • urn:silo:request:000000000000000000000307: contracts/api/v2/fixtures/mark_not_interested_episode_rejected.json
  • urn:silo:request:000000000000000000000308: contracts/api/v2/fixtures/mark_not_interested_not_found.json
  • urn:silo:request:00000000000000000000030d: contracts/api/v2/fixtures/notification_mute_notification_series_not_found.json
  • urn:silo:request:00000000000000000000027f: contracts/api/v2/fixtures/playback_installation_changed.json
  • urn:silo:request:000000000000000000000280: contracts/api/v2/fixtures/playback_invalid_protocol.json
  • urn:silo:request:00000000000000000000027e: contracts/api/v2/fixtures/playback_progress_conflict.json
  • urn:silo:request:0000000000000000000002ca: contracts/api/v2/fixtures/refresh_session_provider_unavailable.json
  • urn:silo:request:0000000000000000000002b2: contracts/api/v2/fixtures/regenerate_admin_item_trickplay_disabled.json
  • urn:silo:request:0000000000000000000002d3: contracts/api/v2/fixtures/sign_in_with_network_identity_off_overlay.json
  • urn:silo:request:0000000000000000000002c6: contracts/api/v2/fixtures/start_account_identity_link_unknown_ticket.json
  • urn:silo:request:00000000000000000000028c: contracts/api/v2/fixtures/start_device_login_temporary_null.json
  • urn:silo:request:000000000000000000000299: contracts/api/v2/fixtures/theme_songs_playback_not_acceptable.json
  • urn:silo:request:00000000000000000000029a: contracts/api/v2/fixtures/theme_songs_playback_unauthorized.json
  • urn:silo:request:0000000000000000000002ad: contracts/api/v2/fixtures/token_refresh_required.json
  • urn:silo:request:000000000000000000000309: contracts/api/v2/fixtures/unmark_not_interested_locked_profile.json
  • SILO_INITIAL_SETUP_ATOMIC_DSN: internal/api/initial_setup_atomic_test.go
  • silo_identity_setup_: internal/api/initial_setup_atomic_test.go
  • /silo_identity_setup_: internal/api/initial_setup_atomic_test.go
  • SiloAtomicSetupProof/1: internal/api/initial_setup_atomic_test.go
  • silo:catalog: internal/cache/redis_eventbus_test.go
  • silo:admin: internal/cache/redis_eventbus_test.go
  • silo:playback: internal/cache/redis_eventbus_test.go
  • silo:logs: internal/cache/redis_eventbus_test.go
  • silo:events: internal/cache/redis_eventbus_test.go
  • silo: internal/cache/redis_eventbus_test.go, web/src/hooks/admin/useSettingsHealth.test.ts
  • silo.admin.advanced.: web/src/pages/admin-settings/LibraryMetadataSettings.test.tsx
  • silo.admin.advanced.playback.preview_images: web/src/pages/admin-settings/PlaybackSettings.test.tsx
  • silo.admin.advanced.search: web/src/pages/admin-settings/SearchSettings.test.tsx

Regeneration commands that failed (see the workflow log):

  • make route-inventory
  • make apiv2-openapi
  • make apiv2-fixtures

@coderabbitai

coderabbitai Bot commented Oct 18, 2026

Copy link
Copy Markdown

Walkthrough

Warning

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

JonahMMay and others added 14 commits October 18, 2026 18:07
…pm lockfile

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Ran: go mod tidy; make route-inventory; make apiv2-openapi; make apiv2-fixtures; cd web && pnpm install --no-frozen-lockfile && pnpm run generate:apiv2;
…proxy, eslint suppressions, prettier

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ructure, prettier

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…uplicate Redis save probe

- artworkkey.VariantWidths: upstream rungs (w1000 poster, w1280 still/logo) with the w200 poster/profile rung kept
- update ladder-dependent Prairie tests, restore upstream ladder-fallback logic and tests
- admin settings: remove ensureRedisReachableBeforeSave, superseded by checkRedisSave
- recommendations similar tests: wire accessibleIDs; featured card artwork presign
- mdblist: mask the API key when only a deeper cause quotes it
- cache event-bus test keeps the silo: channel names the code uses
- regenerate image capabilities fixture

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ed CRLF rego sources)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…le playlist test with Tizen VOD strip

- reasoned nolint (ponytail: lint backlog) on 23 lines of synced upstream code the merge-base gate flags
- rename nilPtrErr and testRefusal to satisfy errname
- add titleutil.IsProvisionalEpisodeTitle test for the coverage gate
- subtitle rendition test: Prairie drops EXT-X-PLAYLIST-TYPE:VOD for Tizen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Reverts the ladder half of 1ae35a7, which swapped Prairie's ladder for
upstream's to get CI green. Back to the merge resolution (as on main since
bf211cd): posters/profiles w500/w300/w200, stills w500/w300, logo w500.
Upstream's backdrop ladder {2560,1920,1280,300} stays: main had no
backdrop-specific trim (its {1920,1280,300} was the merge-base value), so
w2560 is a genuinely added rung and backdrops are the one type in
ladderTypesWithAddedRung. LadderVersion stays 3 for that backdrop rung.

- upstream tests that assumed w1000/w1280 rungs now assert Prairie's rungs
- jellycompat: a 298x446 or 320px card box resolves to w300 again rather
  than jumping past the w200 TV rung to w500 (compatCardRung)
- catalog: an explicit target width is a size hint, so the TV default no
  longer overrides it
- regenerate get_image_capabilities_ok.json

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

Go lint on sync PRs ran --new-from-merge-base=origin/main, so every upstream
line Prairie did not have yet counted as new and upstream's accumulated lint
backlog (funlen/gocyclo/nestif on functions later upstream PRs grew, noctx in
userdb, gosec, ...) surfaced all at once. Upstream gates each of its own PRs on
changed lines only, which is why the same code is green there; and Prairie's
looser goconst/errcheck/test-gosec settings make some of upstream's own
//nolint directives unused here (nolintlint).

For PRs from sync/upstream* branches, scripts/ci-go-lint-sync-patch.sh builds
a patch of the Go lines new relative to both main and the sync merge's
upstream parent, and golangci-lint reports only those (--new-from-patch):
merge resolutions and Prairie commits on top stay strictly gated. Every other
PR keeps the merge-base gate.

- revert the 23 "ponytail: lint backlog" //nolint comments and the errname
  renames in synced upstream code (they only answered the old gate)
- gofmt/goimports: admin.go, catalog/detail.go, engine_test.go,
  notifications_apple_display_test.go, postgres_metrics_test.go

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ensureRedisReachableBeforeSave/probeRedisConfig went in 1ae35a7: upstream's
checkRedisSave probes the Redis a save moves the next start to (ping, the
database number and the event-channel subscription) before the settings lock,
and refuses it, which is the restart-loop failure e934c36 guarded against
(TestAdminSettingsRefuseRedisSettingsTheServerCannotStartWith pins it for
single and batch saves). With the guard gone nothing in production called
newAdminRedisSettingsCheckClient; tests were stubbing a factory no code path
read. Remove it and the stubs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@JonahMMay
JonahMMay merged commit b021bc6 into main Oct 19, 2026
20 checks passed
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.