From e8e5103a79b4b7e367d00f3e5e0c0e0102f0a86c Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:37:15 -0400 Subject: [PATCH 001/196] feat(notifications): name the server and profile in every relay push (#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 --- .../android_push_registration_ok.json | 7 + .../apple_push_registration_capability.json | 6 +- .../fixtures/apple_push_registration_ok.json | 11 + contracts/api/v2/fixtures/index.json | 50 ++++- contracts/api/v2/openapi.json | 14 ++ docs/notifications-api.md | 17 ++ docs/notifications-push-api.md | 17 +- internal/apiv2/device_login.go | 6 +- internal/apiv2/fixtures_test.go | 5 +- internal/apiv2/notification_android_push.go | 3 +- .../apiv2/notification_android_push_test.go | 3 +- internal/apiv2/notification_apple_push.go | 3 +- .../notification_apple_push_fixtures_test.go | 26 ++- .../apiv2/notification_apple_push_test.go | 5 +- internal/apiv2/system_identity.go | 13 ++ internal/notifications/push_sender.go | 174 +++++++++++++-- internal/notifications/push_sender_test.go | 207 +++++++++++++++++- internal/notifications/system.go | 6 + web/src/api/v2/schema.ts | 10 + 19 files changed, 541 insertions(+), 42 deletions(-) create mode 100644 contracts/api/v2/fixtures/android_push_registration_ok.json create mode 100644 contracts/api/v2/fixtures/apple_push_registration_ok.json diff --git a/contracts/api/v2/fixtures/android_push_registration_ok.json b/contracts/api/v2/fixtures/android_push_registration_ok.json new file mode 100644 index 0000000000..218eb6438c --- /dev/null +++ b/contracts/api/v2/fixtures/android_push_registration_ok.json @@ -0,0 +1,7 @@ +{ + "generation": "3", + "registration_id": "registration", + "server_device_id": "server-device", + "push_mode": "private_push", + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" +} diff --git a/contracts/api/v2/fixtures/apple_push_registration_capability.json b/contracts/api/v2/fixtures/apple_push_registration_capability.json index a2a492118d..ce86ca8451 100644 --- a/contracts/api/v2/fixtures/apple_push_registration_capability.json +++ b/contracts/api/v2/fixtures/apple_push_registration_capability.json @@ -1,6 +1,6 @@ { - "revision": "4bc701e05299627e8b05db8a01da13a810e2fc870e80d43066bf735ddf1e014a", - "state": "not_configured", + "revision": "021627e60916f147b6bb80e8512f738e97b7c22cb1f30e20d5eed6abf506305a", + "state": "available", "allowed": false, - "registration_available": false + "registration_available": true } diff --git a/contracts/api/v2/fixtures/apple_push_registration_ok.json b/contracts/api/v2/fixtures/apple_push_registration_ok.json new file mode 100644 index 0000000000..b71553eeb8 --- /dev/null +++ b/contracts/api/v2/fixtures/apple_push_registration_ok.json @@ -0,0 +1,11 @@ +{ + "generation": "3", + "id": "registration", + "server_device_id": "server-device", + "push_mode": "private_push", + "enabled": true, + "removed": false, + "display_token": "synthetic-display", + "display_token_expires_at": "2026-02-01T03:04:05.678Z", + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" +} diff --git a/contracts/api/v2/fixtures/index.json b/contracts/api/v2/fixtures/index.json index ef8137b7ad..1fd3982a21 100644 --- a/contracts/api/v2/fixtures/index.json +++ b/contracts/api/v2/fixtures/index.json @@ -3873,10 +3873,34 @@ "schema": "#/components/schemas/AdminUserPolicyDefaults", "body_file": "admin_user_policy_defaults.json" }, + { + "name": "android_push_registration_ok", + "operation_id": "registerPushDevice", + "scenario": "An Android installation registers for private push; the receipt names the server so the app can route that server's pushes by server and profile.", + "request": { + "method": "POST", + "path": "/api/v2/notifications/push/devices", + "headers": { + "Authorization": "Bearer tok-events", + "X-Profile-Id": "p-owner", + "X-Push-Generation": "3", + "X-Push-Installation-Key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s" + }, + "body": "{\"device_id\":\"install\",\"platform\":\"android\",\"token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"push_mode\":\"private_push\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AndroidPushRegistrationReceipt", + "body_file": "android_push_registration_ok.json" + }, { "name": "apple_push_registration_capability", "operation_id": "getApplePushRegistrationCapabilities", - "scenario": "Local ordered Apple registration is unavailable when the service is absent; this does not describe provider delivery.", + "scenario": "Local ordered Apple registration and current-login validation are configured; this does not describe provider delivery.", "request": { "method": "GET", "path": "/api/v2/devices/push/apple/capabilities", @@ -3894,6 +3918,30 @@ "schema": "#/components/schemas/ApplePushRegistrationCapability", "body_file": "apple_push_registration_capability.json" }, + { + "name": "apple_push_registration_ok", + "operation_id": "registerApplePushDevice", + "scenario": "An iPhone registers for private push and receives a display credential; the receipt names the server so the app can key that credential by server and profile.", + "request": { + "method": "POST", + "path": "/api/v2/devices/push/apple", + "headers": { + "Authorization": "Bearer tok-events", + "X-Profile-Id": "p-owner", + "X-Push-Generation": "3", + "X-Push-Installation-Key": "a2tra2tra2tra2tra2tra2tra2tra2tra2tra2tra2s" + }, + "body": "{\"device_id\":\"install\",\"apns_token\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"apns_environment\":\"production\",\"apns_topic\":\"org.siloserver.silo\",\"push_mode\":\"private_push\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/ApplePushRegistrationReceipt", + "body_file": "apple_push_registration_ok.json" + }, { "name": "approve_device_handoff_ok", "operation_id": "approveDeviceHandoff", diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 452cd33258..5f3dc612ec 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -20326,6 +20326,13 @@ ], "minLength": 1, "type": "string" + }, + "server_id": { + "description": "This deployment's identity, the server_id getServerIdentity reports. Key this registration's push credentials by it and the registering profile: pushes name both. Absent when unavailable.", + "examples": [ + "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" + ], + "type": "string" } }, "required": [ @@ -20457,6 +20464,13 @@ ], "minLength": 1, "type": "string" + }, + "server_id": { + "description": "This deployment's identity, the server_id getServerIdentity reports. Key this registration's push credentials by it and the registering profile: pushes name both. Absent when unavailable.", + "examples": [ + "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" + ], + "type": "string" } }, "required": [ diff --git a/docs/notifications-api.md b/docs/notifications-api.md index ca225d9322..6c34f99fd8 100644 --- a/docs/notifications-api.md +++ b/docs/notifications-api.md @@ -162,6 +162,23 @@ relay, and the relay keeps no device state, so a new deployment needs no client change. This covers a generation superseded by another holder of the same credential (a database cloned to a second server shares it), a signing key the relay retired, and a capability that expired past its renewal grace. + +Every push the server sends, administrator test pushes included, names the +deployment (`server_id`, the identity `getServerIdentity` reports) and the push +device's profile (`profile_id`). The relay forwards them to the device as +`silo_server_id` and `silo_profile_id`, so a device signed into several servers +or profiles fetches display data from the one the push belongs to. The two are +sent together or not at all; without a readable server identity the push goes +out without them. A relay that predates the fields answers 400 +`unexpected_field`. The sender then resends the attempt once without them under +the same idempotency key, and leaves them out of later sends to that relay for an +hour. The fields are part of the relay's idempotency hash, and a retry can +differ from the attempt's first send (an earlier server version, another +replica, or the hour ending). The relay answers such a retry with 422 +`idempotency_key_reuse`, and the sender resends it once with the other choice, +which the relay replays or resumes. When the identity lookup failed, so the +sender has no other choice to offer, that 422 is retried later instead of +failing the push. Only an administrator's clear or a relay `deployment_disabled` response parks delivery behind the re-registration marker; either one stores an empty credential. An older server stored the rejected key along with the marker. That diff --git a/docs/notifications-push-api.md b/docs/notifications-push-api.md index f64b49f996..5259248854 100644 --- a/docs/notifications-push-api.md +++ b/docs/notifications-push-api.md @@ -64,10 +64,20 @@ the display credential: "removed": false, "push_mode": "private_push", "display_token": "", - "display_token_expires_at": "2026-10-03T00:00:00Z" + "display_token_expires_at": "2026-10-03T00:00:00Z", + "server_id": "3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35" } ``` +- `server_id` is the identity `getServerIdentity` reports. Each push from this + server carries it as `silo_server_id`, with the registering profile as + `silo_profile_id`, beside `silo_delivery_id`. A device signed into several + servers or profiles keys each display credential by that pair and fetches + from the server and profile a push names. The receipt omits `server_id` when + the server cannot read its identity, and older servers never send it; pushes + then carry neither key, and the client keeps using its active server and + profile. + - `display_token` is a JWT with `token_type: "apple_push_display"`, bound to the registering user, login session, and profile. - Its lifetime follows the server's refresh-token expiry (30 days by @@ -140,8 +150,9 @@ require these headers in addition to captured login/profile credentials: exact generation, payload and captured authority through uncertainty. Never allocate a new generation merely to retry a request. -POST returns `200` with string `generation`, `registration_id`, `server_device_id` -and the accepted `push_mode`. This acknowledges registration storage, not provider +POST returns `200` with string `generation`, `registration_id`, `server_device_id`, +the accepted `push_mode`, and `server_id`, which names the server in this +server's pushes exactly as in the Apple receipt. This acknowledges registration storage, not provider acceptance or successful delivery. DELETE returns bodyless `204`. The latest exact command may replay with the same receipt and no device rewrite, re-enable, last-seen refresh, or provider request. A lower generation or same generation with diff --git a/internal/apiv2/device_login.go b/internal/apiv2/device_login.go index 6628c62678..c81e61394f 100644 --- a/internal/apiv2/device_login.go +++ b/internal/apiv2/device_login.go @@ -406,15 +406,11 @@ func (reg *Registry) getDeviceLogin(ctx context.Context, in *GetDeviceLoginInput ClientPurpose: info.ClientPurpose, Temporary: info.Temporary, ServerName: reg.serverDisplayName(ctx), + ServerID: reg.knownServerID(ctx), } if !info.ExpiresAt.IsZero() { out.ExpiresAt = ptr(NewInstant(info.ExpiresAt)) } - if reg.deps.ServerIdentity != nil { - if id, err := reg.deps.ServerIdentity.ServerID(ctx); err == nil { - out.ServerID = id - } - } return &DeviceLoginOutput{Body: out}, nil } diff --git a/internal/apiv2/fixtures_test.go b/internal/apiv2/fixtures_test.go index 3ac9fae35f..1157b800e0 100644 --- a/internal/apiv2/fixtures_test.go +++ b/internal/apiv2/fixtures_test.go @@ -1755,7 +1755,8 @@ func fixtureCases() []fixtureCase { cases = append(cases, loginSessionFixtureCases()...) cases = append(cases, adminDownloadStorageFixtureCases()...) cases = append(cases, homeLayoutItemsFixtureCases()...) - return append(cases, catalogLettersFixtureCases()...) + cases = append(cases, catalogLettersFixtureCases()...) + return append(cases, pushRegistrationReceiptFixtureCases()...) } // homeLayoutItemsFixtureCases pin the home layout carrying the cards of its @@ -1922,6 +1923,8 @@ func fixtureDeps() Dependencies { deps := pilotDeps(&fakeProgress{entries: progressRows()}, nil) deps.NetworkAccess = newFakeNetworkAccess() deps.ServerIdentity = fakeServerIdentity{id: fixtureServerID} + deps.OrderedApplePush = new(fakeOrderedApple) + deps.OrderedAndroidPush = new(fakeOrderedPush) fixtureProviders := netaccess.NewStatusCache() fixtureProviders.Report(netaccess.Status{InstallationID: 7, Provider: "stub", State: netaccess.StateConnected, Origin: "https://silo.overlay.example.test"}) deps.ServerConnections = ServerConnections{PublicURL: func() string { return "https://silo.example.test" }, Providers: fixtureProviders} diff --git a/internal/apiv2/notification_android_push.go b/internal/apiv2/notification_android_push.go index a1a82acf08..2074a23492 100644 --- a/internal/apiv2/notification_android_push.go +++ b/internal/apiv2/notification_android_push.go @@ -36,6 +36,7 @@ type AndroidPushRegistrationReceipt struct { RegistrationID ID `json:"registration_id"` ServerDeviceID ID `json:"server_device_id"` PushMode string `json:"push_mode"` + ServerID string `json:"server_id,omitempty" doc:"This deployment's identity, the server_id getServerIdentity reports. Key this registration's push credentials by it and the registering profile: pushes name both. Absent when unavailable." example:"3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35"` } type AndroidPushRegistrationOutput struct { Body AndroidPushRegistrationReceipt @@ -101,7 +102,7 @@ func registerOrderedAndroidPush(reg *Registry) { if err != nil { return nil, orderedPushProblem(err) } - return &AndroidPushRegistrationOutput{Body: AndroidPushRegistrationReceipt{Generation: ID(strconv.FormatInt(result.Generation, 10)), RegistrationID: ID(result.RegistrationID), ServerDeviceID: ID(result.ServerDeviceID), PushMode: result.PushMode}}, nil + return &AndroidPushRegistrationOutput{Body: AndroidPushRegistrationReceipt{Generation: ID(strconv.FormatInt(result.Generation, 10)), RegistrationID: ID(result.RegistrationID), ServerDeviceID: ID(result.ServerDeviceID), PushMode: result.PushMode, ServerID: reg.knownServerID(ctx)}}, nil }) remove := notificationOperation(http.MethodDelete, "/push/devices/{device_id}", "unregisterPushDevice") remove.Summary = "Apply an ordered removal for the installation's current account/profile. Tombstones prevent stale registration replay." diff --git a/internal/apiv2/notification_android_push_test.go b/internal/apiv2/notification_android_push_test.go index b522ca4713..e738676f34 100644 --- a/internal/apiv2/notification_android_push_test.go +++ b/internal/apiv2/notification_android_push_test.go @@ -27,6 +27,7 @@ func TestOrderedAndroidPushTransport(t *testing.T) { f := new(fakeOrderedPush) deps := pilotDeps(nil, nil) deps.OrderedAndroidPush = f + deps.ServerIdentity = fakeServerIdentity{id: fixtureServerID} h := NewHandler(deps) headers := profileOwner() headers["X-Push-Installation-Key"] = base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("k", 32))) @@ -40,7 +41,7 @@ func TestOrderedAndroidPushTransport(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &receipt); err != nil { t.Fatal(err) } - if receipt.Generation != "9007199254740993" || f.cmd.UserID != 1 || f.cmd.ProfileID != "p-owner" || f.cmd.DeviceID != "install" || f.cmd.Remove { + if receipt.Generation != "9007199254740993" || receipt.ServerID != fixtureServerID || f.cmd.UserID != 1 || f.cmd.ProfileID != "p-owner" || f.cmd.DeviceID != "install" || f.cmd.Remove { t.Fatalf("receipt=%+v", receipt) } if strings.Contains(rec.Body.String(), headers["X-Push-Installation-Key"]) || strings.Contains(rec.Body.String(), strings.Repeat("a", 64)) { diff --git a/internal/apiv2/notification_apple_push.go b/internal/apiv2/notification_apple_push.go index bee1379e41..4ec666c72b 100644 --- a/internal/apiv2/notification_apple_push.go +++ b/internal/apiv2/notification_apple_push.go @@ -36,6 +36,7 @@ type ApplePushRegistrationReceipt struct { Removed bool `json:"removed" doc:"The retained intent references a deleted registration. Exact replay never resurrects it."` DisplayToken string `json:"display_token,omitempty"` DisplayTokenExpiresAt string `json:"display_token_expires_at,omitempty" format:"date-time"` + ServerID string `json:"server_id,omitempty" doc:"This deployment's identity, the server_id getServerIdentity reports. Key this registration's push credentials by it and the registering profile: pushes name both. Absent when unavailable." example:"3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35"` } type ApplePushRegistrationOutput struct{ Body ApplePushRegistrationReceipt } type ApplePushRegistrationCapability struct { @@ -92,7 +93,7 @@ func registerOrderedApplePush(reg *Registry) { if err != nil { return nil, applePushProblem(err) } - out := ApplePushRegistrationReceipt{Generation: ID(strconv.FormatInt(receipt.Generation, 10)), ID: ID(receipt.RegistrationID), ServerDeviceID: ID(receipt.ServerDeviceID), PushMode: receipt.PushMode, Enabled: receipt.Enabled, Removed: receipt.Removed, DisplayToken: token} + out := ApplePushRegistrationReceipt{Generation: ID(strconv.FormatInt(receipt.Generation, 10)), ID: ID(receipt.RegistrationID), ServerDeviceID: ID(receipt.ServerDeviceID), PushMode: receipt.PushMode, Enabled: receipt.Enabled, Removed: receipt.Removed, DisplayToken: token, ServerID: reg.knownServerID(ctx)} if token != "" { out.DisplayTokenExpiresAt = expiry.UTC().Format(time.RFC3339Nano) } diff --git a/internal/apiv2/notification_apple_push_fixtures_test.go b/internal/apiv2/notification_apple_push_fixtures_test.go index 9998485142..9028ad4d34 100644 --- a/internal/apiv2/notification_apple_push_fixtures_test.go +++ b/internal/apiv2/notification_apple_push_fixtures_test.go @@ -1,7 +1,31 @@ package apiv2 +import ( + "encoding/base64" + "net/http" + "strings" +) + +// orderedPushFixtureHeaders are a current login session acting as its owner +// profile, plus a synthetic installation credential and generation. +func orderedPushFixtureHeaders() map[string]string { + headers := with(bearer("tok-events"), "X-Profile-Id", "p-owner") + headers["X-Push-Installation-Key"] = base64.RawURLEncoding.EncodeToString([]byte(strings.Repeat("k", 32))) + headers["X-Push-Generation"] = "3" + return headers +} + func applePushFixtureCases() []fixtureCase { return []fixtureCase{ - {name: "apple_push_registration_capability", operationID: "getApplePushRegistrationCapabilities", scenario: "Local ordered Apple registration is unavailable when the service is absent; this does not describe provider delivery.", method: "GET", path: Prefix + "/devices/push/apple/capabilities", headers: profileOwner(), status: 200, schema: "#/components/schemas/ApplePushRegistrationCapability", assertHeaders: []string{"Content-Type", "Cache-Control"}}, + {name: "apple_push_registration_capability", operationID: "getApplePushRegistrationCapabilities", scenario: "Local ordered Apple registration and current-login validation are configured; this does not describe provider delivery.", method: "GET", path: Prefix + "/devices/push/apple/capabilities", headers: profileOwner(), status: 200, schema: "#/components/schemas/ApplePushRegistrationCapability", assertHeaders: []string{"Content-Type", "Cache-Control"}}, + } +} + +// pushRegistrationReceiptFixtureCases are appended last so the request ids of +// earlier fixtures stay put. +func pushRegistrationReceiptFixtureCases() []fixtureCase { + return []fixtureCase{ + {name: "apple_push_registration_ok", operationID: "registerApplePushDevice", scenario: "An iPhone registers for private push and receives a display credential; the receipt names the server so the app can key that credential by server and profile.", method: http.MethodPost, path: Prefix + "/devices/push/apple", headers: orderedPushFixtureHeaders(), body: `{"device_id":"install","apns_token":"` + strings.Repeat("a", 64) + `","apns_environment":"production","apns_topic":"org.siloserver.silo","push_mode":"private_push"}`, status: 200, schema: "#/components/schemas/ApplePushRegistrationReceipt", assertHeaders: []string{"Content-Type", "Cache-Control"}}, + {name: "android_push_registration_ok", operationID: "registerPushDevice", scenario: "An Android installation registers for private push; the receipt names the server so the app can route that server's pushes by server and profile.", method: http.MethodPost, path: Prefix + "/notifications/push/devices", headers: orderedPushFixtureHeaders(), body: `{"device_id":"install","platform":"android","token":"` + strings.Repeat("a", 64) + `","push_mode":"private_push"}`, status: 200, schema: "#/components/schemas/AndroidPushRegistrationReceipt", assertHeaders: []string{"Content-Type", "Cache-Control"}}, } } diff --git a/internal/apiv2/notification_apple_push_test.go b/internal/apiv2/notification_apple_push_test.go index 07de8f289d..08b3ca21bf 100644 --- a/internal/apiv2/notification_apple_push_test.go +++ b/internal/apiv2/notification_apple_push_test.go @@ -27,12 +27,13 @@ func (f *fakeOrderedApple) RegisterApplePush(_ context.Context, cmd notification f.calls++ f.cmd = cmd f.identity = identity - return notifications.ApplePushReceipt{Generation: cmd.Generation, RegistrationID: "registration", ServerDeviceID: "server-device", Enabled: true, PushMode: cmd.PushMode}, "synthetic-display", time.Now().Add(time.Hour), f.err + return notifications.ApplePushReceipt{Generation: cmd.Generation, RegistrationID: "registration", ServerDeviceID: "server-device", Enabled: true, PushMode: cmd.PushMode}, "synthetic-display", fixedTime().Add(30 * 24 * time.Hour), f.err } func TestOrderedApplePushTransport(t *testing.T) { f := new(fakeOrderedApple) deps := pilotDeps(nil, nil) deps.OrderedApplePush = f + deps.ServerIdentity = fakeServerIdentity{id: fixtureServerID} claims := &auth.Claims{UserID: 1, Role: "user", SessionID: "session", TokenType: auth.TokenTypeAccess, RegisteredClaims: jwt.RegisteredClaims{ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Minute))}} deps.Auth = apimw.NewAuthMiddleware(fakeTokens{map[string]*auth.Claims{memberToken: claims}}, fakeSessions{map[string]string{"session": "user"}}, nil, nil) h := NewHandler(deps) @@ -42,7 +43,7 @@ func TestOrderedApplePushTransport(t *testing.T) { body := `{"device_id":"install","apns_token":"` + strings.Repeat("a", 64) + `","apns_environment":"production","apns_topic":"org.siloserver.silo","push_mode":"private_push"}` path := Prefix + "/devices/push/apple" r := do(t, h, http.MethodPost, path, body, headers) - if r.Code != 200 || !strings.Contains(r.Body.String(), `"generation":"9007199254740993"`) || !strings.Contains(r.Body.String(), `"display_token":"synthetic-display"`) || f.identity.SessionID != "session" || f.cmd.ProfileID != "p-owner" || f.cmd.UserID != 1 { + if r.Code != 200 || !strings.Contains(r.Body.String(), `"generation":"9007199254740993"`) || !strings.Contains(r.Body.String(), `"display_token":"synthetic-display"`) || !strings.Contains(r.Body.String(), `"server_id":"`+fixtureServerID+`"`) || f.identity.SessionID != "session" || f.cmd.ProfileID != "p-owner" || f.cmd.UserID != 1 { t.Fatalf("%d %s %+v", r.Code, r.Body.String(), f) } if r.Header().Get("Cache-Control") != "no-store" || strings.Contains(r.Body.String(), headers["X-Push-Installation-Key"]) || strings.Contains(r.Body.String(), strings.Repeat("a", 64)) { diff --git a/internal/apiv2/system_identity.go b/internal/apiv2/system_identity.go index 82118097e8..b7a3bd245b 100644 --- a/internal/apiv2/system_identity.go +++ b/internal/apiv2/system_identity.go @@ -171,6 +171,19 @@ func (reg *Registry) serverID(ctx context.Context) (string, error) { return id, nil } +// knownServerID is the identity for a response that names it as a courtesy +// and must not fail over it: empty when the identity cannot be read. +func (reg *Registry) knownServerID(ctx context.Context) string { + if reg.deps.ServerIdentity == nil { + return "" + } + id, err := reg.deps.ServerIdentity.ServerID(ctx) + if err != nil { + return "" + } + return id +} + func (reg *Registry) getServerIdentity(ctx context.Context, _ *struct{}) (*ServerIdentityOutput, error) { id, err := reg.serverID(ctx) if err != nil { diff --git a/internal/notifications/push_sender.go b/internal/notifications/push_sender.go index a779bcb9b2..c0c33addd4 100644 --- a/internal/notifications/push_sender.go +++ b/internal/notifications/push_sender.go @@ -35,6 +35,15 @@ const ( pushRelayMaxRetryAfter = 23 * time.Hour relayAppleSendPath = "/v1/apple/send" relayFcmSendPath = "/v1/fcm/send" + relayModePrivateAlert = "private_alert" + + // relayCodeIdempotencyKeyReuse is the relay's 422 code for a retry whose + // payload differs from the one recorded under its idempotency key. + relayCodeIdempotencyKeyReuse = "idempotency_key_reuse" + + // relayTargetRecheck is how long sends to a relay that rejected the push + // target fields omit them before trying again. + relayTargetRecheck = time.Hour // relayReplacementCooldown bounds automatic re-registration after the // relay rejects a capability. @@ -94,6 +103,15 @@ func terminalDeviceRejection(platform string, status int, code, message string) return terminalAPNsDeviceRejection(status, code, message) } +// pushRelayTarget names the deployment and profile a push belongs to, so a +// device signed into several servers or profiles fetches its display data +// from the right one. Both are omitted when the server identity cannot be +// read, and for a relay that predates them. +type pushRelayTarget struct { + ServerID string `json:"server_id,omitempty"` + ProfileID string `json:"profile_id,omitempty"` +} + type pushRelayAppleRequest struct { Token string `json:"token"` Environment string `json:"environment"` @@ -102,6 +120,7 @@ type pushRelayAppleRequest struct { ServerDeviceID string `json:"server_device_id"` DeliveryID string `json:"delivery_id"` CollapseID *string `json:"collapse_id,omitempty"` + pushRelayTarget } type pushRelayAppleResponse struct { @@ -118,6 +137,7 @@ type pushRelayFcmRequest struct { ServerDeviceID string `json:"server_device_id"` DeliveryID string `json:"delivery_id"` CollapseID *string `json:"collapse_id,omitempty"` + pushRelayTarget } type pushRelayErrorResponse struct { @@ -147,11 +167,18 @@ type pushSender struct { logger *slog.Logger developmentRelayURL string now func() time.Time + // serverID reads the deployment identity getServerIdentity reports. + serverID func(context.Context) (string, error) // renewMu serializes credential renewal and replacement in this process; // lastRelayReplacement is guarded by it. renewMu sync.Mutex lastRelayReplacement time.Time + + // targetMu guards relayWithoutTarget: per relay base URL, the time until + // which sends omit the push target fields that relay rejected. + targetMu sync.Mutex + relayWithoutTarget map[string]time.Time } func newPushSender(devices *PushDeviceRepository, deliveries *DeliveryRepository, cipher *secret.Cipher, settings *Settings) *pushSender { @@ -168,6 +195,7 @@ func newPushSender(devices *PushDeviceRepository, deliveries *DeliveryRepository logger: slog.Default().With("component", "notifications.apple_push"), developmentRelayURL: os.Getenv("SILO_PUSH_RELAY_DEVELOPMENT_URL"), now: time.Now, + relayWithoutTarget: map[string]time.Time{}, } } @@ -262,20 +290,55 @@ func (s *pushSender) send(ctx context.Context, attempt PushDeliveryAttempt, devi if err != nil { return pushSendResult{HTTPStatus: http.StatusServiceUnavailable, Message: err.Error(), UpstreamReason: "relay_credential_unavailable"} } - result = s.sendWithCapability(ctx, attempt, device, token, credential.RelayURL, credential.APIKey) - if result.HTTPStatus != http.StatusUnauthorized { - return result + target, identityKnown := s.relayTarget(ctx, device) + result = s.sendWithCapability(ctx, attempt, device, token, target, credential.RelayURL, credential.APIKey) + if result.HTTPStatus == http.StatusUnauthorized { + recovered, err := s.recoverRelayCredential(ctx, credential, result.UpstreamReason) + if err != nil { + if result.UpstreamReason == relayCodeDeploymentDisabled { + return result + } + return pushSendResult{HTTPStatus: http.StatusServiceUnavailable, Message: err.Error(), UpstreamReason: "relay_credential_unavailable"} + } + result = s.sendWithCapability(ctx, attempt, device, token, target, recovered.RelayURL, recovered.APIKey) + } + if !identityKnown && relayRecordedOtherTarget(result) { + // The relay may have recorded this attempt with the target the failed + // identity lookup could not name, so a later retry can still match it. + return pushSendResult{HTTPStatus: http.StatusServiceUnavailable, Message: "server identity unavailable", UpstreamReason: "server_identity_unavailable"} + } + return result +} + +// relayTarget names this deployment and the device's profile. A delivery's +// profile is the device's: processAttempt refuses a reassigned device. The +// target is all or nothing, and each part must meet the relay's 1-128 +// character rule: the relay answers an empty or oversized one with 400 +// invalid_field, which would fail every push. Without a target the push still +// goes out and the device falls back to its active server and profile, as it +// does for an older server. identityKnown is false only when the identity +// lookup failed, so a later send may name a target this one could not. +func (s *pushSender) relayTarget(ctx context.Context, device *PushDevice) (target pushRelayTarget, identityKnown bool) { + if s.serverID == nil { + return pushRelayTarget{}, true } - recovered, err := s.recoverRelayCredential(ctx, credential, result.UpstreamReason) + serverID, err := s.serverID(ctx) if err != nil { - if result.UpstreamReason == relayCodeDeploymentDisabled { - return result + if ctx.Err() == nil { + s.logger.WarnContext(ctx, "push sent without its server identity", "error", err) } - return pushSendResult{HTTPStatus: http.StatusServiceUnavailable, Message: err.Error(), UpstreamReason: "relay_credential_unavailable"} + return pushRelayTarget{}, false } - return s.sendWithCapability(ctx, attempt, device, token, recovered.RelayURL, recovered.APIKey) + if !relayTargetID(serverID) || !relayTargetID(device.ProfileID) { + return pushRelayTarget{}, true + } + return pushRelayTarget{ServerID: serverID, ProfileID: device.ProfileID}, true } +// relayTargetID applies the relay's opaque-ID length rule in bytes, which is +// never fewer than the UTF-16 units the relay counts. +func relayTargetID(id string) bool { return id != "" && len(id) <= 128 } + func (s *pushSender) prepareRelayCredential(ctx context.Context) (PushRelayCredential, error) { s.renewMu.Lock() defer s.renewMu.Unlock() @@ -449,7 +512,74 @@ func relayCredentialRejection(err error) (string, bool) { return relayErr.Code, true } -func (s *pushSender) sendWithCapability(ctx context.Context, attempt PushDeliveryAttempt, device *PushDevice, token, relayURL, apiKey string) pushSendResult { +// sendWithCapability posts one attempt to the relay. A relay that predates +// the push target fields rejects them before it records the idempotency key, +// so the attempt is resent once without them under the same key, and later +// sends to that relay omit them for relayTargetRecheck. +// +// Only that rejection (400 unexpected_field naming a target field) may mark a +// relay as lacking them. +// +// The fields are part of the relay's idempotency hash, and nothing records +// which choice an attempt was first sent with: an earlier server version, +// another replica, or a send inside relayTargetRecheck may have made the +// other one. The relay answers a retry that differs with 422 +// idempotency_key_reuse, so the attempt is resent once with the other choice, +// which the relay replays or resumes instead of delivering again. +func (s *pushSender) sendWithCapability(ctx context.Context, attempt PushDeliveryAttempt, device *PushDevice, token string, target pushRelayTarget, relayURL, apiKey string) pushSendResult { + sent := target + if sent != (pushRelayTarget{}) && s.relayLacksTarget(relayURL) { + sent = pushRelayTarget{} + } + result := s.postRelay(ctx, attempt, device, token, sent, relayURL, apiKey) + switch { + case sent != (pushRelayTarget{}) && relayRejectedTarget(result): + s.markRelayLacksTarget(relayURL) + s.logger.InfoContext(ctx, "push relay does not accept server_id and profile_id yet; sending without them") + return s.postRelay(ctx, attempt, device, token, pushRelayTarget{}, relayURL, apiKey) + case target != (pushRelayTarget{}) && relayRecordedOtherTarget(result): + other := pushRelayTarget{} + if sent == (pushRelayTarget{}) { + other = target + } + // A relay that refuses the fields never recorded them, so its answer + // says nothing about the payload the 422 was about. + if retry := s.postRelay(ctx, attempt, device, token, other, relayURL, apiKey); !relayRejectedTarget(retry) { + return retry + } + } + return result +} + +func (s *pushSender) markRelayLacksTarget(relayURL string) { + s.targetMu.Lock() + defer s.targetMu.Unlock() + s.relayWithoutTarget[relayURL] = s.now().Add(relayTargetRecheck) +} + +func (s *pushSender) relayLacksTarget(relayURL string) bool { + s.targetMu.Lock() + defer s.targetMu.Unlock() + return s.now().Before(s.relayWithoutTarget[relayURL]) +} + +// relayRejectedTarget recognizes an older relay's strict-field rejection of +// a push target field. Every other 400 keeps its ordinary handling. +func relayRejectedTarget(result pushSendResult) bool { + if result.HTTPStatus != http.StatusBadRequest || result.UpstreamReason != "unexpected_field" { + return false + } + field := strings.TrimSpace(strings.TrimPrefix(result.Message, "unexpected field:")) + return field == "server_id" || field == "profile_id" +} + +// relayRecordedOtherTarget recognizes the relay refusing a retry whose payload +// differs from the one it recorded under the attempt's idempotency key. +func relayRecordedOtherTarget(result pushSendResult) bool { + return result.HTTPStatus == http.StatusUnprocessableEntity && result.UpstreamReason == relayCodeIdempotencyKeyReuse +} + +func (s *pushSender) postRelay(ctx context.Context, attempt PushDeliveryAttempt, device *PushDevice, token string, target pushRelayTarget, relayURL, apiKey string) pushSendResult { deliveryID := attempt.ID if attempt.NotificationDeliveryID != nil { deliveryID = *attempt.NotificationDeliveryID @@ -457,22 +587,24 @@ func (s *pushSender) sendWithCapability(ctx context.Context, attempt PushDeliver collapseID := deliveryID sendPath := relayAppleSendPath var payload any = pushRelayAppleRequest{ - Token: token, - Environment: device.APNsEnvironment, - Topic: device.APNsTopic, - Mode: "private_alert", - ServerDeviceID: device.ServerDeviceID, - DeliveryID: deliveryID, - CollapseID: &collapseID, + Token: token, + Environment: device.APNsEnvironment, + Topic: device.APNsTopic, + Mode: relayModePrivateAlert, + ServerDeviceID: device.ServerDeviceID, + DeliveryID: deliveryID, + CollapseID: &collapseID, + pushRelayTarget: target, } if device.Platform == PushPlatformAndroid { sendPath = relayFcmSendPath payload = pushRelayFcmRequest{ - Token: token, - Mode: "private_alert", - ServerDeviceID: device.ServerDeviceID, - DeliveryID: deliveryID, - CollapseID: &collapseID, + Token: token, + Mode: relayModePrivateAlert, + ServerDeviceID: device.ServerDeviceID, + DeliveryID: deliveryID, + CollapseID: &collapseID, + pushRelayTarget: target, } } body, err := json.Marshal(payload) diff --git a/internal/notifications/push_sender_test.go b/internal/notifications/push_sender_test.go index 66edcccdca..3977a2083f 100644 --- a/internal/notifications/push_sender_test.go +++ b/internal/notifications/push_sender_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "slices" "strings" "testing" "time" @@ -249,6 +250,180 @@ func TestPushSenderSendMapsRelayTerminalFCMRejection(t *testing.T) { } } +// Every push names the deployment and profile it belongs to, so a device +// signed into several servers or profiles fetches display data from the right +// one. The relay rejects an empty field, so a target it cannot complete is +// left out whole. +func TestPushSenderNamesTheServerAndProfileOfEachPush(t *testing.T) { + deliveryID := "delivery-1" + identity := func(context.Context) (string, error) { return "server-1", nil } + for _, tc := range []struct { + name string + attempt PushDeliveryAttempt + device PushDevice + serverID func(context.Context) (string, error) + path string + named bool + }{ + {name: "apple delivery", attempt: PushDeliveryAttempt{ID: "attempt-1", NotificationDeliveryID: &deliveryID}, + device: PushDevice{ProfileID: "profile-1", APNsEnvironment: APNsEnvironmentSandbox, APNsTopic: ApplePushTopicSilo, ServerDeviceID: "server-device-1"}, + serverID: identity, path: relayAppleSendPath, named: true}, + {name: "android test push", attempt: PushDeliveryAttempt{ID: "attempt-2"}, + device: PushDevice{Platform: PushPlatformAndroid, ProfileID: "profile-1", ServerDeviceID: "server-device-2"}, + serverID: identity, path: relayFcmSendPath, named: true}, + {name: "identity unreadable", attempt: PushDeliveryAttempt{ID: "attempt-3"}, + device: PushDevice{Platform: PushPlatformAndroid, ProfileID: "profile-1", ServerDeviceID: "server-device-3"}, + serverID: func(context.Context) (string, error) { return "", errors.New("settings store down") }, path: relayFcmSendPath}, + {name: "profile missing", attempt: PushDeliveryAttempt{ID: "attempt-4"}, + device: PushDevice{APNsEnvironment: APNsEnvironmentSandbox, APNsTopic: ApplePushTopicSilo, ServerDeviceID: "server-device-4"}, + serverID: identity, path: relayAppleSendPath}, + } { + t.Run(tc.name, func(t *testing.T) { + var body map[string]any + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tc.path { + t.Errorf("path = %q, want %q", r.URL.Path, tc.path) + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + } + _, _ = w.Write([]byte(`{"request_id":"relay-request","status":"accepted"}`)) + })) + defer server.Close() + sender := newPushSender(nil, nil, nil, NewSettings(mapSettingReader{ + SettingPushRelayURL: server.URL, + SettingPushRelayAPIKey: "relay-key", + })) + sender.client = server.Client() + sender.developmentRelayURL = server.URL + sender.serverID = tc.serverID + + result := sender.send(context.Background(), tc.attempt, &tc.device, strings.Repeat("a", 64)) + if !result.OK { + t.Fatalf("result = %+v", result) + } + serverID, hasServer := body["server_id"] + profileID, hasProfile := body["profile_id"] + if tc.named && (serverID != "server-1" || profileID != "profile-1") { + t.Fatalf("relay body = %v, want server-1 and profile-1", body) + } + if !tc.named && (hasServer || hasProfile) { + t.Fatalf("relay body = %v, want neither server_id nor profile_id", body) + } + }) + } +} + +// A relay that predates server_id and profile_id rejects them as unexpected +// before recording the idempotency key. The attempt is resent without them +// under the same key, and later sends to that relay leave them out until the +// recheck window passes. Any other unexpected field keeps its ordinary failure. +func TestPushSenderResendsWithoutTargetToARelayThatPredatesIt(t *testing.T) { + type sent struct { + key string + targeted bool + } + var requests []sent + rejectField := "server_id" + now := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC) + sender := newPushSender(nil, nil, nil, NewSettings(mapSettingStore{})) + sender.now = func() time.Time { return now } + sender.client = &http.Client{Transport: relayRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + } + _, targeted := body["server_id"] + requests = append(requests, sent{key: req.Header.Get("Idempotency-Key"), targeted: targeted}) + if targeted { + return relayResponse(http.StatusBadRequest, `{"error":{"code":"unexpected_field","message":"unexpected field: `+rejectField+`"}}`), nil + } + return relayResponse(http.StatusOK, `{"request_id":"relay-request","status":"accepted"}`), nil + })} + device := &PushDevice{Platform: PushPlatformAndroid, ProfileID: "profile-1", ServerDeviceID: "server-device-1"} + target := pushRelayTarget{ServerID: "server-1", ProfileID: "profile-1"} + send := func(attemptID string) pushSendResult { + requests = nil + return sender.sendWithCapability(context.Background(), PushDeliveryAttempt{ID: attemptID}, device, strings.Repeat("F", 140), target, DefaultPushRelayURL, "capability") + } + + if result := send("attempt-1"); !result.OK { + t.Fatalf("fallback result = %+v", result) + } + if want := []sent{{"attempt-1", true}, {"attempt-1", false}}; !slices.Equal(requests, want) { + t.Fatalf("first send requests = %+v, want %+v", requests, want) + } + if result := send("attempt-2"); !result.OK || !slices.Equal(requests, []sent{{"attempt-2", false}}) { + t.Fatalf("remembered relay: result = %+v, requests = %+v", result, requests) + } + now = now.Add(relayTargetRecheck) + if result := send("attempt-3"); !result.OK || len(requests) != 2 || !requests[0].targeted { + t.Fatalf("after recheck: result = %+v, requests = %+v", result, requests) + } + + now = now.Add(relayTargetRecheck) + rejectField = "collapse_id" + result := send("attempt-4") + if result.OK || result.HTTPStatus != http.StatusBadRequest || result.UpstreamReason != "unexpected_field" || len(requests) != 1 { + t.Fatalf("other unexpected field: result = %+v, requests = %+v", result, requests) + } +} + +func TestPushSenderRetriesAnAttemptWithTheTargetChoiceTheRelayRecorded(t *testing.T) { + type sent struct { + key string + targeted bool + } + var requests []sent + recorded := map[string]bool{} + now := time.Date(2026, 10, 12, 9, 0, 0, 0, time.UTC) + sender := newPushSender(nil, nil, nil, NewSettings(mapSettingStore{})) + sender.now = func() time.Time { return now } + sender.client = &http.Client{Transport: relayRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Errorf("decode request body: %v", err) + } + key := req.Header.Get("Idempotency-Key") + _, targeted := body["server_id"] + requests = append(requests, sent{key: key, targeted: targeted}) + if first, ok := recorded[key]; ok && first != targeted { + return relayResponse(http.StatusUnprocessableEntity, `{"error":{"code":"idempotency_key_reuse","message":"Idempotency-Key was reused with a different payload"}}`), nil + } + recorded[key] = targeted + return relayResponse(http.StatusOK, `{"request_id":"relay-request","status":"accepted"}`), nil + })} + device := &PushDevice{Platform: PushPlatformApple, ProfileID: "profile-1", ServerDeviceID: "server-device-1"} + target := pushRelayTarget{ServerID: "server-1", ProfileID: "profile-1"} + send := func(attemptID string, target pushRelayTarget) pushSendResult { + requests = nil + return sender.sendWithCapability(context.Background(), PushDeliveryAttempt{ID: attemptID}, device, strings.Repeat("a", 64), target, DefaultPushRelayURL, "capability") + } + + // First sent without the fields, by an earlier server version or while the + // relay was remembered as lacking them; retried with them. + recorded["attempt-1"] = false + if result := send("attempt-1", target); !result.OK || !slices.Equal(requests, []sent{{"attempt-1", true}, {"attempt-1", false}}) { + t.Fatalf("recorded without target: result = %+v, requests = %+v", result, requests) + } + + // First sent with them; retried while this process remembers the relay as + // lacking them. + recorded["attempt-2"] = true + sender.markRelayLacksTarget(DefaultPushRelayURL) + if result := send("attempt-2", target); !result.OK || !slices.Equal(requests, []sent{{"attempt-2", false}, {"attempt-2", true}}) { + t.Fatalf("recorded with target: result = %+v, requests = %+v", result, requests) + } + + // Without a target of its own the server has no other choice to offer. + // send keeps such an attempt retryable when only the identity lookup failed. + recorded["attempt-3"] = true + result := send("attempt-3", pushRelayTarget{}) + if result.OK || result.HTTPStatus != http.StatusUnprocessableEntity || result.UpstreamReason != relayCodeIdempotencyKeyReuse || len(requests) != 1 { + t.Fatalf("no target: result = %+v, requests = %+v", result, requests) + } +} + func TestTerminalFCMDeviceRejectionReasons(t *testing.T) { if !terminalFCMDeviceRejection(http.StatusUnprocessableEntity, "fcm_rejected", "FCM rejected the notification: UNREGISTERED") { @@ -506,8 +681,8 @@ func TestPushSenderMapsRelayIdempotencyStatesWithStableKey(t *testing.T) { APNsTopic: ApplePushTopicSilo, ServerDeviceID: "server-device-stable", } - first := sender.sendWithCapability(context.Background(), attempt, device, strings.Repeat("a", 64), DefaultPushRelayURL, "capability") - second := sender.sendWithCapability(context.Background(), attempt, device, strings.Repeat("a", 64), DefaultPushRelayURL, "capability") + first := sender.sendWithCapability(context.Background(), attempt, device, strings.Repeat("a", 64), pushRelayTarget{}, DefaultPushRelayURL, "capability") + second := sender.sendWithCapability(context.Background(), attempt, device, strings.Repeat("a", 64), pushRelayTarget{}, DefaultPushRelayURL, "capability") if first.HTTPStatus != tc.status || first.UpstreamReason != tc.code { t.Fatalf("result = %+v", first) } @@ -781,3 +956,31 @@ func TestPushSenderKeepsCredentialRegisteredWhileParking(t *testing.T) { t.Fatalf("stored state = %#v, want the administrator's credential kept", store.values) } } + +func TestPushSenderRetriesLaterWhenAFailedIdentityLookupMismatchesTheRecordedAttempt(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"error":{"code":"idempotency_key_reuse","message":"Idempotency-Key was reused with a different payload"}}`)) + })) + defer server.Close() + sender := newPushSender(nil, nil, nil, NewSettings(mapSettingReader{ + SettingPushRelayURL: server.URL, + SettingPushRelayAPIKey: "relay-key", + })) + sender.client = server.Client() + sender.developmentRelayURL = server.URL + device := &PushDevice{Platform: PushPlatformAndroid, ProfileID: "profile-1", ServerDeviceID: "server-device-1"} + send := func() pushSendResult { + return sender.send(context.Background(), PushDeliveryAttempt{ID: "attempt-1"}, device, strings.Repeat("a", 64)) + } + + sender.serverID = func(context.Context) (string, error) { return "", errors.New("settings store down") } + if result := send(); result.HTTPStatus != http.StatusServiceUnavailable || result.UpstreamReason != "server_identity_unavailable" || !retryableHTTPStatus(result.HTTPStatus) { + t.Fatalf("identity lookup failed: result = %+v, want a retryable server_identity_unavailable", result) + } + + sender.serverID = nil + if result := send(); result.HTTPStatus != http.StatusUnprocessableEntity || result.UpstreamReason != relayCodeIdempotencyKeyReuse { + t.Fatalf("no identity configured: result = %+v, want the relay's 422", result) + } +} diff --git a/internal/notifications/system.go b/internal/notifications/system.go index 1bd92e9842..6a0e37d921 100644 --- a/internal/notifications/system.go +++ b/internal/notifications/system.go @@ -10,11 +10,13 @@ import ( "sync" "time" + "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/discord" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/mail" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/secret" + "github.com/Silo-Server/silo-server/internal/serveridentity" "github.com/Silo-Server/silo-server/internal/userstore" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -151,6 +153,10 @@ func NewSystem( pushDeviceRepo = NewPushDeviceRepository(pool) pushDeviceService = NewPushDeviceService(pushDeviceRepo, cipher) pushSenderInst = newPushSender(pushDeviceRepo, deliveries, cipher, settings) + // Pushes name the identity getServerIdentity reports, read through the + // raw settings repo as it is: a SECRET_KEY rotation must not change + // who the server is. + pushSenderInst.serverID = serveridentity.New(catalog.NewServerSettingsRepo(pool)).ServerID pushDispatcher = newPushDispatcher(pushSenderInst) dispatchers = append(dispatchers, pushDispatcher) } diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index 03143dfb2c..84149b5703 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -19672,6 +19672,11 @@ export interface components { * @example 1 */ server_device_id: string; + /** + * @description This deployment's identity, the server_id getServerIdentity reports. Key this registration's push credentials by it and the registering profile: pushes name both. Absent when unavailable. + * @example 3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35 + */ + server_id?: string; }; APIKeyScope: { description: string; @@ -19723,6 +19728,11 @@ export interface components { * @example 1 */ server_device_id: string; + /** + * @description This deployment's identity, the server_id getServerIdentity reports. Key this registration's push credentials by it and the registering profile: pushes name both. Absent when unavailable. + * @example 3f2a9d5e-6b1c-4c7e-9a0d-2f4b8c1e7a35 + */ + server_id?: string; }; AppliedQuirkV3: { action: string; From 4761680735315371c807415ccfa7dcb521163a22 Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:40:14 +1100 Subject: [PATCH 002/196] fix(api): stop counting and logging cancelled requests as server failures (#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 --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- internal/activitylog/middleware.go | 19 ++- .../activitylog/middleware_status_test.go | 52 ++++++++ internal/api/handlers/items.go | 5 +- .../api/handlers/libraries_canceled_test.go | 45 +++++++ internal/api/handlers/libraries_service.go | 5 + internal/api/handlers/marker_population.go | 3 +- internal/api/handlers/sections.go | 3 +- internal/api/middleware/metrics.go | 3 +- internal/api/middleware/request_logger.go | 2 +- internal/apiv2/canceled_response_test.go | 115 ++++++++++++++++++ internal/apiv2/catalog_items.go | 3 +- internal/apiv2/observe.go | 18 ++- internal/catalog/detail.go | 11 +- internal/ctxerr/ctxerr.go | 63 ++++++++++ internal/ctxerr/ctxerr_test.go | 66 ++++++++++ internal/scanner/scanner.go | 4 + internal/sections/fetch_error_log_test.go | 33 +++++ internal/sections/fetcher.go | 4 +- 18 files changed, 436 insertions(+), 18 deletions(-) create mode 100644 internal/activitylog/middleware_status_test.go create mode 100644 internal/api/handlers/libraries_canceled_test.go create mode 100644 internal/apiv2/canceled_response_test.go create mode 100644 internal/ctxerr/ctxerr.go create mode 100644 internal/ctxerr/ctxerr_test.go create mode 100644 internal/sections/fetch_error_log_test.go diff --git a/internal/activitylog/middleware.go b/internal/activitylog/middleware.go index 584e2346bb..e2fe04aea8 100644 --- a/internal/activitylog/middleware.go +++ b/internal/activitylog/middleware.go @@ -3,6 +3,7 @@ package activitylog import ( "bufio" "context" + "errors" "fmt" "io" "net" @@ -152,7 +153,7 @@ func NewFilteredMiddleware(w Writer, nodeID string, skipRoute func(pattern strin Method: r.Method, Path: path, PathPattern: pathPattern, - StatusCode: wrapped.status, + StatusCode: RecordedStatus(r, wrapped.status, wrapped.wroteHeader), UserAgent: r.UserAgent(), DurationMs: int(time.Since(start).Milliseconds()), } @@ -169,6 +170,22 @@ func NewFilteredMiddleware(w Writer, nodeID string, skipRoute func(pattern strin } } +// StatusClientClosedRequest is the status recorded for a request its client +// abandoned, as nginx records it. +const StatusClientClosedRequest = 499 + +// RecordedStatus is the status to record for a request that ended with +// status, where wrote says whether a response was started. A request whose +// client went away and that got no response, or a server error, is recorded +// as StatusClientClosedRequest: the client never saw an answer, and the +// failure is not one the server has to count. +func RecordedStatus(r *http.Request, status int, wrote bool) int { + if errors.Is(r.Context().Err(), context.Canceled) && (!wrote || status >= 500) { + return StatusClientClosedRequest + } + return status +} + // RedactSecretPathParams strips bearer credentials from a request path before // it reaches any log sink. Public webhook endpoints authenticate via a secret // path segment (chi route params named "token" or "secret" — autoscan webhook diff --git a/internal/activitylog/middleware_status_test.go b/internal/activitylog/middleware_status_test.go new file mode 100644 index 0000000000..2329067762 --- /dev/null +++ b/internal/activitylog/middleware_status_test.go @@ -0,0 +1,52 @@ +package activitylog + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// A request its client abandoned is recorded as 499 when it got no response +// or a server error: the client never saw an answer, so it is not a server +// failure. Anything else keeps the status that was written. +func TestMiddlewareRecordsAbandonedRequests(t *testing.T) { + canceled := func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + } + expired := func() context.Context { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + t.Cleanup(cancel) + return ctx + } + for _, tc := range []struct { + name string + ctx func() context.Context + status int // 0 writes nothing + want int + }{ + {"client gone, nothing written", canceled, 0, StatusClientClosedRequest}, + {"client gone, server error", canceled, http.StatusInternalServerError, StatusClientClosedRequest}, + {"client gone, not found", canceled, http.StatusNotFound, http.StatusNotFound}, + {"client gone, success started", canceled, http.StatusOK, http.StatusOK}, + {"client waiting, server error", context.Background, http.StatusInternalServerError, http.StatusInternalServerError}, + {"client waiting, nothing written", context.Background, 0, http.StatusOK}, + {"deadline, server error", expired, http.StatusServiceUnavailable, http.StatusServiceUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + capture := &captureWriter{} + h := NewMiddleware(capture, "node")(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + if tc.status != 0 { + w.WriteHeader(tc.status) + } + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/v2/libraries", nil).WithContext(tc.ctx())) + if len(capture.entries) != 1 || capture.entries[0].StatusCode != tc.want { + t.Fatalf("entries = %+v, want status %d", capture.entries, tc.want) + } + }) + } +} diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index be39554fa3..f3a2eb72e3 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -20,6 +20,7 @@ import ( "github.com/Silo-Server/silo-server/internal/artworkkey" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/ctxerr" evt "github.com/Silo-Server/silo-server/internal/events" "github.com/Silo-Server/silo-server/internal/imagesize" "github.com/Silo-Server/silo-server/internal/metadata" @@ -894,7 +895,7 @@ func (h *ItemsHandler) resolvePlayableTargets(ctx context.Context, v ItemViewer, ProgressStore: store, }) if err != nil { - slog.WarnContext(ctx, "resolving playable poster targets", "component", "api", "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "resolving playable poster targets", "component", "api", "error", err) return map[string]catalog.PlayableTarget{} } return targets @@ -2613,7 +2614,7 @@ func (h *ItemsHandler) canViewFilePaths(ctx context.Context) bool { } user, err := h.UserRepo.GetByID(ctx, claims.UserID) if err != nil { - slog.WarnContext(ctx, "checking file path visibility permissions", "component", "api", "user_id", claims.UserID, "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "checking file path visibility permissions", "component", "api", "user_id", claims.UserID, "error", err) return false } return auth.HasEffectivePermission(user, auth.PermissionMetadataCuration) diff --git a/internal/api/handlers/libraries_canceled_test.go b/internal/api/handlers/libraries_canceled_test.go new file mode 100644 index 0000000000..b8a556e9a4 --- /dev/null +++ b/internal/api/handlers/libraries_canceled_test.go @@ -0,0 +1,45 @@ +package handlers + +import ( + "bytes" + "context" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/catalog" +) + +// A client that leaves while its library list loads is not a failure to log: +// the only error is its own cancellation. A list that fails while the client +// waits still logs ERROR and answers 500. +func TestListLibrariesCanceledRequestIsNotAServerError(t *testing.T) { + pool, err := pgxpool.New(context.Background(), "postgres://silo:silo@127.0.0.1:1/silo?connect_timeout=1") + if err != nil { + t.Fatalf("create unreachable pool: %v", err) + } + t.Cleanup(pool.Close) + h := &LibraryHandler{folderRepo: catalog.NewFolderRepository(pool)} + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previous) }) + const failure = `level=ERROR msg="listing libraries"` + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + h.HandleListLibraries(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/v1/libraries", nil).WithContext(ctx)) + if strings.Contains(logs.String(), failure) { + t.Fatalf("canceled request logged as a failure: %q", logs.String()) + } + + waiting := httptest.NewRecorder() + h.HandleListLibraries(waiting, httptest.NewRequest(http.MethodGet, "/api/v1/libraries", nil)) + if waiting.Code != http.StatusInternalServerError || !strings.Contains(logs.String(), failure) { + t.Fatalf("failed list while the client waits: status=%d logs=%q", waiting.Code, logs.String()) + } +} diff --git a/internal/api/handlers/libraries_service.go b/internal/api/handlers/libraries_service.go index 2c65ff242b..6b082f2801 100644 --- a/internal/api/handlers/libraries_service.go +++ b/internal/api/handlers/libraries_service.go @@ -15,6 +15,7 @@ import ( "github.com/Silo-Server/silo-server/internal/adminjob" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/ctxerr" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/scantrigger" @@ -72,6 +73,10 @@ type UnmatchedItemView = unmatchedItemResponse func (h *LibraryHandler) ListLibraries(ctx context.Context) ([]LibraryView, error) { folders, err := h.folderRepo.List(ctx) if err != nil { + if ctxerr.Abandoned(ctx, err) { + // The client left; nothing failed, so there is nothing to log. + return nil, err + } slog.ErrorContext(ctx, "listing libraries", "component", "api", "error", err) return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to list libraries") } diff --git a/internal/api/handlers/marker_population.go b/internal/api/handlers/marker_population.go index e8a63f50ed..87e439c420 100644 --- a/internal/api/handlers/marker_population.go +++ b/internal/api/handlers/marker_population.go @@ -5,6 +5,7 @@ import ( "log/slog" "time" + "github.com/Silo-Server/silo-server/internal/ctxerr" "github.com/Silo-Server/silo-server/internal/models" ) @@ -22,7 +23,7 @@ func populateFileMarkers(ctx context.Context, population MarkerPopulationService defer cancel() populated, _, err := population.Populate(lookupCtx, file) if err != nil { - slog.WarnContext(ctx, "marker lookup failed", "file_id", file.ID, "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "marker lookup failed", "file_id", file.ID, "error", err) } if populated == nil { return file diff --git a/internal/api/handlers/sections.go b/internal/api/handlers/sections.go index ea6deaa904..9dfc253c3d 100644 --- a/internal/api/handlers/sections.go +++ b/internal/api/handlers/sections.go @@ -17,6 +17,7 @@ import ( apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/ctxerr" "github.com/Silo-Server/silo-server/internal/imagesize" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/sections" @@ -1452,7 +1453,7 @@ func (h *SectionHandler) buildSectionsWithUserStates(ctx context.Context, withIt ProgressStore: h.sectionProgressStore(ctx), }) if err != nil { - slog.WarnContext(ctx, "resolving section playable targets", "component", "api", "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "resolving section playable targets", "component", "api", "error", err) return } playTargets = resolvedTargets diff --git a/internal/api/middleware/metrics.go b/internal/api/middleware/metrics.go index cca7063c4a..63d18ad180 100644 --- a/internal/api/middleware/metrics.go +++ b/internal/api/middleware/metrics.go @@ -14,6 +14,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/Silo-Server/silo-server/internal/activitylog" "github.com/Silo-Server/silo-server/internal/httpstream" ) @@ -58,7 +59,7 @@ func Metrics(next http.Handler) http.Handler { } method := metricMethod(r.Method) - httpRequestsTotal.WithLabelValues(method, path, strconv.Itoa(wrapped.status)).Inc() + httpRequestsTotal.WithLabelValues(method, path, strconv.Itoa(activitylog.RecordedStatus(r, wrapped.status, wrapped.written))).Inc() httpRequestDuration.WithLabelValues(method, path).Observe(duration) }) } diff --git a/internal/api/middleware/request_logger.go b/internal/api/middleware/request_logger.go index 8ab3a79657..e1980ab779 100644 --- a/internal/api/middleware/request_logger.go +++ b/internal/api/middleware/request_logger.go @@ -56,7 +56,7 @@ func RequestLogger(nodeID string) func(http.Handler) http.Handler { "method", r.Method, "path", activitylog.RedactSecretPathParams(r, r.URL.Path), "path_pattern", pathPattern, - "status", wrapped.status, + "status", activitylog.RecordedStatus(r, wrapped.status, wrapped.wroteHeader), "duration_ms", time.Since(start).Milliseconds(), "client_ip", clientip.FromContext(r.Context()), "user_agent", r.UserAgent(), diff --git a/internal/apiv2/canceled_response_test.go b/internal/apiv2/canceled_response_test.go new file mode 100644 index 0000000000..8d173765c8 --- /dev/null +++ b/internal/apiv2/canceled_response_test.go @@ -0,0 +1,115 @@ +package apiv2 + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// A server error raised by the client's own cancellation is still written, but +// it is recorded as abandoned, not counted as a 5xx: the client has gone. +// Every other failure is recorded with its status. +func TestCanceledClientServerErrorIsAbandoned(t *testing.T) { + canceled := func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + return ctx + } + expired := func() context.Context { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + t.Cleanup(cancel) + return ctx + } + for _, tc := range []struct { + name string + ctx func() context.Context + problem ProblemType + wantSent int + wantLogged float64 + wantClass string + }{ + {"client gone", canceled, TypeInternalError, http.StatusInternalServerError, 0, "abandoned"}, + {"client gone, unavailable", canceled, TypeDependencyUnavailable, http.StatusServiceUnavailable, 0, "abandoned"}, + {"client gone, body read timeout", canceled, TypeRequestTimeout, http.StatusRequestTimeout, http.StatusRequestTimeout, "4xx"}, + {"client waiting", context.Background, TypeInternalError, http.StatusInternalServerError, http.StatusInternalServerError, "5xx"}, + {"deadline", expired, TypeInternalError, http.StatusInternalServerError, http.StatusInternalServerError, "5xx"}, + } { + t.Run(tc.name, func(t *testing.T) { + logs := captureLogs(t) + h := observe(bufferResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeProblem(w, r, NewProblem(tc.problem, "An unexpected error occurred.")) + }))) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, Prefix+"/libraries", nil).WithContext(tc.ctx())) + if w.Code != tc.wantSent || w.Body.Len() == 0 { + t.Fatalf("sent %d with %d body bytes, want the %d problem", w.Code, w.Body.Len(), tc.wantSent) + } + var line struct { + Msg string `json:"msg"` + Status float64 `json:"status"` + Class string `json:"status_class"` + } + for _, raw := range bytes.Split(logs.Bytes(), []byte("\n")) { + if bytes.Contains(raw, []byte(`"msg":"apiv2 request"`)) { + if err := json.Unmarshal(raw, &line); err != nil { + t.Fatal(err) + } + } + } + if line.Msg != "apiv2 request" { + t.Fatalf("no request log line: %q", logs.String()) + } + if line.Status != tc.wantLogged || line.Class != tc.wantClass { + t.Fatalf("logged status %v class %q, want %v %q", line.Status, line.Class, tc.wantLogged, tc.wantClass) + } + }) + } +} + +// net/http cancels the request context when an HTTP/1.1 client half-closes +// its side, though that client is still reading. Its server error has to +// arrive: a handler that writes nothing gets net/http's empty 200 instead. +func TestHalfClosedClientGetsServerError(t *testing.T) { + handlerDone := make(chan error, 1) + srv := httptest.NewServer(observe(bufferResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + <-r.Context().Done() // the half-close has reached the server + handlerDone <- r.Context().Err() + writeProblem(w, r, NewProblem(TypeInternalError, "An unexpected error occurred.")) + })))) + t.Cleanup(srv.Close) + + conn, err := net.Dial("tcp", srv.Listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + if _, err := io.WriteString(conn, "POST "+Prefix+"/libraries HTTP/1.1\r\nHost: silo\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}"); err != nil { + t.Fatal(err) + } + if err := conn.(*net.TCPConn).CloseWrite(); err != nil { + t.Fatal(err) + } + if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(conn), nil) + if err != nil { + t.Fatalf("reading response: %v", err) + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(resp.Body) + if ctxErr := <-handlerDone; ctxErr != context.Canceled { //nolint:errorlint // net/http cancels with the bare sentinel + t.Fatalf("request context ended with %v, want context.Canceled", ctxErr) + } + if resp.StatusCode != http.StatusInternalServerError || len(body) == 0 { + t.Fatalf("half-closed client got %s with body %q, want the 500 problem", resp.Status, body) + } +} diff --git a/internal/apiv2/catalog_items.go b/internal/apiv2/catalog_items.go index 04f2231556..2ef246dcf2 100644 --- a/internal/apiv2/catalog_items.go +++ b/internal/apiv2/catalog_items.go @@ -15,6 +15,7 @@ import ( "github.com/Silo-Server/silo-server/internal/api/handlers" catalogpkg "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/ctxerr" "github.com/Silo-Server/silo-server/internal/imagesize" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/ratingsources" @@ -1189,7 +1190,7 @@ func (reg *Registry) getCatalogItem(ctx context.Context, in *CatalogItemDeviceIn if reg.deps.ThemeSongs != nil && (detail.Type == themeOwnerMovie || detail.Type == themeOwnerSeries || detail.Type == themeOwnerSeason || detail.Type == themeOwnerEpisode) { themes, err := reg.deps.ThemeSongs.Discover(ctx, in.ID, true, viewer.Access) if err != nil { - slog.WarnContext(ctx, "catalog theme lookup failed", "component", "apiv2", "item_id", in.ID, "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "catalog theme lookup failed", "component", "apiv2", "item_id", in.ID, "error", err) return &CatalogItemDetailOutput{Body: out}, nil } out.Themes = &ThemeSongSet{OwnerID: themes.OwnerID, Items: []ThemeSong{}} diff --git a/internal/apiv2/observe.go b/internal/apiv2/observe.go index eb2b521ded..acee0bcae4 100644 --- a/internal/apiv2/observe.go +++ b/internal/apiv2/observe.go @@ -15,6 +15,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/Silo-Server/silo-server/internal/activitylog" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/clientip" @@ -140,20 +141,29 @@ func observe(next http.Handler) http.Handler { r = r.WithContext(context.WithValue(r.Context(), observationKey{}, o)) sw := &statusRecorder{ResponseWriter: w} next.ServeHTTP(sw, r) + status := sw.status + if activitylog.RecordedStatus(r, status, status != 0) == activitylog.StatusClientClosedRequest { + // The client went away, and its server error is recorded as + // abandoned, as the activity log records it as 499. The error is + // still written: net/http also cancels the context of a client + // that only half-closed, and an unwritten response would reach + // that client as an empty 200. + status = 0 + } if span.IsRecording() { span.SetAttributes(attribute.String("operation", o.operationID), attribute.String("error.type", o.errorCode)) - if sw.status != 0 { - span.SetAttributes(attribute.Int("http.response.status_code", sw.status)) + if status != 0 { + span.SetAttributes(attribute.Int("http.response.status_code", status)) } else if sw.hijacked { span.SetAttributes(attribute.String("http.response.outcome", "hijacked")) } else { span.SetAttributes(attribute.String("http.response.outcome", "abandoned")) } - if sw.status >= 500 { + if status >= 500 { span.SetStatus(codes.Error, "server_error") } } - report(r, o, sw.status, sw.hijacked, sw.bytes, time.Since(start)) + report(r, o, status, sw.hijacked, sw.bytes, time.Since(start)) }) } diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index e3f92514f1..140e275816 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -17,6 +17,7 @@ import ( "github.com/Silo-Server/silo-server/internal/access" "github.com/Silo-Server/silo-server/internal/artworkkey" + "github.com/Silo-Server/silo-server/internal/ctxerr" "github.com/Silo-Server/silo-server/internal/imagesize" "github.com/Silo-Server/silo-server/internal/lang" "github.com/Silo-Server/silo-server/internal/models" @@ -903,7 +904,7 @@ func (s *DetailService) markTrickplay(ctx context.Context, versions []FileVersio } grids, err := availability.TrickplayGrids(ctx, ids) if err != nil { - slog.WarnContext(ctx, "trickplay lookup failed", "component", "catalog", "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "trickplay lookup failed", "component", "catalog", "error", err) return } for i := range versions { @@ -935,7 +936,7 @@ func (s *DetailService) prefetchTrickplay(ctx context.Context, filesByID map[str } grids, err := s.trickplay.TrickplayGrids(ctx, fileIDs) if err != nil { - slog.WarnContext(ctx, "trickplay lookup failed", "component", "catalog", "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "trickplay lookup failed", "component", "catalog", "error", err) grids = nil } return prefetchedTrickplay(grids) @@ -2059,7 +2060,7 @@ func (s *DetailService) fetchItemVideos(ctx context.Context, contentID string, p } else if s.videoRepo != nil { fetched, err := s.videoRepo.GetByContentID(ctx, contentID) if err != nil { - slog.WarnContext(ctx, "failed to fetch item videos", "content_id", contentID, "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "failed to fetch item videos", "content_id", contentID, "error", err) return nil } videos = fetched @@ -2091,7 +2092,7 @@ func (s *DetailService) fetchItemRatingSources(ctx context.Context, contentID st } else if s.ratingSourceRepo != nil { fetched, err := s.ratingSourceRepo.GetByContentID(ctx, contentID) if err != nil { - slog.WarnContext(ctx, "failed to fetch item rating sources", "content_id", contentID, "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "failed to fetch item rating sources", "content_id", contentID, "error", err) return nil } sources = fetched @@ -2119,7 +2120,7 @@ func (s *DetailService) fetchItemExtras(ctx context.Context, contentID string, p } else if s.extraRepo != nil { fetched, err := s.extraRepo.ListWithFilesByParentID(ctx, contentID) if err != nil { - slog.WarnContext(ctx, "failed to fetch item extras", "content_id", contentID, "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelWarn), "failed to fetch item extras", "content_id", contentID, "error", err) return nil } extras = fetched diff --git a/internal/ctxerr/ctxerr.go b/internal/ctxerr/ctxerr.go new file mode 100644 index 0000000000..3de88f3bf8 --- /dev/null +++ b/internal/ctxerr/ctxerr.go @@ -0,0 +1,63 @@ +// Package ctxerr identifies errors that only report that a caller went away, +// so request paths do not count or log them as server failures. +package ctxerr + +import ( + "context" + "errors" + "log/slog" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// IsCanceled reports whether err is a cancellation: context.Canceled, or the +// gRPC Canceled status a plugin call returns when its caller's context ends. +// Either may be wrapped. A deadline is not a cancellation. +func IsCanceled(err error) bool { + if err == nil { + return false + } + return errors.Is(err, context.Canceled) || status.Code(err) == codes.Canceled +} + +// Abandoned reports whether err only says that the caller of ctx went away: +// ctx itself was canceled and every error err carries is a cancellation. A +// real failure that lands after the caller left is not abandoned, even when +// it is joined with the cancellation. +func Abandoned(ctx context.Context, err error) bool { + return errors.Is(ctx.Err(), context.Canceled) && onlyCanceled(err) +} + +// onlyCanceled reports whether err is a cancellation and carries nothing +// else: each error joined into it must be one too. +func onlyCanceled(err error) bool { + switch wrapped := err.(type) { //nolint:errorlint // walks the error tree itself + case interface{ Unwrap() []error }: + found := false + for _, inner := range wrapped.Unwrap() { + if inner == nil { + continue + } + if !onlyCanceled(inner) { + return false + } + found = true + } + return found + case interface{ Unwrap() error }: + if inner := wrapped.Unwrap(); inner != nil { + return onlyCanceled(inner) + } + } + return IsCanceled(err) +} + +// LogLevel is level, or Debug when err only says that the caller of ctx went +// away. +func LogLevel(ctx context.Context, err error, level slog.Level) slog.Level { + if Abandoned(ctx, err) { + return slog.LevelDebug + } + return level +} diff --git a/internal/ctxerr/ctxerr_test.go b/internal/ctxerr/ctxerr_test.go new file mode 100644 index 0000000000..c443db42a5 --- /dev/null +++ b/internal/ctxerr/ctxerr_test.go @@ -0,0 +1,66 @@ +package ctxerr + +import ( + "context" + "errors" + "fmt" + "log/slog" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestIsCanceled(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"canceled", context.Canceled, true}, + {"wrapped canceled", fmt.Errorf("loading paths for list: %w", context.Canceled), true}, + {"joined canceled", errors.Join(errors.New("other"), context.Canceled), true}, + {"grpc canceled", status.Error(codes.Canceled, "context canceled"), true}, + {"wrapped grpc canceled", fmt.Errorf("marker provider: %w", status.Error(codes.Canceled, "context canceled")), true}, + {"deadline", context.DeadlineExceeded, false}, + {"grpc deadline", status.Error(codes.DeadlineExceeded, "context deadline exceeded"), false}, + {"grpc unavailable", status.Error(codes.Unavailable, "connection refused"), false}, + {"other", errors.New("query failed"), false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := IsCanceled(tc.err); got != tc.want { + t.Fatalf("IsCanceled(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} + +func TestLogLevelNeedsTheCallerGone(t *testing.T) { + canceled, cancel := context.WithCancel(t.Context()) + cancel() + expired, cancelExpired := context.WithTimeout(t.Context(), 0) + defer cancelExpired() + for _, tc := range []struct { + name string + ctx context.Context + err error + want slog.Level + }{ + {"caller gone", canceled, fmt.Errorf("query: %w", context.Canceled), slog.LevelDebug}, + {"plugin call after the caller left", canceled, status.Error(codes.Canceled, "context canceled"), slog.LevelDebug}, + {"real failure after the caller left", canceled, errors.New("connection reset"), slog.LevelWarn}, + {"wrapped plugin call after the caller left", canceled, fmt.Errorf("marker provider: %w", status.Error(codes.Canceled, "context canceled")), slog.LevelDebug}, + {"joined cancellations", canceled, errors.Join(context.Canceled, status.Error(codes.Canceled, "context canceled")), slog.LevelDebug}, + {"real failure joined with the cancellation", canceled, errors.Join(errors.New("query failed"), context.Canceled), slog.LevelWarn}, + {"wrapped join with a real failure", canceled, fmt.Errorf("loading: %w", errors.Join(context.Canceled, errors.New("query failed"))), slog.LevelWarn}, + {"cancellation while the caller waits", t.Context(), context.Canceled, slog.LevelWarn}, + {"deadline", expired, context.DeadlineExceeded, slog.LevelWarn}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := LogLevel(tc.ctx, tc.err, slog.LevelWarn); got != tc.want { + t.Fatalf("LogLevel = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go index 2134d4d3e8..c7ac260c3f 100644 --- a/internal/scanner/scanner.go +++ b/internal/scanner/scanner.go @@ -2901,6 +2901,10 @@ func (s *Scanner) watchFolderContext(ctx context.Context, folderID int) (context return case <-ticker.C: enabled, err := s.folderEnabledState(watchCtx, folderID) + if watchCtx.Err() != nil { + // The scan finished while the check ran. + return + } if err != nil { slog.WarnContext(ctx, "scanner: failed to refresh folder state", "component", "scanner", "folder_id", folderID, "error", err) continue diff --git a/internal/sections/fetch_error_log_test.go b/internal/sections/fetch_error_log_test.go new file mode 100644 index 0000000000..797ca03ccf --- /dev/null +++ b/internal/sections/fetch_error_log_test.go @@ -0,0 +1,33 @@ +package sections + +import ( + "context" + "fmt" + "log/slog" + "testing" +) + +// A row that failed only because the caller went away logs at debug from every +// caller of LogFetchError. The same cancellation while the caller is still there +// is a real failure and still logs an ERROR. +func TestLogFetchErrorLogsAbandonedFetchAtDebug(t *testing.T) { + logs := recordSectionLogs(t) + row := ResolvedSection{ID: "row", SectionType: SectionCollection} + canceled := fmt.Errorf("loading row: %w", context.Canceled) + + gone, cancel := context.WithCancel(t.Context()) + cancel() + LogFetchError(gone, "api", row, canceled) + if errs := logs.messages(slog.LevelError); len(errs) != 0 { + t.Fatalf("ERROR records after the caller left = %q, want none", errs) + } + if debug := logs.messages(slog.LevelDebug); len(debug) != 1 || debug[0] != "fetching section items" { + t.Fatalf("DEBUG records after the caller left = %q, want one fetching section items record", debug) + } + + logs.reset() + LogFetchError(t.Context(), "api", row, canceled) + if errs := logs.messages(slog.LevelError); len(errs) != 1 || errs[0] != "fetching section items" { + t.Fatalf("ERROR records with the caller still there = %q, want one fetching section items record", errs) + } +} diff --git a/internal/sections/fetcher.go b/internal/sections/fetcher.go index 11a191800d..1e1e7f5b67 100644 --- a/internal/sections/fetcher.go +++ b/internal/sections/fetcher.go @@ -20,6 +20,7 @@ import ( "golang.org/x/sync/singleflight" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/ctxerr" "github.com/Silo-Server/silo-server/internal/logredact" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/recommendations" @@ -1129,12 +1130,13 @@ func (f *Fetcher) FetchAll(ctx context.Context, resolved []ResolvedSection, libr // LogFetchError records a section that failed to load and is shown empty. A // row whose server or personal collection was deleted is expected to stay on // a layout until someone removes it, so it logs at debug instead of error. +// So does a fetch that failed only because the caller went away. func LogFetchError(ctx context.Context, component string, sec ResolvedSection, err error) { if errors.Is(err, catalog.ErrLibraryCollectionNotFound) || errors.Is(err, userstore.ErrCollectionNotFound) { slog.DebugContext(ctx, "section collection no longer exists", "component", component, "section_id", sec.ID, "type", sec.SectionType, "error", err) return } - slog.ErrorContext(ctx, "fetching section items", "component", component, "section_id", sec.ID, "type", sec.SectionType, "error", err) + slog.Log(ctx, ctxerr.LogLevel(ctx, err, slog.LevelError), "fetching section items", "component", component, "section_id", sec.ID, "type", sec.SectionType, "error", err) } type sectionFetchRunner func(context.Context, ResolvedSection) (SectionWithItems, error) From 71ea4813bcd76f3e8c27ca2f3e3cf5886e8ee18b Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:48:43 +1100 Subject: [PATCH 003/196] feat(metadata): offer local sidecar artwork in the admin image picker (#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 (#2300, #2310, #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 * 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 * 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 * 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 --------- Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- cmd/silo/main.go | 3 + docs/admin-catalog-api.md | 21 +- docs/architecture/local-nfo-metadata.md | 48 ++- internal/api/handlers/admin_images.go | 184 ++++++-- .../api/handlers/admin_images_local_test.go | 223 ++++++++++ .../api/handlers/admin_images_service_test.go | 9 + internal/api/handlers/admin_images_test.go | 13 + internal/metadata/image_cache_processor.go | 176 ++++++-- .../image_cache_processor_local_test.go | 149 +++++++ internal/metadata/local_artwork_copy.go | 2 +- internal/metadata/local_artwork_picker.go | 210 +++++++++ .../metadata/local_artwork_picker_test.go | 401 ++++++++++++++++++ internal/metadata/service.go | 36 +- 13 files changed, 1382 insertions(+), 93 deletions(-) create mode 100644 internal/api/handlers/admin_images_local_test.go create mode 100644 internal/metadata/local_artwork_picker.go create mode 100644 internal/metadata/local_artwork_picker_test.go diff --git a/cmd/silo/main.go b/cmd/silo/main.go index 9492d24532..939abf8e82 100644 --- a/cmd/silo/main.go +++ b/cmd/silo/main.go @@ -2129,6 +2129,9 @@ func main() { metadataImageCacheProcessor.SetLibraryRootResolver(deps.FolderRepo) metadataImageCacheProcessor.SetImagePrefixDeleter(deps.Blobs.Assets) metadataService.SetLocalArtworkCopier(metadataImageCacheProcessor) + // The admin image picker previews and applies the same local files, + // under the same confinement. + metadataService.SetLibraryRootResolver(deps.FolderRepo) metadataService.SetAutoCacheImages(cfg.Metadata.CacheImages) metadataImageCacheProcessor.SetEnabled(cfg.Metadata.CacheImages) configWatcher.OnChange(func(_, updated *config.Config) { diff --git a/docs/admin-catalog-api.md b/docs/admin-catalog-api.md index 218ea47fcc..1ecf998f1e 100644 --- a/docs/admin-catalog-api.md +++ b/docs/admin-catalog-api.md @@ -494,11 +494,30 @@ memory, and stores no server-side snapshot. Provider failures expose generic messages. The web drains all pages under one captured authority and refuses repeated or invalid continuation rather than publishing a partial list. +For a movie or series the list also offers its local sidecar artwork (the +`poster.jpg`, `fanart.jpg` and similar files a refresh would use), found by +Silo's own sidecar discovery whether or not the library's metadata chain uses +the NFO provider. A `file://` URL from any other provider is not offered. These +choices have `provider_id` `local`, a `file://` `original_url`, and a small +WebP preview as a `data:` URI in `url`. A file that cannot be read under the +library-root, symlink and size checks is left out, and `provider_errors` +reports the failure under `local`. Banner, thumb, clear art and disc art +sidecars, and image URLs an NFO names, are not offered. When the library's +provider chain cannot be resolved, the movie or series list still returns `200` +with its local choices and reports the failure under `chain` in +`provider_errors`; other lists return `500`. Frozen v1 lists provider choices +only. See +[Local NFO metadata](architecture/local-nfo-metadata.md#admin-image-picker). + `POST /api/v2/admin/items/{id}/images/apply` accepts `original_url`, `type`, and optional `provider_id`. An HTTP(S) `original_url` may name a public or local-network address; link-local, cloud metadata and other blocked addresses are refused (see -[Outbound address guard](architecture/outbound-address-guard.md#artwork-downloads)). +[Outbound address guard](architecture/outbound-address-guard.md#artwork-downloads)). A `file://` +`original_url` is accepted for a movie or series only when the item's sidecar +discovery offers that file for that image type; anything else returns a `422` +validation problem. A failure to read or cache an offered file returns `500`. +Frozen v1 does not accept local files. It preserves target validation before remote work, episode-to-still coercion, parent/season/episode cache identity, immutable upload, transactional catalog publication and orphan-GC scheduling after publication diff --git a/docs/architecture/local-nfo-metadata.md b/docs/architecture/local-nfo-metadata.md index 6d27dec54c..fa28e242ca 100644 --- a/docs/architecture/local-nfo-metadata.md +++ b/docs/architecture/local-nfo-metadata.md @@ -309,7 +309,8 @@ Sidecar images are discovered by the provider's `GetImages` always receive the normal presigned `poster_url`/`backdrop_url`/`logo_url` (and, on `/api/v2` movie and series detail, `banner_url`, `thumb_url`, `clearart_url`, `disc_url` and `backdrop_urls`); library files are never served -directly, and API nodes never need filesystem access to libraries. +directly. The admin image picker (below) is the only API path that reads +sidecar files. Discovery lists each folder once per request and matches names ignoring case, so `Poster.JPG` and `Folder.jpg` count on case-sensitive filesystems; an entry @@ -401,7 +402,11 @@ Caching and refresh: processor confines each read to the owning library's `media_folders` roots: a lexical check on the logical path, then a symlink-resolving re-check (both path and roots are resolved, so a legitimately symlinked root stays valid - while an intermediate directory symlink escaping a root is rejected). When + while an intermediate directory symlink escaping a root is rejected). Each + library root is opened as an `os.Root` before the path is resolved, and the + file is opened through the handle that is the same directory as the resolved + root, so a root or folder swapped for a symlink after that point cannot lead + the open out of the library. When the re-check or the read fails, the processor probes, through `internal/rootcheck`, the nearest folder that exists at or above the file's folder, searching no higher than the library root: unless it lists entries @@ -547,6 +552,41 @@ scope. A person refresh keeps a cached NFO photo over a provider's. Like other local artwork, a sidecar photo is copied whatever `metadata.cache_images` says; a photo URL an NFO names is used only while the setting is on. +### Admin image picker + +`GET /api/v2/admin/items/{id}/images` also offers a movie's or series' local +sidecar artwork. The built-in sidecar discovery runs with the item's media files +and sidecar directories, so it finds the same files a refresh would, and it runs +whether or not the library's metadata chain uses the NFO provider: choosing an +image is an explicit admin action. Local choices come only from that discovery; +a `file://` URL returned by any provider in the chain is dropped, so a provider +cannot get another library file offered as this item's artwork. Only sidecar +files (including files an NFO names by relative path) for the slots an admin can +apply (poster, backdrop, logo) are offered: an image URL an NFO names has no file +to read here, and banner, thumb, clear art and disc art cannot be applied. Each +local choice has the provider ID `local`, keeps its fingerprinted `file://` +source as `original_url`, and shows a 300-pixel-wide WebP preview as an inline +`data:` URI (the web client authenticates with a bearer token, so an `` +cannot fetch an authenticated preview route). The preview is read under the same +root confinement, symlink and size checks as the processor; a file that fails +them is left out of the list, and the list's `provider_errors` reports it under +`local`. Only the movie and series list does this: any other list treats a +provider's `file://` URL as an ordinary provider choice and never reads it. + +`POST .../images/apply` with a `file://` `original_url` caches the file only +when the item's sidecar discovery offers that exact source for that image type, +so a file replaced since the list (a new fingerprint) must be picked again. It +reads the file under the same checks, stores it under the same `local/...` key +the processor would use, and publishes the fingerprinted source with the images +lock like any other choice. Seasons, episodes and frozen v1 keep provider-only +choices. The server answering the admin API reads the file, so it needs the +library mounted, like a server that copies local artwork; on a server without +the mount discovery finds nothing and local choices are not offered. + +The reads open each library root as an `os.Root`, which needs read (list) +permission on the root directory, not only search permission. The scanner lists +the roots, so a server that scans a library already has it. + ## Series depth and mixed libraries `SeasonsRequest`/`EpisodesRequest` carry local path context; `season.nfo` @@ -681,8 +721,8 @@ refresh (`internal/metadata/local_sidecar.go`, `local_reapply.go`). ## Known limitations -- The admin image picker does not surface local art (automatic chain path - only). +- The admin image picker offers local art for movies and series only, not + for seasons or episodes. - Seasons have no banner, thumb or extra backdrops (`seasonNN-fanart`, `seasonNN-banner`, `seasonNN-landscape` and their season-folder forms are not read). diff --git a/internal/api/handlers/admin_images.go b/internal/api/handlers/admin_images.go index 3b1847a103..c52b9e7803 100644 --- a/internal/api/handlers/admin_images.go +++ b/internal/api/handlers/admin_images.go @@ -13,6 +13,7 @@ import ( "github.com/Silo-Server/silo-server/internal/catalog" evt "github.com/Silo-Server/silo-server/internal/events" + "github.com/Silo-Server/silo-server/internal/logredact" "github.com/Silo-Server/silo-server/internal/metadata" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/netguard" @@ -24,6 +25,9 @@ type ImageService interface { FetchItemImages(ctx context.Context, providerIDs map[string]string, contentType string, language string, folderID int) ([]metadata.RemoteImage, map[string]string, error) FetchSeasonImages(ctx context.Context, providerIDs map[string]string, language string, folderID int, seasonNumber int) ([]metadata.RemoteImage, map[string]string, error) ApplyItemImage(ctx context.Context, req metadata.ApplyItemImageRequest) (*metadata.ApplyItemImageResult, error) + FetchItemImagesWithLocal(ctx context.Context, providerIDs map[string]string, contentType string, language string, folderID int, contentID string) ([]metadata.RemoteImage, map[string]string, error) + LocalImagePreview(ctx context.Context, contentID string, sourceURL string) (string, error) + ApplyLocalItemImage(ctx context.Context, req metadata.ApplyLocalItemImageRequest) (*metadata.ApplyItemImageResult, error) } // ImageItemLookup loads media items, seasons, and episodes by content ID. @@ -80,6 +84,15 @@ func NewAdminImageHandler( } } +// itemTypeSeries is the media item type for a series. +const itemTypeSeries = "series" + +// offersLocalImages reports whether the picker offers local sidecar artwork +// for an item type: the NFO provider serves movies and series only. +func offersLocalImages(itemType string) bool { + return itemType == itemTypeMovie || itemType == itemTypeSeries +} + // --- Request/Response types --- type itemImageEntry struct { @@ -192,7 +205,8 @@ func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.R return } - out, err := h.GetAdminItemImages(r.Context(), contentID) + // Frozen v1 keeps its provider-only choices. + out, err := h.listItemImages(r.Context(), contentID, false) if err != nil { writeAPIError(w, err) return @@ -202,7 +216,13 @@ func (h *AdminImageHandler) HandleGetItemImages(w http.ResponseWriter, r *http.R type AdminItemImagesView = getItemImagesResponse +// GetAdminItemImages lists provider choices plus, for a movie or series, the +// local sidecar artwork next to its files. func (h *AdminImageHandler) GetAdminItemImages(ctx context.Context, contentID string) (AdminItemImagesView, error) { + return h.listItemImages(ctx, contentID, true) +} + +func (h *AdminImageHandler) listItemImages(ctx context.Context, contentID string, includeLocal bool) (AdminItemImagesView, error) { resolved, err := h.resolveContentID(ctx, contentID) if err != nil { if errors.Is(err, catalog.ErrItemNotFound) { @@ -233,10 +253,17 @@ func (h *AdminImageHandler) GetAdminItemImages(ctx context.Context, contentID st var images []metadata.RemoteImage var providerErrors map[string]string + // Only the movie and series list runs sidecar discovery. Every other list + // treats a file:// URL as an ordinary provider choice, as before. + withLocal := includeLocal && resolved.season == nil && resolved.episode == nil && offersLocalImages(resolved.parentItem.Type) if resolved.season != nil { images, providerErrors, err = h.imageSvc.FetchSeasonImages( ctx, providerIDs, language, folderID, resolved.season.SeasonNumber, ) + } else if withLocal { + images, providerErrors, err = h.imageSvc.FetchItemImagesWithLocal( + ctx, providerIDs, resolved.parentItem.Type, language, folderID, resolved.parentItem.ContentID, + ) } else { // Movies, series, and episodes use the parent item's type for the plugin // image call. Season browsing is handled above so a season can never @@ -250,10 +277,13 @@ func (h *AdminImageHandler) GetAdminItemImages(ctx context.Context, contentID st return AdminItemImagesView{}, apiError(http.StatusInternalServerError, "internal_error", "Failed to fetch images") } - // Batch-resolve plugin-prefixed URLs for display. - rawPaths := make([]string, len(images)) - for i, img := range images { - rawPaths[i] = img.URL + // Batch-resolve plugin-prefixed URLs for display. Local files get an + // inline preview below instead. + rawPaths := make([]string, 0, len(images)) + for _, img := range images { + if !withLocal || !metadata.IsLocalImageSource(img.URL) { + rawPaths = append(rawPaths, img.URL) + } } var resolvedURLs map[string]string if h.imageResolver != nil && len(rawPaths) > 0 { @@ -264,11 +294,27 @@ func (h *AdminImageHandler) GetAdminItemImages(ctx context.Context, contentID st entries := make([]itemImageEntry, 0, len(images)) for _, img := range images { displayURL := img.URL - if resolved, ok := resolvedURLs[img.URL]; ok && resolved != "" { + providerID := img.ProviderID + if withLocal && metadata.IsLocalImageSource(img.URL) { + // A file the picker cannot preview could not be applied either: + // both read it under the same confinement and size checks. Report + // the failure so the admin can tell why the choice is missing. + preview, err := h.imageSvc.LocalImagePreview(ctx, resolved.parentItem.ContentID, img.URL) + if err != nil { + slog.WarnContext(ctx, "admin images: local image preview failed", "component", "api", "content_id", contentID, "error", logredact.SanitizeText(err.Error())) + if providerErrors == nil { + providerErrors = map[string]string{} + } + providerErrors[metadata.LocalImageProviderID] = "Local image preview failed" + continue + } + displayURL = preview + providerID = metadata.LocalImageProviderID + } else if resolved, ok := resolvedURLs[img.URL]; ok && resolved != "" { displayURL = resolved } entries = append(entries, itemImageEntry{ - ProviderID: img.ProviderID, + ProviderID: providerID, URL: displayURL, OriginalURL: img.URL, Type: metadata.ImageTypeToString(img.Type), @@ -312,7 +358,8 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http. writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body") return } - out, err := h.ApplyAdminItemImage(r.Context(), contentID, req) + // Frozen v1 keeps applying provider images only. + out, err := h.applyItemImage(r.Context(), contentID, req, false) if err != nil { writeAPIError(w, err) return @@ -323,7 +370,13 @@ func (h *AdminImageHandler) HandleApplyItemImage(w http.ResponseWriter, r *http. type AdminItemImageResult = applyItemImageResponse type AdminItemImageRequest = applyItemImageRequest +// ApplyAdminItemImage applies a provider image, or for a movie or series one +// of the local sidecar images the list offered. func (h *AdminImageHandler) ApplyAdminItemImage(ctx context.Context, contentID string, req AdminItemImageRequest) (AdminItemImageResult, error) { + return h.applyItemImage(ctx, contentID, req, true) +} + +func (h *AdminImageHandler) applyItemImage(ctx context.Context, contentID string, req AdminItemImageRequest, allowLocal bool) (AdminItemImageResult, error) { if req.OriginalURL == "" || req.Type == "" { return AdminItemImageResult{}, apiError(http.StatusBadRequest, "bad_request", "original_url and type are required") } @@ -349,44 +402,14 @@ func (h *AdminImageHandler) ApplyAdminItemImage(ctx context.Context, contentID s return AdminItemImageResult{}, apiError(http.StatusBadRequest, "unsupported_image_type", fmt.Sprintf("%s items do not accept %s images", resolved.contentType, metadata.ImageTypeToString(imageType))) } - providerID := req.ProviderID - if providerID == "" { - providerID = primaryProvider(resolved.parentItem) - } - - // Use the parent item's ContentID for S3 key construction. Season / - // episode numbers (when present) scope the S3 key beneath the series - // prefix so siblings do not collide. - cacheContentID := findBestContentID(resolved.parentItem, providerID) - var seasonNumber, episodeNumber *int - switch resolved.contentType { - case "season": - if resolved.season != nil { - seasonNumber = new(resolved.season.SeasonNumber) - } - case "episode": - if resolved.episode != nil { - seasonNumber = new(resolved.episode.SeasonNumber) - episodeNumber = new(resolved.episode.EpisodeNumber) - } + var result *metadata.ApplyItemImageResult + if allowLocal && metadata.IsLocalImageSource(req.OriginalURL) { + result, err = h.cacheLocalItemImage(ctx, contentID, resolved, imageType, req.OriginalURL) + } else { + result, err = h.cacheProviderItemImage(ctx, contentID, resolved, imageType, req) } - - // Only acting admins reach this call. Like the history-import sources an - // admin configures, the image an admin applies may be on the server's local - // network; netguard still refuses blocked addresses - // (docs/architecture/outbound-address-guard.md). - result, err := h.imageSvc.ApplyItemImage(netguard.WithPrivateAccess(ctx), metadata.ApplyItemImageRequest{ - OriginalURL: req.OriginalURL, - ProviderID: providerID, - ContentType: resolved.parentItem.Type, - ContentID: cacheContentID, - ImageType: imageType, - SeasonNumber: seasonNumber, - EpisodeNumber: episodeNumber, - }) if err != nil { - slog.ErrorContext(ctx, "admin images: apply failed", "component", "api", "content_id", contentID, "error", err) - return AdminItemImageResult{}, apiError(http.StatusInternalServerError, "internal_error", "Failed to apply image") + return AdminItemImageResult{}, err } err = h.detailSvc.PublishArtworkSelection(ctx, catalog.ArtworkSelection{ @@ -436,6 +459,79 @@ func (h *AdminImageHandler) ApplyAdminItemImage(ctx context.Context, contentID s }, nil } +// cacheProviderItemImage downloads and caches a provider or HTTP(S) image. +func (h *AdminImageHandler) cacheProviderItemImage(ctx context.Context, contentID string, resolved *resolvedItem, imageType metadata.ImageType, req AdminItemImageRequest) (*metadata.ApplyItemImageResult, error) { + providerID := req.ProviderID + if providerID == "" { + providerID = primaryProvider(resolved.parentItem) + } + + // Use the parent item's ContentID for S3 key construction. Season / + // episode numbers (when present) scope the S3 key beneath the series + // prefix so siblings do not collide. + cacheContentID := findBestContentID(resolved.parentItem, providerID) + var seasonNumber, episodeNumber *int + switch resolved.contentType { + case "season": + if resolved.season != nil { + seasonNumber = new(resolved.season.SeasonNumber) + } + case "episode": + if resolved.episode != nil { + seasonNumber = new(resolved.episode.SeasonNumber) + episodeNumber = new(resolved.episode.EpisodeNumber) + } + } + + // Only acting admins reach this call. Like the history-import sources an + // admin configures, the image an admin applies may be on the server's local + // network; netguard still refuses blocked addresses + // (docs/architecture/outbound-address-guard.md). + result, err := h.imageSvc.ApplyItemImage(netguard.WithPrivateAccess(ctx), metadata.ApplyItemImageRequest{ + OriginalURL: req.OriginalURL, + ProviderID: providerID, + ContentType: resolved.parentItem.Type, + ContentID: cacheContentID, + ImageType: imageType, + SeasonNumber: seasonNumber, + EpisodeNumber: episodeNumber, + }) + if err != nil { + slog.ErrorContext(ctx, "admin images: apply failed", "component", "api", "content_id", contentID, "error", err) + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to apply image") + } + return result, nil +} + +// cacheLocalItemImage caches one of the local sidecar images the list offered +// for a movie or series. +func (h *AdminImageHandler) cacheLocalItemImage(ctx context.Context, contentID string, resolved *resolvedItem, imageType metadata.ImageType, sourceURL string) (*metadata.ApplyItemImageResult, error) { + if resolved.season != nil || resolved.episode != nil || !offersLocalImages(resolved.parentItem.Type) { + return nil, apiError(http.StatusBadRequest, "bad_request", "Local images can only be applied to movies and series") + } + item := resolved.parentItem + folderID, err := h.resolveImageFolderID(ctx, item.ContentID) + if err != nil { + slog.ErrorContext(ctx, "admin images: resolve folder failed", "component", "api", "content_id", contentID, "error", err) + return nil, apiError(http.StatusInternalServerError, "internal_error", "Could not determine library for item") + } + result, err := h.imageSvc.ApplyLocalItemImage(ctx, metadata.ApplyLocalItemImageRequest{ + ContentID: item.ContentID, + ContentType: item.Type, + FolderID: folderID, + ImageType: imageType, + SourceURL: sourceURL, + }) + if errors.Is(err, metadata.ErrLocalImageNotOffered) { + return nil, apiError(http.StatusBadRequest, "bad_request", "The image is not one of this item's local images") + } + if err != nil { + slog.ErrorContext(ctx, "admin images: local apply failed", "component", "api", "content_id", contentID, "error", err) + return nil, apiError(http.StatusInternalServerError, "internal_error", "Failed to apply image") + } + return result, nil +} + // resolveImageFolderID finds the primary library folder for a content ID. func (h *AdminImageHandler) resolveImageFolderID(ctx context.Context, contentID string) (int, error) { if h.folders == nil { diff --git a/internal/api/handlers/admin_images_local_test.go b/internal/api/handlers/admin_images_local_test.go new file mode 100644 index 0000000000..58e420e7ae --- /dev/null +++ b/internal/api/handlers/admin_images_local_test.go @@ -0,0 +1,223 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "github.com/Silo-Server/silo-server/internal/metadata" +) + +const ( + localPosterURL = "file:///media/Other/Show/poster.jpg" + localBackdropURL = "file:///media/Other/Show/fanart.jpg" + localPreviewURI = "data:image/webp;base64,UklGRg==" +) + +type localImageServiceFake struct { + plainCalls int + localCalls int + localContentID string + providerCalls int + applyLocal []metadata.ApplyLocalItemImageRequest + applyLocalErr error + plainImages []metadata.RemoteImage + previewCalls int +} + +func (f *localImageServiceFake) FetchItemImages(context.Context, map[string]string, string, string, int) ([]metadata.RemoteImage, map[string]string, error) { + f.plainCalls++ + if f.plainImages != nil { + return f.plainImages, nil, nil + } + return []metadata.RemoteImage{{ProviderID: "tmdb", URL: "https://image.example/poster.jpg", Type: metadata.ImagePoster}}, nil, nil +} + +func (f *localImageServiceFake) FetchItemImagesWithLocal(_ context.Context, _ map[string]string, _, _ string, _ int, contentID string) ([]metadata.RemoteImage, map[string]string, error) { + f.localCalls++ + f.localContentID = contentID + return []metadata.RemoteImage{ + {ProviderID: "tmdb", URL: "https://image.example/poster.jpg", Type: metadata.ImagePoster}, + {ProviderID: "nfo", URL: localPosterURL, Type: metadata.ImagePoster}, + {ProviderID: "nfo", URL: localBackdropURL, Type: metadata.ImageBackdrop}, + }, nil, nil +} + +func (f *localImageServiceFake) FetchSeasonImages(context.Context, map[string]string, string, int, int) ([]metadata.RemoteImage, map[string]string, error) { + return nil, nil, nil +} + +// LocalImagePreview previews the poster and fails for the backdrop, the way an +// oversized or unreadable file would. +func (f *localImageServiceFake) LocalImagePreview(_ context.Context, _ string, sourceURL string) (string, error) { + f.previewCalls++ + if sourceURL == localPosterURL { + return localPreviewURI, nil + } + return "", errors.New("local image exceeds 8388608 byte limit") +} + +func (f *localImageServiceFake) ApplyItemImage(context.Context, metadata.ApplyItemImageRequest) (*metadata.ApplyItemImageResult, error) { + f.providerCalls++ + return nil, errors.New("synthetic provider failure") +} + +func (f *localImageServiceFake) ApplyLocalItemImage(_ context.Context, req metadata.ApplyLocalItemImageRequest) (*metadata.ApplyItemImageResult, error) { + f.applyLocal = append(f.applyLocal, req) + return nil, f.applyLocalErr +} + +func newLocalImageHandlerForTest(svc *localImageServiceFake) *AdminImageHandler { + items := curationImageItems{ + "local-series": {ContentID: "local-series", Type: "series", DefaultMetadataLanguage: "fr"}, + "series": {ContentID: "series", Type: "series", TmdbID: "42"}, + "book": {ContentID: "book", Type: "audiobook"}, + } + return NewAdminImageHandler(items, curationImageSeasons{}, curationImageEpisodes{}, nil, svc, nil, nil) +} + +func TestAdminItemImagesOffersPreviewableLocalImages(t *testing.T) { + svc := &localImageServiceFake{} + h := newLocalImageHandlerForTest(svc) + + out, err := h.GetAdminItemImages(context.Background(), "local-series") + if err != nil { + t.Fatalf("GetAdminItemImages: %v", err) + } + if svc.localCalls != 1 || svc.plainCalls != 0 || svc.localContentID != "local-series" { + t.Fatalf("fetch calls local=%d plain=%d content=%q, want one local listing for the item", svc.localCalls, svc.plainCalls, svc.localContentID) + } + var local []itemImageEntry + for _, entry := range out.Images { + if strings.HasPrefix(entry.OriginalURL, "file://") { + local = append(local, entry) + } + } + // The backdrop cannot be previewed, so it is not offered either. + if len(local) != 1 { + t.Fatalf("local choices = %+v, want only the poster", local) + } + if got := local[0]; got.ProviderID != "local" || got.URL != localPreviewURI || got.OriginalURL != localPosterURL || got.Type != "poster" { + t.Fatalf("local choice = %+v", got) + } + if len(out.Images) != 2 { + t.Fatalf("choices = %+v, want the provider poster and the local poster", out.Images) + } + // The admin is told why the backdrop is missing. + // It is told by source only: the preview error stays in the log. + if got := out.ProviderErrors["local"]; got != "Local image preview failed" { + t.Fatalf("provider errors = %v, want the fixed local preview failure", out.ProviderErrors) + } +} + +// Only the movie and series listing runs sidecar discovery. A file:// URL a +// provider returns anywhere else is not read, previewed, or labeled local. +func TestAdminItemImagesDoesNotPreviewProviderFileURLs(t *testing.T) { + svc := &localImageServiceFake{plainImages: []metadata.RemoteImage{ + {ProviderID: "plugin", URL: localPosterURL, Type: metadata.ImagePoster}, + }} + h := newLocalImageHandlerForTest(svc) + + out, err := h.GetAdminItemImages(context.Background(), "book") + if err != nil { + t.Fatalf("GetAdminItemImages: %v", err) + } + if svc.previewCalls != 0 { + t.Fatalf("previewed %d provider file URLs, want none", svc.previewCalls) + } + if len(out.Images) != 1 || out.Images[0].ProviderID != "plugin" || out.Images[0].URL != localPosterURL { + t.Fatalf("choices = %+v, want the provider row unchanged", out.Images) + } +} + +// Frozen v1 keeps its provider-only list. +func TestAdminItemImagesV1ListOmitsLocalImages(t *testing.T) { + svc := &localImageServiceFake{} + router := chi.NewRouter() + router.Get("/{id}/images", newLocalImageHandlerForTest(svc).HandleGetItemImages) + + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/local-series/images", nil)) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "file://") { + t.Fatalf("v1 list %d %s", rec.Code, rec.Body) + } + if svc.localCalls != 0 || svc.plainCalls != 1 { + t.Fatalf("fetch calls local=%d plain=%d, want the plain provider listing", svc.localCalls, svc.plainCalls) + } +} + +func TestAdminApplyLocalImageValidatesTheTarget(t *testing.T) { + t.Run("movie or series uses the local apply", func(t *testing.T) { + svc := &localImageServiceFake{applyLocalErr: metadata.ErrLocalImageNotOffered} + h := newLocalImageHandlerForTest(svc) + + _, err := h.ApplyAdminItemImage(context.Background(), "local-series", AdminItemImageRequest{OriginalURL: localPosterURL, Type: "poster", ProviderID: "local"}) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusBadRequest { + t.Fatalf("err = %v, want 400 for an image the item does not offer", err) + } + if svc.providerCalls != 0 || len(svc.applyLocal) != 1 { + t.Fatalf("provider calls %d, local calls %d", svc.providerCalls, len(svc.applyLocal)) + } + req := svc.applyLocal[0] + if req.ContentID != "local-series" || req.ContentType != "series" || req.ImageType != metadata.ImagePoster || req.SourceURL != localPosterURL { + t.Fatalf("local apply request = %+v", req) + } + }) + + t.Run("cache failure is an internal error", func(t *testing.T) { + svc := &localImageServiceFake{applyLocalErr: errors.New("local image missing: poster.jpg")} + _, err := newLocalImageHandlerForTest(svc).ApplyAdminItemImage(context.Background(), "local-series", AdminItemImageRequest{OriginalURL: localPosterURL, Type: "poster"}) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusInternalServerError { + t.Fatalf("err = %v, want 500", err) + } + }) + + t.Run("episodes do not take local images", func(t *testing.T) { + svc := &localImageServiceFake{} + _, err := newLocalImageHandlerForTest(svc).ApplyAdminItemImage(context.Background(), "episode", AdminItemImageRequest{OriginalURL: localPosterURL, Type: "still"}) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusBadRequest { + t.Fatalf("err = %v, want 400", err) + } + if len(svc.applyLocal) != 0 || svc.providerCalls != 0 { + t.Fatalf("local calls %d, provider calls %d, want none", len(svc.applyLocal), svc.providerCalls) + } + }) + + t.Run("frozen v1 never takes the local path", func(t *testing.T) { + svc := &localImageServiceFake{} + router := chi.NewRouter() + router.Post("/{id}/images/apply", newLocalImageHandlerForTest(svc).HandleApplyItemImage) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/local-series/images/apply", strings.NewReader(`{"original_url":"`+localPosterURL+`","type":"poster"}`))) + if len(svc.applyLocal) != 0 || svc.providerCalls != 1 { + t.Fatalf("v1 apply %d: local calls %d, provider calls %d", rec.Code, len(svc.applyLocal), svc.providerCalls) + } + }) +} + +// Only movies and series offer local artwork; other item types keep the +// provider-only list and refuse a local apply. +func TestAdminLocalImagesAreLimitedToMoviesAndSeries(t *testing.T) { + svc := &localImageServiceFake{} + h := newLocalImageHandlerForTest(svc) + + if _, err := h.GetAdminItemImages(context.Background(), "book"); err != nil { + t.Fatalf("GetAdminItemImages: %v", err) + } + if svc.localCalls != 0 || svc.plainCalls != 1 { + t.Fatalf("fetch calls local=%d plain=%d, want the plain provider listing", svc.localCalls, svc.plainCalls) + } + _, err := h.ApplyAdminItemImage(context.Background(), "book", AdminItemImageRequest{OriginalURL: localPosterURL, Type: "poster"}) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusBadRequest || len(svc.applyLocal) != 0 { + t.Fatalf("err = %v, local calls %d, want 400 and none", err, len(svc.applyLocal)) + } +} diff --git a/internal/api/handlers/admin_images_service_test.go b/internal/api/handlers/admin_images_service_test.go index f329a213c8..db83329b67 100644 --- a/internal/api/handlers/admin_images_service_test.go +++ b/internal/api/handlers/admin_images_service_test.go @@ -59,6 +59,15 @@ func (f *curationImageService) ApplyItemImage(ctx context.Context, r metadata.Ap } return &metadata.ApplyItemImageResult{StoredPath: f.stored, Thumbhash: "hash", Revision: "revision"}, nil } +func (f *curationImageService) FetchItemImagesWithLocal(ctx context.Context, providerIDs map[string]string, contentType, language string, folderID int, _ string) ([]metadata.RemoteImage, map[string]string, error) { + return f.FetchItemImages(ctx, providerIDs, contentType, language, folderID) +} +func (f *curationImageService) LocalImagePreview(context.Context, string, string) (string, error) { + return "", errors.New("no local images") +} +func (f *curationImageService) ApplyLocalItemImage(context.Context, metadata.ApplyLocalItemImageRequest) (*metadata.ApplyItemImageResult, error) { + return nil, errors.New("no local images") +} func TestAdminImageSharedBridgeAndEpisodePreflight(t *testing.T) { svc := &curationImageService{fail: true} h := NewAdminImageHandler(curationImageItems{"series": {ContentID: "series", Type: "series", TmdbID: "42"}}, curationImageSeasons{}, curationImageEpisodes{}, nil, svc, nil, nil) diff --git a/internal/api/handlers/admin_images_test.go b/internal/api/handlers/admin_images_test.go index 02c5bb98b7..33301766fa 100644 --- a/internal/api/handlers/admin_images_test.go +++ b/internal/api/handlers/admin_images_test.go @@ -3,6 +3,7 @@ package handlers import ( "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -82,6 +83,18 @@ func (f *imageServiceFake) ApplyItemImage(context.Context, metadata.ApplyItemIma return nil, nil } +func (f *imageServiceFake) FetchItemImagesWithLocal(ctx context.Context, providerIDs map[string]string, contentType, language string, folderID int, _ string) ([]metadata.RemoteImage, map[string]string, error) { + return f.FetchItemImages(ctx, providerIDs, contentType, language, folderID) +} + +func (f *imageServiceFake) LocalImagePreview(context.Context, string, string) (string, error) { + return "", errors.New("no local images") +} + +func (f *imageServiceFake) ApplyLocalItemImage(context.Context, metadata.ApplyLocalItemImageRequest) (*metadata.ApplyItemImageResult, error) { + return nil, errors.New("no local images") +} + func TestHandleGetItemImagesUsesSeasonSpecificFetch(t *testing.T) { tests := []struct { name string diff --git a/internal/metadata/image_cache_processor.go b/internal/metadata/image_cache_processor.go index d469744d8d..8ff053d39c 100644 --- a/internal/metadata/image_cache_processor.go +++ b/internal/metadata/image_cache_processor.go @@ -1,6 +1,7 @@ package metadata import ( + "cmp" "context" "crypto/sha256" "encoding/hex" @@ -1053,29 +1054,14 @@ func (p *ImageCacheProcessor) processLocalOne(ctx context.Context, job *models.M return imageCacheProcessResult{outcome: "failed"} } if !localImagePathWithinRoots(localPath, roots) { - p.markFailed(ctx, job, "local image path outside library roots: "+localPath) + p.markFailed(ctx, job, localImageOutsideRootsError(localPath).Error()) return imageCacheProcessResult{outcome: "failed"} } - // The lexical check above cannot see through symlinks. Resolve the path and - // roots and re-confine so an intermediate directory symlink planted inside a - // root (e.g. a link pointing out of the library) cannot pull an out-of-root - // file into the cache. EvalSymlinks resolves both sides, so a legitimately - // symlinked root still matches. This is a confinement GATE only: the read - // below still uses the logical path so readLocalImageFile's Lstat keeps - // rejecting a symlinked leaf. A not-yet-existent path (ErrNotExist) falls - // through to the reader, which classifies it as the stable "missing" failure. - // Either failure first asks whether this server has the file's storage - // mounted at all; when it does not, the job waits for one that does - // (deferLocalJobWithoutStorage). - if _, err := localImagePathResolvedWithinRoots(localPath, roots); err != nil && !errors.Is(err, fs.ErrNotExist) { - if p.deferLocalJobWithoutStorage(ctx, job, localPath, roots) { - return imageCacheProcessResult{outcome: imageCacheOutcomeDeferred} - } - p.markFailed(ctx, job, "local image path outside library roots: "+localPath) - return imageCacheProcessResult{outcome: "failed"} - } - - data, err := readLocalImageFile(localPath) + // readConfinedLocalImage re-confines the path through symlinks and reads it + // through an opened library root. A failure there first asks whether this + // server has the file's storage mounted at all; when it does not, the job + // waits for one that does (deferLocalJobWithoutStorage). + data, err := readConfinedLocalImage(localPath, roots) if err != nil { if p.deferLocalJobWithoutStorage(ctx, job, localPath, roots) { return imageCacheProcessResult{outcome: imageCacheOutcomeDeferred} @@ -1143,6 +1129,115 @@ func (p *ImageCacheProcessor) processLocalOne(ctx context.Context, job *models.M return processResult } +// readConfinedLocalImage reads the sidecar image at localPath (a cleaned +// logical path, without the file:// scheme or fingerprint) after confining it +// to roots. Its error texts are the stable local failures +// isStableProviderImageFailure matches. +func readConfinedLocalImage(localPath string, roots []string) ([]byte, error) { + if !localImagePathWithinRoots(localPath, roots) { + return nil, localImageOutsideRootsError(localPath) + } + // Open every library root before resolving anything. All reads go through + // one of these handles, so a root or an intermediate directory replaced + // after this point cannot redirect the read outside the library. + opened := openLibraryRoots(roots) + defer closeLibraryRoots(opened) + // The lexical check above cannot see through symlinks. Resolve the path and + // roots and re-confine so an intermediate directory symlink planted inside a + // root (e.g. a link pointing out of the library) cannot pull an out-of-root + // file into the cache. EvalSymlinks resolves both sides, so a legitimately + // symlinked root still matches. A not-yet-existent path (ErrNotExist) is the + // stable "missing" failure. + resolvedPath, resolvedRoot, err := localImagePathResolvedWithinRoots(localPath, roots) + if errors.Is(err, fs.ErrNotExist) { + return nil, fmt.Errorf("local image missing: %s", localPath) + } + if err != nil { + return nil, localImageOutsideRootsError(localPath) + } + // The resolved root must be one of the directories opened above; a root + // replaced in between resolves somewhere else and is refused. + root, err := openedLibraryRootFor(opened, resolvedRoot) + if err != nil { + // A root that could not be opened or stat'ed is a read failure, not a + // path outside the library: keep it off the stable-failure texts so a + // transient error (EMFILE, EIO, a mount hiccup) is retried normally. + if errors.Is(err, fs.ErrPermission) { + return nil, fmt.Errorf("local image forbidden: %s", localPath) + } + return nil, fmt.Errorf("local image read failed: library root: %w", err) + } + rel, relErr := filepath.Rel(resolvedRoot, resolvedPath) + if root == nil || relErr != nil || !filepath.IsLocal(rel) { + return nil, localImageOutsideRootsError(localPath) + } + return readLocalImageFile(localPath, root, rel) +} + +// openedLibraryRoot is a library root opened before the image path is +// resolved, or the error that kept it from opening. +type openedLibraryRoot struct { + path string + handle *os.Root + err error +} + +// openLibraryRoots opens each library root that exists as an os.Root. A root +// that exists but fails to open keeps its error. +func openLibraryRoots(roots []string) []openedLibraryRoot { + opened := make([]openedLibraryRoot, 0, len(roots)) + for _, root := range roots { + root = filepath.Clean(strings.TrimSpace(root)) + if root == "" || root == "." || !filepath.IsAbs(root) { + continue + } + handle, err := os.OpenRoot(root) + if errors.Is(err, fs.ErrNotExist) { + continue + } + opened = append(opened, openedLibraryRoot{path: root, handle: handle, err: err}) + } + return opened +} + +func closeLibraryRoots(opened []openedLibraryRoot) { + for _, root := range opened { + if root.handle != nil { + _ = root.handle.Close() + } + } +} + +// openedLibraryRootFor returns the opened root that is the same directory as +// resolvedRoot. It returns nil and no error when no opened root is that +// directory, which means the path left the library. An error means the +// matching root failed to open, or a directory could not be stat'ed, so +// nothing is known about confinement. +func openedLibraryRootFor(opened []openedLibraryRoot, resolvedRoot string) (*os.Root, error) { + current, err := os.Stat(resolvedRoot) + if err != nil { + return nil, err + } + var failure error + for _, root := range opened { + if root.handle == nil { + if info, err := os.Stat(root.path); err == nil && os.SameFile(info, current) && failure == nil { + failure = root.err + } + continue + } + info, err := root.handle.Stat(".") + if err != nil { + failure = cmp.Or(failure, err) + continue + } + if os.SameFile(info, current) { + return root.handle, nil + } + } + return nil, failure +} + // deleteStaleLocalPrefix removes the previous hashed local/ image prefix // after a re-cache stored the artwork under a different key. func (p *ImageCacheProcessor) deleteStaleLocalPrefix(ctx context.Context, previousCachedPath, cachedPath string) { @@ -1170,17 +1265,23 @@ func (p *ImageCacheProcessor) deleteStaleLocalPrefix(ctx context.Context, previo // when a fully symlink-resolved path escapes every resolved library root. var errLocalImageOutsideRoots = errors.New("local image path outside library roots") +// localImageOutsideRootsError is the stable failure text for a path outside +// the library roots, which isStableProviderImageFailure matches. +func localImageOutsideRootsError(path string) error { + return errors.New(errLocalImageOutsideRoots.Error() + ": " + path) +} + // localImagePathResolvedWithinRoots resolves symlinks on both the path and the -// library roots and returns the real path when it stays within a real root. +// library roots and returns the real path and the real root containing it. // This closes the gap the lexical check leaves open — an intermediate directory // symlink planted inside a root that points outside it. Resolving both sides // keeps a legitimately symlinked root valid. A path that does not exist yet // surfaces as fs.ErrNotExist for the caller to treat as a stable "missing" // failure via the reader; any other resolution failure fails closed. -func localImagePathResolvedWithinRoots(path string, roots []string) (string, error) { +func localImagePathResolvedWithinRoots(path string, roots []string) (string, string, error) { resolvedPath, err := filepath.EvalSymlinks(path) if err != nil { - return "", err + return "", "", err } for _, root := range roots { resolvedRoot, err := filepath.EvalSymlinks(strings.TrimSpace(root)) @@ -1188,10 +1289,10 @@ func localImagePathResolvedWithinRoots(path string, roots []string) (string, err continue } if localImagePathWithinRoots(resolvedPath, []string{resolvedRoot}) { - return resolvedPath, nil + return resolvedPath, resolvedRoot, nil } } - return "", errLocalImageOutsideRoots + return "", "", errLocalImageOutsideRoots } // localImagePathWithinRoots confines path (already cleaned, logical) to the @@ -1219,11 +1320,13 @@ func localImagePathWithinRoots(path string, roots []string) bool { return false } -// readLocalImageFile reads a sidecar image with the same guards as discovery: -// Lstat rejects symlinked leaves and non-regular files, the opened handle is -// fstat-re-checked, and reads cap at MaxArtworkSourceBytes. ENOENT/EPERM -// map to the stable-failure texts matched by isStableProviderImageFailure. -func readLocalImageFile(path string) ([]byte, error) { +// readLocalImageFile reads rel inside root with the same guards as discovery. +// path is the logical path, used for the leaf check and in error texts: Lstat +// on it rejects symlinked leaves and non-regular files, the handle opened +// through root is fstat-re-checked against it, and reads cap at +// MaxArtworkSourceBytes. ENOENT/EPERM map to the stable-failure texts +// matched by isStableProviderImageFailure. +func readLocalImageFile(path string, root *os.Root, rel string) ([]byte, error) { classify := func(err error) error { switch { case errors.Is(err, fs.ErrNotExist): @@ -1244,7 +1347,10 @@ func readLocalImageFile(path string) ([]byte, error) { if info.Size() > MaxArtworkSourceBytes { return nil, fmt.Errorf("local image exceeds %d byte limit: %s", MaxArtworkSourceBytes, path) } - file, err := os.Open(path) + // Open through the root handle: it refuses any path that leaves the root, + // including through a directory swapped for a symlink after resolution, + // so confinement holds at the open itself. + file, err := root.Open(rel) if err != nil { return nil, classify(err) } @@ -1253,10 +1359,8 @@ func readLocalImageFile(path string) ([]byte, error) { if err != nil { return nil, classify(err) } - // Close the symlink-swap window: os.Open follows symlinks, so a leaf swapped - // to a symlink between the Lstat above and this Open would be followed to its - // target. Reject unless the opened handle is the exact file Lstat inspected, - // so an out-of-root target can never be pulled into the public cache. + // Reject unless the opened handle is the exact file the Lstat above + // inspected, so a leaf swapped between the two cannot be read instead. if !os.SameFile(info, stat) { return nil, fmt.Errorf("local image is not a regular file: %s", path) } diff --git a/internal/metadata/image_cache_processor_local_test.go b/internal/metadata/image_cache_processor_local_test.go index 96a875fdd7..89d07e6d48 100644 --- a/internal/metadata/image_cache_processor_local_test.go +++ b/internal/metadata/image_cache_processor_local_test.go @@ -482,3 +482,152 @@ func TestProcessLocalPersonPhotoConfinedToTitleRoots(t *testing.T) { t.Errorf("person update = %+v", people) } } + +// A directory swapped for a symlink to outside the library after the path was +// resolved must not let the read follow it out: the open goes through the +// root handle taken before resolution. +func TestReadLocalImageFileRefusesDirectorySwappedAfterResolution(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + showDir := filepath.Join(root, "Show") + if err := os.Mkdir(showDir, 0o755); err != nil { + t.Fatal(err) + } + posterPath := writeLocalPoster(t, showDir) + if err := os.WriteFile(filepath.Join(outside, "poster.jpg"), []byte("outside-bytes"), 0o644); err != nil { + t.Fatal(err) + } + + opened := openLibraryRoots([]string{root}) + defer closeLibraryRoots(opened) + resolvedPath, resolvedRoot, err := localImagePathResolvedWithinRoots(posterPath, []string{root}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + handle, _ := openedLibraryRootFor(opened, resolvedRoot) + rel, err := filepath.Rel(resolvedRoot, resolvedPath) + if handle == nil || err != nil { + t.Fatalf("pinned root %v, rel error %v", handle, err) + } + // Swap the show directory for a link out of the library. + if err := os.Rename(showDir, filepath.Join(root, "Show.moved")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, showDir); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + + data, err := readLocalImageFile(posterPath, handle, rel) + if err == nil || strings.Contains(string(data), "outside") { + t.Fatalf("read %q (err %v) through a swapped directory, want an error", data, err) + } +} + +// A library root replaced by a link out of the library after Silo opened it +// cannot redirect the read, whether the swap lands before or after the image +// path is resolved. +func TestReadConfinedLocalImageRefusesRootSwappedAfterOpening(t *testing.T) { + setup := func(t *testing.T) (root, posterPath, outside string) { + t.Helper() + base := t.TempDir() + root = filepath.Join(base, "library") + outside = filepath.Join(base, "outside") + for dir, content := range map[string]string{root: "poster-bytes", outside: "outside-bytes"} { + if err := os.MkdirAll(filepath.Join(dir, "Show"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "Show", "poster.jpg"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return root, filepath.Join(root, "Show", "poster.jpg"), outside + } + swapRoot := func(t *testing.T, root, outside string) { + t.Helper() + if err := os.Rename(root, root+".moved"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, root); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + } + + t.Run("swapped before resolution", func(t *testing.T) { + root, posterPath, outside := setup(t) + opened := openLibraryRoots([]string{root}) + defer closeLibraryRoots(opened) + swapRoot(t, root, outside) + + _, resolvedRoot, err := localImagePathResolvedWithinRoots(posterPath, []string{root}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if handle, _ := openedLibraryRootFor(opened, resolvedRoot); handle != nil { + t.Fatal("the replacement root matched the root opened before the swap") + } + }) + + t.Run("swapped after resolution", func(t *testing.T) { + root, posterPath, outside := setup(t) + opened := openLibraryRoots([]string{root}) + defer closeLibraryRoots(opened) + resolvedPath, resolvedRoot, err := localImagePathResolvedWithinRoots(posterPath, []string{root}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + handle, _ := openedLibraryRootFor(opened, resolvedRoot) + rel, err := filepath.Rel(resolvedRoot, resolvedPath) + if handle == nil || err != nil { + t.Fatalf("pinned root %v, rel error %v", handle, err) + } + swapRoot(t, root, outside) + + data, err := readLocalImageFile(posterPath, handle, rel) + if err == nil || strings.Contains(string(data), "outside") { + t.Fatalf("read %q (err %v) after the root was swapped, want an error", data, err) + } + }) +} + +// A root the process cannot open is a read failure, not a path outside the +// library, so it never gets the stable out-of-roots classification. +func TestReadConfinedLocalImageReportsUnopenableRoot(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root ignores directory permissions") + } + root := filepath.Join(t.TempDir(), "library") + showDir := filepath.Join(root, "Show") + if err := os.MkdirAll(showDir, 0o755); err != nil { + t.Fatal(err) + } + posterPath := writeLocalPoster(t, showDir) + // Traverse-only: the poster is reachable, but the root cannot be opened. + if err := os.Chmod(root, 0o111); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(root, 0o755) }) + + _, err := readConfinedLocalImage(posterPath, []string{root}) + if err == nil || !strings.Contains(err.Error(), "local image forbidden") { + t.Fatalf("err = %v, want the forbidden failure", err) + } +} + +// A library root that cannot be stat'ed after the path resolved says nothing +// about confinement, so it is a retryable read failure, not the stable +// out-of-roots one; a resolved root that is none of the opened roots is. +func TestOpenedLibraryRootForSeparatesStatFailuresFromEscapes(t *testing.T) { + root := t.TempDir() + opened := openLibraryRoots([]string{root}) + defer closeLibraryRoots(opened) + + if handle, err := openedLibraryRootFor(opened, filepath.Join(root, "gone")); handle != nil || err == nil { + t.Fatalf("missing resolved root = (%v, %v), want an error", handle, err) + } + if handle, err := openedLibraryRootFor(opened, t.TempDir()); handle != nil || err != nil { + t.Fatalf("other directory = (%v, %v), want no root and no error", handle, err) + } + if handle, err := openedLibraryRootFor(opened, root); handle == nil || err != nil { + t.Fatalf("the root itself = (%v, %v), want its handle", handle, err) + } +} diff --git a/internal/metadata/local_artwork_copy.go b/internal/metadata/local_artwork_copy.go index 2320bce332..390103b360 100644 --- a/internal/metadata/local_artwork_copy.go +++ b/internal/metadata/local_artwork_copy.go @@ -202,7 +202,7 @@ func nearestExistingFolderListsEntries(ctx context.Context, dir string, roots [] probe := rootcheck.ProbeWithTimeout(ctx, dir, rootcheck.DefaultProbeTimeout) switch { case probe.Reachable: - if _, err := localImagePathResolvedWithinRoots(dir, roots); errors.Is(err, errLocalImageOutsideRoots) { + if _, _, err := localImagePathResolvedWithinRoots(dir, roots); errors.Is(err, errLocalImageOutsideRoots) { return true } return !probe.Empty diff --git a/internal/metadata/local_artwork_picker.go b/internal/metadata/local_artwork_picker.go new file mode 100644 index 0000000000..623ae64e4a --- /dev/null +++ b/internal/metadata/local_artwork_picker.go @@ -0,0 +1,210 @@ +package metadata + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "log/slog" + "slices" + + "github.com/Silo-Server/silo-server/internal/imageutil" + "github.com/Silo-Server/silo-server/internal/logredact" +) + +// localArtworkPreviewWidth matches the "card" width the admin picker shows +// provider choices at. +const localArtworkPreviewWidth = 300 + +// LocalImageProviderID labels local sidecar choices in the admin image list +// and keys their failures in its provider errors. +const LocalImageProviderID = imageCacheLocalProviderID + +// localArtworkCapabilityID is the built-in provider that discovers sidecar +// artwork next to an item's files. +const localArtworkCapabilityID = "nfo" + +// pickableLocalImageTypes are the image types an admin can apply to a movie +// or series. Discovery also finds banners, thumbs, clear art and disc art, +// which the picker cannot apply, so it does not offer or preview them. +var pickableLocalImageTypes = []ImageType{ImagePoster, ImageBackdrop, ImageLogo} + +// ErrLocalImageNotOffered rejects a local image the item's own discovery did +// not return, so the apply endpoint cannot be used to read arbitrary files. +var ErrLocalImageNotOffered = errors.New("local image is not one of the item's local images") + +// IsLocalImageSource reports whether an image URL names a local sidecar file +// (file://). An image URL an NFO names (nfo+http(s)://) is not one: it has no +// file to preview or copy. +func IsLocalImageSource(url string) bool { + _, ok := localImageSourceFilePath(url) + return ok +} + +// FetchItemImagesWithLocal is FetchItemImages plus the item's local sidecar +// artwork. Local choices come only from Silo's own sidecar discovery, run +// whether or not the library's metadata chain uses the NFO provider: a +// file:// URL from any provider in the chain is dropped, so a provider cannot +// get an arbitrary library file offered as this item's artwork. +func (s *MetadataService) FetchItemImagesWithLocal(ctx context.Context, providerIDs map[string]string, contentType string, language string, folderID int, contentID string) ([]RemoteImage, map[string]string, error) { + images, providerErrors, err := s.FetchItemImages(ctx, providerIDs, contentType, language, folderID) + if err != nil { + // Sidecar discovery does not use the chain, so a chain failure still + // lists the item's local artwork and reports the failure. + slog.WarnContext(ctx, "metadata: image provider chain unavailable for picker", "component", "metadata", "folder_id", folderID, "error", logredact.SanitizeText(err.Error())) + images, providerErrors = nil, map[string]string{"chain": "Image provider chain unavailable"} + } + images = slices.DeleteFunc(images, func(image RemoteImage) bool { + return isLocalImageSourcePath(image.URL) + }) + local, err := s.localItemImages(ctx, contentType, folderID, contentID) + if err != nil { + // The error can carry paths and provider text, so the listing only says + // which source failed, and the log keeps the text with any credential + // assignments masked. + slog.WarnContext(ctx, "metadata: local artwork discovery failed for picker", "component", "metadata", "folder_id", folderID, "content_id", contentID, "error", logredact.SanitizeText(err.Error())) + if providerErrors == nil { + providerErrors = map[string]string{} + } + providerErrors[LocalImageProviderID] = "Local artwork discovery failed" + } + return append(images, local...), providerErrors, nil +} + +// localItemImages runs sidecar discovery for an item with its media files and +// sidecar directories, finding the same poster, backdrop and logo files a +// refresh would use. It keeps sidecar files only: an image URL an NFO names +// has no file to preview or copy here. +func (s *MetadataService) localItemImages(ctx context.Context, contentType string, folderID int, contentID string) ([]RemoteImage, error) { + discovery := s.localImageProvider + if discovery == nil { + provider, ok := builtinProvider(localArtworkCapabilityID) + if !ok { + return nil, nil + } + if discovery, ok = provider.(ImageProvider); !ok { + return nil, nil + } + } + // A failure to read the item's files is reported rather than read as an + // item without sidecars. + localCtx, err := s.loadLocalProviderContext(ctx, contentID, folderID) + if err != nil { + return nil, err + } + if localCtx.representativeFilePath == "" && len(localCtx.primarySidecarSearchPaths) == 0 { + return nil, nil + } + images, err := discovery.GetImages(ctx, ImageRequest{ + ContentType: contentType, + RepresentativeFilePath: localCtx.representativeFilePath, + AllGroupFilePaths: localCtx.allGroupFilePaths, + PrimarySidecarSearchPaths: localCtx.primarySidecarSearchPaths, + }) + if err != nil { + return nil, err + } + return slices.DeleteFunc(images, func(image RemoteImage) bool { + return !IsLocalImageSource(image.URL) || !slices.Contains(pickableLocalImageTypes, image.Type) + }), nil +} + +// LocalImagePreview returns a small WebP data URI of a local sidecar image for +// the admin picker. The file is read under the same library-root confinement, +// symlink and size checks as background caching. +func (s *MetadataService) LocalImagePreview(ctx context.Context, contentID string, sourceURL string) (string, error) { + data, err := s.readLocalSidecar(ctx, contentID, sourceURL) + if err != nil { + return "", err + } + preview, err := imageutil.EncodeWebPWidth(data, localArtworkPreviewWidth) + if err != nil { + return "", err + } + return "data:image/webp;base64," + base64.StdEncoding.EncodeToString(preview), nil +} + +// readLocalSidecar reads a file:// sidecar source of contentID, without its +// fingerprint, under the library-root confinement of readConfinedLocalImage. +func (s *MetadataService) readLocalSidecar(ctx context.Context, contentID, source string) ([]byte, error) { + if s.libraryRoots == nil { + return nil, errors.New("missing library root resolver for local artwork") + } + localPath, ok := localImageSourceFilePath(source) + if !ok { + return nil, fmt.Errorf("not a local image source: %q", source) + } + roots, err := s.libraryRoots.LibraryRootsForContent(ctx, contentID) + if err != nil { + return nil, fmt.Errorf("resolving library roots: %w", err) + } + return readConfinedLocalImage(localPath, roots) +} + +// ApplyLocalItemImageRequest describes a local sidecar image an admin chose +// for a movie or series. +type ApplyLocalItemImageRequest struct { + ContentID string + ContentType string // "movie" or "series" + FolderID int + ImageType ImageType + SourceURL string // a file:// choice returned by FetchItemImagesWithLocal +} + +// ApplyLocalItemImage caches a local sidecar image the item's own discovery +// offers. It stores the image under the same local/ key a refresh would use, +// so a later refresh of the same file finds it already cached. +func (s *MetadataService) ApplyLocalItemImage(ctx context.Context, req ApplyLocalItemImageRequest) (*ApplyItemImageResult, error) { + byteCacher, ok := s.imageCacher.(ImageByteCacher) + if !ok { + return nil, fmt.Errorf("image caching does not support local artwork") + } + if !isLocalImageSourcePath(req.SourceURL) { + return nil, ErrLocalImageNotOffered + } + images, err := s.localItemImages(ctx, req.ContentType, req.FolderID, req.ContentID) + if err != nil { + return nil, err + } + offered := false + for _, image := range images { + if image.URL == req.SourceURL && image.Type == req.ImageType { + offered = true + break + } + } + if !offered { + return nil, ErrLocalImageNotOffered + } + + data, err := s.readLocalSidecar(ctx, req.ContentID, req.SourceURL) + if err != nil { + return nil, err + } + digest := sha256.Sum256(data) + result, err := byteCacher.CacheImageBytes(ctx, data, CacheImageRequest{ + SourceURL: req.SourceURL, + ProviderID: imageCacheLocalProviderID, + ContentType: imageCacheContentType(req.ContentType), + ContentID: req.ContentID, + ImageType: req.ImageType, + KeyDiscriminator: hex.EncodeToString(digest[:4]), + }) + if err != nil { + return nil, fmt.Errorf("caching image: %w", err) + } + if result == nil { + return nil, errors.New("image cache returned no result") + } + storedPath := CachedImageOriginalPath(result) + if storedPath == "" { + return nil, errors.New("image cache returned empty stored path") + } + return &ApplyItemImageResult{ + StoredPath: storedPath, + Revision: result.Revision, + Thumbhash: result.Thumbhash, + }, nil +} diff --git a/internal/metadata/local_artwork_picker_test.go b/internal/metadata/local_artwork_picker_test.go new file mode 100644 index 0000000000..c5d937f0d5 --- /dev/null +++ b/internal/metadata/local_artwork_picker_test.go @@ -0,0 +1,401 @@ +package metadata + +import ( + "bytes" + "cmp" + "context" + "encoding/base64" + "errors" + "image" + "image/color" + "image/png" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/Silo-Server/silo-server/internal/models" +) + +// recordingImageProvider returns fixed images and records each request. +type recordingImageProvider struct { + slug string + images []RemoteImage + err error + requests []ImageRequest +} + +func (p *recordingImageProvider) Slug() string { return cmp.Or(p.slug, "nfo") } +func (p *recordingImageProvider) Name() string { return p.Slug() } +func (p *recordingImageProvider) ForTypes() []string { return []string{"series"} } +func (p *recordingImageProvider) GetImages(_ context.Context, req ImageRequest) ([]RemoteImage, error) { + p.requests = append(p.requests, req) + return p.images, p.err +} + +const ( + localSeriesEpisodePath = "/media/Other/Show/Season 1/Show S01E01.mkv" + localMoviePath = "/media/Movies/Film/Film.mkv" +) + +// newLocalPickerServiceForTest wires local as the sidecar discovery and remote +// as the library's whole provider chain, with one media file for each of +// "local-series-1" and "movie-1". +func newLocalPickerServiceForTest(local ImageProvider, remote Provider, roots []string) (*MetadataService, *fakeByteImageCacher) { + if remote == nil { + remote = &recordingImageProvider{slug: "tmdb"} + } + cacher := &fakeByteImageCacher{thumbhash: "th-local"} + service := &MetadataService{chainCache: map[string]chainCacheEntry{ + "7:series": {providers: []Provider{remote}, expiresAt: time.Now().Add(time.Hour)}, + "7:movie": {providers: []Provider{remote}, expiresAt: time.Now().Add(time.Hour)}, + }} + service.localImageProvider = local + files := newFakeFileRepo() + files.contentIDs[1] = "local-series-1" + files.contentIDs[2] = "movie-1" + files.groupFiles["series"] = []*models.MediaFile{{ID: 1, FilePath: localSeriesEpisodePath, MediaFolderID: 7}} + files.groupFiles["movie"] = []*models.MediaFile{{ID: 2, FilePath: localMoviePath, MediaFolderID: 7}} + service.fileRepo = files + service.SetImageCacher(cacher) + service.SetLibraryRootResolver(&fakeLibraryRootResolver{roots: roots}) + return service, cacher +} + +func localApplyRequest(sourceURL string, imageType ImageType) ApplyLocalItemImageRequest { + return ApplyLocalItemImageRequest{ + ContentID: "local-series-1", + ContentType: "series", + FolderID: 7, + ImageType: imageType, + SourceURL: sourceURL, + } +} + +// An offered local image is cached under the same local/ key a refresh of +// that file would use, so a later refresh finds it already cached. +func TestApplyLocalItemImageCachesOfferedFile(t *testing.T) { + root := t.TempDir() + source := "file://" + writeLocalPoster(t, root) + provider := &recordingImageProvider{images: []RemoteImage{{ProviderID: "nfo", URL: source, Type: ImagePoster}}} + service, cacher := newLocalPickerServiceForTest(provider, nil, []string{root}) + + result, err := service.ApplyLocalItemImage(context.Background(), localApplyRequest(source, ImagePoster)) + if err != nil { + t.Fatalf("ApplyLocalItemImage: %v", err) + } + if len(cacher.bytesReq) != 1 { + t.Fatalf("CacheImageBytes calls = %d, want 1", len(cacher.bytesReq)) + } + req := cacher.bytesReq[0] + if req.ProviderID != "local" || req.ContentType != "series" || req.ContentID != "local-series-1" || req.SourceURL != source || len(req.KeyDiscriminator) != 8 { + t.Fatalf("cache request = %+v, want the refresh path's local key", req) + } + want := "local/series/local-series-1/" + req.KeyDiscriminator + "/poster/original.webp" + if result.StoredPath != want || result.Thumbhash != "th-local" { + t.Fatalf("result = %+v, want stored path %q", result, want) + } +} + +// The apply endpoint accepts a file:// URL from the request, so it must only +// read files the item's own discovery offered, for the type it offered them. +func TestApplyLocalItemImageRejectsFilesNotOffered(t *testing.T) { + root := t.TempDir() + offered := "file://" + writeLocalPoster(t, root) + other := filepath.Join(root, "other.jpg") + if err := os.WriteFile(other, []byte("other-bytes"), 0o644); err != nil { + t.Fatal(err) + } + provider := &recordingImageProvider{images: []RemoteImage{{ProviderID: "nfo", URL: offered, Type: ImagePoster}}} + service, cacher := newLocalPickerServiceForTest(provider, nil, []string{root}) + + for name, req := range map[string]ApplyLocalItemImageRequest{ + "file not offered": localApplyRequest("file://"+other, ImagePoster), + "offered as poster": localApplyRequest(offered, ImageBackdrop), + "remote source": localApplyRequest("https://image.example/poster.jpg", ImagePoster), + "relative file scheme": localApplyRequest("file://../poster.jpg", ImagePoster), + } { + if _, err := service.ApplyLocalItemImage(context.Background(), req); !errors.Is(err, ErrLocalImageNotOffered) { + t.Errorf("%s: err = %v, want ErrLocalImageNotOffered", name, err) + } + } + if len(cacher.bytesReq) != 0 { + t.Fatalf("cached %d images, want none", len(cacher.bytesReq)) + } +} + +// Discovery offering a file does not bypass the library-root confinement. +func TestApplyLocalItemImageRejectsOfferedFileOutsideRoots(t *testing.T) { + root := t.TempDir() + outside := "file://" + writeLocalPoster(t, t.TempDir()) + provider := &recordingImageProvider{images: []RemoteImage{{ProviderID: "nfo", URL: outside, Type: ImagePoster}}} + service, cacher := newLocalPickerServiceForTest(provider, nil, []string{root}) + + _, err := service.ApplyLocalItemImage(context.Background(), localApplyRequest(outside, ImagePoster)) + if err == nil || !strings.Contains(err.Error(), "outside library roots") { + t.Fatalf("err = %v, want outside library roots", err) + } + if len(cacher.bytesReq) != 0 { + t.Fatalf("cached %d images, want none", len(cacher.bytesReq)) + } +} + +func TestLocalImagePreviewReturnsWebPDataURI(t *testing.T) { + root := t.TempDir() + poster := filepath.Join(root, "poster.png") + img := image.NewNRGBA(image.Rect(0, 0, 600, 900)) + for y := range 900 { + for x := range 600 { + img.Set(x, y, color.NRGBA{R: uint8(x), G: uint8(y), B: 90, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(poster, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } + service, _ := newLocalPickerServiceForTest(&recordingImageProvider{}, nil, []string{root}) + + preview, err := service.LocalImagePreview(context.Background(), "local-series-1", "file://"+poster) + if err != nil { + t.Fatalf("LocalImagePreview: %v", err) + } + encoded, ok := strings.CutPrefix(preview, "data:image/webp;base64,") + if !ok { + t.Fatalf("preview = %.40q..., want a WebP data URI", preview) + } + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || len(data) < 12 || string(data[8:12]) != "WEBP" { + t.Fatalf("preview is not WebP (err %v)", err) + } + + if _, err := service.LocalImagePreview(context.Background(), "local-series-1", "file://"+writeLocalPoster(t, t.TempDir())); err == nil { + t.Fatal("preview of a file outside the library roots succeeded") + } +} + +// Local choices come only from sidecar discovery, which gets the item's file +// context even when the library's chain does not use the NFO provider. A +// file:// URL from a chain provider is neither listed nor applyable. +func TestFetchItemImagesWithLocalTrustsOnlySidecarDiscovery(t *testing.T) { + root := t.TempDir() + poster := "file://" + writeLocalPoster(t, root) + elsewhere := filepath.Join(root, "elsewhere.jpg") + if err := os.WriteFile(elsewhere, []byte("elsewhere-bytes"), 0o644); err != nil { + t.Fatal(err) + } + local := &recordingImageProvider{images: []RemoteImage{{ProviderID: "nfo", URL: poster, Type: ImagePoster}}} + remote := &recordingImageProvider{slug: "tmdb", images: []RemoteImage{ + {ProviderID: "tmdb", URL: "https://image.example/poster.jpg", Type: ImagePoster, Rating: 7}, + {ProviderID: "tmdb", URL: "file://" + elsewhere, Type: ImagePoster, Rating: 9}, + }} + service, cacher := newLocalPickerServiceForTest(local, remote, []string{root}) + + images, _, err := service.FetchItemImagesWithLocal(context.Background(), map[string]string{"tmdb": "1"}, "series", "en", 7, "local-series-1") + if err != nil { + t.Fatal(err) + } + var urls []string + for _, image := range images { + urls = append(urls, image.URL) + } + if want := []string{"https://image.example/poster.jpg", poster}; !slices.Equal(urls, want) { + t.Fatalf("images = %v, want %v", urls, want) + } + if len(local.requests) != 1 || local.requests[0].RepresentativeFilePath != localSeriesEpisodePath { + t.Fatalf("sidecar discovery requests = %+v, want the item's media file", local.requests) + } + if len(remote.requests) != 1 || remote.requests[0].RepresentativeFilePath != "" || len(remote.requests[0].PrimarySidecarSearchPaths) != 0 { + t.Fatalf("chain requests = %+v, want no local context", remote.requests) + } + + _, err = service.ApplyLocalItemImage(context.Background(), localApplyRequest("file://"+elsewhere, ImagePoster)) + if !errors.Is(err, ErrLocalImageNotOffered) || len(cacher.bytesReq) != 0 { + t.Fatalf("apply of a chain provider's file = %v (cached %d), want ErrLocalImageNotOffered", err, len(cacher.bytesReq)) + } +} + +// Movies use the plural content type in their cache key, like the refresh +// path's enqueueItemImages does. +func TestApplyLocalItemImageUsesTheRefreshKeyForMovies(t *testing.T) { + root := t.TempDir() + source := "file://" + writeLocalPoster(t, root) + provider := &recordingImageProvider{images: []RemoteImage{{ProviderID: "nfo", URL: source, Type: ImagePoster}}} + service, cacher := newLocalPickerServiceForTest(provider, nil, []string{root}) + req := localApplyRequest(source, ImagePoster) + req.ContentID, req.ContentType = "movie-1", "movie" + + result, err := service.ApplyLocalItemImage(context.Background(), req) + if err != nil { + t.Fatalf("ApplyLocalItemImage: %v", err) + } + if len(provider.requests) != 1 || provider.requests[0].RepresentativeFilePath != localMoviePath { + t.Fatalf("sidecar discovery requests = %+v, want the movie's file", provider.requests) + } + cacheReq := cacher.bytesReq[0] + want := "local/movies/movie-1/" + cacheReq.KeyDiscriminator + "/poster/original.webp" + if cacheReq.ContentType != "movies" || result.StoredPath != want { + t.Fatalf("content type %q, stored path %q, want movies and %q", cacheReq.ContentType, result.StoredPath, want) + } +} + +// A directory symlink inside the library that points outside it cannot pull +// an outside file in, even when discovery offers the linked path. +func TestApplyLocalItemImageRejectsSymlinkedDirectoryEscape(t *testing.T) { + root := t.TempDir() + outside := t.TempDir() + writeLocalPoster(t, outside) + if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + source := "file://" + filepath.Join(root, "linked", "poster.jpg") + provider := &recordingImageProvider{images: []RemoteImage{{ProviderID: "nfo", URL: source, Type: ImagePoster}}} + service, cacher := newLocalPickerServiceForTest(provider, nil, []string{root}) + + _, err := service.ApplyLocalItemImage(context.Background(), localApplyRequest(source, ImagePoster)) + if err == nil || !strings.Contains(err.Error(), "outside library roots") { + t.Fatalf("err = %v, want outside library roots", err) + } + if len(cacher.bytesReq) != 0 { + t.Fatalf("cached %d images, want none", len(cacher.bytesReq)) + } +} + +type countingObservedLocationRepo struct { + gets int +} + +func (r *countingObservedLocationRepo) Get(context.Context, int, string) (*models.ObservedMediaLocation, error) { + r.gets++ + return &models.ObservedMediaLocation{ContentGroupCount: 1}, nil +} + +// The picker lists a series' local artwork on every page and again on apply, +// so the sidecar directory check runs once per distinct observed root rather +// than once per episode file. +func TestDirectorySidecarSearchPathsChecksEachRootOnce(t *testing.T) { + locations := &countingObservedLocationRepo{} + service := &MetadataService{observedLocationRepo: locations} + files := make([]*models.MediaFile, 0, 301) + for i := range 300 { + files = append(files, &models.MediaFile{ID: i + 1, MediaFolderID: 7, ObservedRootPath: "/media/Other/Show", ContentGroupKey: "show"}) + } + files = append(files, &models.MediaFile{ID: 301, MediaFolderID: 7, ObservedRootPath: "/media/Other/Show Extras", ContentGroupKey: "show"}) + + paths := service.directorySidecarSearchPathsForFiles(context.Background(), files) + if locations.gets != 2 { + t.Fatalf("observed location lookups = %d, want one per distinct root (2)", locations.gets) + } + if len(paths) != 2 || paths[0] != "/media/Other/Show" || paths[1] != "/media/Other/Show Extras" { + t.Fatalf("paths = %v", paths) + } +} + +// Sidecar discovery does not depend on the provider chain, so a chain that +// fails to resolve still lists the item's local artwork and reports the +// failure instead of failing the whole listing. +func TestFetchItemImagesWithLocalSurvivesChainFailure(t *testing.T) { + root := t.TempDir() + poster := "file://" + writeLocalPoster(t, root) + local := &recordingImageProvider{images: []RemoteImage{{ProviderID: "nfo", URL: poster, Type: ImagePoster}}} + service, _ := newLocalPickerServiceForTest(local, nil, []string{root}) + pool, err := pgxpool.New(context.Background(), "postgres://silo@127.0.0.1:1/silo?connect_timeout=1") + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + service.chainRepo = NewChainRepository(pool) + service.chainCache = map[string]chainCacheEntry{} + + images, providerErrors, err := service.FetchItemImagesWithLocal(context.Background(), nil, "series", "en", 7, "local-series-1") + if err != nil { + t.Fatalf("FetchItemImagesWithLocal: %v", err) + } + if len(images) != 1 || images[0].URL != poster { + t.Fatalf("images = %+v, want only the local poster", images) + } + if got := providerErrors["chain"]; got != "Image provider chain unavailable" { + t.Fatalf("provider errors = %v, want the fixed chain failure message", providerErrors) + } +} + +// A discovery failure is reported by source only. Its error text can carry +// paths or provider details, so it stays in the log and out of the listing. +func TestFetchItemImagesWithLocalReportsDiscoveryFailureWithoutItsText(t *testing.T) { + local := &recordingImageProvider{err: errors.New("nfo read failed: desc = api_key=secret-value /media/Other/Show/tvshow.nfo")} + service, _ := newLocalPickerServiceForTest(local, nil, []string{t.TempDir()}) + + _, providerErrors, err := service.FetchItemImagesWithLocal(context.Background(), map[string]string{"tmdb": "1"}, "series", "en", 7, "local-series-1") + if err != nil { + t.Fatalf("FetchItemImagesWithLocal: %v", err) + } + got := providerErrors[imageCacheLocalProviderID] + if got != "Local artwork discovery failed" { + t.Fatalf("provider errors = %v, want the fixed local failure message", providerErrors) + } + if strings.Contains(got, "api_key") || strings.Contains(got, "/media/") { + t.Fatalf("local failure message %q leaks the underlying error", got) + } +} + +// The discovery failure is still logged for the admin, with credential +// assignments masked, including one nested inside another assignment. +func TestFetchItemImagesWithLocalLogsDiscoveryFailureWithoutSecrets(t *testing.T) { + var logs bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + local := &recordingImageProvider{err: errors.New("nfo read failed: desc = api_key=secret-value")} + service, _ := newLocalPickerServiceForTest(local, nil, []string{t.TempDir()}) + if _, _, err := service.FetchItemImagesWithLocal(context.Background(), map[string]string{"tmdb": "1"}, "series", "en", 7, "local-series-1"); err != nil { + t.Fatalf("FetchItemImagesWithLocal: %v", err) + } + out := logs.String() + if !strings.Contains(out, "local artwork discovery failed") || !strings.Contains(out, "nfo read failed") { + t.Fatalf("log = %q, want the discovery failure with its context", out) + } + if strings.Contains(out, "secret-value") { + t.Fatalf("log = %q, leaks the api_key value", out) + } +} + +// Discovery returns fingerprinted sidecar sources, image URLs an NFO names, +// and slots the picker cannot apply. Only sidecar files of an applicable type +// are offered, and an offered fingerprinted source applies. +func TestFetchItemImagesWithLocalOffersApplicableSidecarFiles(t *testing.T) { + root := t.TempDir() + poster := fingerprintedSource(t, writeLocalPoster(t, root)) + banner := filepath.Join(root, "banner.jpg") + if err := os.WriteFile(banner, []byte("banner-bytes"), 0o644); err != nil { + t.Fatal(err) + } + local := &recordingImageProvider{images: []RemoteImage{ + {ProviderID: "nfo", URL: poster, Type: ImagePoster}, + {ProviderID: "nfo", URL: fingerprintedSource(t, banner), Type: ImageBanner}, + {ProviderID: "nfo", URL: LocalMetadataURLSource("https://image.example/fanart.jpg"), Type: ImageBackdrop}, + }} + service, cacher := newLocalPickerServiceForTest(local, nil, []string{root}) + + images, providerErrors, err := service.FetchItemImagesWithLocal(context.Background(), nil, "series", "en", 7, "local-series-1") + if err != nil || len(providerErrors) != 0 { + t.Fatalf("FetchItemImagesWithLocal: err %v, provider errors %v", err, providerErrors) + } + if len(images) != 1 || images[0].URL != poster { + t.Fatalf("images = %+v, want only the fingerprinted poster", images) + } + + if _, err := service.ApplyLocalItemImage(context.Background(), localApplyRequest(poster, ImagePoster)); err != nil { + t.Fatalf("ApplyLocalItemImage: %v", err) + } + if len(cacher.bytesReq) != 1 || cacher.bytesReq[0].SourceURL != poster { + t.Fatalf("cache requests = %+v, want the fingerprinted poster", cacher.bytesReq) + } +} diff --git a/internal/metadata/service.go b/internal/metadata/service.go index 860edb0a73..2f0c9ae6cb 100644 --- a/internal/metadata/service.go +++ b/internal/metadata/service.go @@ -465,8 +465,11 @@ type MetadataService struct { imageCacher ImageCacher imageCacheJobs ImageCacheJobEnqueuer localArtwork LocalArtworkCopier - autoCacheImages atomic.Bool // hot-reloaded from metadata.cache_images - imageResolver interface { + libraryRoots LibraryRootResolver + // localImageProvider overrides the built-in sidecar discovery in tests. + localImageProvider ImageProvider + autoCacheImages atomic.Bool // hot-reloaded from metadata.cache_images + imageResolver interface { ResolveImageURL(ctx context.Context, path string, variant string) string } @@ -611,6 +614,12 @@ func (s *MetadataService) SetImageCacheJobEnqueuer(enqueuer ImageCacheJobEnqueue s.imageCacheJobs = enqueuer } +// SetLibraryRootResolver sets the resolver that confines local sidecar artwork +// reads for the admin image picker to the item's library roots. +func (s *MetadataService) SetLibraryRootResolver(resolver LibraryRootResolver) { + s.libraryRoots = resolver +} + // SetAutoCacheImages controls whether refresh pipelines automatically cache // provider images into object storage. Explicit admin image applies still use // the configured image cacher when available, and local sidecar artwork is @@ -1193,14 +1202,28 @@ func (s *MetadataService) collectDirectorySidecarSearchPaths(files []*models.Med if len(files) == 0 { return nil, nil } + // Every episode of a series usually shares one observed root, so check + // each distinct root and group once rather than once per file. + type observedRootKey struct { + folderID int + root string + groupKeyVersion int + contentGroupKey string + } + checkedRoots := make(map[observedRootKey]bool) paths := make([]string, 0, len(files)) for _, file := range files { if file == nil || file.ObservedRootPath == "" { continue } - ok, err := usable(file) - if err != nil { - return nil, err + key := observedRootKey{file.MediaFolderID, file.ObservedRootPath, file.GroupKeyVersion, file.ContentGroupKey} + ok, checked := checkedRoots[key] + if !checked { + var err error + if ok, err = usable(file); err != nil { + return nil, err + } + checkedRoots[key] = ok } if ok { paths = append(paths, file.ObservedRootPath) @@ -9166,8 +9189,7 @@ func primaryProviderID(ids map[string]string) string { // providerIDs should come from the parent MediaItem (for seasons/episodes, // the caller resolves up to the series item). contentType is "movie" or "series". func (s *MetadataService) FetchItemImages(ctx context.Context, providerIDs map[string]string, contentType string, language string, folderID int) ([]RemoteImage, map[string]string, error) { - contentLevel := contentType - chain, err := s.resolveChainCached(ctx, folderID, contentLevel) + chain, err := s.resolveChainCached(ctx, folderID, contentType) if err != nil { return nil, nil, fmt.Errorf("resolving provider chain: %w", err) } From 3583af84a867f368cd1954b1d789a85ef7aaccb0 Mon Sep 17 00:00:00 2001 From: blurbery <271863866+blurbery@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:00:08 +1100 Subject: [PATCH 004/196] fix(playback): keep the requested version when only its subtitle is blocked (#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 --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/architecture/playback-protocol-v3.md | 12 +- internal/api/handlers/playback_v3.go | 69 +++- .../playback_v3_subtitle_version_test.go | 380 ++++++++++++++++++ internal/playback/protocol_v3.go | 9 + 4 files changed, 462 insertions(+), 8 deletions(-) create mode 100644 internal/api/handlers/playback_v3_subtitle_version_test.go diff --git a/docs/architecture/playback-protocol-v3.md b/docs/architecture/playback-protocol-v3.md index 100fa05e0d..1ee7dc8df3 100644 --- a/docs/architecture/playback-protocol-v3.md +++ b/docs/architecture/playback-protocol-v3.md @@ -994,11 +994,15 @@ fallback to another media version must remap the selected subtitle. It prefers a version with an equivalent track; when none has one, playback continues with subtitles off and the plan carries `subtitle_track_unavailable` rather than ending in a terminal. A selection the effective file cannot honor -on a direct start degrades the same way. A replan that would have to drop the +on a direct start degrades the same way. When the file being planned was +refused with `subtitle_conversion_unsupported` and the only other versions that +play drop the subtitle, the server plays that file with subtitles off instead +of moving to one of them, provided that plan can run; when no other version +plays, the terminal stands. A replan that would have to drop the subtitle to return to the requested version stays on the version already playing, unless quality `original` pins the requested version; the requested -version remains a fallback without the subtitle if no version that keeps it can -play. Malformed +version remains a fallback without the subtitle, ahead of any other version +without it, if no version that keeps it can play. Malformed selections (a track identity that does not parse or names another file, or a negative index) are still rejected. @@ -1240,7 +1244,7 @@ The plan will play, but something the user might notice was given up. | `bandwidth_cap_applied` | `bandwidth_cap_kbps` limited the selection | | `evidence_insufficient_for_direct` | Evidence tier blocked a direct route | | `audio_track_unavailable` | The selected audio track is not on the effective file; its default track plays | -| `subtitle_track_unavailable` | The selected subtitle has no equivalent on the effective file; playback starts with subtitles off | +| `subtitle_track_unavailable` | The selected subtitle has no equivalent on the effective file, or is on it but cannot be shown there; playback continues with subtitles off. `message` says which | ### 7.3 Terminal reasons diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 1f265ae358..081b0b3a6f 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -1807,13 +1807,28 @@ func (h *PlaybackHandler) startPlaybackApplicationV3(r *http.Request, body []byt firstFailureAudioIndex = candidateAudioIndex } } + subtitleNotShown := false + if result.Terminal != nil && droppedSubtitle != nil && subtitleRefusalV3(result.Terminal) { + // The planner pinned the requested file's refusal on the + // subtitle, and every sibling that plays drops the subtitle + // too. Playing the requested file without it keeps its + // picture, when that plan can run. + if candidate := h.planWithoutSubtitleV3(r.Context(), playback.PlannerInputV3{Request: baseReq, RequestedFile: requestedFile, EffectiveFile: effectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, effectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: settings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)}); candidate != nil { + droppedSubtitle = candidate + subtitleNotShown = true + } + } if result.Terminal != nil && droppedSubtitle != nil { req = droppedSubtitle.request effectiveFile = droppedSubtitle.file audioIndex = droppedSubtitle.audioIndex result = droppedSubtitle.result toneMapCapabilityErr = droppedSubtitle.toneMapErr - warnings = append(warnings, playback.SubtitleTrackUnavailableWarningV3()) + if subtitleNotShown { + warnings = append(warnings, playback.SubtitleNotShownWarningV3()) + } else { + warnings = append(warnings, playback.SubtitleTrackUnavailableWarningV3()) + } } else if result.Terminal != nil && firstFailureFile != nil { req = firstFailureReq effectiveFile = firstFailureFile @@ -4842,6 +4857,9 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte r = r.WithContext(withServerBitrateCapV3(r.Context(), record.ServerBitrateCapKbps)) audioDegraded := false subtitleDegraded := false + // subtitleNotShown marks a subtitle the effective file has but cannot + // show, as opposed to one the file lacks. + subtitleNotShown := false reservationHeld := false reservationHandedOff := false cancelReservation := func() { @@ -5196,8 +5214,10 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte } if candidateResult.Terminal == nil && subtitleDropped { // Prefer a version that keeps the viewer's subtitle; hold - // this one back in case none does. - if droppedSubtitle == nil { + // this one back in case none does. The requested edition, + // listed last when it was passed over only for the + // subtitle, outranks any other version held back. + if droppedSubtitle == nil || candidateFile.ID == requestedFile.ID { droppedSubtitle = &alternateCandidateV3{file: candidateFile, request: candidateStart, audioIndex: candidateAudioIndex, result: candidateResult, toneMapErr: candidateToneMapErr} } continue @@ -5218,6 +5238,18 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte firstFailureAudioIndex = candidateAudioIndex } } + // A held-back requested edition is already the picture the + // viewer asked for, so it keeps its place. + if result.Terminal != nil && droppedSubtitle != nil && droppedSubtitle.file.ID != requestedFile.ID && subtitleRefusalV3(result.Terminal) { + // The planner pinned the effective file's refusal on the + // subtitle, and every sibling that plays drops the subtitle + // too. Playing the effective file without it keeps its + // picture, when that plan can run. + if candidate := h.planWithoutSubtitleV3(r.Context(), playback.PlannerInputV3{Request: baseStart, RequestedFile: plannerRequestedFile, EffectiveFile: baseEffectiveFile, LowerVersion: lowerVersionForFileV3(lowerVersion, baseEffectiveFile), ServerBitrateCapKbps: serverBitrateCapV3(r.Context()), AudioTrackIndex: baseAudioIndex, Settings: plannerSettings, Registry: h.transformationRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), baseEffectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), baseEffectiveFile)}); candidate != nil { + droppedSubtitle = candidate + subtitleNotShown = true + } + } if result.Terminal != nil && droppedSubtitle != nil { start = droppedSubtitle.request effectiveFile = droppedSubtitle.file @@ -5267,7 +5299,11 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte result.Plan.DegradationWarnings = append(result.Plan.DegradationWarnings, playback.AudioTrackUnavailableWarningV3()) } if subtitleDegraded { - result.Plan.DegradationWarnings = appendWarningOnceV3(result.Plan.DegradationWarnings, playback.SubtitleTrackUnavailableWarningV3()) + warning := playback.SubtitleTrackUnavailableWarningV3() + if subtitleNotShown { + warning = playback.SubtitleNotShownWarningV3() + } + result.Plan.DegradationWarnings = appendWarningOnceV3(result.Plan.DegradationWarnings, warning) } session, err := h.sessionMgr.GetSession(record.SessionID) if err != nil { @@ -6040,6 +6076,31 @@ func terminalAllowsAlternateFileV3(terminal *playback.TerminalV3) bool { } } +// subtitleRefusalV3 reports a refusal the planner pinned on the selected +// subtitle: a burn-in that was the only reason for an adaptation the file +// cannot take, or a subtitle the fidelity policy cannot serve. The file may +// then play with the subtitle cleared; callers re-plan it to find out. +func subtitleRefusalV3(terminal *playback.TerminalV3) bool { + return terminal != nil && terminal.Reason == terminalSubtitleConversionUnsupportedV3 +} + +// planWithoutSubtitleV3 plans input's file again with the selected subtitle +// cleared. It returns nil unless that plan can run: a progressive remux this +// attempt cannot reach must escalate here, because the caller drops a sibling +// that already plays in favor of the result. +func (h *PlaybackHandler) planWithoutSubtitleV3(ctx context.Context, input playback.PlannerInputV3) *alternateCandidateV3 { + input.Request.SubtitleTrackID, input.Request.SubtitleTrackIndex = "", nil + result, toneMapErr := h.planPlaybackWithCapabilitiesV3(ctx, input) + if result.Terminal != nil { + return nil + } + result, escalateErr := h.escalateRefusedProgressiveRemuxV3(ctx, headerAuthenticatedMediaV3(input.Request.ClientFeatures), func() playback.PlannerInputV3 { return input }, result) + if escalateErr != nil { + return nil + } + return &alternateCandidateV3{file: input.EffectiveFile, request: input.Request, audioIndex: input.AudioTrackIndex, result: result, toneMapErr: toneMapErr} +} + // alternateCandidateV3 is a planned alternate version held back while the // fallback loop looks for a better one. type alternateCandidateV3 struct { diff --git a/internal/api/handlers/playback_v3_subtitle_version_test.go b/internal/api/handlers/playback_v3_subtitle_version_test.go new file mode 100644 index 0000000000..7a9793a067 --- /dev/null +++ b/internal/api/handlers/playback_v3_subtitle_version_test.go @@ -0,0 +1,380 @@ +package handlers + +import ( + "encoding/json" + "maps" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/playback" +) + +// subtitleBlockedHDRFixtureV3 is a 4K HDR10 HEVC file with an English PGS +// subtitle and a 1080p SDR H.264 sibling without one. The client direct +// plays the 4K file but cannot render PGS, and the server cannot re-encode +// HDR, so burning the subtitle in is the only thing the 4K file cannot do. +func subtitleBlockedHDRFixtureV3(t *testing.T) (*PlaybackHandler, *models.MediaFile, *models.MediaFile, playback.StartRequestV3) { + t.Helper() + source := v3HandlerFixtureFile(t) + source.Container = "mkv" + source.FilePath = writePlaybackTestMediaFile(t, "movie-4k.mkv") + source.CodecVideo = "hevc" + source.Resolution = "2160p" + source.Bitrate = 64_000 + source.VideoTracks = []models.VideoTrack{{ + Codec: "hevc", Profile: "main 10", Level: 153, Width: 3840, Height: 2160, + FrameRate: "24000/1001", Bitrate: 64_000, BitDepth: 10, + VideoRange: "HDR10", VideoRangeType: "HDR10", + }} + source.SubtitleTracks = []models.SubtitleTrack{{Index: 4, Codec: "hdmv_pgs_subtitle", Language: "eng", Title: "English"}} + + alternateValue := *source + alternate := &alternateValue + alternate.ID = 84 + alternate.FilePath = writePlaybackTestMediaFile(t, "movie-1080p.mkv") + alternate.CodecVideo = "h264" + alternate.Resolution = "1080p" + alternate.Bitrate = 8_000 + alternate.VideoTracks = []models.VideoTrack{{ + Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, + FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, + VideoRange: "SDR", VideoRangeType: "SDR", + }} + alternate.SubtitleTracks = nil + + files := map[int]*models.MediaFile{source.ID: source, alternate.ID: alternate} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), mapPlaybackFileResolver{files: files}) + handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{ + source.ContentID: {source, alternate}, + }} + handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"transcode_enabled": "true", "allow_4k_transcode": "true"}} + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{ + {Name: playback.TransformationAudioToAACV3, RecipeVersion: playback.TransformationAudioToAACRecipeVersionV3, Available: true}, + {Name: playback.TransformationVideoToH264V3, RecipeVersion: "2", Available: true}, + })) + handler.ItemAccess = allowAllPlaybackItemAccess{} + + startRequest := v3HandlerStartRequest() + startRequest.QualityPreference = "auto" + startRequest.Capabilities.CodecsVideo = []string{"hevc", "h264"} + startRequest.Capabilities.CodecsVideoHardware = []string{"hevc", "h264"} + startRequest.Capabilities.Containers = []string{"mkv", "hls"} + startRequest.Capabilities.MaxResolution = "2160p" + startRequest.Capabilities.VideoDecode = []playback.VideoDecodeCapabilityV3{{ + Codec: "hevc", Profiles: []string{"main 10"}, Levels: []int{153}, BitDepths: []int{10}, + MaxWidth: 3840, MaxHeight: 2160, MaxFrameRate: 60, MaxBitrateKbps: 80_000, Hardware: true, + }} + hdr := &playback.HDRCapabilitiesV3{HDR10: true} + startRequest.Capabilities.HDRDetails = hdr + startRequest.ClientPlaybackContext.Output.HDRDetails = hdr + startRequest.ClientPlaybackContext.Deliveries = map[string]playback.DeliveryCapabilityV3{ + playback.DeliveryClassOriginalHTTPV3: { + Enabled: true, SupportedOnDevice: true, Containers: []string{"mkv"}, + VideoCodecs: []string{"hevc", "h264"}, AudioDecodeCodecs: []string{"aac"}, + }, + playback.DeliveryClassHLSV3: { + Enabled: true, SupportedOnDevice: true, Containers: []string{"hls"}, + VideoCodecs: []string{"h264"}, AudioDecodeCodecs: []string{"aac"}, + }, + } + return handler, source, alternate, startRequest +} + +// requireRequestedVersionWithoutSubtitleV3 fails unless plan stays on the +// requested 4K file, plays it without converting the video, and reports the +// subtitle it could not show exactly once. +func requireRequestedVersionWithoutSubtitleV3(t *testing.T, plan *playback.PlanV3, source *models.MediaFile) { + t.Helper() + if plan.EffectiveMediaFileID != source.ID { + t.Fatalf("effective file = %d, want the requested 4K file %d: only its subtitle was blocked", plan.EffectiveMediaFileID, source.ID) + } + if plan.Subtitle.Mode != playback.SubtitleOffV3 || plan.SelectedTracks.Subtitle != nil { + t.Fatalf("subtitle = %#v selected = %#v, want subtitles off", plan.Subtitle, plan.SelectedTracks.Subtitle) + } + if plan.EffectiveRecipe.VideoCodec != "hevc" || !plan.Claims.Video.HDR10 { + t.Fatalf("recipe = %#v claims = %#v, want the 4K HDR10 HEVC picture", plan.EffectiveRecipe, plan.Claims.Video) + } + warned := 0 + for _, warning := range plan.DegradationWarnings { + if warning.Code == playback.DegradationWarningSubtitleTrackUnavailableV3 { + warned++ + } + } + if warned != 1 { + t.Fatalf("subtitle_track_unavailable reported %d times: %#v", warned, plan.DegradationWarnings) + } + if !slices.Contains(plan.DegradationWarnings, playback.SubtitleNotShownWarningV3()) { + t.Fatalf("warnings = %#v, want the subtitle reported as on the file but not shown", plan.DegradationWarnings) + } +} + +// A subtitle that only the requested file cannot show is no reason to switch +// to a lower version that has no subtitle either: the viewer would lose the +// 4K picture and still get no subtitle. Playback starts on the requested +// file with the subtitle off and says so. +func TestHandleStartPlaybackV3SubtitleOnlyRefusalKeepsTheRequestedVersion(t *testing.T) { + handler, source, _, startRequest := subtitleBlockedHDRFixtureV3(t) + subtitleIndex := 0 + startRequest.SubtitleTrackID = playback.TrackIDV3(source.ID, "subtitle", subtitleIndex) + startRequest.SubtitleTrackIndex = &subtitleIndex + + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil { + t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String()) + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + requireRequestedVersionWithoutSubtitleV3(t, started.PlaybackPlan, source) +} + +// The same holds when the viewer turns the subtitle on during playback. +func TestHandleReplanPlaybackV3SubtitleOnlyRefusalKeepsTheRequestedVersion(t *testing.T) { + handler, source, _, startRequest := subtitleBlockedHDRFixtureV3(t) + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil { + t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String()) + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + if started.PlaybackPlan.EffectiveMediaFileID != source.ID { + t.Fatalf("start effective file = %d, want the 4K file %d", started.PlaybackPlan.EffectiveMediaFileID, source.ID) + } + + subtitleIndex := 0 + replanned := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationTrackChangeV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "subtitle-only-refusal-0001", + FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "subtitle-only-refusal-attempt-0001", + PlanAttemptKey: started.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 30, + QualityPreference: "auto", + SelectedTracks: playback.SelectedTracksV3{ + Audio: started.PlaybackPlan.SelectedTracks.Audio, + Subtitle: &playback.TrackIdentityV3{ + ID: playback.TrackIDV3(source.ID, "subtitle", subtitleIndex), Index: &subtitleIndex, + }, + }, + Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext, + }) + if replanned.PlaybackPlan == nil || replanned.Terminal != nil { + t.Fatalf("subtitle replan = %#v", replanned) + } + requireRequestedVersionWithoutSubtitleV3(t, replanned.PlaybackPlan, source) +} + +// outputChangeAfterKeptAlternateV3 starts on the 4K edition (every other +// version is unreadable at start), turns its English PGS on, repairs the other +// versions, and then switches to an output that cannot draw PGS. The active +// 4K edition was kept only for that subtitle, so the returned plan shows +// where playback goes once the subtitle cannot be shown there either. +func outputChangeAfterKeptAlternateV3(t *testing.T, handler *PlaybackHandler, active, requested *models.MediaFile, startRequest playback.StartRequestV3, others ...*models.MediaFile) *playback.PlanV3 { + t.Helper() + failedAt := time.Now().UTC() + unreadable := append([]*models.MediaFile{requested}, others...) + repaired := make([]models.MediaFile, len(unreadable)) + for i, file := range unreadable { + repaired[i] = *file + *file = models.MediaFile{ID: file.ID, ContentID: file.ContentID, FilePath: file.FilePath, ProbeFailedAt: &failedAt} + } + startRequest.FileID = requested.ID + original := startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] + original.Subtitles.EmbeddedBitmap = true + startRequest.ClientPlaybackContext.Deliveries = maps.Clone(startRequest.ClientPlaybackContext.Deliveries) + startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original + + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil || started.PlaybackPlan.EffectiveMediaFileID != active.ID { + t.Fatalf("start did not land on the 4K edition: status=%d body=%s", rec.Code, rec.Body.String()) + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + for i, file := range unreadable { + *file = repaired[i] + } + + subtitleIndex := 0 + subtitled := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationTrackChangeV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "kept-edition-track-0001", + FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "kept-edition-track-attempt-0001", + PlanAttemptKey: started.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 30, + QualityPreference: "auto", + SelectedTracks: playback.SelectedTracksV3{ + Audio: started.PlaybackPlan.SelectedTracks.Audio, + Subtitle: &playback.TrackIdentityV3{ID: playback.TrackIDV3(active.ID, "subtitle", subtitleIndex), Index: &subtitleIndex}, + }, + Capabilities: startRequest.Capabilities, ClientPlaybackContext: startRequest.ClientPlaybackContext, + }) + if subtitled.PlaybackPlan == nil || subtitled.PlaybackPlan.EffectiveMediaFileID != active.ID || subtitled.PlaybackPlan.SelectedTracks.Subtitle == nil { + t.Fatalf("the PGS on the 4K edition was not selected: %#v", subtitled) + } + + // The new output cannot draw PGS, so the 4K edition would need a burn-in + // its HDR picture cannot take. + nextContext := startRequest.ClientPlaybackContext + nextContext.Output.OutputContextID = "route-2" + nextContext.Deliveries = maps.Clone(startRequest.ClientPlaybackContext.Deliveries) + original.Subtitles.EmbeddedBitmap = false + nextContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original + replanned := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationOutputChangeV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "kept-edition-output-0001", + FailedPlanID: subtitled.PlaybackPlan.PlanID, PlanAttemptID: "kept-edition-output-attempt-0001", + PlanAttemptKey: subtitled.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 40, + QualityPreference: "auto", + SelectedTracks: subtitled.PlaybackPlan.SelectedTracks, + Capabilities: startRequest.Capabilities, ClientPlaybackContext: nextContext, + }) + if replanned.PlaybackPlan == nil || replanned.Terminal != nil { + t.Fatalf("output change = %#v", replanned) + } + return replanned.PlaybackPlan +} + +// requireRequestedEditionWithoutSubtitleV3 fails unless plan is back on the +// requested edition with the subtitle off and reported as not on that file. +func requireRequestedEditionWithoutSubtitleV3(t *testing.T, plan *playback.PlanV3, requested *models.MediaFile) { + t.Helper() + if plan.EffectiveMediaFileID != requested.ID || plan.SelectedTracks.Subtitle != nil { + t.Fatalf("effective file = %d subtitle = %#v, want the requested edition %d without the subtitle", plan.EffectiveMediaFileID, plan.SelectedTracks.Subtitle, requested.ID) + } + if !slices.Contains(plan.DegradationWarnings, playback.SubtitleTrackUnavailableWarningV3()) { + t.Fatalf("warnings = %#v, want the subtitle reported as not on this file", plan.DegradationWarnings) + } +} + +// The active alternate is kept during an output change only to keep the +// viewer's subtitle. When the new output cannot show that subtitle on the +// alternate either, playback still returns to the requested edition without +// it: the requested edition is the picture the viewer asked for. +func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionWhenSubtitleCannotBeShown(t *testing.T) { + handler, active, requested, startRequest := subtitleBlockedHDRFixtureV3(t) + requireRequestedEditionWithoutSubtitleV3(t, outputChangeAfterKeptAlternateV3(t, handler, active, requested, startRequest), requested) +} + +// The requested edition is listed after every other version, which the +// fallback tries lower resolutions first. Another version that also drops the +// subtitle must not take its place, and neither may the 4K edition without it. +func TestHandleReplanPlaybackV3OutputChangeReturnsToRequestedEditionAheadOfOtherVersions(t *testing.T) { + handler, active, requested, startRequest := subtitleBlockedHDRFixtureV3(t) + lowerValue := *requested + lower := &lowerValue + lower.ID = 126 + lower.FilePath = writePlaybackTestMediaFile(t, "movie-720p.mkv") + lower.Resolution = "720p" + lower.Bitrate = 3_000 + lower.VideoTracks = []models.VideoTrack{{ + Codec: "h264", Profile: "high", Level: 31, Width: 1280, Height: 720, + FrameRate: "24000/1001", Bitrate: 3_000, BitDepth: 8, + VideoRange: "SDR", VideoRangeType: "SDR", + }} + handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{ + active.ContentID: {active, requested, lower}, + }} + requireRequestedEditionWithoutSubtitleV3(t, outputChangeAfterKeptAlternateV3(t, handler, active, requested, startRequest, lower), requested) +} + +// workerOnlyHeaderAuthenticatedV3 moves the fixture onto header-authenticated +// media with remux_execution=worker_only, where the API refuses progressive +// remuxes. The 1080p sibling becomes an MP4 the client direct plays, while +// the 4K MKV without its subtitle needs a progressive MP4 remux, and HLS +// carries only H.264, so that remux has nowhere to escalate. +func workerOnlyHeaderAuthenticatedV3(t *testing.T, handler *PlaybackHandler, alternate *models.MediaFile, startRequest *playback.StartRequestV3) { + t.Helper() + requireWorkerRoutingV3(handler) + alternate.Container = "mp4" + alternate.FilePath = writePlaybackTestMediaFile(t, "movie-1080p.mp4") + startRequest.ClientFeatures = append(startRequest.ClientFeatures, playback.FeatureHeaderAuthenticatedMediaV3) + startRequest.Capabilities.VideoDecode = append(startRequest.Capabilities.VideoDecode, v3HandlerStartRequest().Capabilities.VideoDecode[0]) + startRequest.Capabilities.Containers = []string{"mp4", "hls"} + deliveries := maps.Clone(startRequest.ClientPlaybackContext.Deliveries) + original := deliveries[playback.DeliveryClassOriginalHTTPV3] + original.Containers = []string{"mp4"} + deliveries[playback.DeliveryClassOriginalHTTPV3] = original + deliveries[playback.DeliveryClassProgressiveV3] = playback.DeliveryCapabilityV3{ + Enabled: true, SupportedOnDevice: true, Containers: []string{"mp4"}, + VideoCodecs: []string{"hevc", "h264"}, AudioDecodeCodecs: []string{"aac"}, + } + startRequest.ClientPlaybackContext.Deliveries = deliveries +} + +// requireExecutableSiblingV3 fails unless response plays the sibling that was +// already found playable, rather than a requested-file route nothing can run. +func requireExecutableSiblingV3(t *testing.T, response playback.DecisionResponseV3, alternate *models.MediaFile) { + t.Helper() + if response.PlaybackPlan == nil || response.Terminal != nil { + t.Fatalf("response = terminal %#v, want the playable sibling %d", response.Terminal, alternate.ID) + } + if response.PlaybackPlan.EffectiveMediaFileID != alternate.ID || response.PlaybackPlan.Delivery != playback.DeliveryClassOriginalHTTPV3 { + t.Fatalf("effective file = %d delivery = %s, want sibling %d direct", response.PlaybackPlan.EffectiveMediaFileID, response.PlaybackPlan.Delivery, alternate.ID) + } +} + +// The requested file without its subtitle only replaces a playable sibling +// when its plan can run. A progressive remux the API refuses and HLS cannot +// carry would otherwise end the start in a terminal. +func TestHandleStartPlaybackV3SubtitleOnlyRefusalKeepsAnExecutableSibling(t *testing.T) { + handler, source, alternate, startRequest := subtitleBlockedHDRFixtureV3(t) + workerOnlyHeaderAuthenticatedV3(t, handler, alternate, &startRequest) + startRequest.SubtitleTrackID = playback.TrackIDV3(source.ID, "subtitle", 0) + startRequest.SubtitleTrackIndex = new(0) + + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil { + t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String()) + } + if started.SessionID != "" { + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + } + requireExecutableSiblingV3(t, started, alternate) +} + +// The same holds for an output change that leaves the 4K file unable to show +// the PGS it was playing with. +func TestHandleReplanPlaybackV3SubtitleOnlyRefusalKeepsAnExecutableSibling(t *testing.T) { + handler, source, alternate, startRequest := subtitleBlockedHDRFixtureV3(t) + workerOnlyHeaderAuthenticatedV3(t, handler, alternate, &startRequest) + original := startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] + original.Containers = []string{"mkv", "mp4"} + original.Subtitles.EmbeddedBitmap = true + startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original + startRequest.Capabilities.Containers = []string{"mkv", "mp4", "hls"} + startRequest.SubtitleTrackID = playback.TrackIDV3(source.ID, "subtitle", 0) + startRequest.SubtitleTrackIndex = new(0) + + rec := httptest.NewRecorder() + handler.HandleStartPlayback(rec, httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())) + var started playback.DecisionResponseV3 + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &started) != nil || started.PlaybackPlan == nil || started.PlaybackPlan.EffectiveMediaFileID != source.ID || started.PlaybackPlan.SelectedTracks.Subtitle == nil { + t.Fatalf("start did not play the 4K file with its PGS: status=%d body=%s", rec.Code, rec.Body.String()) + } + t.Cleanup(func() { handler.tm.CloseTranscodeSession(started.SessionID, "") }) + + capabilities := startRequest.Capabilities + capabilities.Containers = []string{"mp4", "hls"} + nextContext := startRequest.ClientPlaybackContext + nextContext.Output.OutputContextID = "route-2" + nextContext.Deliveries = maps.Clone(startRequest.ClientPlaybackContext.Deliveries) + original.Containers = []string{"mp4"} + original.Subtitles.EmbeddedBitmap = false + nextContext.Deliveries[playback.DeliveryClassOriginalHTTPV3] = original + replanned := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{ + ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationOutputChangeV3, + PlaybackAttemptID: startRequest.PlaybackAttemptID, ReplanRequestID: "executable-sibling-output-0001", + FailedPlanID: started.PlaybackPlan.PlanID, PlanAttemptID: "executable-sibling-output-attempt-0001", + PlanAttemptKey: started.PlaybackPlan.PlanAttemptKey, AttemptCount: 1, PositionSeconds: 30, + QualityPreference: "auto", SelectedTracks: started.PlaybackPlan.SelectedTracks, + Capabilities: capabilities, ClientPlaybackContext: nextContext, + }) + requireExecutableSiblingV3(t, replanned, alternate) +} diff --git a/internal/playback/protocol_v3.go b/internal/playback/protocol_v3.go index 878e77c18a..361ce90b26 100644 --- a/internal/playback/protocol_v3.go +++ b/internal/playback/protocol_v3.go @@ -144,6 +144,15 @@ func SubtitleTrackUnavailableWarningV3() DegradationWarningV3 { return DegradationWarningV3{Code: DegradationWarningSubtitleTrackUnavailableV3, Message: "The selected subtitle track is not on this file; starting without it."} } +// SubtitleNotShownWarningV3 reports a selected subtitle that is on the file +// but cannot be shown with any route the file can take (a bitmap burn-in an +// HDR source cannot be re-encoded for, say), so playback continues without +// it. The code is the one SubtitleTrackUnavailableWarningV3 uses: clients +// handle both the same way. +func SubtitleNotShownWarningV3() DegradationWarningV3 { + return DegradationWarningV3{Code: DegradationWarningSubtitleTrackUnavailableV3, Message: "The selected subtitle cannot be shown on this file; playing without it."} +} + // ServerFeaturesV3 returns the complete feature set advertised by protocol-v3 // capability and decision responses. A fresh slice prevents callers from // mutating the shared contract. From 0768b476c608a0801c24c9a54521ade74a5982b9 Mon Sep 17 00:00:00 2001 From: Brady3035 <40967324+Brady3035@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:02:04 -0500 Subject: [PATCH 005/196] fix(jellycompat): offer a route to clients whose H.264 codec rules are required (#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 --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- internal/jellycompat/deviceprofile.go | 47 +- .../jellycompat/deviceprofile_conditions.go | 63 ++- .../deviceprofile_conditions_test.go | 9 +- .../hevc_output_conditions_test.go | 8 + internal/jellycompat/moonfin_profile_test.go | 68 +++ .../playback_output_conditions_test.go | 79 ++- .../testdata/moonfin_ios_device_profile.json | 459 ++++++++++++++++++ internal/playback/prepare_file.go | 15 + internal/playback/quality_ladder_test.go | 19 + 9 files changed, 755 insertions(+), 12 deletions(-) create mode 100644 internal/jellycompat/moonfin_profile_test.go create mode 100644 internal/jellycompat/testdata/moonfin_ios_device_profile.json diff --git a/internal/jellycompat/deviceprofile.go b/internal/jellycompat/deviceprofile.go index 7a928181b2..c961de65e7 100644 --- a/internal/jellycompat/deviceprofile.go +++ b/internal/jellycompat/deviceprofile.go @@ -269,9 +269,12 @@ func (p DeviceProfile) supportsVideoTranscodingOutput(version catalog.FileVersio output.HDR = false sourceVideo := compatPrimaryVideoTrack(version) // Source codec levels, reference frames and HDR metadata do not describe - // the encoded stream. Profile and level depend on the chosen worker's - // encoder; leave them unknown so required conditions remain fail-closed. - // Full HDR encodes are separately gated on tone-map availability. + // the encoded stream. An H.264 encode states its profile and level (see + // h264EncodeFacts); its reference frames stay the encoder's choice (see + // encodeOutputConditionsMatch). An HEVC encode's profile, level and + // reference frames stay unknown, so required rules on them fail closed and + // an uncertain HEVC encode falls back to H.264. Full HDR encodes are + // separately gated on tone-map availability. video := models.VideoTrack{ Codec: videoCodec, BitDepth: 8, VideoRangeType: compatRangeSDR, Width: sourceVideo.Width, Height: sourceVideo.Height, @@ -286,6 +289,9 @@ func (p DeviceProfile) supportsVideoTranscodingOutput(version catalog.FileVersio video.Width = int(math.Round(float64(sourceVideo.Width)*float64(height)/float64(sourceVideo.Height)/2)) * 2 } } + if videoCodec == compatTargetVideoCodec { + video.Profile, video.Level = h264EncodeFacts(video, videoBitrateKbps, version) + } if videoBitrateKbps > 0 { video.Bitrate = videoBitrateKbps * 1000 output.Bitrate = videoBitrateKbps + audioBitrateKbps @@ -295,7 +301,12 @@ func (p DeviceProfile) supportsVideoTranscodingOutput(version catalog.FileVersio audioIndex := len(output.VideoTracks) // Both encoded outputs use HLS. Apply codec profiles scoped to its // segment container, including Container=hls with SubContainer=mp4/ts. - if !p.codecProfileCompatibilityWithValues(output, &audioIndex, buildConditionValues(output, &audioIndex), container, true).supportsDirectPlay() { + outputValues := encodeOutputConditionValues(output, &audioIndex) + matchOutput := conditionsMatch + if videoCodec == compatTargetVideoCodec { + matchOutput = encodeOutputConditionsMatch + } + if !p.codecProfileCompatibilityWithMatcher(output, &audioIndex, outputValues, container, true, matchOutput).supportsDirectPlay() { return false } @@ -313,7 +324,7 @@ func (p DeviceProfile) supportsVideoTranscodingOutput(version catalog.FileVersio if maxChannels, _ := strconv.Atoi(profile.MaxAudioChannels); maxChannels > 0 && channels > maxChannels { continue } - if !conditionsMatch(profile.Conditions, buildConditionValues(output, &audioIndex)) { + if !matchOutput(profile.Conditions, outputValues) { continue } if (requireExplicitProfile && strings.TrimSpace(profile.VideoCodec) == "") || !matchesCSV(profile.VideoCodec, videoCodec) { @@ -330,6 +341,32 @@ func (p DeviceProfile) supportsVideoTranscodingOutput(version catalog.FileVersio return false } +// h264StreamingLevel is the level the streaming libx264 encode is pinned to +// (playback.appendVideoArgs); other encoders pick a level from the stream. +const h264StreamingLevel = 41 + +// h264EncodeFacts states the profile and level of an H.264 encode of video, +// whose frame size and rate are already the output's. Every H.264 encode +// here is High profile (playback.H264EncodeProfile). The level is the one the +// stream needs at the rate the encoder is configured with: a capped encode +// sets its maximum rate to videoBitrateKbps (appendCappedVBRArgs), whatever +// the source's rate; an uncapped encode has no rate ceiling and is estimated +// at the source's H.264-equivalent rate. It is never below the 4.1 the +// streaming libx264 encode is pinned to, so a rule checks the higher of the +// two. +func h264EncodeFacts(video models.VideoTrack, videoBitrateKbps int, source catalog.FileVersion) (profile string, level int) { + kbps := videoBitrateKbps + if kbps <= 0 { + kbps = playback.H264EquivalentKbps(source.Bitrate, source.CodecVideo) + } + // Not max: this package's max(value, fallback) returns any positive value. + level = playback.H264LevelFor(video.Width, video.Height, parseCompatFrameRate(video.FrameRate), kbps) + if level < h264StreamingLevel { + level = h264StreamingLevel + } + return playback.H264EncodeProfile, level +} + // SupportsHLSRemuxForAudioStream reports whether the client accepts the // source codecs in an HLS fragmented-MP4 stream. Codec-profile conditions are // evaluated against the sample entry Silo will write, not against an unknown diff --git a/internal/jellycompat/deviceprofile_conditions.go b/internal/jellycompat/deviceprofile_conditions.go index 7914d60d05..4680cd32b1 100644 --- a/internal/jellycompat/deviceprofile_conditions.go +++ b/internal/jellycompat/deviceprofile_conditions.go @@ -307,6 +307,50 @@ func conditionsOnlyTargetAudio(conditions []ProfileCondition) bool { return true } +// encodeOutputConditionValues are the condition values of a planned encode. +// Unlike a scanned file's, an encode's unknown reference-frame count gets no +// FFmpeg 7 stand-in (see buildConditionValues): the encoder chooses it. +func encodeOutputConditionValues(output catalog.FileVersion, audioStreamIndex *int) conditionValues { + values := buildConditionValues(output, audioStreamIndex) + if compatPrimaryVideoTrack(output).ReferenceFrames <= 0 { + delete(values, conditionPropertyRefFrames) + } + return values +} + +// Normalized condition properties of the facts an encoder chooses. +const ( + conditionPropertyRefFrames = "refframes" + conditionPropertyVideoProfile = "videoprofile" + conditionPropertyVideoLevel = "videolevel" +) + +// encodeOutputConditionsMatch matches conditions against a planned H.264 +// encode. Its reference frames are the encoder's choice and Silo does not yet +// pass a client's limit to the encoder, so, as Jellyfin's StreamBuilder treats +// a RefFrames rule (ApplyTranscodingConditions), the rule is an encode target +// rather than a refusal. The streaming libx264 presets use one reference +// frame. An optional rule on the stated profile or level is an encode target +// too: Jellyfin applies it the same way, and before the encode stated them it +// passed as unknown, so a client that sends one with a high bitrate limit +// keeps its transcode. Every other rule, and a required profile or level +// rule, is checked as usual. +func encodeOutputConditionsMatch(conditions []ProfileCondition, values conditionValues) bool { + for _, condition := range conditions { + property := normalizeConditionToken(condition.Property) + if _, known := values[property]; !known && property == conditionPropertyRefFrames { + continue + } + if !condition.IsRequired && (property == conditionPropertyVideoProfile || property == conditionPropertyVideoLevel) { + continue + } + if !conditionMatches(condition, values) { + return false + } + } + return true +} + func conditionsMatch(conditions []ProfileCondition, values conditionValues) bool { for _, condition := range conditions { if !conditionMatches(condition, values) { @@ -357,9 +401,10 @@ func buildConditionValues(version catalog.FileVersion, audioStreamIndex *int) co "isinterlaced": {text: strconv.FormatBool(video.Interlaced)}, } - // Everything below is only inserted when Silo actually knows the value. - // An absent key falls through to conditionMatches' IsRequired handling, - // mirroring how Jellyfin's ConditionProcessor treats a null value. + // Everything below is only inserted when Silo actually knows the value, + // except a scanned file's reference frames (see below). An absent key + // falls through to conditionMatches' IsRequired handling, mirroring how + // Jellyfin's ConditionProcessor treats a null value. if strings.TrimSpace(video.Profile) != "" { values["videoprofile"] = conditionValue{text: video.Profile} } @@ -368,6 +413,18 @@ func buildConditionValues(version catalog.FileVersion, audioStreamIndex *int) co } if refFrames := intConditionValue(video.ReferenceFrames); refFrames.hasNum { values["refframes"] = refFrames + } else { + // FFmpeg 8.1 reports a stream's reference frames only while reading + // frames, so no scanned file carries a count (see mediaprobe.Stream). + // Under FFmpeg 7, ffprobe reported the decoder default of 1 for every + // file, Jellyfin recorded that 1, and clients tuned their rules to it: + // Moonfin requires RefFrames <= 4 from 1900 pixels wide. A planned + // encode does not get this stand-in (see encodeOutputConditionValues). + // Failing such a rule here would force an encode of every HD H.264 + // file those clients direct play on Jellyfin, so evaluate it against + // the same 1. Reading max_num_ref_frames from the SPS at scan time + // would replace this with the real count. + values["refframes"] = intConditionValue(1) } if width := intConditionValue(video.Width); width.hasNum { values["width"] = width diff --git a/internal/jellycompat/deviceprofile_conditions_test.go b/internal/jellycompat/deviceprofile_conditions_test.go index d2ad61c1c1..c3dee02497 100644 --- a/internal/jellycompat/deviceprofile_conditions_test.go +++ b/internal/jellycompat/deviceprofile_conditions_test.go @@ -540,10 +540,15 @@ func TestCodecProfileAVCRefFramesConstraint(t *testing.T) { if !source.SupportsTranscoding { t.Fatal("SupportsTranscoding = false for an optional unknown output value") } + // A required limit is an encode target too: the encoder chooses the + // output's reference frames, as Jellyfin's StreamBuilder treats them. profile.CodecProfiles[0].Conditions[0].IsRequired = true source = (&PlaybackHandler{codec: NewResourceIDCodec()}).buildPlaybackSource("item", "play", version, profile, playbackInfoRequest{}, true) - if source.SupportsTranscoding { - t.Fatal("SupportsTranscoding = true for a required unknown output value") + if source.SupportsDirectPlay || source.SupportsDirectStream { + t.Fatalf("a known eight reference frames passed a required limit of four: direct=%v stream=%v", source.SupportsDirectPlay, source.SupportsDirectStream) + } + if !source.SupportsTranscoding { + t.Fatal("SupportsTranscoding = false for a required limit the encode meets") } } diff --git a/internal/jellycompat/hevc_output_conditions_test.go b/internal/jellycompat/hevc_output_conditions_test.go index 086a435936..a49523e93e 100644 --- a/internal/jellycompat/hevc_output_conditions_test.go +++ b/internal/jellycompat/hevc_output_conditions_test.go @@ -32,6 +32,14 @@ func TestHEVCEncodingHonorsHLSCodecProfileConditions(t *testing.T) { }}, wantCodec: "h264", }, + { + // An HEVC encode's reference frames are unknown and get no stand-in. + name: "required output reference frames are unknown", + codecProfile: CodecProfile{Type: "Video", Codec: "hevc", Container: "hls", SubContainer: "mp4", Conditions: []ProfileCondition{ + {Condition: "LessThanEqual", Property: "RefFrames", Value: "4", IsRequired: true}, + }}, + wantCodec: "h264", + }, { name: "HLS MP4 audio channel limit", codecProfile: CodecProfile{Type: "VideoAudio", Codec: "aac", Container: "hls", SubContainer: "mp4", Conditions: []ProfileCondition{ diff --git a/internal/jellycompat/moonfin_profile_test.go b/internal/jellycompat/moonfin_profile_test.go new file mode 100644 index 0000000000..2162f5ad12 --- /dev/null +++ b/internal/jellycompat/moonfin_profile_test.go @@ -0,0 +1,68 @@ +package jellycompat + +import ( + "encoding/json" + "os" + "testing" + + "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/models" +) + +// moonfinIOSProfile is the DeviceProfile Moonfin for iOS 2.5.1 sends with +// PlaybackInfo, as Silo stored it. Every H.264 rule in it is IsRequired. +func moonfinIOSProfile(t *testing.T) DeviceProfile { + t.Helper() + data, err := os.ReadFile("testdata/moonfin_ios_device_profile.json") + if err != nil { + t.Fatal(err) + } + var profile DeviceProfile + if err := json.Unmarshal(data, &profile); err != nil { + t.Fatal(err) + } + return profile +} + +// A 1080p H.264 file as FFmpeg 8.1 probes it: profile and level known, +// reference frames unknown (ffprobe reports refs only when it reads frames). +func moonfinH264Version() catalog.FileVersion { + return catalog.FileVersion{ + FileID: 90516, Container: "mkv", CodecVideo: "h264", CodecAudio: "ac3", Resolution: "1080p", Bitrate: 12000, + VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "High", Level: 41, Width: 1920, Height: 804, BitDepth: 8}}, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Channels: 6, Default: true}}, + } +} + +// Moonfin's H.264 rules (a known VideoProfile; RefFrames <= 4 from 1900 +// pixels wide) used to refuse every H.264 encode, and with the source's +// reference frames unreported, a 1080p H.264 file had no route at all: +// PlaybackInfo answered 400 PlaybackUnavailable for every such title. +func TestMoonfinIOSProfileCanTranscodeH264(t *testing.T) { + h := &PlaybackHandler{codec: NewResourceIDCodec()} + req := playbackInfoRequest{AudioStreamIndex: compatIntValuePtr(1), MaxStreamingBitrate: 120_000_000} + source := h.buildPlaybackSource("item", "play", moonfinH264Version(), moonfinIOSProfile(t), req, true) + if !source.SupportsTranscoding { + t.Errorf("SupportsTranscoding = false: the encode target can satisfy profile, level and reference-frame rules") + } +} + +// FFmpeg 8.1 never reports reference frames, so Moonfin's required +// RefFrames <= 4 rule evaluates against the 1 that FFmpeg 7 reported, and a +// 1080p H.264 file Moonfin direct plays on Jellyfin direct plays here too. +func TestMoonfinIOSProfileDirectPlaysH264WithUnreportedRefFrames(t *testing.T) { + h := &PlaybackHandler{codec: NewResourceIDCodec()} + req := playbackInfoRequest{AudioStreamIndex: compatIntValuePtr(1), MaxStreamingBitrate: 120_000_000} + source := h.buildPlaybackSource("item", "play", moonfinH264Version(), moonfinIOSProfile(t), req, true) + if !source.SupportsDirectPlay { + t.Errorf("SupportsDirectPlay = false for a file Moonfin direct plays on Jellyfin") + } + + // A count the probe did report is still enforced. + version := moonfinH264Version() + version.VideoTracks[0].ReferenceFrames = 8 + source = h.buildPlaybackSource("item", "play", version, moonfinIOSProfile(t), req, true) + if source.SupportsDirectPlay || !source.SupportsTranscoding { + t.Errorf("eight known reference frames: direct=%v transcoding=%v, want an encode", source.SupportsDirectPlay, source.SupportsTranscoding) + } +} diff --git a/internal/jellycompat/playback_output_conditions_test.go b/internal/jellycompat/playback_output_conditions_test.go index 385c1f129f..2fa3d8e421 100644 --- a/internal/jellycompat/playback_output_conditions_test.go +++ b/internal/jellycompat/playback_output_conditions_test.go @@ -84,8 +84,22 @@ func TestEncodedOutputConditionsDoNotUseSourceCodecFacts(t *testing.T) { {"scaled width", ProfileCondition{Condition: "LessThanEqual", Property: "Width", Value: "1280", IsRequired: true}, 4000000, true}, {"scaled height", ProfileCondition{Condition: "LessThanEqual", Property: "Height", Value: "720", IsRequired: true}, 4000000, true}, {"known width exceeds constraint", ProfileCondition{Condition: "LessThanEqual", Property: "Width", Value: "1280", IsRequired: true}, 0, false}, - {"required encoder level unknown", ProfileCondition{Condition: "LessThanEqual", Property: "VideoLevel", Value: "51", IsRequired: true}, 0, false}, - {"required encoder profile unknown", ProfileCondition{Condition: "EqualsAny", Property: "VideoProfile", Value: "high|main|baseline", IsRequired: true}, 0, false}, + // An H.264 encode is High profile at the level its frame size, rate and + // bitrate need, never below the streaming libx264 pin of 4.1; here 4.1. + {"required level the encode meets", ProfileCondition{Condition: "LessThanEqual", Property: "VideoLevel", Value: "51", IsRequired: true}, 0, true}, + {"required level below the encode", ProfileCondition{Condition: "LessThanEqual", Property: "VideoLevel", Value: "40", IsRequired: true}, 0, false}, + {"required profile the encode meets", ProfileCondition{Condition: "EqualsAny", Property: "VideoProfile", Value: "high|main|baseline", IsRequired: true}, 0, true}, + {"required baseline profile", ProfileCondition{Condition: "Equals", Property: "VideoProfile", Value: "baseline", IsRequired: true}, 0, false}, + // A 720p encode at 4 Mbps fits level 3.1, but libx264 writes 4.1. + {"required level below the libx264 pin", ProfileCondition{Condition: "LessThanEqual", Property: "VideoLevel", Value: "31", IsRequired: true}, 4000000, false}, + // An optional profile or level rule is an encode target, as in Jellyfin. + // At 120 Mbps the encode is stated at level 5.1, above an optional 4.2. + {"optional level below the encode", ProfileCondition{Condition: "LessThanEqual", Property: "VideoLevel", Value: "42"}, 120_000_000, true}, + {"optional baseline profile", ProfileCondition{Condition: "Equals", Property: "VideoProfile", Value: "baseline"}, 0, true}, + // The encoder chooses reference frames, so a rule on them is an encode + // target either way, as in Jellyfin. + {"required reference frame limit", ProfileCondition{Condition: "LessThanEqual", Property: "RefFrames", Value: "4", IsRequired: true}, 0, true}, + {"required reference frame minimum", ProfileCondition{Condition: "GreaterThanEqual", Property: "RefFrames", Value: "2", IsRequired: true}, 0, true}, } { t.Run(tc.name, func(t *testing.T) { profile := DeviceProfile{ @@ -128,3 +142,64 @@ func TestPlaybackBitrateResolutionDoesNotUpscale(t *testing.T) { }) } } + +// A transcoding profile's own RefFrames rule describes the same encode as a +// codec profile's, so an H.264 encode treats it as an encode target too and +// an HEVC encode fails it closed. +func TestTranscodingProfileRefFramesRuleOnEncode(t *testing.T) { + version := catalog.FileVersion{ + FileID: 42, Container: "mkv", CodecVideo: "vp9", CodecAudio: "aac", Bitrate: 8000, Resolution: "1080p", + VideoTracks: []models.VideoTrack{{Codec: "vp9", Width: 1920, Height: 1080}}, + AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2}}, + } + refFrames := []ProfileCondition{{Condition: "LessThanEqual", Property: "RefFrames", Value: "4", IsRequired: true}} + h := &PlaybackHandler{codec: NewResourceIDCodec()} + req := playbackInfoRequest{EnableDirectPlay: new(false), AllowVideoStreamCopy: new(false)} + + profile := DeviceProfile{TranscodingProfiles: []TranscodingProfile{ + {Type: "Video", Protocol: "hls", Container: "ts", VideoCodec: "h264", AudioCodec: "aac", Conditions: refFrames}, + }} + if source := h.buildPlaybackSource("item", "play", version, profile, req, true); !source.SupportsTranscoding { + t.Fatal("SupportsTranscoding = false for an H.264 encode under a transcoding profile's RefFrames rule") + } + + profile = DeviceProfile{TranscodingProfiles: []TranscodingProfile{ + {Type: "Video", Protocol: "hls", Container: "mp4", VideoCodec: "hevc", AudioCodec: "aac", Conditions: refFrames}, + {Type: "Video", Protocol: "hls", Container: "ts", VideoCodec: "h264", AudioCodec: "aac"}, + }} + if source := h.buildPlaybackSource("item", "play", version, profile, req, true, true); !source.SupportsTranscoding || source.TargetVideoCodec != "h264" { + t.Fatalf("transcoding=%v codec=%q, want an H.264 fallback from the HEVC profile's RefFrames rule", source.SupportsTranscoding, source.TargetVideoCodec) + } +} + +// The streaming encoder's maximum rate is the requested cap, not the source's +// rate, so a client's level rule is checked at the cap: a 4,000 kbps source +// capped near 60,000 kbps needs level 5.0, above a required 4.1. +func TestH264EncodeLevelUsesTheConfiguredRate(t *testing.T) { + version := catalog.FileVersion{ + FileID: 42, Container: "mkv", CodecVideo: "h264", CodecAudio: "aac", Bitrate: 4000, Resolution: "1080p", + VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "High", Level: 40, Width: 1920, Height: 1080, FrameRate: "24"}}, + AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2}}, + } + profile := DeviceProfile{ + TranscodingProfiles: []TranscodingProfile{{Type: "Video", Protocol: "hls", Container: "ts", VideoCodec: "h264", AudioCodec: "aac"}}, + CodecProfiles: []CodecProfile{{Type: "Video", Codec: "h264", Conditions: []ProfileCondition{ + {Condition: "LessThanEqual", Property: "VideoLevel", Value: "41", IsRequired: true}, + }}}, + } + h := &PlaybackHandler{codec: NewResourceIDCodec()} + req := playbackInfoRequest{EnableDirectPlay: new(false), EnableDirectStream: new(false), MaxStreamingBitrate: 64_000_000} + source := h.buildPlaybackSource("item", "play", version, profile, req, true) + if source.TargetBitrateKbps < 60_000 { + t.Fatalf("target rate %d kbps, want a cap of at least 60,000", source.TargetBitrateKbps) + } + if source.SupportsTranscoding { + t.Fatalf("SupportsTranscoding = true: a %d kbps cap needs level 5.0, above the required 4.1", source.TargetBitrateKbps) + } + + // Uncapped, the encode is estimated at the source's rate: level 4.0, so 4.1. + req.MaxStreamingBitrate = 0 + if source := h.buildPlaybackSource("item", "play", version, profile, req, true); !source.SupportsTranscoding { + t.Fatal("SupportsTranscoding = false for an uncapped encode of a 4,000 kbps 1080p24 source") + } +} diff --git a/internal/jellycompat/testdata/moonfin_ios_device_profile.json b/internal/jellycompat/testdata/moonfin_ios_device_profile.json new file mode 100644 index 0000000000..e5ee9a6b98 --- /dev/null +++ b/internal/jellycompat/testdata/moonfin_ios_device_profile.json @@ -0,0 +1,459 @@ +{ + "Name": "Moonfin iOS", + "CodecProfiles": [ + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "none", + "Property": "VideoProfile", + "Condition": "NotEquals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "52", + "Property": "VideoLevel", + "Condition": "LessThanEqual", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Value": "high", + "Property": "VideoProfile", + "Condition": "Equals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "52", + "Property": "VideoLevel", + "Condition": "LessThanEqual", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Value": "main", + "Property": "VideoProfile", + "Condition": "Equals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "52", + "Property": "VideoLevel", + "Condition": "LessThanEqual", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Value": "baseline", + "Property": "VideoProfile", + "Condition": "Equals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "52", + "Property": "VideoLevel", + "Condition": "LessThanEqual", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Value": "constrained baseline", + "Property": "VideoProfile", + "Condition": "Equals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "52", + "Property": "VideoLevel", + "Condition": "LessThanEqual", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Value": "high 10", + "Property": "VideoProfile", + "Condition": "Equals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "12", + "Property": "RefFrames", + "Condition": "LessThanEqual", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Value": "1200", + "Property": "Width", + "Condition": "GreaterThanEqual", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "4", + "Property": "RefFrames", + "Condition": "LessThanEqual", + "IsRequired": true + } + ], + "ApplyConditions": [ + { + "Value": "1900", + "Property": "Width", + "Condition": "GreaterThanEqual", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "hevc", + "Conditions": [ + { + "Value": "none", + "Property": "VideoProfile", + "Condition": "NotEquals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "av1", + "Conditions": [ + { + "Value": "none", + "Property": "VideoProfile", + "Condition": "NotEquals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "vc1", + "Conditions": [ + { + "Value": "none", + "Property": "VideoProfile", + "Condition": "NotEquals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "mpeg4", + "Conditions": [ + { + "Value": "none", + "Property": "VideoProfile", + "Condition": "Equals", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "h264", + "Conditions": [ + { + "Value": "3840", + "Property": "Width", + "Condition": "LessThanEqual", + "IsRequired": true + }, + { + "Value": "2160", + "Property": "Height", + "Condition": "LessThanEqual", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "hevc", + "Conditions": [ + { + "Value": "3840", + "Property": "Width", + "Condition": "LessThanEqual", + "IsRequired": true + }, + { + "Value": "2160", + "Property": "Height", + "Condition": "LessThanEqual", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "av1", + "Conditions": [ + { + "Value": "3840", + "Property": "Width", + "Condition": "LessThanEqual", + "IsRequired": true + }, + { + "Value": "2160", + "Property": "Height", + "Condition": "LessThanEqual", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "vc1", + "Conditions": [ + { + "Value": "1920", + "Property": "Width", + "Condition": "LessThanEqual", + "IsRequired": true + }, + { + "Value": "1080", + "Property": "Height", + "Condition": "LessThanEqual", + "IsRequired": true + } + ] + }, + { + "Type": "Video", + "Codec": "av1", + "Conditions": [ + { + "Value": "DOVI|DOVIWithHDR10Plus|DOVIWithHLG|DOVIWithSDR|DOVI_WITH_HDR10_PLUS|DOVI_WITH_HLG|DOVI_WITH_SDR", + "Property": "VideoRangeType", + "Condition": "NotEquals" + } + ], + "ApplyConditions": [ + { + "Value": "DOVI|DOVIWithHDR10Plus|DOVIWithHLG|DOVIWithSDR|DOVI_WITH_HDR10_PLUS|DOVI_WITH_HLG|DOVI_WITH_SDR", + "Property": "VideoRangeType", + "Condition": "EqualsAny" + } + ] + }, + { + "Type": "VideoAudio", + "Conditions": [ + { + "Value": "8", + "Property": "AudioChannels", + "Condition": "LessThanEqual", + "IsRequired": true + } + ] + } + ], + "SubtitleProfiles": [ + { + "Format": "vtt", + "Method": "Embed" + }, + { + "Format": "vtt", + "Method": "External" + }, + { + "Format": "vtt", + "Method": "Hls" + }, + { + "Format": "webvtt", + "Method": "Embed" + }, + { + "Format": "webvtt", + "Method": "External" + }, + { + "Format": "webvtt", + "Method": "Hls" + }, + { + "Format": "srt", + "Method": "Embed" + }, + { + "Format": "srt", + "Method": "External" + }, + { + "Format": "subrip", + "Method": "Embed" + }, + { + "Format": "subrip", + "Method": "External" + }, + { + "Format": "ttml", + "Method": "Embed" + }, + { + "Format": "ttml", + "Method": "External" + }, + { + "Format": "dvbsub", + "Method": "Embed" + }, + { + "Format": "dvbsub", + "Method": "Encode" + }, + { + "Format": "dvdsub", + "Method": "Embed" + }, + { + "Format": "dvdsub", + "Method": "Encode" + }, + { + "Format": "idx", + "Method": "Embed" + }, + { + "Format": "idx", + "Method": "Encode" + }, + { + "Format": "pgs", + "Method": "Embed" + }, + { + "Format": "pgs", + "Method": "Encode" + }, + { + "Format": "pgssub", + "Method": "Embed" + }, + { + "Format": "pgssub", + "Method": "Encode" + }, + { + "Format": "ass", + "Method": "Embed" + }, + { + "Format": "ass", + "Method": "External" + }, + { + "Format": "ass", + "Method": "Encode" + }, + { + "Format": "ssa", + "Method": "Embed" + }, + { + "Format": "ssa", + "Method": "External" + }, + { + "Format": "ssa", + "Method": "Encode" + } + ], + "DirectPlayProfiles": [ + { + "Type": "Video", + "Container": "asf,dash,hls,m4v,mkv,mov,mp4,ogm,ogv,ts,vob,webm,wmv,xvid", + "AudioCodec": "aac,aac_latm,ac3,alac,dca,dts,eac3,flac,mlp,mp2,mp3,opus,pcm_alaw,pcm_mulaw,pcm_s16le,pcm_s20le,pcm_s24le,truehd,vorbis", + "VideoCodec": "av1,h264,hevc,mpeg,mpeg2video,mpeg4,vc1,vp8,vp9" + }, + { + "Type": "Audio", + "Container": "aac,ac3,alac,ape,dts,eac3,flac,m4a,m4b,mka,mp3,oga,ogg,opus,wav,wma", + "AudioCodec": "aac,aac_latm,ac3,alac,dca,dts,eac3,flac,mlp,mp2,mp3,opus,pcm_alaw,pcm_mulaw,pcm_s16le,pcm_s20le,pcm_s24le,truehd,vorbis" + } + ], + "MaxStreamingBitrate": 120000000, + "TranscodingProfiles": [ + { + "Type": "Video", + "Context": "Streaming", + "Protocol": "hls", + "Container": "mp4", + "AudioCodec": "aac,ac3,eac3,alac,flac,opus", + "VideoCodec": "h264" + }, + { + "Type": "Video", + "Context": "Streaming", + "Protocol": "hls", + "Container": "ts", + "AudioCodec": "aac,ac3,eac3", + "VideoCodec": "h264" + }, + { + "Type": "Audio", + "Context": "Streaming", + "Protocol": "hls", + "Container": "ts", + "AudioCodec": "aac" + } + ] +} diff --git a/internal/playback/prepare_file.go b/internal/playback/prepare_file.go index 48198687ed..b575e6efbd 100644 --- a/internal/playback/prepare_file.go +++ b/internal/playback/prepare_file.go @@ -241,6 +241,18 @@ func h264LevelFor(width, height int, frameRate float64, kbps int) int { return 60 } +// H264LevelFor is h264LevelFor for callers outside this package. +func H264LevelFor(width, height int, frameRate float64, kbps int) int { + return h264LevelFor(width, height, frameRate, kbps) +} + +// H264EquivalentKbps is the rate an H.264 encode of a source at sourceKbps in +// sourceCodec needs for the same quality (sourceEquivalentKbps), or 0 when +// the source's rate is unknown. +func H264EquivalentKbps(sourceKbps int, sourceCodec string) int { + return sourceEquivalentKbps(SourceDescriptorV3{BitrateKbps: sourceKbps, VideoCodec: sourceCodec}, transcodeCodecH264) +} + // h264LevelBitrateKbps is the highest cap whose rate and doubled buffer fit // the given level; 0 for an unknown level. func h264LevelBitrateKbps(idc int) int { @@ -255,6 +267,9 @@ func h264LevelBitrateKbps(idc int) int { // h264HighProfileV3 is the profile every H.264 encode here produces. const h264HighProfileV3 = "high" +// H264EncodeProfile is h264HighProfileV3 for callers outside this package. +const H264EncodeProfile = h264HighProfileV3 + // downloadDecoder is the decoder bound a download encode must fit. Zero // fields are unbounded. type downloadDecoder struct { diff --git a/internal/playback/quality_ladder_test.go b/internal/playback/quality_ladder_test.go index 6f5627cc8f..7834095c19 100644 --- a/internal/playback/quality_ladder_test.go +++ b/internal/playback/quality_ladder_test.go @@ -403,3 +403,22 @@ func TestLadderClassForSize(t *testing.T) { } } } + +func TestH264EquivalentKbps(t *testing.T) { + for _, tc := range []struct { + name string + srcKbps int + srcCodec string + want int + }{ + {"an H.264 source keeps its rate", 12000, "h264", 12000}, + {"an HEVC source counts at its H.264-equivalent rate", 12000, "hevc", 20000}, + {"an unknown source rate", 0, "h264", 0}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := H264EquivalentKbps(tc.srcKbps, tc.srcCodec); got != tc.want { + t.Errorf("H264EquivalentKbps(%d, %q) = %d, want %d", tc.srcKbps, tc.srcCodec, got, tc.want) + } + }) + } +} From f095e0cadfd5a23ae3963269f86985362f56e2a2 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:02:25 -0400 Subject: [PATCH 006/196] fix(notifications): send a real notification as the admin test push (#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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- DEVELOPMENT.md | 5 + .../admin_apple_push_test_notification.json | 8 + contracts/api/v2/fixtures/index.json | 21 +++ contracts/api/v2/openapi.json | 12 +- docs/notifications-api.md | 13 ++ docs/notifications-push-api.md | 6 + internal/apiv2/admin_notification_push.go | 5 +- .../apiv2/admin_notification_push_test.go | 24 ++- internal/apiv2/fixtures_test.go | 3 +- internal/database/invalid_index_rebuild.go | 45 ++++-- internal/database/migrate.go | 7 +- .../database/push_attempt_key_migration.go | 79 ++++++++++ .../push_attempt_key_migration_test.go | 111 +++++++++++++ internal/notifications/push_delivery.go | 30 +++- internal/notifications/push_sender.go | 6 + .../notifications/push_test_send_db_test.go | 149 ++++++++++++++++++ scripts/ci/db-contracts.txt | 4 + web/src/api/v2/schema.ts | 9 +- 18 files changed, 503 insertions(+), 34 deletions(-) create mode 100644 contracts/api/v2/fixtures/admin_apple_push_test_notification.json create mode 100644 internal/database/push_attempt_key_migration.go create mode 100644 internal/database/push_attempt_key_migration_test.go create mode 100644 internal/notifications/push_test_send_db_test.go diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 04c857b38b..c25d7fd2d2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -142,6 +142,11 @@ that can hold millions of rows. Keep locks on existing tables short: before creating a new index: a copy left by an interrupted run is invalid, and `CREATE INDEX CONCURRENTLY IF NOT EXISTS` would keep it, so the planner would never use the index. +- When a migration replaces one index with another, a retry must not drop the + replacement: goose records the version only after the last statement, so a + run that already dropped the old index can run again. Use a Go migration + that builds through `ensureIndexConcurrently` in `internal/database`, which + drops only an invalid copy (`internal/database/push_attempt_key_migration.go`). - Add foreign keys and `CHECK` constraints to existing tables as `NOT VALID`, and run `VALIDATE CONSTRAINT` only after that commits: in a later migration, or as a separate statement in a `NO TRANSACTION` file. Inside one diff --git a/contracts/api/v2/fixtures/admin_apple_push_test_notification.json b/contracts/api/v2/fixtures/admin_apple_push_test_notification.json new file mode 100644 index 0000000000..5b01ec24ca --- /dev/null +++ b/contracts/api/v2/fixtures/admin_apple_push_test_notification.json @@ -0,0 +1,8 @@ +{ + "attempt_id": "attempt-1", + "push_device_id": "device-1", + "server_device_id": "target-device", + "delivery_id": "delivery-1", + "outcome": "delivered", + "upstream_status": 200 +} diff --git a/contracts/api/v2/fixtures/index.json b/contracts/api/v2/fixtures/index.json index 1fd3982a21..3306d8d02a 100644 --- a/contracts/api/v2/fixtures/index.json +++ b/contracts/api/v2/fixtures/index.json @@ -867,6 +867,27 @@ "schema": "#/components/schemas/AdminNotificationPushTestResult", "body_file": "admin_apple_push_test.json" }, + { + "name": "admin_apple_push_test_notification", + "operation_id": "testAdminApplePushNotification", + "scenario": "The test push carries the profile's newest notification and names it, so the device shows that notification's real text.", + "request": { + "method": "POST", + "path": "/api/v2/admin/notifications/push/apple/test", + "headers": { + "Authorization": "Bearer tok-admin" + }, + "body": "{\"profile_id\":\"notified-profile\",\"server_device_id\":\"target-device\"}" + }, + "expected_status": 200, + "response_headers": { + "Cache-Control": "no-store", + "Content-Type": "application/json" + }, + "response_media_type": "application/json", + "schema": "#/components/schemas/AdminNotificationPushTestResult", + "body_file": "admin_apple_push_test_notification.json" + }, { "name": "admin_catalog_export_link", "operation_id": "publishCatalogExportJob", diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 5f3dc612ec..97481f013d 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -10803,6 +10803,14 @@ "minLength": 1, "type": "string" }, + "delivery_id": { + "description": "The target profile's newest notification, which the test push carried so the device shows its real text; absent when the profile has no notification and the device can show only generic text.", + "examples": [ + "1" + ], + "minLength": 1, + "type": "string" + }, "failure_message": { "type": "string" }, @@ -91886,7 +91894,7 @@ "bearerAuth": [] } ], - "summary": "Dispatch one test push notification and report its current delivery outcome.", + "summary": "Dispatch one test push carrying the target profile's newest notification and report its current delivery outcome.", "tags": [ "admin" ], @@ -92070,7 +92078,7 @@ "bearerAuth": [] } ], - "summary": "Dispatch one test push notification and report its current delivery outcome.", + "summary": "Dispatch one test push carrying the target profile's newest notification and report its current delivery outcome.", "tags": [ "admin" ], diff --git a/docs/notifications-api.md b/docs/notifications-api.md index 6c34f99fd8..bfe101cb35 100644 --- a/docs/notifications-api.md +++ b/docs/notifications-api.md @@ -115,6 +115,19 @@ new outbox attempt, invokes the configured sender, and returns HTTP 200 with `attempt_id`, `push_device_id`, `server_device_id`, `outcome`, and any existing relay/upstream diagnostic fields. A `retrying` or `failed` outcome is still a successful HTTP report of that attempt, not proof that a push was delivered. + +The test push carries the newest notification of the selected device's profile +(matched by account as well, since profile IDs repeat across accounts), and the +response names it as `delivery_id`. The device fetches that notification's real +title, body and tap target exactly as for an ordinary push, and because the +push reuses the notification's collapse ID it replaces that notification on the +device if it is still shown. When the profile has no notification yet, +`delivery_id` is absent and the push carries the attempt ID instead: it still +proves transport, but the display fetch returns `404` and the device shows the +relay's generic text. The test changes neither the notification's read state +nor the inbox; it adds one `test` attempt to that notification's push attempt +history, which nothing counts as a delivery. Pruning the notification removes +the attempt with it. Background attempt recovery and sender retries retain their existing semantics. These operations are `non_retryable`: clients must not repeat a lost request automatically because another request creates another test attempt. diff --git a/docs/notifications-push-api.md b/docs/notifications-push-api.md index 5259248854..ce785fccd7 100644 --- a/docs/notifications-push-api.md +++ b/docs/notifications-push-api.md @@ -126,6 +126,12 @@ Authentication rules for this route only: authenticated profile. The route is rate-limited like other authenticated routes. +An administrator test push names the profile's newest notification, so this +fetch returns that notification's display data. A test sent to a profile with +no notification names its own attempt ID instead, and the fetch returns `404`; +keep the generic text in that case. Android's `getNotification` fetch behaves +the same way. + ## Ordered Android registration `GET /api/v2/notifications/push/devices/capabilities` describes the diff --git a/internal/apiv2/admin_notification_push.go b/internal/apiv2/admin_notification_push.go index 00adca8464..42a5ec0243 100644 --- a/internal/apiv2/admin_notification_push.go +++ b/internal/apiv2/admin_notification_push.go @@ -31,6 +31,7 @@ type AdminNotificationPushTestResult struct { AttemptID ID `json:"attempt_id"` PushDeviceID ID `json:"push_device_id"` ServerDeviceID ID `json:"server_device_id"` + DeliveryID ID `json:"delivery_id,omitempty" doc:"The target profile's newest notification, which the test push carried so the device shows its real text; absent when the profile has no notification and the device can show only generic text."` Outcome string `json:"outcome"` RelayRequestID string `json:"relay_request_id,omitempty"` UpstreamStatus *int `json:"upstream_status,omitempty"` @@ -49,7 +50,7 @@ func registerAdminNotificationPush(reg *Registry) { {"apple", testAdminApplePushOperation, true}, {"fcm", testAdminAndroidPushOperation, false}, } { - op := Operation{Operation: humaOp(http.MethodPost, Prefix+"/admin/notifications/push/"+platform.path+"/test", platform.operation, "admin", "Dispatch one test push notification and report its current delivery outcome."), Class: ClassActingAdmin, DemoRestricted: true, ServiceBacked: true, RetrySafety: RetrySafetyNonRetryable} + op := Operation{Operation: humaOp(http.MethodPost, Prefix+"/admin/notifications/push/"+platform.path+"/test", platform.operation, "admin", "Dispatch one test push carrying the target profile's newest notification and report its current delivery outcome."), Class: ClassActingAdmin, DemoRestricted: true, ServiceBacked: true, RetrySafety: RetrySafetyNonRetryable} Register(reg, op, func(ctx context.Context, in *AdminNotificationPushTestInput) (*AdminNotificationPushTestOutput, error) { svc := reg.deps.AdminNotificationPush if svc == nil { @@ -72,7 +73,7 @@ func registerAdminNotificationPush(reg *Registry) { return nil, serviceProblem(err) } } - return &AdminNotificationPushTestOutput{Body: AdminNotificationPushTestResult{AttemptID: ID(view.AttemptID), PushDeviceID: ID(view.PushDeviceID), ServerDeviceID: ID(view.ServerDeviceID), Outcome: view.Outcome, RelayRequestID: view.RelayRequestID, UpstreamStatus: view.UpstreamStatus, UpstreamReason: view.UpstreamReason, FailureMessage: view.FailureMessage}}, nil + return &AdminNotificationPushTestOutput{Body: AdminNotificationPushTestResult{AttemptID: ID(view.AttemptID), PushDeviceID: ID(view.PushDeviceID), ServerDeviceID: ID(view.ServerDeviceID), DeliveryID: ID(view.DeliveryID), Outcome: view.Outcome, RelayRequestID: view.RelayRequestID, UpstreamStatus: view.UpstreamStatus, UpstreamReason: view.UpstreamReason, FailureMessage: view.FailureMessage}}, nil }) } } diff --git a/internal/apiv2/admin_notification_push_test.go b/internal/apiv2/admin_notification_push_test.go index abbcb6bac6..88d1f763f6 100644 --- a/internal/apiv2/admin_notification_push_test.go +++ b/internal/apiv2/admin_notification_push_test.go @@ -16,10 +16,18 @@ type fakeAdminNotificationPush struct { err error } +// fixtureNotifiedProfile is the one profile the fake reports a notification +// for, standing in for a profile whose inbox is not empty. +const fixtureNotifiedProfile = "notified-profile" + func (f *fakeAdminNotificationPush) send(platform, profile, device string) (*notifications.ApplePushTestResult, error) { f.calls++ f.platform, f.profile, f.device = platform, profile, device - return ¬ifications.ApplePushTestResult{AttemptID: "attempt-1", PushDeviceID: "device-1", ServerDeviceID: device, Outcome: "retrying", UpstreamStatus: new(503)}, f.err + result := ¬ifications.ApplePushTestResult{AttemptID: "attempt-1", PushDeviceID: "device-1", ServerDeviceID: device, Outcome: "retrying", UpstreamStatus: new(503)} + if profile == fixtureNotifiedProfile { + result.DeliveryID, result.Outcome, result.UpstreamStatus = "delivery-1", "delivered", new(200) + } + return result, f.err } func (f *fakeAdminNotificationPush) SendApplePushTest(_ context.Context, profile, device string) (*notifications.ApplePushTestResult, error) { return f.send("apple", profile, device) @@ -55,9 +63,13 @@ func TestAdminNotificationPushDispatch(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil { t.Fatal(err) } - if fake.calls != 1 || fake.platform != platform || fake.profile != "target-profile" || fake.device != "target-device" || result.Outcome != "retrying" || result.UpstreamStatus == nil || *result.UpstreamStatus != 503 { + if fake.calls != 1 || fake.platform != platform || fake.profile != "target-profile" || fake.device != "target-device" || result.Outcome != "retrying" || result.UpstreamStatus == nil || *result.UpstreamStatus != 503 || result.DeliveryID != "" { t.Fatalf("dispatch/result mismatch: %+v %+v", fake, result) } + rec = do(t, h, http.MethodPost, path, `{"profile_id":"`+fixtureNotifiedProfile+`"}`, bearer(adminToken)) + if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil || rec.Code != 200 || result.DeliveryID != "delivery-1" { + t.Fatalf("notified profile: %d %s", rec.Code, rec.Body.String()) + } for _, tc := range []struct { err error status int @@ -88,3 +100,11 @@ func adminNotificationPushFixtureCases() []fixtureCase { {name: "admin_android_push_test", operationID: testAdminAndroidPushOperation, method: "POST", path: Prefix + "/admin/notifications/push/fcm/test", body: `{"profile_id":"target-profile","server_device_id":"target-device"}`, headers: bearer(adminToken), status: 200, schema: "#/components/schemas/AdminNotificationPushTestResult", assertHeaders: []string{"Content-Type", "Cache-Control"}, scenario: "A synthetic Android test dispatch preserves its upstream status."}, } } + +// adminNotificationPushDeliveryFixtureCases are appended last so the request +// ids of earlier fixtures stay put. +func adminNotificationPushDeliveryFixtureCases() []fixtureCase { + return []fixtureCase{ + {name: "admin_apple_push_test_notification", operationID: testAdminApplePushOperation, method: "POST", path: Prefix + "/admin/notifications/push/apple/test", body: `{"profile_id":"` + fixtureNotifiedProfile + `","server_device_id":"target-device"}`, headers: bearer(adminToken), status: 200, schema: "#/components/schemas/AdminNotificationPushTestResult", assertHeaders: []string{"Content-Type", "Cache-Control"}, scenario: "The test push carries the profile's newest notification and names it, so the device shows that notification's real text."}, + } +} diff --git a/internal/apiv2/fixtures_test.go b/internal/apiv2/fixtures_test.go index 1157b800e0..6cdd930023 100644 --- a/internal/apiv2/fixtures_test.go +++ b/internal/apiv2/fixtures_test.go @@ -1756,7 +1756,8 @@ func fixtureCases() []fixtureCase { cases = append(cases, adminDownloadStorageFixtureCases()...) cases = append(cases, homeLayoutItemsFixtureCases()...) cases = append(cases, catalogLettersFixtureCases()...) - return append(cases, pushRegistrationReceiptFixtureCases()...) + cases = append(cases, pushRegistrationReceiptFixtureCases()...) + return append(cases, adminNotificationPushDeliveryFixtureCases()...) } // homeLayoutItemsFixtureCases pin the home layout carrying the cards of its diff --git a/internal/database/invalid_index_rebuild.go b/internal/database/invalid_index_rebuild.go index bbea42ca1f..9d91d776d5 100644 --- a/internal/database/invalid_index_rebuild.go +++ b/internal/database/invalid_index_rebuild.go @@ -56,24 +56,35 @@ func rebuildInvalidIndexes(ctx context.Context, db *sql.DB, schema string) error quotedSchema := pgx.Identifier{schema}.Sanitize() for _, index := range rebuiltConcurrentIndexes { quotedName := pgx.Identifier{index.name}.Sanitize() - qualified := quotedSchema + "." + quotedName - var valid bool - if err := db.QueryRowContext(ctx, - `SELECT coalesce((SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass($1)), false)`, - qualified, - ).Scan(&valid); err != nil { - return fmt.Errorf("checking index %s: %w", index.name, err) - } - if valid { - continue - } - if _, err := db.ExecContext(ctx, "DROP INDEX CONCURRENTLY IF EXISTS "+qualified); err != nil { - return fmt.Errorf("dropping invalid index %s: %w", index.name, err) - } - if _, err := db.ExecContext(ctx, fmt.Sprintf("CREATE INDEX CONCURRENTLY IF NOT EXISTS %s ON %s.%s", - quotedName, quotedSchema, index.definition)); err != nil { - return fmt.Errorf("rebuilding index %s: %w", index.name, err) + if err := ensureIndexConcurrently(ctx, db, quotedSchema+"."+quotedName, fmt.Sprintf( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS %s ON %s.%s", quotedName, quotedSchema, index.definition)); err != nil { + return err } } return nil } + +// ensureIndexConcurrently runs build, a CREATE INDEX CONCURRENTLY statement +// for qualifiedName, unless a valid index of that name already exists. An +// invalid copy an interrupted build left behind is dropped concurrently +// first. A valid index is never dropped, so a retried migration keeps what an +// earlier attempt built. +func ensureIndexConcurrently(ctx context.Context, db *sql.DB, qualifiedName, build string) error { + var valid bool + if err := db.QueryRowContext(ctx, + `SELECT coalesce((SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass($1)), false)`, + qualifiedName, + ).Scan(&valid); err != nil { + return fmt.Errorf("checking index %s: %w", qualifiedName, err) + } + if valid { + return nil + } + if _, err := db.ExecContext(ctx, "DROP INDEX CONCURRENTLY IF EXISTS "+qualifiedName); err != nil { + return fmt.Errorf("dropping invalid index %s: %w", qualifiedName, err) + } + if _, err := db.ExecContext(ctx, build); err != nil { + return fmt.Errorf("building index %s: %w", qualifiedName, err) + } + return nil +} diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 74d1fc66f4..3af5d02c9e 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -191,14 +191,15 @@ func newMigrationProvider(pool *pgxpool.Pool, fsys fs.FS, dir string) (*goose.Pr // typed JSON, the displayprefs move parses the legacy jellycompat // keys, and the subtitle language backfill applies the scanner's // lang.CompatibleTag — none expressible in SQL without duplicating - // those rules. The invalid index rebuild decides per index whether to - // drop and rebuild it concurrently, which SQL cannot do outside a - // transaction. + // those rules. The invalid index rebuild and the push attempt key + // scope decide per index whether to drop and build it concurrently, + // which SQL cannot do outside a transaction. goose.WithGoMigrations( settingsBackfillMigration(), displayPrefsMoveMigration(), subtitleLanguageBackfillMigration(), invalidIndexRebuildMigration(), + pushAttemptKeyScopeMigration(), ), ) if err != nil { diff --git a/internal/database/push_attempt_key_migration.go b/internal/database/push_attempt_key_migration.go new file mode 100644 index 0000000000..fcfc23cff1 --- /dev/null +++ b/internal/database/push_attempt_key_migration.go @@ -0,0 +1,79 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/pressly/goose/v3" +) + +// pushAttemptKeyScopeVersion first shipped as an SQL migration of the same +// version; a deployment that applied that file skips this one. +const pushAttemptKeyScopeVersion int64 = 20261012230946 + +const ( + // pushAttemptKey is the original at-most-once key over every attempt + // that names a delivery. + pushAttemptKey = "push_delivery_attempts_delivery_unique" + // pushAttemptDeliveryKey is the same key over delivery attempts only. + pushAttemptDeliveryKey = "push_delivery_attempts_delivery_trigger_unique" + pushAttemptKeyColumns = "push_delivery_attempts (push_device_id, notification_delivery_id, attempt_number)" +) + +// pushAttemptKeyScopeMigration scopes push_delivery_attempts' at-most-once key +// to delivery attempts. An administrator test push carries the target +// profile's newest notification, so a test attempt names a real delivery and +// can share its (device, delivery, attempt number) with that delivery's own +// attempt and with earlier tests. The key only ever guarded fanout's enqueue. +// +// A Go migration for the reason invalidIndexRebuildMigration is: a retry must +// keep an index an earlier attempt built. goose records the version only after +// every statement has run, so an attempt that dropped the old key but was not +// recorded runs again, and dropping the replacement then would leave the table +// with neither key while it was rebuilt. +func pushAttemptKeyScopeMigration() *goose.Migration { + m := goose.NewGoMigration( + pushAttemptKeyScopeVersion, + &goose.GoFunc{RunDB: func(ctx context.Context, db *sql.DB) error { + return scopePushAttemptKey(ctx, db, "public") + }}, + &goose.GoFunc{RunDB: func(ctx context.Context, db *sql.DB) error { + return widenPushAttemptKey(ctx, db, "public") + }}, + ) + m.Source = fmt.Sprintf("%d_scope_push_attempt_key_to_deliveries.go", pushAttemptKeyScopeVersion) + return m +} + +// scopePushAttemptKey replaces the old key with the delivery-only one. +func scopePushAttemptKey(ctx context.Context, db *sql.DB, schema string) error { + return swapPushAttemptKey(ctx, db, schema, pushAttemptDeliveryKey, "trigger_type = 'delivery'", pushAttemptKey) +} + +// widenPushAttemptKey restores the old key. It cannot hold test attempts +// beside a delivery's own, so they are detached from their deliveries first; +// their history rows remain. +func widenPushAttemptKey(ctx context.Context, db *sql.DB, schema string) error { + if _, err := db.ExecContext(ctx, `UPDATE `+pgx.Identifier{schema}.Sanitize()+`.push_delivery_attempts + SET notification_delivery_id = NULL + WHERE trigger_type = 'test' AND notification_delivery_id IS NOT NULL`); err != nil { + return fmt.Errorf("detaching test push attempts: %w", err) + } + return swapPushAttemptKey(ctx, db, schema, pushAttemptKey, "notification_delivery_id IS NOT NULL", pushAttemptDeliveryKey) +} + +// swapPushAttemptKey builds the key named keep, over the rows predicate +// selects, before dropping the key named drop, so fanout is never without one. +func swapPushAttemptKey(ctx context.Context, db *sql.DB, schema, keep, predicate, drop string) error { + quotedSchema := pgx.Identifier{schema}.Sanitize() + if err := ensureIndexConcurrently(ctx, db, quotedSchema+"."+keep, fmt.Sprintf( + "CREATE UNIQUE INDEX CONCURRENTLY %s ON %s.%s WHERE %s", keep, quotedSchema, pushAttemptKeyColumns, predicate)); err != nil { + return err + } + if _, err := db.ExecContext(ctx, "DROP INDEX CONCURRENTLY IF EXISTS "+quotedSchema+"."+drop); err != nil { + return fmt.Errorf("dropping %s: %w", drop, err) + } + return nil +} diff --git a/internal/database/push_attempt_key_migration_test.go b/internal/database/push_attempt_key_migration_test.go new file mode 100644 index 0000000000..85750d8994 --- /dev/null +++ b/internal/database/push_attempt_key_migration_test.go @@ -0,0 +1,111 @@ +package database + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/stdlib" +) + +// goose records the push attempt key migration only after its last statement, +// so a run that stopped there runs again. Each direction must recover an +// interrupted build and, on a retry, keep the key it already built rather than +// leave the table with neither key while rebuilding it. +func TestPushAttemptKeyMigrationRetriesKeepTheBuiltKeyPostgres(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := t.Context() + config, err := pgx.ParseConfig(dsn) + if err != nil { + t.Fatal(err) + } + db := stdlib.OpenDB(*config) + t.Cleanup(func() { _ = db.Close() }) + schema := fmt.Sprintf("push_attempt_key_%d", time.Now().UnixNano()) + exec := func(query string) { + t.Helper() + if _, err := db.ExecContext(ctx, query); err != nil { + t.Fatal(err) + } + } + exec("CREATE SCHEMA " + schema) + t.Cleanup(func() { + if _, err := db.ExecContext(context.Background(), "DROP SCHEMA "+schema+" CASCADE"); err != nil { + t.Error(err) + } + }) + exec("CREATE TABLE " + schema + `.push_delivery_attempts (id text PRIMARY KEY, notification_delivery_id text, + push_device_id text NOT NULL, trigger_type text NOT NULL, attempt_number integer NOT NULL)`) + exec("INSERT INTO " + schema + ".push_delivery_attempts VALUES ('own', 'delivery-1', 'device', 'delivery', 1), ('other', 'delivery-2', 'device', 'delivery', 1)") + exec("CREATE UNIQUE INDEX " + pushAttemptKey + " ON " + schema + "." + pushAttemptKeyColumns + " WHERE notification_delivery_id IS NOT NULL") + // An earlier attempt stopped mid-build and left an invalid copy of the + // new key beside the old one. + _, err = db.ExecContext(ctx, "CREATE UNIQUE INDEX CONCURRENTLY "+pushAttemptDeliveryKey+" ON "+schema+".push_delivery_attempts (push_device_id)") + if pgErr, ok := errors.AsType[*pgconn.PgError](err); !ok || pgErr.Code != "23505" { + t.Fatalf("expected failed concurrent build, got %v", err) + } + + type index struct { + oid uint32 + valid bool + definition string + } + lookup := func(name string) index { + t.Helper() + var got index + if err := db.QueryRowContext(ctx, `SELECT coalesce(to_regclass($1)::oid, 0), + coalesce((SELECT indisvalid FROM pg_index WHERE indexrelid = to_regclass($1)), false), + coalesce(pg_get_indexdef(to_regclass($1)), '')`, schema+"."+name).Scan(&got.oid, &got.valid, &got.definition); err != nil { + t.Fatal(err) + } + return got + } + expect := func(step string, kept, gone, predicate string) index { + t.Helper() + key := lookup(kept) + if !key.valid || !strings.HasPrefix(key.definition, "CREATE UNIQUE INDEX") || !strings.Contains(key.definition, predicate) { + t.Fatalf("%s: %s = %+v, want a valid unique index %s", step, kept, key, predicate) + } + if lookup(gone).oid != 0 { + t.Fatalf("%s: %s still exists", step, gone) + } + return key + } + + if err := scopePushAttemptKey(ctx, db, schema); err != nil { + t.Fatal(err) + } + scoped := expect("up", pushAttemptDeliveryKey, pushAttemptKey, "WHERE (trigger_type = 'delivery'::text)") + if err := scopePushAttemptKey(ctx, db, schema); err != nil { + t.Fatal(err) + } + if again := expect("up retry", pushAttemptDeliveryKey, pushAttemptKey, "trigger_type"); again.oid != scoped.oid { + t.Fatal("up retry rebuilt the key the first run built") + } + + // A test attempt carrying a delivery shares the delivery's own key. + exec("INSERT INTO " + schema + ".push_delivery_attempts VALUES ('test', 'delivery-1', 'device', 'test', 1)") + if err := widenPushAttemptKey(ctx, db, schema); err != nil { + t.Fatal(err) + } + widened := expect("down", pushAttemptKey, pushAttemptDeliveryKey, "WHERE (notification_delivery_id IS NOT NULL)") + if err := widenPushAttemptKey(ctx, db, schema); err != nil { + t.Fatal(err) + } + if again := expect("down retry", pushAttemptKey, pushAttemptDeliveryKey, "notification_delivery_id IS NOT NULL"); again.oid != widened.oid { + t.Fatal("down retry rebuilt the key the first run built") + } + var detached bool + if err := db.QueryRowContext(ctx, "SELECT notification_delivery_id IS NULL FROM "+schema+".push_delivery_attempts WHERE id = 'test'").Scan(&detached); err != nil || !detached { + t.Fatalf("down kept the test attempt on its delivery (err %v)", err) + } +} diff --git a/internal/notifications/push_delivery.go b/internal/notifications/push_delivery.go index 2659a7068a..b34d8554de 100644 --- a/internal/notifications/push_delivery.go +++ b/internal/notifications/push_delivery.go @@ -166,7 +166,10 @@ func (r *PushDeviceRepository) EnqueuePushAttempts(ctx context.Context, tx pgx.T } // EnqueueTestAttempt creates a pending diagnostic attempt for one enabled -// device on the given platform. +// device on the given platform. The attempt carries the device profile's +// newest notification, so the device fetches and shows that notification's +// real text exactly as for an ordinary push. A profile with no notification +// yet gets a bare test whose push the device can only show as generic text. func (r *PushDeviceRepository) EnqueueTestAttempt(ctx context.Context, platform, profileID, serverDeviceID string) (*PushDeliveryAttempt, *PushDevice, error) { if r == nil || r.pool == nil { return nil, nil, ErrPushDeliveryUnavailable @@ -204,12 +207,26 @@ func (r *PushDeviceRepository) EnqueueTestAttempt(ctx context.Context, platform, return nil, nil, fmt.Errorf("select push test device: %w", err) } + // Profile IDs are unique only within an account, so match the device's + // account too. The key-share lock keeps the row from being deleted + // before the attempt references it. + var deliveryID *string + err = tx.QueryRow(ctx, ` + SELECT id FROM notification_deliveries + WHERE user_id = $1 AND profile_id = $2 + ORDER BY created_at DESC, id DESC + LIMIT 1 + FOR KEY SHARE`, device.UserID, device.ProfileID).Scan(&deliveryID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return nil, nil, fmt.Errorf("select push test delivery: %w", err) + } + attemptID := ulid.Make().String() rows, err := tx.Query(ctx, ` INSERT INTO push_delivery_attempts (id, notification_delivery_id, push_device_id, trigger_type, provider, platform, attempt_number, outcome) - VALUES ($1, NULL, $2, $3, $4, $5, 0, $6)`+pushAttemptReturning, - attemptID, device.ID, PushTriggerTest, PushProviderSiloRelay, platform, PushOutcomePending) + VALUES ($1, $2, $3, $4, $5, $6, 0, $7)`+pushAttemptReturning, + attemptID, deliveryID, device.ID, PushTriggerTest, PushProviderSiloRelay, platform, PushOutcomePending) if err != nil { return nil, nil, fmt.Errorf("insert push test attempt: %w", err) } @@ -259,15 +276,18 @@ func (r *PushDeviceRepository) getPushDeviceByID(ctx context.Context, id string) return device, nil } +// ClaimPendingPushForDelivery claims the delivery's own pending attempts for +// immediate dispatch. An administrator test that carries the delivery stays +// with the request that enqueued it. func (r *PushDeviceRepository) ClaimPendingPushForDelivery(ctx context.Context, deliveryID string) ([]PushDeliveryAttempt, error) { return r.claimPushAttempts(ctx, ` UPDATE push_delivery_attempts SET outcome = 'retrying', next_retry_at = now() + $2, updated_at = now() WHERE id IN ( SELECT id FROM push_delivery_attempts - WHERE notification_delivery_id = $1 AND outcome = 'pending' + WHERE notification_delivery_id = $1 AND trigger_type = $3 AND outcome = 'pending' FOR UPDATE SKIP LOCKED )`+pushAttemptReturning, - deliveryID, webhookClaimLease) + deliveryID, webhookClaimLease, PushTriggerDelivery) } func (r *PushDeviceRepository) ClaimPushAttemptByID(ctx context.Context, attemptID string) ([]PushDeliveryAttempt, error) { diff --git a/internal/notifications/push_sender.go b/internal/notifications/push_sender.go index c0c33addd4..2cf1db32f9 100644 --- a/internal/notifications/push_sender.go +++ b/internal/notifications/push_sender.go @@ -712,6 +712,9 @@ type ApplePushTestResult struct { AttemptID string PushDeviceID string ServerDeviceID string + // DeliveryID is the notification the test carried; empty when the + // profile had none. + DeliveryID string Outcome string RelayRequestID string UpstreamStatus *int @@ -765,6 +768,9 @@ func (s *System) sendPushTest(ctx context.Context, platform, profileID, serverDe Outcome: updated.Outcome, UpstreamStatus: updated.UpstreamStatus, } + if updated.NotificationDeliveryID != nil { + result.DeliveryID = *updated.NotificationDeliveryID + } if updated.RelayRequestID != nil { result.RelayRequestID = *updated.RelayRequestID } diff --git a/internal/notifications/push_test_send_db_test.go b/internal/notifications/push_test_send_db_test.go new file mode 100644 index 0000000000..b598e85573 --- /dev/null +++ b/internal/notifications/push_test_send_db_test.go @@ -0,0 +1,149 @@ +package notifications + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// An administrator test push carries the device profile's newest +// notification, so the device fetches and shows that notification's real +// text. Profile IDs repeat across accounts, so neither another account's +// same-named profile nor a sibling profile lends its notification, and a +// profile with no notification keeps a bare test. Dispatching that +// notification must leave a pending test to the request that enqueued it. +// Repeating the test beside the delivery's own attempt must not collide on the +// attempt key, and none of it touches the inbox. +func TestSendPushTestCarriesTheProfilesNewestNotification(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + prefix := fmt.Sprintf("testpush-%d", time.Now().UnixNano()) + var accounts []int + t.Cleanup(func() { + cleanup := context.Background() + _, _ = pool.Exec(cleanup, `DELETE FROM push_devices WHERE user_id = ANY($1)`, accounts) + _, _ = pool.Exec(cleanup, `DELETE FROM notification_deliveries WHERE user_id = ANY($1)`, accounts) + _, _ = pool.Exec(cleanup, `DELETE FROM users WHERE id = ANY($1)`, accounts) + }) + for _, name := range []string{"account", "other-account"} { + var id int + username := prefix + "-" + name + if err := pool.QueryRow(ctx, `INSERT INTO users (username, email, password_hash, role) VALUES ($1, $2, '', 'user') RETURNING id`, + username, username+"@example.test").Scan(&id); err != nil { + t.Fatalf("seed account: %v", err) + } + accounts = append(accounts, id) + } + account, otherAccount := accounts[0], accounts[1] + exec := func(sql string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, sql, args...); err != nil { + t.Fatalf("seed: %v", err) + } + } + + const profile = "default" + quietProfile := prefix + "-quiet" + newest := prefix + "-newest" + base := time.Now().Add(-time.Hour).UTC() + for i, delivery := range []struct { + id string + account int + profile string + }{ + {prefix + "-older", account, profile}, + {newest, account, profile}, + {prefix + "-other-account", otherAccount, profile}, + {prefix + "-sibling", account, prefix + "-sibling"}, + } { + exec(`INSERT INTO notification_deliveries (id, user_id, profile_id, type, reason_flags, created_at) + VALUES ($1, $2, $3, 'webhook.auto_disabled', '{}', $4)`, + delivery.id, delivery.account, delivery.profile, base.Add(time.Duration(i)*time.Minute)) + } + + cipher := testPushCipher(t) + repo := NewPushDeviceRepository(pool) + register := func(profile, device, token string) *PushDevice { + t.Helper() + registered, err := repo.UpsertApple(ctx, ApplePushDeviceRegistration{ + UserID: account, ProfileID: profile, DeviceID: prefix + "-" + device, APNsToken: token, + APNsEnvironment: APNsEnvironmentSandbox, APNsTopic: ApplePushTopicSilo, PushMode: PushModePrivatePush, + }, cipher) + if err != nil { + t.Fatalf("register %s: %v", device, err) + } + return registered + } + phone := register(profile, "phone", strings.Repeat("a", 64)) + quietPhone := register(quietProfile, "quiet-phone", strings.Repeat("b", 64)) + // The delivery's own first attempt holds attempt number 1 for the phone. + exec(`INSERT INTO push_delivery_attempts (id, notification_delivery_id, push_device_id, trigger_type, provider, platform, attempt_number, outcome) + VALUES ($1, $2, $3, 'delivery', 'silo_relay', 'apple', 1, 'delivered')`, prefix+"-own", newest, phone.ID) + + var relayed []map[string]any + settings := NewSettings(mapSettingReader{SettingPushRelayURL: DefaultPushRelayURL, SettingPushRelayAPIKey: "relay-key"}) + sender := newPushSender(repo, NewDeliveryRepository(pool), cipher, settings) + sender.client = &http.Client{Transport: relayRoundTripFunc(func(req *http.Request) (*http.Response, error) { + var body map[string]any + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + t.Errorf("decode relay body: %v", err) + } + relayed = append(relayed, body) + return relayResponse(http.StatusOK, `{"request_id":"relay-request","status":"accepted"}`), nil + })} + system := &System{Settings: settings, pushDeviceRepo: repo, pushSender: sender} + + pending, _, err := repo.EnqueueTestAttempt(ctx, PushPlatformApple, profile, phone.ServerDeviceID) + if err != nil { + t.Fatalf("enqueue test: %v", err) + } + if dispatched, err := repo.ClaimPendingPushForDelivery(ctx, newest); err != nil || len(dispatched) != 0 { + t.Fatalf("delivery dispatch claimed %+v (err %v); the pending test belongs to its request", dispatched, err) + } + if claimed, err := repo.ClaimPushAttemptByID(ctx, pending.ID); err != nil || len(claimed) != 1 { + t.Fatalf("test claim = %+v (err %v), want the pending test", claimed, err) + } + + for range 2 { + result, err := system.SendApplePushTest(ctx, profile, phone.ServerDeviceID) + if err != nil { + t.Fatalf("send test: %v", err) + } + body := relayed[len(relayed)-1] + if result.Outcome != PushOutcomeDelivered || result.DeliveryID != newest || body["delivery_id"] != newest || body["collapse_id"] != newest { + t.Fatalf("result = %+v, relay body = %v; want delivered carrying %s", result, body, newest) + } + } + + result, err := system.SendApplePushTest(ctx, quietProfile, quietPhone.ServerDeviceID) + if err != nil { + t.Fatalf("send bare test: %v", err) + } + if body := relayed[len(relayed)-1]; result.Outcome != PushOutcomeDelivered || result.DeliveryID != "" || body["delivery_id"] != result.AttemptID { + t.Fatalf("bare test result = %+v, relay body = %v", result, body) + } + + var rows, read int + if err := pool.QueryRow(ctx, `SELECT count(*), count(read_at) FROM notification_deliveries WHERE user_id = ANY($1)`, accounts).Scan(&rows, &read); err != nil { + t.Fatal(err) + } + if rows != 4 || read != 0 { + t.Fatalf("inbox rows = %d with %d read; test pushes must not touch the inbox", rows, read) + } +} diff --git a/scripts/ci/db-contracts.txt b/scripts/ci/db-contracts.txt index 2596fde25d..b1db769ccc 100644 --- a/scripts/ci/db-contracts.txt +++ b/scripts/ci/db-contracts.txt @@ -137,6 +137,7 @@ ./internal/notifications TestNotifyFulfilledDeliversOncePerAccountForSharedProfileID ./internal/notifications TestRecomputeSeriesFollowsHomeRemovalsPostgres ./internal/notifications TestPushDeviceRepositoryUpsertApplePreservesStableIDs +./internal/notifications TestSendPushTestCarriesTheProfilesNewestNotification ./internal/notifications TestNotificationInboxCutoffReplayAndTies ./internal/notifications TestFanoutSkipsRecipientsWithoutAccess ./internal/notifications TestNotifyFulfilledSkipsRecipientWithoutAccess @@ -186,6 +187,9 @@ # Transactional administrator audit details and rollback isolation. ./internal/activitylog TestMutationCommitRollbackAndHistoryDB +# A retried push attempt key migration keeps the key an earlier run built. +./internal/database TestPushAttemptKeyMigrationRetriesKeepTheBuiltKeyPostgres + # Online partitioned audit indexes preserve writers and resume canceled builds. ./internal/partman TestOnlinePartitionIndexesKeepWritersAvailableDB diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index 84149b5703..382d1934f0 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -3091,7 +3091,7 @@ export interface paths { }; get?: never; put?: never; - /** Dispatch one test push notification and report its current delivery outcome. */ + /** Dispatch one test push carrying the target profile's newest notification and report its current delivery outcome. */ post: operations["testAdminApplePushNotification"]; delete?: never; options?: never; @@ -3108,7 +3108,7 @@ export interface paths { }; get?: never; put?: never; - /** Dispatch one test push notification and report its current delivery outcome. */ + /** Dispatch one test push carrying the target profile's newest notification and report its current delivery outcome. */ post: operations["testAdminAndroidPushNotification"]; delete?: never; options?: never; @@ -16176,6 +16176,11 @@ export interface components { * @example 1 */ attempt_id: string; + /** + * @description The target profile's newest notification, which the test push carried so the device shows its real text; absent when the profile has no notification and the device can show only generic text. + * @example 1 + */ + delivery_id?: string; failure_message?: string; outcome: string; /** From eecb7f5024249377948b50d8d65b98b4f41e9876 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:24:47 -0400 Subject: [PATCH 007/196] fix(nodes): sign worker requests instead of sending the shared secret (#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 * 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 * docs(nodes): scope the no-secret-on-the-wire claims to signed requests Co-Authored-By: Claude Opus 5.5 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- docs/admin-api.md | 4 +- docs/architecture/media-sampling.md | 4 +- docs/architecture/network-access.md | 5 +- docs/architecture/observability.md | 3 +- .../restart-resilient-playback.md | 11 +- docs/architecture/worker-http-protocol.md | 77 +++- docs/network-access-api.md | 7 +- .../api/handlers/admin_node_commands_test.go | 6 +- .../handlers/admin_node_configuration_test.go | 9 +- .../api/handlers/admin_node_network_access.go | 9 +- .../admin_node_network_access_test.go | 11 +- internal/api/handlers/admin_node_reload.go | 5 +- .../api/handlers/admin_node_reload_test.go | 20 +- internal/api/handlers/nodes.go | 13 +- internal/api/handlers/nodes_reprobe_test.go | 21 +- internal/api/handlers/nodes_test.go | 11 +- internal/api/handlers/playback.go | 8 +- internal/api/handlers/playback_transport.go | 5 +- .../playback_transport_security_test.go | 9 +- .../apiv2/worker_download_prepare_test.go | 3 +- internal/apiv2/worker_protocols_test.go | 9 +- internal/apiv2/worker_start_test.go | 3 +- internal/apiv2/worker_stop_test.go | 3 +- internal/apiv2/worker_streaming_test.go | 5 +- internal/chapterthumbs/remote.go | 5 +- internal/chapterthumbs/remote_signing_test.go | 29 ++ internal/downloadprepare/transport.go | 15 +- internal/downloadprepare/transport_test.go | 40 +- .../remote_preparer_security_test.go | 9 +- internal/downloads/remote_preparer_test.go | 5 +- internal/downloads/service_remote_test.go | 3 +- .../auto_transcode_fallback_test.go | 4 + internal/jellycompat/handlers_playback.go | 5 +- .../remote_relay_transport_test.go | 8 +- internal/jellycompat/streams.go | 12 +- internal/mediasample/remote.go | 8 +- internal/mediasample/remote_signing_test.go | 27 ++ internal/nodeauth/nodeauth.go | 317 +++++++++++++++ internal/nodeauth/nodeauth_test.go | 384 ++++++++++++++++++ .../nodeauth/nodeauthtest/nodeauthtest.go | 27 ++ .../playback/remote_delete_signing_test.go | 31 ++ internal/playback/transcode_manager.go | 8 +- internal/proxy/capability_snapshot_test.go | 7 +- internal/proxy/download_protocol_test.go | 3 +- internal/proxy/download_test.go | 5 +- internal/proxy/mediagrant_test.go | 10 +- internal/proxy/network_access_test.go | 7 +- internal/proxy/node_auth_test.go | 42 ++ internal/proxy/reprobe_test.go | 8 +- internal/proxy/server.go | 21 +- internal/proxy/streaming_protocol_test.go | 3 +- internal/proxy/theme_audio_test.go | 6 +- internal/transcodenode/capability_client.go | 5 +- .../transcodenode/capability_snapshot_test.go | 5 +- internal/transcodenode/drain_test.go | 10 +- internal/transcodenode/media_sample_test.go | 19 +- internal/transcodenode/node_auth_test.go | 49 +++ internal/transcodenode/reprobe_test.go | 7 +- internal/transcodenode/server.go | 12 +- internal/transcodenode/server_test.go | 15 +- internal/transcodenode/start_protocol_test.go | 4 +- internal/transcodenode/streamdeny_test.go | 3 +- .../transcodenode/streamtelemetry_test.go | 3 +- internal/transcodenode/theme_audio_test.go | 3 +- internal/transcodenode/trickplay_gate_test.go | 4 +- internal/transcodenode/trickplay_test.go | 15 +- internal/transcodeproxy/completion.go | 15 +- internal/transcodeproxy/completion_test.go | 8 +- internal/trickplay/remote.go | 5 +- internal/trickplay/remote_test.go | 3 +- 70 files changed, 1318 insertions(+), 177 deletions(-) create mode 100644 internal/chapterthumbs/remote_signing_test.go create mode 100644 internal/mediasample/remote_signing_test.go create mode 100644 internal/nodeauth/nodeauth.go create mode 100644 internal/nodeauth/nodeauth_test.go create mode 100644 internal/nodeauth/nodeauthtest/nodeauthtest.go create mode 100644 internal/playback/remote_delete_signing_test.go create mode 100644 internal/proxy/node_auth_test.go create mode 100644 internal/transcodenode/node_auth_test.go diff --git a/docs/admin-api.md b/docs/admin-api.md index 92667b8d6f..130e582183 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -418,7 +418,7 @@ Each entry in `disks`: | Field | Type | Meaning | |---|---|---| -| `path` | string | Where the mount is. Absent from a node's `last_stats`: the node reports it on its own bearer-authed `/status`, and the API host reports its own on `GET /admin/system/resources`, but a node's `/health` takes no credential and withholds it. Use `role`. | +| `path` | string | Where the mount is. Absent from a node's `last_stats`: the node reports it on its own `/status`, which needs a [signed request](architecture/worker-http-protocol.md#authentication), and the API host reports its own on `GET /admin/system/resources`, but a node's `/health` takes no credential and withholds it. Use `role`. | | `role` | string | What the mount is for: `scratch`, or `library-N` positionally per media root. Assigned when the sample is built, so it names the same mount on `/health`, `/status`, `/admin/system/resources` and the `streamapp_node_disk_*` series, and it stays with the mount even when a probe cannot measure it. | | `used_gb`, `total_gb` | float | Capacity in GiB. `used_gb` counts filesystem-reserved blocks, as `df` does. `total_gb` is the capacity usable by the node process — used plus still-available — so it reads lower than the device's nameplate size on a volume that reserves blocks for root, and `used_gb`/`total_gb` is the ratio `df` prints as Use%. | | `stale` | bool | The numbers are real but carried over from an earlier pass because the current probe has not returned — the normal reading for a network mount whose server went away. Omitted when false. | @@ -763,7 +763,7 @@ this route can legitimately outlive the API listener's ordinary 120-second as a hung request. This re-probes only — it does not reload configuration or tear anything down. -Under the hood this is a bearer-authenticated `POST +Under the hood this is a [signed](architecture/worker-http-protocol.md#authentication) `POST /admin/reprobe-capabilities` on the node's own listener, which both transcode nodes and proxy nodes serve. That route is internal to the cluster and is not part of any client contract. diff --git a/docs/architecture/media-sampling.md b/docs/architecture/media-sampling.md index a1924f5491..1ce4ec35f8 100644 --- a/docs/architecture/media-sampling.md +++ b/docs/architecture/media-sampling.md @@ -152,8 +152,8 @@ is held as PCM. Subtitle sync is its consumer. `POST /media-samples/run` on the transcode-node listener runs one `Request` with the node's ffmpeg and returns its `Result` as JSON -(`mediasample.RemoteClient` is the caller). The node requires its bearer -secret and an input path it is allowed to read, accepts software attempts +(`mediasample.RemoteClient` is the caller). The node requires a +[signed request](worker-http-protocol.md#authentication) and an input path it is allowed to read, accepts software attempts only, and answers a failed run with `422` and a `RemoteFailure` whose reason is the run's `Classify` cause. `RemoteError.Infrastructure` tells a caller whether running the request elsewhere may succeed: an unreachable node, a 5xx, diff --git a/docs/architecture/network-access.md b/docs/architecture/network-access.md index 32af37a2cd..d9a2b4fdd4 100644 --- a/docs/architecture/network-access.md +++ b/docs/architecture/network-access.md @@ -182,11 +182,12 @@ to them. (`Service.FollowLifecycleChanges`), so a missed publish costs at most one minute. A caller that cancels an accepted restart stops waiting while the supervisor continues the restart and records the result. -- The API reaches proxies over their backend URL with the node bearer: +- The API reaches proxies over their backend URL with + [signed worker requests](worker-http-protocol.md#authentication): `GET /network-access/status`, `POST /network-access/{provider}/connect` and `.../disconnect` on the proxy listener, ten seconds each, in parallel, like force-reload. Those routes carry `auth_url` and error text because - they take the bearer; the public `/health` report carries only state, + they require a signed request; the public `/health` report carries only state, origin and hostname. The proxy listener also runs `netaccess.Middleware`, so its own provider's stamped requests are validated there too. - The proxy's `/health` `network_access` block is what the API stores on the diff --git a/docs/architecture/observability.md b/docs/architecture/observability.md index aa555a0f42..0f088611b5 100644 --- a/docs/architecture/observability.md +++ b/docs/architecture/observability.md @@ -360,7 +360,8 @@ application listener; the API process serves it only on `SILO_METRICS_LISTEN`. `mount="library-1"`), never by path, so an unauthenticated scrape cannot enumerate media locations. A node's unauthenticated `/api/v1/health` withholds paths on the same terms. Paths appear only in the admin resources response and a - node's bearer-authenticated `/status`. + node's `/status`, which needs a + [signed request](worker-http-protocol.md#authentication). - **Probing is bounded.** Each process's sampler checks at most eight mounts: the transcode scratch directory first, then library roots in order. The process logs how many go unsampled. The cap is per process, not per host, so diff --git a/docs/architecture/restart-resilient-playback.md b/docs/architecture/restart-resilient-playback.md index edd73e7c60..afc1467403 100644 --- a/docs/architecture/restart-resilient-playback.md +++ b/docs/architecture/restart-resilient-playback.md @@ -414,10 +414,13 @@ evolution: surviving token. No cross-process enumeration-failure mode for the supported topology. - **Signing-key blast radius (E / tr-7, G13).** A single `JWTSecret` signs native - auth, the stream token, and the node bearer, so a node-secret leak can forge - native logins. **Accepted risk** on the trusted-node precondition. **Deferred - hardening:** split the stream-token secret from the native-auth secret, then - asymmetric stream-token signing before any untrusted/edge node. + auth and the stream token, and every node holds it, so a compromised node can + forge native logins. **Accepted risk** on the trusted-node precondition. + Watching a node link is not the same as compromising a node: calls to nodes + carry an HMAC under a key derived from the secret, never the secret itself + (see [worker authentication](worker-http-protocol.md#authentication)). + **Deferred hardening:** split the stream-token secret from the native-auth + secret, then asymmetric stream-token signing before any untrusted/edge node. --- diff --git a/docs/architecture/worker-http-protocol.md b/docs/architecture/worker-http-protocol.md index 6d9139495d..497afa0977 100644 --- a/docs/architecture/worker-http-protocol.md +++ b/docs/architecture/worker-http-protocol.md @@ -6,6 +6,11 @@ worker operation: proxy `/status` returns `active_sessions`, while transcode `/status` returns `status`, `active_jobs`, and `sessions`. Both can include the same optional system and GPU samples. +"Node bearer" in this document names the route inventory's `node_bearer` +authorization class: routes that admit only API servers and proxies. Despite the +name, those callers send a per-request signature, never the shared secret; see +[Authentication](#authentication). + The `x-silo-worker-protocols` extension in `contracts/api/v2/openapi.json` describes retained operations by listener, method, path, handler identity, authorization class, and response status/media/schema. Schemas are generated by Huma from the @@ -50,7 +55,7 @@ disconnected. A proxy that hosts no plugins answers plain-text 503. See [network-access.md](network-access.md). An unconfigured transcode listener also returns plain-text 503 from its bearer -middleware, including for both reload commands. All six require the existing node bearer token. Reprobe refuses active probes; +middleware, including for both reload commands. All six require node bearer authentication. Reprobe refuses active probes; the transcode node also refuses active jobs while holding its GPU admission gate. These commands have no durable replay receipt. Their descriptions classify them as `non_retryable`: a lost reply does not establish that the command had no effect, @@ -302,6 +307,76 @@ deadlines and stream accounting remain unchanged; a later read failure can truncate committed bytes without a new status. No transfer receipt, worker reconstruction or new native route is introduced. +## Authentication + +API servers and proxies sign every call to a worker's node bearer routes; +`internal/nodeauth` implements both sides. Senders call `nodeauth.Sign` just +before sending, and the transcode node and proxy middleware call +`nodeauth.Verify`. There is no worker-to-API call. + +A signed request never carries the shared `auth.jwt_secret`. That secret also +signs login, profile, and stream tokens, so a copy captured from a worker link +would mint them. Each side instead derives a MAC key from it with HKDF-SHA256 (no salt, +info `silo/node-request-auth/v1`, 32 bytes). Nothing else uses that label, so +the key cannot sign or verify any other token. A request carries: + +```text +Authorization: Silo-Node-HMAC-SHA256 t=,b=,sig= +``` + +`b` is the SHA-256 of the body, or of the empty string when there is none. The +MAC covers these values, joined by newlines: the literal +`silo-node-request-v1`, `t`, the method, the escaped request path (`/` when +empty), the raw query, and `b` as sent. The sender signs the path relative to +the worker's registered URL, leaving out any path prefix in that URL: workers +serve their routes from `/`, so a reverse proxy that puts one under a prefix +strips it, and the worker verifies the path it receives. The worker checks, in +order: + +1. the MAC, recomputed over the request it received and compared with + `hmac.Equal`, before reading any body, so a caller without the secret cannot + make the worker buffer one; +2. the timestamp, refused when more than 30 seconds from the worker's clock in + either direction; +3. the body, at most 1 MiB, whose SHA-256 must equal `b`. The worker then hands + the buffered body to the handler. + +Every authentication refusal is a plain-text `401`. A sender with no secret +configured sends the request unsigned, and the worker refuses it. The worker +logs an invalid signature, a valid signature outside the window (with the +clock offset), and a body that does not match its signature. Each kind logs at +most once every ten minutes and counts the refusals it suppressed. + +What this requires and allows: + +- Worker clocks stay within 30 seconds of the API servers and proxies. +- A reverse proxy in front of a worker may strip the path prefix of the + worker's registered URL, but must otherwise forward the path and query + unchanged. Headers, including `Host`, are not signed. +- A captured signed request can be replayed, to the same or another worker, + until its timestamp expires. Its method, path, query, and body cannot be altered; its + headers can, since they are not signed. It reveals nothing that signs another + request or token. Signing does not encrypt: worker links that cross untrusted + networks still need TLS to keep media and requests private. + +### Upgrading to signed requests + +Release notes certify which component versions may run together +([release versioning](../release-versioning.md)). Upgrading every component +together, as a stop-and-replace, has no mixed state. For a rolling upgrade, +workers also accept the previous release's `Authorization: Bearer `, +compared in constant time, and log a warning at most once every ten minutes +when they do. Senders from this release only sign, which earlier workers +refuse, so upgrade transcode nodes first, then proxies, then API servers. Until +the last older sender is upgraded, its requests still carry the secret, so a +rolling upgrade over links without TLS exposes it for that window; a +stop-and-replace upgrade does not. Recovery from a failed upgrade is the release-wide backup and redeploy; +rolling one worker back alone leaves it refusing every upgraded sender. + +The next release removes the legacy bearer from `nodeauth.Verify`. Its release +notes must name this release as a required step for rolling upgrades, because +its workers refuse senders from before this release. + ## Remaining progressive and HLS delivery chain Proxy token remux routes keep the legacy and audio-v2 suffixes distinct. The diff --git a/docs/network-access-api.md b/docs/network-access-api.md index 13d693c627..ec0ddb60f7 100644 --- a/docs/network-access-api.md +++ b/docs/network-access-api.md @@ -91,9 +91,10 @@ for its live status, with a ten-second timeout per host: - `host.id` is `api` for the API server and `node:` for a proxy node; it is also the instance-state scope the plugin's node keys are stored under. The API host comes first, then every enabled proxy node in id order. Proxy - rows are read over the node's backend URL with the node bearer + rows are read over the node's backend URL with + [signed worker requests](architecture/worker-http-protocol.md#authentication) (`GET /network-access/{provider}/status` on the proxy listener) with the same - ten-second timeout; a proxy that cannot be reached, refuses the bearer, + ten-second timeout; a proxy that cannot be reached, refuses the request, runs a build without the routes, or has not resolved its `stream_nodes` row yet answers `unavailable` with the reason in `error`. Transcode nodes are never listed. Each request reads only the named provider, so another @@ -143,7 +144,7 @@ Both are naturally idempotent: repeating connect converges on one connected instance per host, repeating disconnect on disconnected. An unknown provider is `404 not_found`. -Proxy hosts are commanded over the node bearer routes +Proxy hosts are commanded over the signed worker routes `POST /network-access/{provider}/connect` and `POST /network-access/{provider}/disconnect` on the proxy listener, each bounded by the same ten seconds; the proxy answers the state its own diff --git a/internal/api/handlers/admin_node_commands_test.go b/internal/api/handlers/admin_node_commands_test.go index 8ccfc57f3d..a8284edbd6 100644 --- a/internal/api/handlers/admin_node_commands_test.go +++ b/internal/api/handlers/admin_node_commands_test.go @@ -56,7 +56,7 @@ func TestAdminNodeReprobeView(t *testing.T) { refreshErr error }{{"success", 200, nil}, {"store failure", 200, errors.New("store unavailable")}, {"busy", 409, nil}, {"unavailable", 503, nil}} { t.Run(tc.name, func(t *testing.T) { - url, auth := fakeNodeServer(t, tc.status, `{"resolved":"qsv","capability_hash":"new"}`) + url, signedWith := fakeNodeServer(t, tc.status, `{"resolved":"qsv","capability_hash":"new"}`) repo := &stubNodeRepository{node: &nodepool.Node{ID: 1, Name: "Synthetic", URL: url, Type: nodepool.NodeTypeTranscode}} refresh := &stubCapabilityRefresher{err: tc.refreshErr} h := NewNodeHandler(repo, nil, nil, nil, nil, nil, "synthetic-secret") @@ -64,8 +64,8 @@ func TestAdminNodeReprobeView(t *testing.T) { writer := &deadlineRecorder{ResponseRecorder: httptest.NewRecorder()} before := time.Now() out, err := h.ReprobeAdminNode(writer, httptest.NewRequest("POST", "/", nil).WithContext(t.Context()), 1) - if err != nil || *auth != "Bearer synthetic-secret" || !writer.deadline.After(before) { - t.Fatal(out, err, *auth, writer.deadline) + if err != nil || !signedWith("synthetic-secret") || !writer.deadline.After(before) { + t.Fatal(out, err, writer.deadline) } if tc.status == 200 { if out.Status != "ok" || out.CapabilityHash != "new" || out.CapabilitiesRefreshed != (tc.refreshErr == nil) || refresh.calls.Load() != 1 { diff --git a/internal/api/handlers/admin_node_configuration_test.go b/internal/api/handlers/admin_node_configuration_test.go index c8984d0168..3c3c391456 100644 --- a/internal/api/handlers/admin_node_configuration_test.go +++ b/internal/api/handlers/admin_node_configuration_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" ) @@ -101,12 +102,12 @@ func waitNodeConfiguration(t *testing.T, ready func() bool) { // server's cached capabilities for the node; a rename does neither. The response // is the committed row either way and does not wait on the worker. func TestUpdateAdminNodeNudgesWorkerOnlyOnPolicyTargetChange(t *testing.T) { - reloaded := make(chan string, 4) + reloaded := make(chan bool, 4) var destructive atomic.Bool worker := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/admin/reload-config": - reloaded <- r.Header.Get("Authorization") + reloaded <- nodeauthtest.Signed(r, "secret") case "/admin/force-reload": destructive.Store(true) } @@ -135,8 +136,8 @@ func TestUpdateAdminNodeNudgesWorkerOnlyOnPolicyTargetChange(t *testing.T) { t.Fatal("post-commit work did not finish") } for len(reloaded) > 0 { - if auth := <-reloaded; auth != "Bearer secret" { - t.Fatalf("authorization %q", auth) + if signed := <-reloaded; !signed { + t.Fatal("reload request was not signed with the shared secret") } reloads++ } diff --git a/internal/api/handlers/admin_node_network_access.go b/internal/api/handlers/admin_node_network_access.go index 93d4b7a76b..0cc2eda642 100644 --- a/internal/api/handlers/admin_node_network_access.go +++ b/internal/api/handlers/admin_node_network_access.go @@ -10,6 +10,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/plugins" "github.com/Silo-Server/silo-server/internal/telemetry" @@ -64,7 +65,7 @@ func (h *NodeHandler) NodeNetworkAccessDisconnect(ctx context.Context, node plug return status, err } -// nodeNetworkAccessCall performs one bearer-authenticated request against a +// nodeNetworkAccessCall performs one signed request against a // proxy's network-access routes and decodes the JSON answer into out. A 404 // is the proxy saying the provider is not installed there and maps to // plugins.ErrNetworkAccessProviderNotFound; every other failure carries the @@ -79,7 +80,9 @@ func (h *NodeHandler) nodeNetworkAccessCall(ctx context.Context, node plugins.Ne if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+h.jwtSecret) + if err := nodeauth.Sign(req, node.URL, h.jwtSecret); err != nil { + return err + } req.Header.Set("Accept", "application/json") client := &http.Client{Timeout: nodeNetworkAccessTimeout} resp, err := telemetry.DoTrustedNode(client, req, operation) @@ -99,7 +102,7 @@ func (h *NodeHandler) nodeNetworkAccessCall(ctx context.Context, node plugins.Ne case http.StatusNotFound: return plugins.ErrNetworkAccessProviderNotFound case http.StatusUnauthorized: - return fmt.Errorf("proxy node refused the node bearer") + return fmt.Errorf("proxy node refused the signed request; check that it shares this server's secret, runs this Silo version, and keeps the correct time") case http.StatusServiceUnavailable: return fmt.Errorf("proxy node does not host network access providers: %s", trimNodeReason(body)) default: diff --git a/internal/api/handlers/admin_node_network_access_test.go b/internal/api/handlers/admin_node_network_access_test.go index 858bb6f9da..569331678e 100644 --- a/internal/api/handlers/admin_node_network_access_test.go +++ b/internal/api/handlers/admin_node_network_access_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/plugins" ) @@ -40,7 +41,7 @@ func (f *fakeProxyNode) ServeHTTP(w http.ResponseWriter, r *http.Request) { f.mu.Lock() defer f.mu.Unlock() f.calls = append(f.calls, r.Method+" "+r.URL.Path) - if r.Header.Get("Authorization") != "Bearer "+f.secret { + if !nodeauthtest.Signed(r, f.secret) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -131,11 +132,11 @@ func TestNodeHandlerFansNetworkAccessOutToProxies(t *testing.T) { t.Fatalf("proxy calls = %v, want %v", calls, want) } - // A wrong node secret is reported as the proxy refusing the bearer, not as - // an unknown provider. + // A wrong node secret is reported as the proxy refusing the signed + // request, not as an unknown provider. wrong := NewNodeHandler(repo, nil, nil, nil, nil, nil, "other-secret") - if _, err := wrong.NodeNetworkAccessStatus(ctx, node, "stub"); err == nil || errors.Is(err, plugins.ErrNetworkAccessProviderNotFound) || !strings.Contains(err.Error(), "bearer") { - t.Fatalf("wrong bearer err = %v", err) + if _, err := wrong.NodeNetworkAccessStatus(ctx, node, "stub"); err == nil || errors.Is(err, plugins.ErrNetworkAccessProviderNotFound) || !strings.Contains(err.Error(), "refused the signed request") { + t.Fatalf("wrong secret err = %v", err) } // An unreachable proxy is an error the service turns into unavailable. diff --git a/internal/api/handlers/admin_node_reload.go b/internal/api/handlers/admin_node_reload.go index 41b244153a..22b3ad831a 100644 --- a/internal/api/handlers/admin_node_reload.go +++ b/internal/api/handlers/admin_node_reload.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/telemetry" "github.com/Silo-Server/silo-server/internal/nodepool" @@ -17,7 +18,9 @@ func (h *NodeHandler) forceReloadRequest(ctx context.Context, node *nodepool.Nod if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+h.jwtSecret) + if err := nodeauth.Sign(req, node.URL, h.jwtSecret); err != nil { + return nil, err + } return req, nil } func (h *NodeHandler) ForceReloadAdminNodes(ctx context.Context) ([]ForceReloadResult, error) { diff --git a/internal/api/handlers/admin_node_reload_test.go b/internal/api/handlers/admin_node_reload_test.go index be2600e30e..41aeb3b49c 100644 --- a/internal/api/handlers/admin_node_reload_test.go +++ b/internal/api/handlers/admin_node_reload_test.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "testing" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" ) @@ -21,19 +22,26 @@ func TestAdminNodeReloadBulk(t *testing.T) { entered := make(chan string, 2) release := make(chan struct{}) var calls atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + node := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - if r.Method != "POST" || r.Header.Get("Authorization") != "Bearer synthetic-secret" { + if r.Method != "POST" || r.URL.Path != "/admin/force-reload" || !nodeauthtest.Signed(r, "synthetic-secret") { t.Error("wrong command authority or method") } - entered <- r.URL.Path + entered <- r.RequestURI <-release - if r.URL.Path == "/first/admin/force-reload" { + if r.RequestURI == "/first/admin/force-reload" { w.WriteHeader(204) } else { w.WriteHeader(503) } - })) + }) + // Each node sits under its own path prefix, which a reverse proxy strips: + // workers serve their routes from the root. + gateway := http.NewServeMux() + for _, prefix := range []string{"/first", "/second", "/disabled"} { + gateway.Handle(prefix+"/", http.StripPrefix(prefix, node)) + } + server := httptest.NewServer(gateway) defer server.Close() repo := &reloadNodeRepo{stubNodeRepository: stubNodeRepository{nodes: []*nodepool.Node{ {ID: 1, Name: "First", URL: server.URL + "/first", Enabled: true}, @@ -77,7 +85,7 @@ func TestAdminNodeReloadSingle(t *testing.T) { defer target.Close() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - if r.Method != "POST" || r.URL.Path != "/admin/force-reload" || r.Header.Get("Authorization") != "Bearer synthetic-secret" { + if r.Method != "POST" || r.URL.Path != "/admin/force-reload" || !nodeauthtest.Signed(r, "synthetic-secret") { t.Error("wrong command") } w.Header().Set("Location", target.URL) diff --git a/internal/api/handlers/nodes.go b/internal/api/handlers/nodes.go index 2b65ae635f..949e1acb07 100644 --- a/internal/api/handlers/nodes.go +++ b/internal/api/handlers/nodes.go @@ -15,6 +15,7 @@ import ( "sync" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/telemetry" "github.com/Silo-Server/silo-server/internal/cache" @@ -63,7 +64,7 @@ type NodeHandler struct { lister NodeListEnabled eventBus cache.EventBus redisClient *redis.Client // for reading session keys - jwtSecret string // for bearer auth when calling force-reload on nodes + jwtSecret string // signs calls to nodes (see nodeauth) // capabilities refreshes a node's stored inventory on demand; nil in a // deployment with no health checker, where a re-probe still runs on the node // and the stored row catches up on the next sweep. @@ -415,7 +416,9 @@ func (h *NodeHandler) reloadNodeConfig(ctx context.Context, node *nodepool.Node) if err != nil { return false } - req.Header.Set("Authorization", "Bearer "+h.jwtSecret) + if nodeauth.Sign(req, node.URL, h.jwtSecret) != nil { + return false + } resp, err := telemetry.DoTrustedNode(&http.Client{Timeout: nodeConfigReloadTimeout}, req, "reload") if err != nil { slog.WarnContext(ctx, "node did not reload after an acceleration override change; it will pick it up on its next config poll", @@ -767,7 +770,7 @@ type nodeReprobeResponse struct { CapabilityHash string `json:"capability_hash"` } -// reprobeNode performs the bearer-authenticated re-probe call against one node, +// reprobeNode performs the signed re-probe call against one node, // mirroring the force-reload client. func (h *NodeHandler) reprobeNode(ctx context.Context, node *nodepool.Node) (nodeReprobeResponse, error) { timeout := h.nodeReprobeTimeout(node) @@ -779,7 +782,9 @@ func (h *NodeHandler) reprobeNode(ctx context.Context, node *nodepool.Node) (nod if err != nil { return nodeReprobeResponse{}, err } - req.Header.Set("Authorization", "Bearer "+h.jwtSecret) + if err := nodeauth.Sign(req, node.URL, h.jwtSecret); err != nil { + return nodeReprobeResponse{}, err + } resp, err := telemetry.DoTrustedNode(client, req, "reprobe") if err != nil { diff --git a/internal/api/handlers/nodes_reprobe_test.go b/internal/api/handlers/nodes_reprobe_test.go index 30d4f8abaa..2aa2a56c3f 100644 --- a/internal/api/handlers/nodes_reprobe_test.go +++ b/internal/api/handlers/nodes_reprobe_test.go @@ -1,8 +1,10 @@ package handlers import ( + "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -11,6 +13,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/tonemap" @@ -37,28 +40,30 @@ func reprobeRequest(t *testing.T) *http.Request { } // fakeNodeServer answers the node-side re-probe route with the given status and -// body, recording the bearer token it was given. -func fakeNodeServer(t *testing.T, status int, body string) (url string, authorization *string) { +// body. signedWith reports whether the request it saw was signed with secret. +func fakeNodeServer(t *testing.T, status int, body string) (url string, signedWith func(secret string) bool) { t.Helper() - seen := "" + var seen *http.Request server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/admin/reprobe-capabilities" || r.Method != http.MethodPost { w.WriteHeader(http.StatusNotFound) return } - seen = r.Header.Get("Authorization") + requestBody, _ := io.ReadAll(r.Body) + seen = r.Clone(context.WithoutCancel(r.Context())) + seen.Body = io.NopCloser(bytes.NewReader(requestBody)) w.WriteHeader(status) _, _ = w.Write([]byte(body)) })) t.Cleanup(server.Close) - return server.URL, &seen + return server.URL, func(secret string) bool { return seen != nil && nodeauthtest.Signed(seen, secret) } } // The point of the API-side action is that the operator does not have to wait a // sweep interval: the node re-probes, and this server immediately refetches and // stores the report so the list, the pools, and the planner agree with it. func TestHandleReprobeNodeRefreshesStoredCapabilities(t *testing.T) { - url, authorization := fakeNodeServer(t, http.StatusOK, + url, signedWith := fakeNodeServer(t, http.StatusOK, `{"resolved":"qsv","capability_hash":"sha256:new"}`) repo := &stubNodeRepository{node: &nodepool.Node{ID: 1, Name: "gpu-1", Type: nodepool.NodeTypeTranscode, URL: url}} refresher := &stubCapabilityRefresher{} @@ -87,8 +92,8 @@ func TestHandleReprobeNodeRefreshesStoredCapabilities(t *testing.T) { if got := refresher.calls.Load(); got != 1 { t.Fatalf("refresh calls = %d, want exactly one", got) } - if *authorization != "Bearer secret" { - t.Fatalf("node saw authorization %q, want the bearer secret", *authorization) + if !signedWith("secret") { + t.Fatal("node request was not signed with the shared secret") } } diff --git a/internal/api/handlers/nodes_test.go b/internal/api/handlers/nodes_test.go index b2330fd39c..0964e86eab 100644 --- a/internal/api/handlers/nodes_test.go +++ b/internal/api/handlers/nodes_test.go @@ -13,6 +13,7 @@ import ( "github.com/Silo-Server/silo-server/internal/cache" "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/go-chi/chi/v5" ) @@ -257,12 +258,12 @@ func equalStringPointer(a, b *string) bool { // The nudge targets /admin/reload-config, never /admin/force-reload: the latter // tears down every live playback session on a transcode node. func TestHandleUpdateNodeReloadsTheNodeAfterAnOverrideChange(t *testing.T) { - reloaded := make(chan string, 4) + reloaded := make(chan bool, 4) var destructive atomic.Bool node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/admin/reload-config": - reloaded <- r.Header.Get("Authorization") + reloaded <- nodeauthtest.Signed(r, "secret") case "/admin/force-reload": destructive.Store(true) } @@ -286,9 +287,9 @@ func TestHandleUpdateNodeReloadsTheNodeAfterAnOverrideChange(t *testing.T) { t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String()) } select { - case authorization := <-reloaded: - if authorization != "Bearer secret" { - t.Fatalf("node saw authorization %q, want the bearer secret", authorization) + case signed := <-reloaded: + if !signed { + t.Fatal("reload request was not signed with the shared secret") } default: t.Fatal("the node was not asked to reload after its overrides changed") diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index b9602c68af..e785425be5 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -28,6 +28,7 @@ import ( "github.com/Silo-Server/silo-server/internal/markers" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/playback" @@ -2081,7 +2082,10 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re http.Error(w, "internal error", http.StatusInternalServerError) return } - req.Header.Set("Authorization", "Bearer "+h.JWTSecret) + if err := nodeauth.Sign(req, transcodeNodeURL, h.JWTSecret); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } if isSegmentRoute { // The node's immediate transport peer is this API process, so receiving a // complete response there does not prove that the browser received it. @@ -2156,7 +2160,7 @@ func (h *PlaybackHandler) proxyToTranscodeNode(w http.ResponseWriter, r *http.Re fullSize := transcodeproxy.FullRepresentationSize(resp) if isMediaSegment && generation != "" && r.Method == http.MethodGet && sw.CompletedFullResponse(fullSize) { - if ackErr := transcodeproxy.Acknowledge(r.Context(), transcodeproxy.NodeClient(), transcodeNodeURL+path, h.JWTSecret, generation); ackErr != nil { + if ackErr := transcodeproxy.Acknowledge(r.Context(), transcodeproxy.NodeClient(), transcodeNodeURL, path, h.JWTSecret, generation); ackErr != nil { slog.WarnContext(r.Context(), "acknowledge transcode segment completion", "component", "api", "error", ackErr, "playback_session_id", sessionID) } } diff --git a/internal/api/handlers/playback_transport.go b/internal/api/handlers/playback_transport.go index 8a3dfe96f3..2806131dd7 100644 --- a/internal/api/handlers/playback_transport.go +++ b/internal/api/handlers/playback_transport.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/telemetry" "github.com/Silo-Server/silo-server/internal/logredact" @@ -46,7 +47,9 @@ func (h *PlaybackHandler) startRemotePlaybackTransport(ctx context.Context, node return transcodenode.TranscodeStartResponse{}, 0, logredact.SanitizeURLError(err) } httpRequest.Header.Set("Content-Type", "application/json") - httpRequest.Header.Set("Authorization", "Bearer "+h.JWTSecret) + if err := nodeauth.Sign(httpRequest, nodeURL, h.JWTSecret); err != nil { + return transcodenode.TranscodeStartResponse{}, 0, err + } response, err := telemetry.DoTrustedNode(transcodeproxy.NodeClient(), httpRequest, "transcode_start") if err != nil { nodepool.ReportConnectFailure(h.NodePlanner, nodeURL, err) diff --git a/internal/api/handlers/playback_transport_security_test.go b/internal/api/handlers/playback_transport_security_test.go index 0fd8969be9..cab5b6b033 100644 --- a/internal/api/handlers/playback_transport_security_test.go +++ b/internal/api/handlers/playback_transport_security_test.go @@ -9,6 +9,7 @@ import ( "sync/atomic" "testing" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/playback" ) @@ -23,16 +24,16 @@ func TestFetchRemoteTranscodeCapabilitiesDoesNotForwardJWTOnRedirect(t *testing. })) defer redirectTarget.Close() - var sourceAuthorization atomic.Value + var sourceSigned atomic.Bool source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sourceAuthorization.Store(r.Header.Get("Authorization")) + sourceSigned.Store(nodeauthtest.Signed(r, jwt)) http.Redirect(w, r, redirectTarget.URL+"/hw-capabilities", http.StatusFound) })) defer source.Close() _, err := fetchRemoteTranscodeCapabilities(context.Background(), source.URL, jwt) - if got, want := sourceAuthorization.Load(), "Bearer "+jwt; got != want { - t.Fatalf("source Authorization = %#v, want %q", got, want) + if !sourceSigned.Load() { + t.Fatal("source request was not signed with the shared secret") } if got := redirectTargetHits.Load(); got != 0 { t.Fatalf("redirect target requests = %d, Authorization = %#v; want no redirect request", got, redirectTargetAuthorization.Load()) diff --git a/internal/apiv2/worker_download_prepare_test.go b/internal/apiv2/worker_download_prepare_test.go index 1a24ade070..9129e8ede0 100644 --- a/internal/apiv2/worker_download_prepare_test.go +++ b/internal/apiv2/worker_download_prepare_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/transcodenode" "github.com/Silo-Server/silo-server/internal/workerprotocol" @@ -87,7 +88,7 @@ func TestWorkerDownloadPreparationProtocol(t *testing.T) { } { req := httptest.NewRequest(http.MethodPost, op.Path, strings.NewReader(tc.body)) if tc.auth { - req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, req, cfg.Auth.JWTSecret) } rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) diff --git a/internal/apiv2/worker_protocols_test.go b/internal/apiv2/worker_protocols_test.go index 1b6c322cda..362b64853a 100644 --- a/internal/apiv2/worker_protocols_test.go +++ b/internal/apiv2/worker_protocols_test.go @@ -14,6 +14,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/proxy" @@ -110,7 +111,7 @@ func TestWorkerStatusSchemasMatchRealListeners(t *testing.T) { } schemas[read.Listener] = schema request := httptest.NewRequest(http.MethodGet, read.Path, nil) - request.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, request, cfg.Auth.JWTSecret) response := httptest.NewRecorder() handlers[read.Listener].ServeHTTP(response, request) if response.Code != http.StatusOK || response.Header().Get("Content-Type") != "application/json" { @@ -247,7 +248,7 @@ func TestWorkerChapterExtractionProtocol(t *testing.T) { } { request := httptest.NewRequest(op.Method, op.Path, strings.NewReader(tc.body)) if tc.auth { - request.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, request, cfg.Auth.JWTSecret) } response := httptest.NewRecorder() handler.ServeHTTP(response, request) @@ -305,7 +306,7 @@ func TestWorkerArtifactConditionalProtocol(t *testing.T) { if err != nil { t.Fatal(err) } - req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, req, cfg.Auth.JWTSecret) if tc.header != "" { req.Header.Set(tc.header, tc.value) } @@ -413,7 +414,7 @@ func TestWorkerSegmentAcknowledgementRefusals(t *testing.T) { } { req := httptest.NewRequest(http.MethodPost, "/transcode/missing/segment/"+tc.name+"/downloaded", nil) if tc.auth { - req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, req, cfg.Auth.JWTSecret) } req.Header.Set(transcodeproxy.GenerationHeader, tc.generation) response := httptest.NewRecorder() diff --git a/internal/apiv2/worker_start_test.go b/internal/apiv2/worker_start_test.go index eddfc84616..e16b6db1fb 100644 --- a/internal/apiv2/worker_start_test.go +++ b/internal/apiv2/worker_start_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/transcodenode" "github.com/santhosh-tekuri/jsonschema/v6" @@ -84,7 +85,7 @@ func TestWorkerStartProtocol(t *testing.T) { } { req := httptest.NewRequest(http.MethodPost, op.Path, strings.NewReader(tc.body)) if tc.auth { - req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, req, cfg.Auth.JWTSecret) } rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) diff --git a/internal/apiv2/worker_stop_test.go b/internal/apiv2/worker_stop_test.go index 9be7756c31..f2bc7e6c0b 100644 --- a/internal/apiv2/worker_stop_test.go +++ b/internal/apiv2/worker_stop_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/transcodenode" ) @@ -34,7 +35,7 @@ func TestWorkerLegacyStopProtocol(t *testing.T) { } req := httptest.NewRequest(http.MethodDelete, "/transcode/missing", nil) if tc.authenticated { - req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, req, cfg.Auth.JWTSecret) } rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) diff --git a/internal/apiv2/worker_streaming_test.go b/internal/apiv2/worker_streaming_test.go index 6141c71b17..0b4ca53e42 100644 --- a/internal/apiv2/worker_streaming_test.go +++ b/internal/apiv2/worker_streaming_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/routeinventory" "github.com/Silo-Server/silo-server/internal/transcodenode" @@ -53,7 +54,7 @@ func TestWorkerStreamingRefusalDescription(t *testing.T) { for _, authenticated := range []bool{false, true} { req := httptest.NewRequest(op.Method, path, nil) if authenticated { - req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, req, cfg.Auth.JWTSecret) } response := httptest.NewRecorder() handler.ServeHTTP(response, req) @@ -72,7 +73,7 @@ func TestWorkerStreamingRefusalDescription(t *testing.T) { // Proxy manifest HEAD is forwarded unchanged to a worker that registers GET // only. The existing node therefore refuses it; do not promise HEAD support. req := httptest.NewRequest(http.MethodHead, "/transcode/missing/master.m3u8", nil) - req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + nodeauthtest.Sign(t, req, cfg.Auth.JWTSecret) response := httptest.NewRecorder() handler.ServeHTTP(response, req) if response.Code != 405 { diff --git a/internal/chapterthumbs/remote.go b/internal/chapterthumbs/remote.go index 0ddd79912c..24eb3fa5e3 100644 --- a/internal/chapterthumbs/remote.go +++ b/internal/chapterthumbs/remote.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/telemetry" "github.com/Silo-Server/silo-server/internal/nodepool" @@ -77,7 +78,9 @@ func (e *httpRemoteFrameExtractor) ExtractFrame( return nil, chapterThumbnailNodeUnavailableReason, fmt.Errorf("chapter thumbnail remote extract: build request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+jwtSecret) + if err := nodeauth.Sign(httpReq, node.URL, jwtSecret); err != nil { + return nil, chapterThumbnailNodeUnavailableReason, fmt.Errorf("chapter thumbnail remote extract: sign request: %w", err) + } client := e.client if client == nil { diff --git a/internal/chapterthumbs/remote_signing_test.go b/internal/chapterthumbs/remote_signing_test.go new file mode 100644 index 0000000000..06bcd663fc --- /dev/null +++ b/internal/chapterthumbs/remote_signing_test.go @@ -0,0 +1,29 @@ +package chapterthumbs + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" + "github.com/Silo-Server/silo-server/internal/nodepool" +) + +func TestRemoteFrameExtractorSignsTheRequest(t *testing.T) { + const secret = "chapter-secret" + node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chapter-thumbnails/extract" || !nodeauthtest.Signed(r, secret) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write([]byte("frame")) + })) + t.Cleanup(node.Close) + + data, reason, err := (&httpRemoteFrameExtractor{}).ExtractFrame(t.Context(), &nodepool.Node{URL: node.URL}, secret, + RemoteExtractRequest{InputPath: "/media/movie.mkv", SeekSeconds: 12}) + if err != nil || reason != "" || string(data) != "frame" { + t.Fatalf("ExtractFrame = %q, %q, %v; want the frame from a signed request", data, reason, err) + } +} diff --git a/internal/downloadprepare/transport.go b/internal/downloadprepare/transport.go index efc7fd83b7..8b718eb9d3 100644 --- a/internal/downloadprepare/transport.go +++ b/internal/downloadprepare/transport.go @@ -21,6 +21,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/downloadstorage" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/tonemap" ) @@ -357,7 +358,7 @@ type RemotePreparer interface { Delete(ctx context.Context, nodeURL, jwtSecret, artifactID string) error } -// HTTPPreparer implements RemotePreparer over bearer-protected node APIs. A +// HTTPPreparer implements RemotePreparer over signed node APIs. A // full-file prepare has no transport timeout; its caller owns cancellation. // Metadata and relay operations use a bounded response-header wait by default. type HTTPPreparer struct { @@ -652,14 +653,22 @@ func (p HTTPPreparer) request(ctx context.Context, method, nodeURL, jwtSecret, p if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" { return nil, fmt.Errorf("invalid node URL") } - base.Path = strings.TrimRight(base.Path, "/") + path + // Sign against the same normalized node URL the request is built from: + // re-escaping or trimming only one of them would sign a path prefix the + // worker never sees. + base.Path = strings.TrimRight(base.Path, "/") + base.RawPath = "" base.RawQuery = "" base.Fragment = "" + signingBase := base.String() + base.Path += path req, err := http.NewRequestWithContext(ctx, method, base.String(), body) if err != nil { return nil, fmt.Errorf("build request: %w", err) } - req.Header.Set("Authorization", "Bearer "+jwtSecret) + if err := nodeauth.Sign(req, signingBase, jwtSecret); err != nil { + return nil, err + } return req, nil } diff --git a/internal/downloadprepare/transport_test.go b/internal/downloadprepare/transport_test.go index b2f37b4271..ed6da4529f 100644 --- a/internal/downloadprepare/transport_test.go +++ b/internal/downloadprepare/transport_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/tonemap" ) @@ -21,8 +22,8 @@ func TestHTTPPreparerSendsAuthenticatedRecipe(t *testing.T) { if r.Method != http.MethodPost || r.URL.Path != "/downloads/prepare" { t.Fatalf("request = %s %s", r.Method, r.URL.Path) } - if auth := r.Header.Get("Authorization"); auth != "Bearer secret" { - t.Fatalf("Authorization = %q", auth) + if !nodeauthtest.Signed(r, "secret") { + t.Fatalf("request is not signed: Authorization = %q", r.Header.Get("Authorization")) } if err := json.NewDecoder(r.Body).Decode(&got); err != nil { t.Fatal(err) @@ -304,8 +305,8 @@ func TestHTTPPreparerManagesOpaqueArtifact(t *testing.T) { if r.URL.Path != "/downloads/artifacts/artifact-1" { t.Fatalf("path = %q", r.URL.Path) } - if r.Header.Get("Authorization") != "Bearer secret" { - t.Fatalf("Authorization = %q", r.Header.Get("Authorization")) + if !nodeauthtest.Signed(r, "secret") { + t.Fatalf("request is not signed: Authorization = %q", r.Header.Get("Authorization")) } switch r.Method { case http.MethodHead: @@ -330,6 +331,35 @@ func TestHTTPPreparerManagesOpaqueArtifact(t *testing.T) { } } +// A node URL that request() normalizes (an escaped path prefix, surrounding +// whitespace) must be signed in its normalized form, or the worker behind a +// prefix-stripping reverse proxy refuses the request. +func TestHTTPPreparerSignsNormalizedNodeURLPrefix(t *testing.T) { + worker := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !nodeauthtest.Signed(r, "secret") { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Length", "42") + }) + for _, tc := range []struct { + name, strip, prefix, pad string + }{ + {name: "escaped prefix", strip: "/worker", prefix: "/%77orker"}, + {name: "padded URL", strip: "/node1", prefix: "/node1", pad: " "}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.StripPrefix(tc.strip, worker)) + defer server.Close() + nodeURL := tc.pad + server.URL + tc.prefix + tc.pad + result, err := HTTPPreparer{}.Stat(t.Context(), nodeURL, "secret", "artifact-1") + if err != nil || result.FileSize != 42 { + t.Fatalf("Stat = (%+v, %v), want a signed request the worker accepts", result, err) + } + }) + } +} + func TestHTTPPreparerStatRecoversToneMapAttestationFromHeaders(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodHead { @@ -534,7 +564,7 @@ func TestHTTPPreparerOpenAllowsContinuedBodyProgress(t *testing.T) { func TestHTTPPreparerProgress(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Authorization") != "Bearer secret" { + if !nodeauthtest.Signed(r, "secret") { http.Error(w, "unauthorized", http.StatusUnauthorized) return } diff --git a/internal/downloads/remote_preparer_security_test.go b/internal/downloads/remote_preparer_security_test.go index 4e45d9ea92..7cf11f426c 100644 --- a/internal/downloads/remote_preparer_security_test.go +++ b/internal/downloads/remote_preparer_security_test.go @@ -12,6 +12,7 @@ import ( "testing/synctest" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/tonemap" ) @@ -27,9 +28,9 @@ func TestNodeAwarePreparerCapabilityProbeDoesNotForwardJWTOnRedirect(t *testing. })) defer redirectTarget.Close() - var sourceAuthorization atomic.Value + var sourceSigned atomic.Bool source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - sourceAuthorization.Store(r.Header.Get("Authorization")) + sourceSigned.Store(nodeauthtest.Signed(r, jwt)) http.Redirect(w, r, redirectTarget.URL+"/hw-capabilities", http.StatusFound) })) defer source.Close() @@ -45,8 +46,8 @@ func TestNodeAwarePreparerCapabilityProbeDoesNotForwardJWTOnRedirect(t *testing. preparer.probeClient = injectedClient _, err := preparer.toneMapCapabilitiesForNode(context.Background(), source.URL) - if got, want := sourceAuthorization.Load(), "Bearer "+jwt; got != want { - t.Fatalf("source Authorization = %#v, want %q", got, want) + if !sourceSigned.Load() { + t.Fatal("source request was not signed with the shared secret") } if got := redirectTargetHits.Load(); got != 0 { t.Fatalf("redirect target requests = %d, Authorization = %#v; want no redirect request", got, redirectTargetAuthorization.Load()) diff --git a/internal/downloads/remote_preparer_test.go b/internal/downloads/remote_preparer_test.go index c0942805c2..cd3b89e2d0 100644 --- a/internal/downloads/remote_preparer_test.go +++ b/internal/downloads/remote_preparer_test.go @@ -16,6 +16,7 @@ import ( "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/downloadprepare" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/tonemap" @@ -790,8 +791,8 @@ func TestNodeAwarePreparerUsesTargetNodeProbeBudget(t *testing.T) { w.WriteHeader(http.StatusNotFound) return } - if got, want := request.Header.Get("Authorization"), "Bearer secret"; got != want { - t.Errorf("Authorization = %q, want %q", got, want) + if !nodeauthtest.Signed(request, "secret") { + t.Errorf("request is not signed: Authorization = %q", request.Header.Get("Authorization")) w.WriteHeader(http.StatusUnauthorized) return } diff --git a/internal/downloads/service_remote_test.go b/internal/downloads/service_remote_test.go index 1793eee077..aff68696ca 100644 --- a/internal/downloads/service_remote_test.go +++ b/internal/downloads/service_remote_test.go @@ -12,12 +12,13 @@ import ( "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/downloadprepare" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" ) func TestServiceRelaysRemoteArtifactOnEstablishedRoute(t *testing.T) { const secret = "artifact-secret" origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/downloads/artifacts/artifact-1" || r.Header.Get("Authorization") != "Bearer "+secret { + if r.URL.Path != "/downloads/artifacts/artifact-1" || !nodeauthtest.Signed(r, secret) { t.Errorf("origin request = %s %s auth=%q", r.Method, r.URL.Path, r.Header.Get("Authorization")) http.Error(w, "unexpected origin request", http.StatusBadRequest) return diff --git a/internal/jellycompat/auto_transcode_fallback_test.go b/internal/jellycompat/auto_transcode_fallback_test.go index 29ca69b08e..b53aae2560 100644 --- a/internal/jellycompat/auto_transcode_fallback_test.go +++ b/internal/jellycompat/auto_transcode_fallback_test.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/transcodenode" @@ -163,6 +164,9 @@ func startCompatRemoteAutoTranscode(t *testing.T, report string, response transc w.WriteHeader(http.StatusNotFound) return } + if !nodeauthtest.Signed(r, "secret") { + t.Error("remote transcode start was not signed with the shared secret") + } if err := json.NewDecoder(r.Body).Decode(&remoteReq); err != nil { t.Errorf("decode remote request: %v", err) } diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index 557572ca74..de2e9a1fbe 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -23,6 +23,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/clientip" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/telemetry" "github.com/Silo-Server/silo-server/internal/transcodeproxy" @@ -1772,7 +1773,9 @@ func (h *PlaybackHandler) startRemoteTranscodeWithToneMapMode( return transcodenode.TranscodeStartResponse{}, 0, false, fmt.Errorf("build transcode request: %w", logredact.SanitizeURLError(err)) } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+h.JWTSecret) + if err := nodeauth.Sign(httpReq, transcodeNodeURL, h.JWTSecret); err != nil { + return transcodenode.TranscodeStartResponse{}, 0, false, fmt.Errorf("sign transcode request: %w", err) + } resp, err := telemetry.DoTrustedNode(transcodeproxy.NodeClient(), httpReq, "transcode_start") if err != nil { nodepool.ReportConnectFailure(h.NodePlanner, transcodeNodeURL, err) diff --git a/internal/jellycompat/remote_relay_transport_test.go b/internal/jellycompat/remote_relay_transport_test.go index e783ff7db5..000f38a6dc 100644 --- a/internal/jellycompat/remote_relay_transport_test.go +++ b/internal/jellycompat/remote_relay_transport_test.go @@ -12,6 +12,7 @@ import ( "github.com/prometheus/client_golang/prometheus" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/transcodeproxy" ) @@ -25,12 +26,17 @@ func TestRemoteCompatRelayReusesNodeConnectionsAndRecordsDependencyMetrics(t *te concurrency = 20 waves = 5 upstream = "remote-session" + secret = "compat-relay-secret" ) var dials, acks atomic.Int32 arrived := make(chan struct{}) release := make(chan struct{}) done := make(chan struct{}) node := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !nodeauthtest.Signed(r, secret) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } switch { case r.Method == http.MethodPost: acks.Add(1) @@ -61,7 +67,7 @@ func TestRemoteCompatRelayReusesNodeConnectionsAndRecordsDependencyMetrics(t *te t.Cleanup(node.Close) t.Cleanup(func() { close(done) }) - handler := &PlaybackHandler{JWTSecret: "compat-relay-secret"} + handler := &PlaybackHandler{JWTSecret: secret} streamCallsBefore := nodeDependencyOperations(t, "stream") if _, err := handler.fetchRemoteCompatManifest(t.Context(), node.URL, upstream); err != nil { diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index 3d7ec17e45..7295839782 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -26,6 +26,7 @@ import ( "github.com/Silo-Server/silo-server/internal/httpstream" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/playback" @@ -1349,7 +1350,9 @@ func (h *PlaybackHandler) fetchRemoteCompatManifest(ctx context.Context, nodeURL if err != nil { return nil, err } - req.Header.Set("Authorization", "Bearer "+h.JWTSecret) + if err := nodeauth.Sign(req, nodeURL, h.JWTSecret); err != nil { + return nil, err + } resp, err := telemetry.DoTrustedNode(transcodeproxy.NodeClient(), req, "stream") if err != nil { nodepool.ReportConnectFailure(h.NodePlanner, nodeURL, err) @@ -1371,7 +1374,10 @@ func (h *PlaybackHandler) proxyRemoteCompatSegment(w http.ResponseWriter, r *htt writeError(w, http.StatusInternalServerError, "ServerError", "Failed to build remote segment request") return } - req.Header.Set("Authorization", "Bearer "+h.JWTSecret) + if err := nodeauth.Sign(req, nodeURL, h.JWTSecret); err != nil { + writeError(w, http.StatusInternalServerError, "ServerError", "Failed to build remote segment request") + return + } transcodeproxy.PrepareRequest(req, r) resp, err := telemetry.DoTrustedNode(transcodeproxy.NodeClient(), req, "stream") if err != nil { @@ -1388,7 +1394,7 @@ func (h *PlaybackHandler) proxyRemoteCompatSegment(w http.ResponseWriter, r *htt return } if generation != "" && r.Method == http.MethodGet && sw.CompletedFullResponse(transcodeproxy.FullRepresentationSize(resp)) { - if err := transcodeproxy.Acknowledge(r.Context(), transcodeproxy.NodeClient(), nodepool.NodeEndpoint(nodeURL, path), h.JWTSecret, generation); err != nil { + if err := transcodeproxy.Acknowledge(r.Context(), transcodeproxy.NodeClient(), nodepool.NodeEndpoint(nodeURL, ""), path, h.JWTSecret, generation); err != nil { slog.WarnContext(r.Context(), "acknowledge Jellyfin-compatible transcode segment", "component", "jellycompat", "error", err, "playback_session_id", upstreamSessionID) } } diff --git a/internal/mediasample/remote.go b/internal/mediasample/remote.go index 18ae73cf5a..b5afc9b5fb 100644 --- a/internal/mediasample/remote.go +++ b/internal/mediasample/remote.go @@ -8,8 +8,10 @@ import ( "fmt" "io" "net/http" + "strings" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/telemetry" ) @@ -88,7 +90,7 @@ type RemoteClient struct { } // Run sends req to the node route at endpoint (the node URL joined with -// RemotePath) with the node bearer secret. A failure is a *RemoteError. +// RemotePath), signed with the node secret. A failure is a *RemoteError. func (c RemoteClient) Run(ctx context.Context, endpoint, secret string, req Request) (Result, error) { body, err := json.Marshal(req) if err != nil { @@ -99,7 +101,9 @@ func (c RemoteClient) Run(ctx context.Context, endpoint, secret string, req Requ return Result{}, &RemoteError{Reason: ReasonNodeUnavailable, Err: err} } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+secret) + if err := nodeauth.Sign(httpReq, strings.TrimSuffix(endpoint, RemotePath), secret); err != nil { + return Result{}, &RemoteError{Reason: ReasonNodeUnavailable, Err: err} + } resp, err := telemetry.DoTrustedNode(c.HTTP, httpReq, "media_sample") if err != nil { reason := ReasonNodeUnavailable diff --git a/internal/mediasample/remote_signing_test.go b/internal/mediasample/remote_signing_test.go new file mode 100644 index 0000000000..03dcb262d6 --- /dev/null +++ b/internal/mediasample/remote_signing_test.go @@ -0,0 +1,27 @@ +package mediasample + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" +) + +func TestRemoteClientSignsTheRequest(t *testing.T) { + const secret = "sample-secret" + node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != RemotePath || !nodeauthtest.Signed(r, secret) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(Result{Decoder: softwareDecoder}) + })) + t.Cleanup(node.Close) + + result, err := RemoteClient{}.Run(t.Context(), node.URL+RemotePath, secret, Request{Input: "/media/movie.mkv"}) + if err != nil || result.Decoder != softwareDecoder { + t.Fatalf("Run = %+v, %v; want the node's result for a signed request", result, err) + } +} diff --git a/internal/nodeauth/nodeauth.go b/internal/nodeauth/nodeauth.go new file mode 100644 index 0000000000..2513b0799d --- /dev/null +++ b/internal/nodeauth/nodeauth.go @@ -0,0 +1,317 @@ +// Package nodeauth authenticates the internal HTTP calls that API servers and +// proxies make to Silo workers (proxies and transcode nodes). +// +// The shared auth.jwt_secret never crosses the wire: it also signs login, +// profile, and stream tokens, so a copy captured from a worker link could mint +// them. Each request instead carries an HMAC-SHA256 over its method, path, +// query, a timestamp, and the SHA-256 of its body. The MAC key is derived from +// the shared secret with HKDF under a label nothing else uses, so it cannot +// sign or verify any other token. +// +// See docs/architecture/worker-http-protocol.md#authentication. +package nodeauth + +import ( + "bytes" + "crypto/hkdf" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" + "sync/atomic" + "time" +) + +// Scheme is the Authorization scheme of a signed worker request. +const Scheme = "Silo-Node-HMAC-SHA256" + +const ( + // maxSkew is how far a signed request's timestamp may be from the + // receiver's clock, in either direction. + maxSkew = 30 * time.Second + // maxBodyBytes bounds the body a receiver buffers to check its hash. + // Worker request bodies are small JSON documents. + maxBodyBytes = 1 << 20 + + // keyLabel is the HKDF info string for the MAC key. Changing it, or + // reusing it for another purpose, breaks the key separation. + keyLabel = "silo/node-request-auth/v1" + // canonicalVersion opens the signed string so a later format cannot be + // confused with this one. + canonicalVersion = "silo-node-request-v1" +) + +var ( + // ErrNoSecret reports that the receiver has no shared secret configured, + // so it can verify nothing. + ErrNoSecret = errors.New("node auth: shared secret is not configured") + // ErrUnauthorized reports a missing, malformed, expired, or wrong + // credential. + ErrUnauthorized = errors.New("node auth: unauthorized") +) + +var emptyBodyHash = sha256.Sum256(nil) + +// Sign authenticates req for the Silo worker registered at nodeURL. Call it +// once the URL and body are final and just before sending: the signature +// expires after maxSkew. A body without GetBody is read into memory and +// replaced. +// +// The signature covers the request path relative to nodeURL, which is the +// path the worker serves: a reverse proxy that puts the worker under a path +// prefix strips that prefix before the worker sees it. +// +// Without a secret, Sign leaves req unsigned and the worker refuses it. Every +// server generates the secret at startup, so this happens only in tests. +func Sign(req *http.Request, nodeURL, secret string) error { + return sign(req, nodeURL, secret, time.Now()) +} + +func sign(req *http.Request, nodeURL, secret string, now time.Time) error { + if secret == "" { + return nil + } + bodyHash, err := outgoingBodyHash(req) + if err != nil { + return fmt.Errorf("node auth: hash request body: %w", err) + } + timestamp := strconv.FormatInt(now.Unix(), 10) + body := hex.EncodeToString(bodyHash) + mac, err := requestMAC(secret, timestamp, req.Method, workerPath(req.URL, nodeURL), req.URL.RawQuery, body) + if err != nil { + return err + } + req.Header.Set("Authorization", Scheme+" t="+timestamp+",b="+body+",sig="+hex.EncodeToString(mac)) + return nil +} + +// Verify checks that r came from a holder of the shared secret. It checks the +// MAC before reading any body, so a caller without the secret cannot make the +// worker buffer one. A signed body (at most maxBodyBytes) is then read to +// check its hash and left readable for the handler. +// +// Verify also accepts the legacy "Bearer " credential so API servers +// and proxies from the previous release keep working while a deployment +// upgrades. Remove that branch in the release after the one that introduced +// signed requests, with the tests marked for removal with it; every sender +// already signs. +func Verify(r *http.Request, secret string) error { + return verify(r, secret, time.Now()) +} + +func verify(r *http.Request, secret string, now time.Time) error { + if secret == "" { + return ErrNoSecret + } + scheme, params, _ := strings.Cut(r.Header.Get("Authorization"), " ") + switch { + case strings.EqualFold(scheme, Scheme): + return verifySigned(r, secret, params, now) + case strings.EqualFold(scheme, "Bearer"): + if subtle.ConstantTimeCompare([]byte(params), []byte(secret)) != 1 { + return ErrUnauthorized + } + if suppressed, ok := legacyBearerLog.allow(); ok { + slog.WarnContext(r.Context(), "accepted a legacy bearer credential from an older Silo API server or proxy; upgrade it, because the next release accepts only signed requests", + "component", "nodeauth", "remote_addr", r.RemoteAddr, "suppressed", suppressed) + } + return nil + } + return ErrUnauthorized +} + +func verifySigned(r *http.Request, secret, params string, now time.Time) error { + fields, ok := parseParams(params) + if !ok { + return ErrUnauthorized + } + seconds, err := strconv.ParseInt(fields.timestamp, 10, 64) + if err != nil { + return ErrUnauthorized + } + got, err := hex.DecodeString(fields.signature) + if err != nil || len(got) != sha256.Size { + return ErrUnauthorized + } + bodyHash, err := hex.DecodeString(fields.body) + if err != nil || len(bodyHash) != sha256.Size { + return ErrUnauthorized + } + want, err := requestMAC(secret, fields.timestamp, r.Method, r.URL.EscapedPath(), r.URL.RawQuery, fields.body) + if err != nil { + return ErrUnauthorized + } + if !hmac.Equal(got, want) { + if suppressed, ok := badSignatureLog.allow(); ok { + slog.WarnContext(r.Context(), "refused a node request with an invalid signature; check that the sender uses this worker's secret and that nothing in front of the worker rewrites the request path or query", + "component", "nodeauth", "remote_addr", r.RemoteAddr, "suppressed", suppressed) + } + return ErrUnauthorized + } + // The MAC covers the timestamp, so a valid signature outside the window + // means the clocks disagree (or an old request was replayed). + if skew := now.Sub(time.Unix(seconds, 0)); skew > maxSkew || skew < -maxSkew { + if suppressed, ok := staleLog.allow(); ok { + slog.WarnContext(r.Context(), "refused a signed node request outside the 30-second window; check that this worker's clock agrees with its API servers and proxies", + "component", "nodeauth", "remote_addr", r.RemoteAddr, "offset_seconds", int64(skew.Seconds()), "suppressed", suppressed) + } + return ErrUnauthorized + } + if !bodyMatches(r, bodyHash) { + if suppressed, ok := badBodyLog.allow(); ok { + slog.WarnContext(r.Context(), "refused a signed node request whose body does not match its signature; check that nothing in front of the worker changes request bodies", + "component", "nodeauth", "remote_addr", r.RemoteAddr, "suppressed", suppressed) + } + return ErrUnauthorized + } + return nil +} + +type signedParams struct { + timestamp, body, signature string +} + +// parseParams reads "t=,b=,sig=" in any +// order. +func parseParams(params string) (signedParams, bool) { + var fields signedParams + for part := range strings.SplitSeq(params, ",") { + name, value, found := strings.Cut(strings.TrimSpace(part), "=") + if !found { + return signedParams{}, false + } + switch name { + case "t": + fields.timestamp = value + case "b": + fields.body = value + case "sig": + fields.signature = value + } + } + return fields, fields.timestamp != "" && fields.body != "" && fields.signature != "" +} + +// workerPath is target's escaped path without the path prefix of nodeURL, if +// it has one. +func workerPath(target *url.URL, nodeURL string) string { + path := target.EscapedPath() + base, err := url.Parse(nodeURL) + if err != nil { + return path + } + if prefix := strings.TrimRight(base.EscapedPath(), "/"); prefix != "" && strings.HasPrefix(path, prefix+"/") { + return path[len(prefix):] + } + return path +} + +// requestMAC signs the canonical form of one request. An empty path is the +// root, which is what a client puts on the wire for it. +func requestMAC(secret, timestamp, method, path, rawQuery, bodyHash string) ([]byte, error) { + key, err := hkdf.Key(sha256.New, []byte(secret), nil, keyLabel, sha256.Size) + if err != nil { + return nil, fmt.Errorf("node auth: derive key: %w", err) + } + if path == "" { + path = "/" + } + mac := hmac.New(sha256.New, key) + _, _ = io.WriteString(mac, strings.Join([]string{ + canonicalVersion, timestamp, method, path, rawQuery, bodyHash, + }, "\n")) + return mac.Sum(nil), nil +} + +func outgoingBodyHash(req *http.Request) ([]byte, error) { + if req.Body == nil || req.Body == http.NoBody { + return emptyBodyHash[:], nil + } + if req.GetBody != nil { + body, err := req.GetBody() + if err != nil { + return nil, err + } + defer func() { _ = body.Close() }() + hash := sha256.New() + if _, err := io.Copy(hash, body); err != nil { + return nil, err + } + return hash.Sum(nil), nil + } + data, err := io.ReadAll(req.Body) + _ = req.Body.Close() + if err != nil { + return nil, err + } + req.Body = io.NopCloser(bytes.NewReader(data)) + req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(data)), nil } + req.ContentLength = int64(len(data)) + sum := sha256.Sum256(data) + return sum[:], nil +} + +// bodyMatches reads r's body, at most maxBodyBytes, compares its hash with +// the signed one, and leaves the body readable for the handler. +func bodyMatches(r *http.Request, want []byte) bool { + if r.Body == nil || r.Body == http.NoBody { + return hmac.Equal(want, emptyBodyHash[:]) + } + if r.ContentLength > maxBodyBytes { + return false + } + data, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1)) + if err != nil || len(data) > maxBodyBytes { + return false + } + r.Body = io.NopCloser(bytes.NewReader(data)) + sum := sha256.Sum256(data) + return hmac.Equal(want, sum[:]) +} + +// logThrottle limits one kind of warning to a line per interval per process: +// segment relays and capability probes would otherwise log on every request. +// Each line counts the warnings suppressed since the previous one, so a +// stranger's junk request cannot hide a run of genuine refusals. +type logThrottle struct { + last atomic.Int64 + suppressed atomic.Int64 +} + +const logInterval = 10 * time.Minute + +var legacyBearerLog, badSignatureLog, staleLog, badBodyLog logThrottle + +// processStart anchors the throttles to Go's monotonic clock, which wall-clock +// corrections do not move, so a clock fix neither silences nor re-opens them. +var processStart = time.Now() + +// allow reports whether a warning may be logged now; if so it records the +// time and returns the number suppressed since the last one. +func (l *logThrottle) allow() (suppressed int64, ok bool) { + return l.allowAt(time.Since(processStart)) +} + +// allowAt is allow at elapsed time since processStart. A concurrent caller +// that sampled an earlier time than the last recorded one counts as inside the +// interval. +func (l *logThrottle) allowAt(elapsed time.Duration) (suppressed int64, ok bool) { + last := l.last.Load() + if last != 0 && elapsed-time.Duration(last) < logInterval { + l.suppressed.Add(1) + return 0, false + } + if !l.last.CompareAndSwap(last, max(int64(elapsed), 1)) { + l.suppressed.Add(1) + return 0, false + } + return l.suppressed.Swap(0), true +} diff --git a/internal/nodeauth/nodeauth_test.go b/internal/nodeauth/nodeauth_test.go new file mode 100644 index 0000000000..3af44dd7b7 --- /dev/null +++ b/internal/nodeauth/nodeauth_test.go @@ -0,0 +1,384 @@ +package nodeauth + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +const testSecret = "shared-secret-for-tests" + +var testNow = time.Unix(1_760_000_000, 0) + +// signedServerRequest signs a client request, then rebuilds it the way a +// server receives it: from the request line and body bytes on the wire. +func signedServerRequest(t *testing.T, method, target string, body []byte, signedAt time.Time) *http.Request { + t.Helper() + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + client, err := http.NewRequest(method, target, reader) + if err != nil { + t.Fatal(err) + } + if err := sign(client, "http://node.test", testSecret, signedAt); err != nil { + t.Fatalf("sign: %v", err) + } + server := httptest.NewRequest(method, client.URL.RequestURI(), bytes.NewReader(body)) + server.Header.Set("Authorization", client.Header.Get("Authorization")) + return server +} + +func TestSignNeverSendsTheSecret(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "http://node.test/hw-capabilities", nil) + if err != nil { + t.Fatal(err) + } + if err := Sign(req, "http://node.test", testSecret); err != nil { + t.Fatal(err) + } + auth := req.Header.Get("Authorization") + if !strings.HasPrefix(auth, Scheme+" ") { + t.Fatalf("Authorization = %q, want %s scheme", auth, Scheme) + } + if strings.Contains(auth, testSecret) { + t.Fatal("Authorization carries the shared secret") + } +} + +func TestVerifyAcceptsSignedRequests(t *testing.T) { + for _, tc := range []struct { + name, method, target string + body []byte + }{ + {"get", http.MethodGet, "http://node.test/transcode/abc/segment/3.m4s", nil}, + {"query", http.MethodGet, "http://node.test/transcode/abc/master.m3u8?source_timeline=1&x=a%20b", nil}, + {"escaped path", http.MethodDelete, "http://node.test/downloads/artifacts/a%2Fb", nil}, + {"root", http.MethodGet, "http://node.test", nil}, + {"body", http.MethodPost, "http://node.test/transcode/start", []byte(`{"session_id":"abc"}`)}, + } { + t.Run(tc.name, func(t *testing.T) { + r := signedServerRequest(t, tc.method, tc.target, tc.body, testNow) + if err := verify(r, testSecret, testNow); err != nil { + t.Fatalf("verify: %v", err) + } + got, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, tc.body) { + t.Fatalf("handler body = %q, want %q", got, tc.body) + } + }) + } +} + +func TestVerifyRejectsAlteredRequests(t *testing.T) { + body := []byte(`{"session_id":"abc"}`) + for _, tc := range []struct { + name string + alter func(r *http.Request) + }{ + {"method", func(r *http.Request) { r.Method = http.MethodPut }}, + {"path", func(r *http.Request) { r.URL.Path = "/transcode/other" }}, + {"query", func(r *http.Request) { r.URL.RawQuery = "x=1" }}, + {"body", func(r *http.Request) { r.Body = io.NopCloser(strings.NewReader(`{"session_id":"xyz"}`)) }}, + {"signature", func(r *http.Request) { + r.Header.Set("Authorization", strings.Replace(r.Header.Get("Authorization"), "sig=", "sig=00", 1)) + }}, + {"timestamp", func(r *http.Request) { + r.Header.Set("Authorization", strings.Replace(r.Header.Get("Authorization"), "t=1760000000", "t=1760000001", 1)) + }}, + {"claimed body hash", func(r *http.Request) { + // A correct hash of a different body, with the original signature. + other := sha256.Sum256([]byte(`{"session_id":"xyz"}`)) + auth := r.Header.Get("Authorization") + start := strings.Index(auth, "b=") + len("b=") + r.Header.Set("Authorization", auth[:start]+hex.EncodeToString(other[:])+auth[start+64:]) + r.Body = io.NopCloser(strings.NewReader(`{"session_id":"xyz"}`)) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + r := signedServerRequest(t, http.MethodPost, "http://node.test/transcode/start", body, testNow) + tc.alter(r) + if err := verify(r, testSecret, testNow); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("verify = %v, want ErrUnauthorized", err) + } + }) + } +} + +func TestVerifyEnforcesTheValidityWindow(t *testing.T) { + for _, tc := range []struct { + name string + offset time.Duration + ok bool + }{ + {"within past", -maxSkew, true}, + {"within future", maxSkew, true}, + {"too old", -maxSkew - time.Second, false}, + {"too far ahead", maxSkew + time.Second, false}, + } { + t.Run(tc.name, func(t *testing.T) { + r := signedServerRequest(t, http.MethodGet, "http://node.test/status", nil, testNow.Add(tc.offset)) + err := verify(r, testSecret, testNow) + if tc.ok && err != nil { + t.Fatalf("verify: %v", err) + } + if !tc.ok && !errors.Is(err, ErrUnauthorized) { + t.Fatalf("verify = %v, want ErrUnauthorized", err) + } + }) + } +} + +func TestVerifyRejectsAnotherSecret(t *testing.T) { + r := signedServerRequest(t, http.MethodGet, "http://node.test/status", nil, testNow) + if err := verify(r, "a-different-secret", testNow); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("verify = %v, want ErrUnauthorized", err) + } +} + +func TestVerifyRejectsOversizedBodies(t *testing.T) { + body := bytes.Repeat([]byte("x"), maxBodyBytes+1) + r := signedServerRequest(t, http.MethodPost, "http://node.test/transcode/start", body, testNow) + if err := verify(r, testSecret, testNow); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("verify = %v, want ErrUnauthorized", err) + } +} + +// A caller without the secret must not be able to make the worker read, let +// alone buffer, a request body. +func TestVerifyChecksTheSignatureBeforeReadingTheBody(t *testing.T) { + body := &countingReader{} + r := httptest.NewRequest(http.MethodPost, "/admin/reload-config", body) + r.Header.Set("Authorization", Scheme+" t=1760000000,b="+hex.EncodeToString(emptyBodyHash[:])+",sig="+strings.Repeat("00", 32)) + if err := verify(r, testSecret, testNow); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("verify = %v, want ErrUnauthorized", err) + } + if body.read != 0 { + t.Fatalf("read %d body bytes before the signature was checked", body.read) + } +} + +type countingReader struct{ read int } + +func (c *countingReader) Read(p []byte) (int, error) { + n := min(len(p), 1<<10) + c.read += n + return n, nil +} + +func TestVerifyRequiresACredential(t *testing.T) { + for _, auth := range []string{ + "", + "Bearer", + "Bearer ", + "Bearer wrong", + Scheme, + Scheme + " t=1760000000", + Scheme + " sig=00", + Scheme + " t=1760000000,sig=" + strings.Repeat("00", 32), + Scheme + " t=now,b=" + hex.EncodeToString(emptyBodyHash[:]) + ",sig=" + strings.Repeat("00", 32), + Scheme + " t=1760000000,b=" + hex.EncodeToString(emptyBodyHash[:]) + ",sig=zz", + Scheme + " t=1760000000,b=00,sig=" + strings.Repeat("00", 32), + "Basic " + testSecret, + } { + r := httptest.NewRequest(http.MethodGet, "/status", nil) + if auth != "" { + r.Header.Set("Authorization", auth) + } + if err := verify(r, testSecret, testNow); !errors.Is(err, ErrUnauthorized) { + t.Errorf("Authorization %q: verify = %v, want ErrUnauthorized", auth, err) + } + } +} + +func TestNoSecretSignsAndVerifiesNothing(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/status", nil) + if err := Sign(req, "http://node.test", ""); err != nil || req.Header.Get("Authorization") != "" { + t.Fatalf("Sign = %v with Authorization %q, want an unsigned request", err, req.Header.Get("Authorization")) + } + req.Header.Set("Authorization", "Bearer ") + if err := Verify(req, ""); !errors.Is(err, ErrNoSecret) { + t.Fatalf("Verify = %v, want ErrNoSecret", err) + } +} + +// The legacy bearer keeps older API servers and proxies working during a +// rolling upgrade. Remove with the legacy bearer in Verify. +func TestVerifyAcceptsTheLegacyBearerForOneRelease(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/status", nil) + r.Header.Set("Authorization", "Bearer "+testSecret) + if err := verify(r, testSecret, testNow); err != nil { + t.Fatalf("verify: %v", err) + } +} + +// The MAC key must not be the shared secret itself, or a signature would be +// an HMAC under the key that also signs login and stream tokens. +func TestMACKeyIsDerivedFromTheSecret(t *testing.T) { + got, err := requestMAC(testSecret, "1760000000", http.MethodGet, "/status", "", hex.EncodeToString(emptyBodyHash[:])) + if err != nil { + t.Fatal(err) + } + direct := hmac.New(sha256.New, []byte(testSecret)) + _, _ = io.WriteString(direct, strings.Join([]string{canonicalVersion, "1760000000", http.MethodGet, "/status", "", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, "\n")) + if hmac.Equal(got, direct.Sum(nil)) { + t.Fatal("MAC uses the shared secret directly") + } +} + +func TestSignKeepsUnrewindableBodiesReadable(t *testing.T) { + body := `{"artifact_id":"a"}` + req, err := http.NewRequest(http.MethodPost, "http://node.test/downloads/prepare", io.NopCloser(strings.NewReader(body))) + if err != nil { + t.Fatal(err) + } + if req.GetBody != nil { + t.Fatal("test needs a body without GetBody") + } + if err := sign(req, "http://node.test", testSecret, testNow); err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(req.Body) + if err != nil { + t.Fatal(err) + } + if string(got) != body || req.ContentLength != int64(len(body)) { + t.Fatalf("body = %q (length %d), want %q", got, req.ContentLength, body) + } +} + +// TestSignedRequestRoundTrip sends a signed request over a real connection, so +// the path, query, and body are the bytes a server actually receives. +func TestSignedRequestRoundTrip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := Verify(r, testSecret); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + body, _ := io.ReadAll(r.Body) + _, _ = w.Write(body) + })) + defer server.Close() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/transcode/a%20b/segment/1.ts/downloaded?generation=x%2By", strings.NewReader("payload")) + if err != nil { + t.Fatal(err) + } + if err := Sign(req, server.URL, testSecret); err != nil { + t.Fatal(err) + } + resp, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + got, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || string(got) != "payload" { + t.Fatalf("status %d body %q, want 200 payload", resp.StatusCode, got) + } +} + +func TestLogThrottleCountsSuppressedWarnings(t *testing.T) { + var throttle logThrottle + start := time.Hour + if suppressed, ok := throttle.allowAt(start); !ok || suppressed != 0 { + t.Fatalf("first warning: allow = %d, %v; want 0, true", suppressed, ok) + } + for range 3 { + if _, ok := throttle.allowAt(start + time.Minute); ok { + t.Fatal("a warning inside the interval was allowed") + } + } + // A concurrent caller that sampled the time before the last warning was + // recorded is inside the interval too, and must not move it backwards. + if _, ok := throttle.allowAt(start - time.Millisecond); ok { + t.Fatal("an out-of-order earlier sample was allowed") + } + if suppressed, ok := throttle.allowAt(start + logInterval); !ok || suppressed != 4 { + t.Fatalf("after the interval: allow = %d, %v; want 4, true", suppressed, ok) + } +} + +// A worker registered under a path prefix that its reverse proxy strips +// serves, and verifies, the path without the prefix. +func TestSignedPathIsRelativeToTheNodeURL(t *testing.T) { + for _, tc := range []struct { + name, nodeURL, target, workerSees string + }{ + {"no prefix", "http://gateway.test", "http://gateway.test/transcode/abc/master.m3u8?x=1", "/transcode/abc/master.m3u8?x=1"}, + {"prefix", "http://gateway.test/node1", "http://gateway.test/node1/transcode/abc/master.m3u8?x=1", "/transcode/abc/master.m3u8?x=1"}, + {"prefix with trailing slash", "http://gateway.test/node1/", "http://gateway.test/node1/status", "/status"}, + } { + t.Run(tc.name, func(t *testing.T) { + client, err := http.NewRequest(http.MethodGet, tc.target, nil) + if err != nil { + t.Fatal(err) + } + if err := sign(client, tc.nodeURL, testSecret, testNow); err != nil { + t.Fatal(err) + } + worker := httptest.NewRequest(http.MethodGet, tc.workerSees, nil) + worker.Header.Set("Authorization", client.Header.Get("Authorization")) + if err := verify(worker, testSecret, testNow); err != nil { + t.Fatalf("verify: %v", err) + } + }) + } + // The prefix is not a wildcard: a different route still fails. + client, err := http.NewRequest(http.MethodGet, "http://gateway.test/node1/status", nil) + if err != nil { + t.Fatal(err) + } + if err := sign(client, "http://gateway.test/node1", testSecret, testNow); err != nil { + t.Fatal(err) + } + worker := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil) + worker.Header.Set("Authorization", client.Header.Get("Authorization")) + if err := verify(worker, testSecret, testNow); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("verify = %v, want ErrUnauthorized", err) + } +} + +// A worker behind a reverse proxy that strips the path prefix of its +// registered URL still verifies the request, over a real connection. +func TestSignedRequestThroughAPrefixStrippingProxy(t *testing.T) { + worker := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := Verify(r, testSecret); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _, _ = io.WriteString(w, r.URL.Path) + }) + server := httptest.NewServer(http.StripPrefix("/node1", worker)) + defer server.Close() + + nodeURL := server.URL + "/node1" + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, nodeURL+"/transcode/start", strings.NewReader(`{"session_id":"abc"}`)) + if err != nil { + t.Fatal(err) + } + if err := Sign(req, nodeURL, testSecret); err != nil { + t.Fatal(err) + } + resp, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + got, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || string(got) != "/transcode/start" { + t.Fatalf("status %d body %q, want 200 /transcode/start", resp.StatusCode, got) + } +} diff --git a/internal/nodeauth/nodeauthtest/nodeauthtest.go b/internal/nodeauth/nodeauthtest/nodeauthtest.go new file mode 100644 index 0000000000..e62a9d6c77 --- /dev/null +++ b/internal/nodeauth/nodeauthtest/nodeauthtest.go @@ -0,0 +1,27 @@ +// Package nodeauthtest checks worker request signatures in tests. +package nodeauthtest + +import ( + "net/http" + "strings" + "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth" +) + +// Signed reports whether r carries a valid node signature for secret. Unlike +// nodeauth.Verify it refuses the legacy bearer, so a sender that goes back to +// sending the shared secret fails the test. It reads and restores r.Body. +func Signed(r *http.Request, secret string) bool { + return strings.HasPrefix(r.Header.Get("Authorization"), nodeauth.Scheme+" ") && + nodeauth.Verify(r, secret) == nil +} + +// Sign signs a request for a worker handler under test. Set the URL and body +// first: the signature covers them. +func Sign(t testing.TB, r *http.Request, secret string) { + t.Helper() + if err := nodeauth.Sign(r, "", secret); err != nil { + t.Fatalf("sign worker request: %v", err) + } +} diff --git a/internal/playback/remote_delete_signing_test.go b/internal/playback/remote_delete_signing_test.go new file mode 100644 index 0000000000..8b0b413b8d --- /dev/null +++ b/internal/playback/remote_delete_signing_test.go @@ -0,0 +1,31 @@ +package playback + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" +) + +func TestDeleteRemoteTranscodeSignsTheRequest(t *testing.T) { + const secret = "delete-secret" + var signed atomic.Bool + node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete && r.URL.Path == "/transcode/session-1" { + signed.Store(nodeauthtest.Signed(r, secret)) + } + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(node.Close) + + manager := NewTranscodeManager() + manager.JWTSecretFn = func() string { return secret } + if err := manager.deleteRemoteTranscode(t.Context(), "session-1", node.URL); err != nil { + t.Fatalf("deleteRemoteTranscode: %v", err) + } + if !signed.Load() { + t.Fatal("remote DELETE was not signed with the shared secret") + } +} diff --git a/internal/playback/transcode_manager.go b/internal/playback/transcode_manager.go index 85f76125b8..c5072d94d2 100644 --- a/internal/playback/transcode_manager.go +++ b/internal/playback/transcode_manager.go @@ -15,6 +15,7 @@ import ( "golang.org/x/sync/singleflight" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/tonemap" ) @@ -65,7 +66,8 @@ type TranscodeManager struct { Config func() TranscodeRuntimeConfig // LogSinkFn returns the ffmpeg log sink for reconstructed processes. LogSinkFn func() FFmpegLogSink - // JWTSecretFn returns the bearer used for remote transcode-node DELETEs. + // JWTSecretFn returns the shared secret that signs remote transcode-node + // DELETEs. JWTSecretFn func() string // OnFFmpegCrash is invoked when a reconstructed/local ffmpeg exits with an // error so the embedding handler can tear down the playback session (keeping @@ -1173,7 +1175,9 @@ func (m *TranscodeManager) deleteRemoteTranscode(ctx context.Context, sessionID, if err != nil { return fmt.Errorf("build remote transcode delete request: %w", err) } - req.Header.Set("Authorization", "Bearer "+m.jwtSecret()) + if err := nodeauth.Sign(req, transcodeNodeURL, m.jwtSecret()); err != nil { + return fmt.Errorf("sign remote transcode delete: %w", err) + } resp, err := remoteTranscodeControlClient.Do(req) if err != nil { diff --git a/internal/proxy/capability_snapshot_test.go b/internal/proxy/capability_snapshot_test.go index c094809c7d..aa1c8a78d6 100644 --- a/internal/proxy/capability_snapshot_test.go +++ b/internal/proxy/capability_snapshot_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/playback" ) @@ -62,7 +63,7 @@ func TestProxyCapabilitiesPublishCapabilityHash(t *testing.T) { server := newCapabilityProxyServer(t, secret) request := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil) - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) @@ -106,7 +107,7 @@ func TestProxyCapabilitiesRejectsIncompleteProbeWithoutPublishing(t *testing.T) canceled, cancel := context.WithCancel(context.Background()) cancel() request := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil).WithContext(canceled) - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) @@ -190,7 +191,7 @@ func newCapabilityProxyServerRecordingFFmpeg(t *testing.T, secret string, aacAva func fetchProxyCapabilities(t *testing.T, server *Server, secret string) playback.HWAccelInfo { t.Helper() request := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil) - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { diff --git a/internal/proxy/download_protocol_test.go b/internal/proxy/download_protocol_test.go index 858c53a0cc..2ce3aba67f 100644 --- a/internal/proxy/download_protocol_test.go +++ b/internal/proxy/download_protocol_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/streamtoken" ) @@ -24,7 +25,7 @@ func TestDownloadProtocolRemoteStatusAndHead(t *testing.T) { } { t.Run(tc.method+strconv.Itoa(tc.origin), func(t *testing.T) { origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != tc.method || r.Header.Get("Authorization") != "Bearer "+secret || r.Header.Get("If-Match") != "fixture" { + if r.Method != tc.method || !nodeauthtest.Signed(r, secret) || r.Header.Get("If-Match") != "fixture" { t.Error("origin request changed") } w.Header().Set("Content-Type", "application/x-worker-fixture") diff --git a/internal/proxy/download_test.go b/internal/proxy/download_test.go index 4395f720a8..0f77f5bef4 100644 --- a/internal/proxy/download_test.go +++ b/internal/proxy/download_test.go @@ -13,6 +13,7 @@ import ( "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/downloadprepare" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/streamtoken" ) @@ -82,8 +83,8 @@ func TestProxyDownloadRelaysNodeLocalArtifactRange(t *testing.T) { http.Error(w, "unexpected path", http.StatusBadRequest) return } - if auth := r.Header.Get("Authorization"); auth != "Bearer "+secret { - t.Errorf("origin Authorization = %q", auth) + if !nodeauthtest.Signed(r, secret) { + t.Errorf("origin request is not signed: Authorization = %q", r.Header.Get("Authorization")) http.Error(w, "unexpected authorization", http.StatusBadRequest) return } diff --git a/internal/proxy/mediagrant_test.go b/internal/proxy/mediagrant_test.go index cbf3d96cb3..82cd40463d 100644 --- a/internal/proxy/mediagrant_test.go +++ b/internal/proxy/mediagrant_test.go @@ -14,6 +14,7 @@ import ( "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/nodesessions" @@ -268,10 +269,11 @@ func TestProxyGrantRoutesEnforceCommittedProxyEgress(t *testing.T) { // A transcode grant is relayed to the node exactly like the token route, with // the node-facing token minted here — never handed to the client. func TestProxyGrantTranscodeRelaysToTheNodeWithAProxyMintedToken(t *testing.T) { - var forwarded, authorization, relayPath string + var forwarded, relayPath string + var signed bool node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { forwarded = r.Header.Get("X-Silo-Stream-Token") - authorization = r.Header.Get("Authorization") + signed = nodeauthtest.Signed(r, grantTestSecret) relayPath = r.URL.Path w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") _, _ = w.Write([]byte("#EXTM3U\nsegment/seg_00001.m4s\n")) @@ -299,8 +301,8 @@ func TestProxyGrantTranscodeRelaysToTheNodeWithAProxyMintedToken(t *testing.T) { if relayPath != "/transcode/session-hls-plan-a/master.m3u8" { t.Fatalf("relay path = %q, want the plan-scoped transport manifest", relayPath) } - if authorization != "Bearer "+grantTestSecret { - t.Fatalf("node authorization = %q", authorization) + if !signed { + t.Fatal("node request was not signed with the shared secret") } claims, err := streamtoken.Verify(forwarded, grantTestSecret) if err != nil { diff --git a/internal/proxy/network_access_test.go b/internal/proxy/network_access_test.go index 3b7c99863b..ac88020eab 100644 --- a/internal/proxy/network_access_test.go +++ b/internal/proxy/network_access_test.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" ) // fakeProviderHost stands in for the plugin service: one provider, "stub", @@ -68,7 +69,7 @@ func networkAccessRequest(t *testing.T, server *Server, method, path, secret str t.Helper() request := httptest.NewRequest(method, path, nil) if secret != "" { - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) } recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) @@ -216,7 +217,7 @@ func TestProxyNetworkAccessProviderStatusDoesNotWaitForOtherProviders(t *testing go func() { defer close(slowDone) request := httptest.NewRequest(http.MethodGet, "/network-access/slow/status", nil).WithContext(ctx) - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) router.ServeHTTP(httptest.NewRecorder(), request) }() select { @@ -225,7 +226,7 @@ func TestProxyNetworkAccessProviderStatusDoesNotWaitForOtherProviders(t *testing t.Fatal("slow provider status was not requested") } request := httptest.NewRequest(http.MethodGet, "/network-access/stub/status", nil) - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) recorder := httptest.NewRecorder() router.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { diff --git a/internal/proxy/node_auth_test.go b/internal/proxy/node_auth_test.go new file mode 100644 index 0000000000..c190a4dd60 --- /dev/null +++ b/internal/proxy/node_auth_test.go @@ -0,0 +1,42 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" +) + +// The proxy's admin routes admit only signed requests, plus the previous +// release's bearer while a deployment upgrades. +func TestProxyAdminRoutesRequireASignature(t *testing.T) { + const secret = "proxy-admin-secret" + server := newCapabilityProxyServer(t, secret) + status := func(r *http.Request) int { + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, r) + return recorder.Code + } + + signed := httptest.NewRequest(http.MethodGet, "/status", nil) + nodeauthtest.Sign(t, signed, secret) + if code := status(signed); code != http.StatusOK { + t.Fatalf("signed request: status %d, want 200", code) + } + if code := status(httptest.NewRequest(http.MethodGet, "/status", nil)); code != http.StatusUnauthorized { + t.Fatalf("unsigned request: status %d, want 401", code) + } + wrong := httptest.NewRequest(http.MethodGet, "/status", nil) + wrong.Header.Set("Authorization", "Bearer not-the-secret") + if code := status(wrong); code != http.StatusUnauthorized { + t.Fatalf("wrong bearer: status %d, want 401", code) + } + + // Remove with the legacy bearer in nodeauth.Verify. + legacy := httptest.NewRequest(http.MethodGet, "/status", nil) + legacy.Header.Set("Authorization", "Bearer "+secret) + if code := status(legacy); code != http.StatusOK { + t.Fatalf("legacy bearer during the upgrade window: status %d, want 200", code) + } +} diff --git a/internal/proxy/reprobe_test.go b/internal/proxy/reprobe_test.go index 3e11fa7759..04ec994b4f 100644 --- a/internal/proxy/reprobe_test.go +++ b/internal/proxy/reprobe_test.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" ) // A proxy runs ffmpeg for remux recipes, so it gets the same escape hatch a @@ -19,7 +21,7 @@ func TestProxyReprobeCapabilitiesRecomputesAndStoresHash(t *testing.T) { server.storeCapabilityHash("sha256:stale") request := httptest.NewRequest(http.MethodPost, "/admin/reprobe-capabilities", nil) - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) @@ -52,7 +54,7 @@ func TestProxyReprobeCapabilitiesKeepsHashOnIncompleteProbe(t *testing.T) { canceled, cancel := context.WithCancel(context.Background()) cancel() request := httptest.NewRequest(http.MethodPost, "/admin/reprobe-capabilities", nil).WithContext(canceled) - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) @@ -87,7 +89,7 @@ func TestProxyReprobeCapabilitiesRefusedWhileProbesAreRunning(t *testing.T) { server.countProbesInFlight = func() int { return 1 } request := httptest.NewRequest(http.MethodPost, "/admin/reprobe-capabilities", nil) - request.Header.Set("Authorization", "Bearer "+secret) + nodeauthtest.Sign(t, request, secret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) diff --git a/internal/proxy/server.go b/internal/proxy/server.go index 935cc9cef6..990448fcdd 100644 --- a/internal/proxy/server.go +++ b/internal/proxy/server.go @@ -29,6 +29,7 @@ import ( "github.com/Silo-Server/silo-server/internal/downloads" "github.com/Silo-Server/silo-server/internal/httpstream" "github.com/Silo-Server/silo-server/internal/netaccess" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/nodemetrics" "github.com/Silo-Server/silo-server/internal/noderouting" @@ -99,7 +100,7 @@ type Server struct { // (SetNetworkAccessStatus), which leaves the health field absent — the same // as a build that predates it. networkAccess NetworkAccessStatusSource - // networkAccessHost drives the provider instances for the bearer + // networkAccessHost drives the provider instances for the signed // network-access routes; nil answers 503 there. See network_access.go. networkAccessHost NetworkAccessProviderHost // ingressTokens validates the X-Silo-Ingress-Token a provider plugin @@ -297,7 +298,7 @@ func (s *Server) router() chi.Router { r.Get("/downloads/file/{token}", observeProxy(s.telemetry, http.MethodGet, "/downloads/file/{token}", s.handleDownloadFile)) }) - // Admin routes — bearer-auth protected. + // Admin routes — callers must sign each request (nodeauth). r.Group(func(r chi.Router) { r.Use(s.requireBearer) r.Get("/hw-capabilities", s.handleHWCapabilities) @@ -306,7 +307,7 @@ func (s *Server) router() chi.Router { r.Post("/admin/reprobe-capabilities", s.handleReprobeCapabilities) r.Get("/status", s.handleStatus) // Network access providers running beside this proxy; the API fans its - // admin status/connect/disconnect out to these with the node bearer. + // admin status/connect/disconnect out to these with signed requests. r.Get("/network-access/status", s.handleNetworkAccessStatus) r.Get("/network-access/{provider}/status", s.handleNetworkAccessProviderStatus) r.Post("/network-access/{provider}/connect", s.handleNetworkAccessConnect) @@ -560,13 +561,14 @@ func (s *Server) StartMetricsSampler(ctx context.Context) { s.metrics.Start(ctx) } -// requireBearer checks Authorization: Bearer {secret} for admin endpoints. +// requireBearer admits only API servers to the admin endpoints: it checks the +// request's node signature (see nodeauth). The name predates signing and is +// kept because the route inventory classifies by it. func (s *Server) requireBearer(next http.Handler) http.Handler { next = telemetry.TrustedHTTPHandler("worker", next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cfg := s.watcher.Config() - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != cfg.Auth.JWTSecret { + if nodeauth.Verify(r, cfg.Auth.JWTSecret) != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -1159,7 +1161,10 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl http.Error(w, "internal error", http.StatusInternalServerError) return } - req.Header.Set("Authorization", "Bearer "+cfg.Auth.JWTSecret) + if err := nodeauth.Sign(req, claims.TranscodeNode, cfg.Auth.JWTSecret); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } if isSegmentRoute { transcodeproxy.PrepareRequest(req, r) } @@ -1189,7 +1194,7 @@ func (s *Server) proxyToTranscodeNode(w http.ResponseWriter, r *http.Request, cl } if isMediaSegment && generation != "" && r.Method == http.MethodGet && sw.CompletedFullResponse(transcodeproxy.FullRepresentationSize(resp)) { - if ackErr := transcodeproxy.Acknowledge(r.Context(), s.httpClient, claims.TranscodeNode+path, cfg.Auth.JWTSecret, generation); ackErr != nil { + if ackErr := transcodeproxy.Acknowledge(r.Context(), s.httpClient, claims.TranscodeNode, path, cfg.Auth.JWTSecret, generation); ackErr != nil { slog.WarnContext(r.Context(), "acknowledge transcode segment completion", "component", "proxy", "error", ackErr, "playback_session_id", claims.SessionID) } } diff --git a/internal/proxy/streaming_protocol_test.go b/internal/proxy/streaming_protocol_test.go index 4af9604ca3..9866bc9446 100644 --- a/internal/proxy/streaming_protocol_test.go +++ b/internal/proxy/streaming_protocol_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/clientip" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/streamtoken" "github.com/Silo-Server/silo-server/internal/transcodeproxy" ) @@ -22,7 +23,7 @@ func TestStreamingProtocolOriginResponses(t *testing.T) { } { t.Run(tc.method+tc.suffix, func(t *testing.T) { origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != tc.method || r.URL.Path != "/transcode/transport/"+tc.suffix || r.URL.RawQuery != "opaque=1" || r.Header.Get("Authorization") != "Bearer "+secret || r.Header.Get("X-Silo-Stream-Token") == "" { + if r.Method != tc.method || r.URL.Path != "/transcode/transport/"+tc.suffix || r.URL.RawQuery != "opaque=1" || !nodeauthtest.Signed(r, secret) || r.Header.Get("X-Silo-Stream-Token") == "" { t.Error("relay identity/query lost") } segment := strings.HasPrefix(tc.suffix, "segment/") diff --git a/internal/proxy/theme_audio_test.go b/internal/proxy/theme_audio_test.go index 22987cde98..5f72c8c7f8 100644 --- a/internal/proxy/theme_audio_test.go +++ b/internal/proxy/theme_audio_test.go @@ -5,10 +5,10 @@ import ( "net/http/httptest" "os" "path/filepath" - "strings" "testing" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/streamtoken" @@ -115,8 +115,10 @@ func TestThemeConversionHeadAndRelay(t *testing.T) { const secret = "proxy-theme-relay-secret" path, size, modified := writeThemeAudio(t) var relayed *http.Request + var relayedSigned bool node := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { relayed = r.Clone(r.Context()) + relayedSigned = nodeauthtest.Signed(r, secret) w.Header().Set("Content-Type", playback.AudioOnlyRemuxMIMEV3) _, _ = w.Write([]byte("converted")) })) @@ -152,7 +154,7 @@ func TestThemeConversionHeadAndRelay(t *testing.T) { t.Fatalf("relayed conversion = %d %q", got.status, got.body) } if relayed == nil || relayed.URL.Path != "/remux/theme-transport" || relayed.URL.Query().Get("seek") != "4" || - relayed.Header.Get("X-Silo-Stream-Token") != relayToken || !strings.HasPrefix(relayed.Header.Get("Authorization"), "Bearer ") { + relayed.Header.Get("X-Silo-Stream-Token") != relayToken || !relayedSigned { t.Fatalf("relayed request = %+v", relayed) } diff --git a/internal/transcodenode/capability_client.go b/internal/transcodenode/capability_client.go index 70302705da..4b05edf66b 100644 --- a/internal/transcodenode/capability_client.go +++ b/internal/transcodenode/capability_client.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/telemetry" "github.com/Silo-Server/silo-server/internal/logredact" @@ -42,7 +43,9 @@ func FetchHWCapabilitiesPayload(ctx context.Context, baseClient *http.Client, no if err != nil { return playback.HWAccelInfo{}, nil, 0, logredact.SanitizeURLError(err) } - request.Header.Set("Authorization", "Bearer "+jwtSecret) + if err := nodeauth.Sign(request, nodeURL, jwtSecret); err != nil { + return playback.HWAccelInfo{}, nil, 0, err + } if baseClient == nil { baseClient = http.DefaultClient diff --git a/internal/transcodenode/capability_snapshot_test.go b/internal/transcodenode/capability_snapshot_test.go index cafaf98e7f..81ba4a114b 100644 --- a/internal/transcodenode/capability_snapshot_test.go +++ b/internal/transcodenode/capability_snapshot_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/config" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/playback" ) @@ -79,7 +80,7 @@ func TestHWCapabilitiesPublishesCapabilityHash(t *testing.T) { server.nodeRowID = func() (int, bool) { return 42, true } request := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil) - request.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, request, testSecret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) @@ -114,7 +115,7 @@ func TestHWCapabilitiesWithholdsProgressiveRemuxWithoutNodeIdentity(t *testing.T server := newTestServer(t) request := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil) - request.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, request, testSecret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) diff --git a/internal/transcodenode/drain_test.go b/internal/transcodenode/drain_test.go index ab7ebb9964..1fb4319ba3 100644 --- a/internal/transcodenode/drain_test.go +++ b/internal/transcodenode/drain_test.go @@ -20,6 +20,7 @@ import ( "github.com/Silo-Server/silo-server/internal/chapterthumbs" "github.com/Silo-Server/silo-server/internal/downloadprepare" "github.com/Silo-Server/silo-server/internal/mediasample" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/nodesessions" @@ -84,14 +85,15 @@ func stopSession(t *testing.T, s *Server, sessionID string) { } r := httptest.NewRequest(http.MethodDelete, "/transcode/"+sessionID, nil) rr := httptest.NewRecorder() - s.Handler().ServeHTTP(rr, withBearer(r)) + s.Handler().ServeHTTP(rr, signed(t, r)) if rr.Code >= 300 { t.Fatalf("stop %s: status %d %s", sessionID, rr.Code, rr.Body.String()) } } -func withBearer(r *http.Request) *http.Request { - r.Header.Set("Authorization", "Bearer "+testSecret) +func signed(t *testing.T, r *http.Request) *http.Request { + t.Helper() + nodeauthtest.Sign(t, r, testSecret) return r } @@ -294,7 +296,7 @@ func TestDrainingNodeRefusesNewJobs(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { rr := httptest.NewRecorder() - handler.ServeHTTP(rr, withBearer(httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(test.body)))) + handler.ServeHTTP(rr, signed(t, httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(test.body)))) if rr.Code != http.StatusServiceUnavailable || !strings.Contains(rr.Body.String(), test.reason) { t.Fatalf("status %d body %q, want 503 carrying %q", rr.Code, rr.Body.String(), test.reason) } diff --git a/internal/transcodenode/media_sample_test.go b/internal/transcodenode/media_sample_test.go index c87db3f90c..6565e99dff 100644 --- a/internal/transcodenode/media_sample_test.go +++ b/internal/transcodenode/media_sample_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/mediasample" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" ) func speechRequestBody(t *testing.T, mutate func(*mediasample.Request)) []byte { @@ -33,10 +34,10 @@ func speechRequestBody(t *testing.T, mutate func(*mediasample.Request)) []byte { return body } -func postMediaSample(server *Server, body []byte, authorized bool) *httptest.ResponseRecorder { +func postMediaSample(t *testing.T, server *Server, body []byte, authorized bool) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, mediasample.RemotePath, bytes.NewReader(body)) if authorized { - req.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, req, testSecret) } rec := httptest.NewRecorder() server.router().ServeHTTP(rec, req) @@ -60,7 +61,7 @@ func TestMediaSampleRunsSpeechRequest(t *testing.T) { } server.watcher.Config().Playback.FFmpegPath = ffmpeg - rec := postMediaSample(server, speechRequestBody(t, nil), true) + rec := postMediaSample(t, server, speechRequestBody(t, nil), true) if rec.Code != http.StatusOK { t.Fatalf("status %d: %s", rec.Code, rec.Body) } @@ -100,7 +101,7 @@ func TestMediaSampleRefusals(t *testing.T) { if tc.paths != nil { server.inputPaths = tc.paths } - rec := postMediaSample(server, tc.body, tc.authorized) + rec := postMediaSample(t, server, tc.body, tc.authorized) if rec.Code != tc.status { t.Fatalf("status %d, want %d: %s", rec.Code, tc.status, rec.Body) } @@ -121,7 +122,7 @@ func TestMediaSampleReportsSamplingFailure(t *testing.T) { t.Fatal(err) } server.watcher.Config().Playback.FFmpegPath = ffmpeg - rec := postMediaSample(server, speechRequestBody(t, nil), true) + rec := postMediaSample(t, server, speechRequestBody(t, nil), true) if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("status %d: %s", rec.Code, rec.Body) } @@ -185,7 +186,7 @@ func TestMediaSampleAdmitsConfiguredCapacity(t *testing.T) { } done := make(chan int, 2) for range 2 { - go func() { done <- postMediaSample(server, speechRequestBody(t, nil), true).Code }() + go func() { done <- postMediaSample(t, server, speechRequestBody(t, nil), true).Code }() } deadline := time.Now().Add(10 * time.Second) for started() == 0 && time.Now().Before(deadline) { @@ -220,7 +221,7 @@ func TestMediaSampleRefusesWhenNoSlotFreesUp(t *testing.T) { } server.watcher.Config().Playback.FFmpegPath = ffmpeg first := make(chan int, 1) - go func() { first <- postMediaSample(server, speechRequestBody(t, nil), true).Code }() + go func() { first <- postMediaSample(t, server, speechRequestBody(t, nil), true).Code }() deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if matches, _ := filepath.Glob(filepath.Join(dir, "start.*")); len(matches) == 1 { @@ -231,7 +232,7 @@ func TestMediaSampleRefusesWhenNoSlotFreesUp(t *testing.T) { // The slot is held: the next request waits its admission time, then is // refused as unavailable, which callers may run elsewhere. - rec := postMediaSample(server, speechRequestBody(t, nil), true) + rec := postMediaSample(t, server, speechRequestBody(t, nil), true) var failure mediasample.RemoteFailure if rec.Code != http.StatusServiceUnavailable || json.Unmarshal(rec.Body.Bytes(), &failure) != nil || failure.Reason != mediasample.ReasonNodeUnavailable { t.Fatalf("over capacity: %d %s", rec.Code, rec.Body) @@ -244,7 +245,7 @@ func TestMediaSampleRefusesWhenNoSlotFreesUp(t *testing.T) { if code := <-first; code != http.StatusOK { t.Fatalf("first run: %d", code) } - if code := postMediaSample(server, speechRequestBody(t, nil), true).Code; code != http.StatusOK { + if code := postMediaSample(t, server, speechRequestBody(t, nil), true).Code; code != http.StatusOK { t.Fatalf("after release: %d", code) } } diff --git a/internal/transcodenode/node_auth_test.go b/internal/transcodenode/node_auth_test.go new file mode 100644 index 0000000000..fa66b1a7d2 --- /dev/null +++ b/internal/transcodenode/node_auth_test.go @@ -0,0 +1,49 @@ +package transcodenode + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" +) + +// The node admits only requests signed for exactly the route they reach, plus +// the previous release's bearer while a deployment upgrades. +func TestNodeRoutesRequireASignatureForTheRequest(t *testing.T) { + server := newTestServer(t) + status := func(r *http.Request) int { + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, r) + return recorder.Code + } + + signed := httptest.NewRequest(http.MethodGet, "/status", nil) + nodeauthtest.Sign(t, signed, testSecret) + if code := status(signed); code != http.StatusOK { + t.Fatalf("signed request: status %d, want 200", code) + } + + if code := status(httptest.NewRequest(http.MethodGet, "/status", nil)); code != http.StatusUnauthorized { + t.Fatalf("unsigned request: status %d, want 401", code) + } + + moved := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil) + moved.Header.Set("Authorization", signed.Header.Get("Authorization")) + if code := status(moved); code != http.StatusUnauthorized { + t.Fatalf("signature reused on another route: status %d, want 401", code) + } + + otherSecret := httptest.NewRequest(http.MethodGet, "/status", nil) + nodeauthtest.Sign(t, otherSecret, "another-secret") + if code := status(otherSecret); code != http.StatusUnauthorized { + t.Fatalf("request signed with another secret: status %d, want 401", code) + } + + // Remove with the legacy bearer in nodeauth.Verify. + legacy := httptest.NewRequest(http.MethodGet, "/status", nil) + legacy.Header.Set("Authorization", "Bearer "+testSecret) + if code := status(legacy); code != http.StatusOK { + t.Fatalf("legacy bearer during the upgrade window: status %d, want 200", code) + } +} diff --git a/internal/transcodenode/reprobe_test.go b/internal/transcodenode/reprobe_test.go index ac46815faf..90d6d240db 100644 --- a/internal/transcodenode/reprobe_test.go +++ b/internal/transcodenode/reprobe_test.go @@ -11,13 +11,14 @@ import ( "testing" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/playback" ) func postReprobe(t *testing.T, server *Server) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(http.MethodPost, "/admin/reprobe-capabilities", nil) - request.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, request, testSecret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) return recorder @@ -61,7 +62,7 @@ func TestReprobeCapabilitiesRecomputesAndStoresHash(t *testing.T) { // The reported hash must describe the report the capability endpoint would // now serve, or the API would refetch and store something else. capabilityRequest := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil) - capabilityRequest.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, capabilityRequest, testSecret) capabilityRecorder := httptest.NewRecorder() server.Handler().ServeHTTP(capabilityRecorder, capabilityRequest) if capabilityRecorder.Code != http.StatusOK { @@ -210,7 +211,7 @@ func TestReloadConfigKeepsActiveSessions(t *testing.T) { server.activeJobs.Store(1) request := httptest.NewRequest(http.MethodPost, "/admin/reload-config", nil) - request.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, request, testSecret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index aeb06b7b0e..c862e53eb9 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -28,6 +28,7 @@ import ( "github.com/Silo-Server/silo-server/internal/downloadstorage" "github.com/Silo-Server/silo-server/internal/httpstream" "github.com/Silo-Server/silo-server/internal/mediasample" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/nodemetrics" "github.com/Silo-Server/silo-server/internal/noderouting" @@ -1563,7 +1564,9 @@ func writeChapterThumbnailError(w http.ResponseWriter, status int, reason string }) } -// requireBearer is middleware that checks for Authorization: Bearer {secret}. +// requireBearer is middleware that admits only API servers and proxies: it +// checks the request's node signature (see nodeauth). The name predates +// signing and is kept because the route inventory classifies by it. func (s *Server) requireBearer(next http.Handler) http.Handler { next = telemetry.TrustedHTTPHandler("worker", next) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1572,8 +1575,7 @@ func (s *Server) requireBearer(next http.Handler) http.Handler { http.Error(w, "node not configured", http.StatusServiceUnavailable) return } - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != cfg.Auth.JWTSecret { + if nodeauth.Verify(r, cfg.Auth.JWTSecret) != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } @@ -1867,7 +1869,7 @@ func (s *Server) requireApprovedInputPath(w http.ResponseWriter, r *http.Request // identity-only by design (see internal/noderecipe), and a header-authenticated // (tokenless) attempt, where no client-visible URL carries a credential and the // relayed request therefore has no token to forward. The token was never this -// route's authorization — the static bearer already authenticated the caller — +// route's authorization — the node signature already authenticated the caller — // so its absence only removes a recipe source, never a permission. // // Returns nil when no source yields a complete transcode recipe for the session @@ -2230,7 +2232,7 @@ func (s *Server) acquireReconstructSlot(ctx context.Context) (func(), bool) { } } -// handleRemux executes a progressive remux on demand. The static bearer +// handleRemux executes a progressive remux on demand. The node signature // authenticates the proxy process; the independently verified stream token // binds the media recipe and the exact transcode-execution/proxy-egress route. func (s *Server) handleRemux(w http.ResponseWriter, r *http.Request) { diff --git a/internal/transcodenode/server_test.go b/internal/transcodenode/server_test.go index 9c03b09163..fb85f6080f 100644 --- a/internal/transcodenode/server_test.go +++ b/internal/transcodenode/server_test.go @@ -23,6 +23,7 @@ import ( "github.com/Silo-Server/silo-server/internal/downloadprepare" "github.com/Silo-Server/silo-server/internal/mediaprobe" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/nodesessions" @@ -743,7 +744,7 @@ func TestHandleDownloadPreparePublishesToneMapReceiptAndStatusAttestation(t *tes } head := httptest.NewRequest(http.MethodHead, "/downloads/artifacts/artifact-tone-map", nil) - head.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, head, testSecret) headRecorder := httptest.NewRecorder() server.Handler().ServeHTTP(headRecorder, head) if headRecorder.Code != http.StatusOK { @@ -814,7 +815,7 @@ func TestHandleDownloadPreparePublishesStereoDownmixReceiptAndStatusAttestation( } head := httptest.NewRequest(http.MethodHead, "/downloads/artifacts/"+prepareRequest.ArtifactID, nil) - head.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, head, testSecret) headRecorder := httptest.NewRecorder() server.Handler().ServeHTTP(headRecorder, head) if headRecorder.Code != http.StatusOK { @@ -1679,7 +1680,7 @@ func TestDownloadArtifactRoutesServeRangeAndDeleteNodeLocalFile(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "/downloads/artifacts/artifact-range", nil) - req.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, req, testSecret) req.Header.Set("Range", "bytes=3-6") rr := httptest.NewRecorder() server.Handler().ServeHTTP(rr, req) @@ -1691,7 +1692,7 @@ func TestDownloadArtifactRoutesServeRangeAndDeleteNodeLocalFile(t *testing.T) { } deleteReq := httptest.NewRequest(http.MethodDelete, "/downloads/artifacts/artifact-range", nil) - deleteReq.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, deleteReq, testSecret) deleteRR := httptest.NewRecorder() server.Handler().ServeHTTP(deleteRR, deleteReq) if deleteRR.Code != http.StatusNoContent { @@ -1723,7 +1724,7 @@ func TestDeleteDownloadArtifactReceiptInvalidationFailurePreservesArtifact(t *te } request := httptest.NewRequest(http.MethodDelete, "/downloads/artifacts/artifact-delete-invalidation", nil) - request.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, request, testSecret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) if recorder.Code != http.StatusInternalServerError { @@ -1793,7 +1794,7 @@ func TestDownloadArtifactHeadDoesNotWaitForConcurrentRelay(t *testing.T) { getDone := make(chan struct{}) go func() { req := httptest.NewRequest(http.MethodGet, "/downloads/artifacts/"+artifactID, nil) - req.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, req, testSecret) server.Handler().ServeHTTP(blocked, req) close(getDone) }() @@ -1807,7 +1808,7 @@ func TestDownloadArtifactHeadDoesNotWaitForConcurrentRelay(t *testing.T) { headDone := make(chan *httptest.ResponseRecorder, 1) go func() { req := httptest.NewRequest(http.MethodHead, "/downloads/artifacts/"+artifactID, nil) - req.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, req, testSecret) rr := httptest.NewRecorder() server.Handler().ServeHTTP(rr, req) headDone <- rr diff --git a/internal/transcodenode/start_protocol_test.go b/internal/transcodenode/start_protocol_test.go index c4f57d77cd..5210cc4e7c 100644 --- a/internal/transcodenode/start_protocol_test.go +++ b/internal/transcodenode/start_protocol_test.go @@ -8,6 +8,8 @@ import ( "os/exec" "strings" "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" ) func TestWorkerStartActualMedia(t *testing.T) { @@ -31,7 +33,7 @@ func TestWorkerStartActualMedia(t *testing.T) { if err != nil { t.Fatal(err) } - req.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, req, testSecret) response, err := handler.Client().Do(req) if err != nil { t.Fatal(err) diff --git a/internal/transcodenode/streamdeny_test.go b/internal/transcodenode/streamdeny_test.go index 99d6652bfc..fe0de478d4 100644 --- a/internal/transcodenode/streamdeny_test.go +++ b/internal/transcodenode/streamdeny_test.go @@ -11,6 +11,7 @@ import ( "github.com/redis/go-redis/v9" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/streamtoken" @@ -30,7 +31,7 @@ func unreachableStreamDeny(t *testing.T) *playback.StreamDeny { func nodeRequest(t *testing.T, server *Server, method, path, token string) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(method, path, nil) - req.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, req, testSecret) if token != "" { req.Header.Set("X-Silo-Stream-Token", token) } diff --git a/internal/transcodenode/streamtelemetry_test.go b/internal/transcodenode/streamtelemetry_test.go index 74e9c00048..3b5367b230 100644 --- a/internal/transcodenode/streamtelemetry_test.go +++ b/internal/transcodenode/streamtelemetry_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/streamtelemetry" @@ -35,7 +36,7 @@ func nodeMediaRequest(t *testing.T, server *httptest.Server, path, token string) if err != nil { t.Fatal(err) } - req.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, req, testSecret) if token != "" { req.Header.Set("X-Silo-Stream-Token", token) } diff --git a/internal/transcodenode/theme_audio_test.go b/internal/transcodenode/theme_audio_test.go index eabb78bc58..9bcd70f661 100644 --- a/internal/transcodenode/theme_audio_test.go +++ b/internal/transcodenode/theme_audio_test.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/noderouting" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/streamtoken" @@ -159,7 +160,7 @@ func TestHWCapabilitiesAdvertisesThemeExecutionOnlyWithAuthority(t *testing.T) { features := func() []string { t.Helper() request := httptest.NewRequest(http.MethodGet, "/hw-capabilities", nil) - request.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, request, testSecret) recorder := httptest.NewRecorder() server.Handler().ServeHTTP(recorder, request) if recorder.Code == http.StatusServiceUnavailable { diff --git a/internal/transcodenode/trickplay_gate_test.go b/internal/transcodenode/trickplay_gate_test.go index 448da73968..db169acbbd 100644 --- a/internal/transcodenode/trickplay_gate_test.go +++ b/internal/transcodenode/trickplay_gate_test.go @@ -19,7 +19,7 @@ func TestTrickplayRejectsReprobeBeforeResolvingHardware(t *testing.T) { t.Fatal("could not start reprobe") } defer server.gpu.endReprobe() - rec := postTrickplay(server, sheetsRequestJSON(t, filepath.Join(t.TempDir(), "missing.mkv"))) + rec := postTrickplay(t, server, sheetsRequestJSON(t, filepath.Join(t.TempDir(), "missing.mkv"))) if calls != 0 { t.Fatalf("hardware resolved %d times during reprobe", calls) } @@ -47,7 +47,7 @@ func TestTrickplayResolutionHoldsAndReleasesGPUAdmission(t *testing.T) { } return "none" } - rec := postTrickplay(server, sheetsRequestJSON(t, filepath.Join(t.TempDir(), "missing.mkv"))) + rec := postTrickplay(t, server, sheetsRequestJSON(t, filepath.Join(t.TempDir(), "missing.mkv"))) if calls != 1 || rec.Code != http.StatusUnprocessableEntity { t.Fatalf("calls=%d response=%d %s", calls, rec.Code, rec.Body.String()) } diff --git a/internal/transcodenode/trickplay_test.go b/internal/transcodenode/trickplay_test.go index 0ef3e3361b..5cc64bf62a 100644 --- a/internal/transcodenode/trickplay_test.go +++ b/internal/transcodenode/trickplay_test.go @@ -13,6 +13,7 @@ import ( "github.com/Silo-Server/silo-server/internal/config" "github.com/Silo-Server/silo-server/internal/mediasample" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodeconfig" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/trickplay" @@ -30,9 +31,9 @@ func newTrickplayTestServer(t *testing.T, ffmpeg string) *Server { return &Server{watcher: watcher, inputPaths: allowInputPaths{}, sessions: make(map[string]*playback.TranscodeSession)} } -func postTrickplay(server *Server, body string) *httptest.ResponseRecorder { +func postTrickplay(t *testing.T, server *Server, body string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/trickplay/extract", strings.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+testSecret) + nodeauthtest.Sign(t, req, testSecret) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() server.Handler().ServeHTTP(rec, req) @@ -67,7 +68,7 @@ func TestTrickplayExtractMakesSheets(t *testing.T) { t.Skipf("cannot generate the clip: %v: %s", err, output) } server := newTrickplayTestServer(t, ffmpeg) - rec := postTrickplay(server, sheetsRequestJSON(t, clip)) + rec := postTrickplay(t, server, sheetsRequestJSON(t, clip)) if rec.Code != http.StatusOK { t.Fatalf("%d %s", rec.Code, rec.Body.String()) } @@ -91,20 +92,20 @@ func TestTrickplayExtractRefuses(t *testing.T) { _ = json.Unmarshal(rec.Body.Bytes(), &failure) return failure } - if rec := postTrickplay(server, `{`); rec.Code != http.StatusBadRequest { + if rec := postTrickplay(t, server, `{`); rec.Code != http.StatusBadRequest { t.Fatalf("bad body: %d", rec.Code) } stats := `{"input":"/media/movie.mkv","samples":{"seconds":[1]},"stats":{"crop_width":1,"crop_height":1,"width":64}}` - if rec := postTrickplay(server, stats); rec.Code != http.StatusBadRequest || decode(rec).Reason != "invalid_request" { + if rec := postTrickplay(t, server, stats); rec.Code != http.StatusBadRequest || decode(rec).Reason != "invalid_request" { t.Fatalf("stats request: %d %s", rec.Code, rec.Body.String()) } server.trickplay.busy.Lock() - if rec := postTrickplay(server, valid); rec.Code != http.StatusServiceUnavailable || decode(rec).Reason != trickplay.NodeBusyReason { + if rec := postTrickplay(t, server, valid); rec.Code != http.StatusServiceUnavailable || decode(rec).Reason != trickplay.NodeBusyReason { t.Fatalf("busy: %d %s", rec.Code, rec.Body.String()) } server.trickplay.busy.Unlock() server.inputPaths = denyInputPaths{} - if rec := postTrickplay(server, valid); rec.Code == http.StatusOK || rec.Code == http.StatusUnprocessableEntity { + if rec := postTrickplay(t, server, valid); rec.Code == http.StatusOK || rec.Code == http.StatusUnprocessableEntity { t.Fatalf("unapproved path: %d", rec.Code) } unauthorized := httptest.NewRecorder() diff --git a/internal/transcodeproxy/completion.go b/internal/transcodeproxy/completion.go index 30a411803b..c6f8e03588 100644 --- a/internal/transcodeproxy/completion.go +++ b/internal/transcodeproxy/completion.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/telemetry" ) @@ -81,10 +82,10 @@ func FullRepresentationSize(resp *http.Response) int64 { return size } -// Acknowledge reports a completed downstream response to the transcode node. -// The opaque generation token makes delayed acknowledgements harmless after a -// restart or reconstruction. -func Acknowledge(ctx context.Context, client *http.Client, targetURL, jwtSecret, generation string) error { +// Acknowledge reports a completed downstream response to the transcode node at +// nodeURL for the segment at segmentPath. The opaque generation token makes +// delayed acknowledgements harmless after a restart or reconstruction. +func Acknowledge(ctx context.Context, client *http.Client, nodeURL, segmentPath, jwtSecret, generation string) error { if client == nil { client = http.DefaultClient } @@ -94,11 +95,13 @@ func Acknowledge(ctx context.Context, client *http.Client, targetURL, jwtSecret, // bounded lifetime so a successful delivery is not mistaken for a disconnect. ackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() - req, err := http.NewRequestWithContext(ackCtx, http.MethodPost, targetURL+"/downloaded", nil) + req, err := http.NewRequestWithContext(ackCtx, http.MethodPost, nodeURL+segmentPath+"/downloaded", nil) if err != nil { return fmt.Errorf("build acknowledgement: %w", err) } - req.Header.Set("Authorization", "Bearer "+jwtSecret) + if err := nodeauth.Sign(req, nodeURL, jwtSecret); err != nil { + return fmt.Errorf("sign acknowledgement: %w", err) + } req.Header.Set(GenerationHeader, generation) resp, err := telemetry.DoTrustedNode(client, req, "stream_ack") if err != nil { diff --git a/internal/transcodeproxy/completion_test.go b/internal/transcodeproxy/completion_test.go index 505e3a01a4..6a741822ef 100644 --- a/internal/transcodeproxy/completion_test.go +++ b/internal/transcodeproxy/completion_test.go @@ -5,6 +5,8 @@ import ( "net/http" "net/http/httptest" "testing" + + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" ) func TestAcknowledgeSurvivesCompletedDownstreamRequest(t *testing.T) { @@ -18,8 +20,8 @@ func TestAcknowledgeSurvivesCompletedDownstreamRequest(t *testing.T) { if r.Method != http.MethodPost { t.Errorf("method = %q, want POST", r.Method) } - if got := r.Header.Get("Authorization"); got != "Bearer "+jwtSecret { - t.Errorf("Authorization = %q", got) + if !nodeauthtest.Signed(r, jwtSecret) { + t.Errorf("acknowledgement is not signed: Authorization = %q", r.Header.Get("Authorization")) } if got := r.Header.Get(GenerationHeader); got != generation { t.Errorf("generation = %q, want %q", got, generation) @@ -31,7 +33,7 @@ func TestAcknowledgeSurvivesCompletedDownstreamRequest(t *testing.T) { requestCtx, cancelRequest := context.WithCancel(context.Background()) cancelRequest() - if err := Acknowledge(requestCtx, server.Client(), server.URL+"/segment", jwtSecret, generation); err != nil { + if err := Acknowledge(requestCtx, server.Client(), server.URL, "/segment", jwtSecret, generation); err != nil { t.Fatalf("Acknowledge() error = %v", err) } select { diff --git a/internal/trickplay/remote.go b/internal/trickplay/remote.go index fb45dd753a..eb8ca72f21 100644 --- a/internal/trickplay/remote.go +++ b/internal/trickplay/remote.go @@ -14,6 +14,7 @@ import ( "time" "github.com/Silo-Server/silo-server/internal/mediasample" + "github.com/Silo-Server/silo-server/internal/nodeauth" "github.com/Silo-Server/silo-server/internal/nodepool" "github.com/Silo-Server/silo-server/internal/playback" "github.com/Silo-Server/silo-server/internal/telemetry" @@ -159,7 +160,9 @@ func (e *NodeExtractor) remote(ctx context.Context, node *nodepool.Node, secret return mediasample.Result{}, &ExtractError{Reason: NodeUnavailableReason, Message: err.Error()} } httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+secret) + if err := nodeauth.Sign(httpReq, node.URL, secret); err != nil { + return mediasample.Result{}, &ExtractError{Reason: NodeUnavailableReason, Message: err.Error()} + } resp, err := telemetry.DoTrustedNode(e.client, httpReq, "trickplay_extract") if err != nil { // A node that dies mid-request is unavailable, not a failure of the file. diff --git a/internal/trickplay/remote_test.go b/internal/trickplay/remote_test.go index f0da01ca36..540a7b35b9 100644 --- a/internal/trickplay/remote_test.go +++ b/internal/trickplay/remote_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/Silo-Server/silo-server/internal/mediasample" + "github.com/Silo-Server/silo-server/internal/nodeauth/nodeauthtest" "github.com/Silo-Server/silo-server/internal/nodepool" ) @@ -27,7 +28,7 @@ func fakeNode(t *testing.T, status int, body any) (*nodepool.Node, *atomic.Int32 calls := &atomic.Int32{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) - if r.URL.Path != "/trickplay/extract" || r.Header.Get("Authorization") != "Bearer secret" { + if r.URL.Path != "/trickplay/extract" || !nodeauthtest.Signed(r, "secret") { w.WriteHeader(http.StatusUnauthorized) return } From 271698235d2643465193751ad0b9633a4e2effcb Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:07:18 -0400 Subject: [PATCH 008/196] feat(web): filter the image picker by provider and list local art first (#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 #2391 Co-authored-by: Claude Opus 5.5 --- docs/admin-catalog-api.md | 6 +- docs/architecture/local-nfo-metadata.md | 4 +- internal/apiv2/admin_catalog_images.go | 17 ++++- internal/apiv2/admin_catalog_images_test.go | 25 ++++++++ web/src/components/ImageSelectorTab.test.tsx | 42 +++++++++++- web/src/components/ImageSelectorTab.tsx | 67 +++++++++++++++++--- 6 files changed, 146 insertions(+), 15 deletions(-) diff --git a/docs/admin-catalog-api.md b/docs/admin-catalog-api.md index 1ecf998f1e..a622f3ffb3 100644 --- a/docs/admin-catalog-api.md +++ b/docs/admin-catalog-api.md @@ -485,8 +485,10 @@ array. No current web, native or Jellyfin consumer needs migration for this rout `GET /api/v2/admin/items/{id}/images` requires acting-admin access and returns `items`, `page`, `current`, and optional `provider_errors`. Pages default to 50 -choices and accept `limit` 1–200. Choice metadata determines a stable order; -continuation binds the content ID, authority, limit and complete choice digest. +choices and accept `limit` 1–200. On movie and series lists, local sidecar +choices come first, in discovery order. Provider choices follow by rating, +highest first; choice metadata breaks ties, so the order is stable. +Continuation binds the content ID, authority, limit and complete choice digest. Expiring display URLs are excluded from that digest. A provider choice change returns `invalid_cursor` and requires reloading. Each page fetches and resolves the provider list again; pagination bounds response size, not provider work or diff --git a/docs/architecture/local-nfo-metadata.md b/docs/architecture/local-nfo-metadata.md index fa28e242ca..6e3747900b 100644 --- a/docs/architecture/local-nfo-metadata.md +++ b/docs/architecture/local-nfo-metadata.md @@ -567,7 +567,9 @@ to read here, and banner, thumb, clear art and disc art cannot be applied. Each local choice has the provider ID `local`, keeps its fingerprinted `file://` source as `original_url`, and shows a 300-pixel-wide WebP preview as an inline `data:` URI (the web client authenticates with a bearer token, so an `` -cannot fetch an authenticated preview route). The preview is read under the same +cannot fetch an authenticated preview route). Local choices lead the list, ahead +of every provider's, in discovery order, so each slot's first local choice is +the sidecar file a refresh uses when it takes local artwork. The preview is read under the same root confinement, symlink and size checks as the processor; a file that fails them is left out of the list, and the list's `provider_errors` reports it under `local`. Only the movie and series list does this: any other list treats a diff --git a/internal/apiv2/admin_catalog_images.go b/internal/apiv2/admin_catalog_images.go index db9b14f15c..ce5fed277a 100644 --- a/internal/apiv2/admin_catalog_images.go +++ b/internal/apiv2/admin_catalog_images.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/Silo-Server/silo-server/internal/api/handlers" + "github.com/Silo-Server/silo-server/internal/metadata" ) type AdminCatalogImagesService interface { @@ -86,9 +87,21 @@ func registerAdminCatalogImages(reg *Registry) { } choices = append(choices, choice{string(raw), row}) } - // Keep the metadata service's rating-first presentation. The stable - // identity breaks ties without letting expiring display URLs affect pages. + // Local sidecar artwork leads in discovery order, which puts the file a + // refresh would pick first. Provider choices follow, highest rated + // first; the stable identity breaks their ties without letting expiring + // display URLs affect pages. slices.SortStableFunc(choices, func(a, b choice) int { + aLocal := a.row.ProviderID == metadata.LocalImageProviderID + bLocal := b.row.ProviderID == metadata.LocalImageProviderID + switch { + case aLocal && bLocal: + return 0 + case aLocal: + return -1 + case bLocal: + return 1 + } if order := cmp.Compare(b.row.Rating, a.row.Rating); order != 0 { return order } diff --git a/internal/apiv2/admin_catalog_images_test.go b/internal/apiv2/admin_catalog_images_test.go index ac56ef52cf..db34bc9973 100644 --- a/internal/apiv2/admin_catalog_images_test.go +++ b/internal/apiv2/admin_catalog_images_test.go @@ -3,10 +3,12 @@ package apiv2 import ( "context" "encoding/json" + "slices" "strings" "testing" "github.com/Silo-Server/silo-server/internal/api/handlers" + "github.com/Silo-Server/silo-server/internal/metadata" ) type fakeAdminImages struct { @@ -24,6 +26,29 @@ func (f *fakeAdminImages) ApplyAdminItemImage(_ context.Context, id string, r ha f.request = r return handlers.AdminItemImageResult{ContentID: id, StoredPath: "immutable-artwork", Revision: "revision", Thumbhash: "hash"}, nil } +func TestAdminImageListLeadsWithLocalArtwork(t *testing.T) { + deps := pilotDeps(nil, nil) + deps.AdminCatalogImages = &fakeAdminImages{rows: []handlers.AdminImageEntryView{ + {ProviderID: "tvdb", OriginalURL: "unrated", Type: "poster"}, + {ProviderID: "tmdb", OriginalURL: "rated", Type: "poster", Rating: 9}, + {ProviderID: metadata.LocalImageProviderID, OriginalURL: "file:///fanart.jpg", Type: "backdrop"}, + {ProviderID: metadata.LocalImageProviderID, OriginalURL: "file:///backdrop1.jpg", Type: "backdrop"}, + }} + rec := do(t, newTestHandler(t, deps), "GET", Prefix+"/admin/items/item-1/images", "", bearer(adminToken)) + var page AdminImagesPage + if err := json.Unmarshal(rec.Body.Bytes(), &page); err != nil { + t.Fatal(err) + } + var got []string + for _, row := range page.Items { + got = append(got, row.OriginalURL) + } + // Local choices keep discovery order rather than sorting by path. + if want := []string{"file:///fanart.jpg", "file:///backdrop1.jpg", "rated", "unrated"}; !slices.Equal(got, want) { + t.Fatalf("order %v, want %v", got, want) + } +} + func TestAdminImagePagesAndApply(t *testing.T) { deps := pilotDeps(nil, nil) f := &fakeAdminImages{rows: []handlers.AdminImageEntryView{{ProviderID: "p", OriginalURL: "b", URL: "display-b", Type: "poster", Rating: 9}, {ProviderID: "p", OriginalURL: "a", URL: "display-a", Type: "poster", Rating: 1}}} diff --git a/web/src/components/ImageSelectorTab.test.tsx b/web/src/components/ImageSelectorTab.test.tsx index 08d9b140e4..37b6a876c8 100644 --- a/web/src/components/ImageSelectorTab.test.tsx +++ b/web/src/components/ImageSelectorTab.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { ItemDetail } from "@/api/types"; @@ -61,6 +61,46 @@ describe("ImageSelectorTab", () => { expect(onImageApplied).toHaveBeenCalledOnce(); }); + it("filters choices by provider, with counts that follow Textless", () => { + const poster = (provider_id: string, original_url: string, language = "") => ({ + type: "poster", + provider_id, + original_url, + url: `/${original_url}`, + language, + }); + mocks.useItemImages.mockReturnValue({ + data: { + images: [ + poster("local", "file:///poster.jpg"), + poster("tmdb", "tmdb-1"), + poster("tmdb", "tmdb-2", "en"), + { ...poster("tvdb", "tvdb-1"), type: "backdrop" }, + ], + current: {}, + }, + isLoading: false, + isError: false, + }); + mocks.useApplyItemImage.mockReturnValue({ mutate: vi.fn(), isPending: false }); + + render(); + const chips = within(screen.getByRole("group", { name: "Filter by provider" })); + expect(chips.getAllByRole("button").map((chip) => chip.textContent)).toEqual([ + "All3", + "LOCAL1", + "TMDB2", + ]); + + fireEvent.click(chips.getByRole("button", { name: /^TMDB\s*2$/ })); + expect(screen.queryAllByRole("button", { name: "LOCAL" })).toHaveLength(0); + expect(screen.getAllByRole("button", { name: "TMDB" })).toHaveLength(2); + + fireEvent.click(screen.getByRole("button", { name: "Textless" })); + expect(chips.getByRole("button", { name: /^TMDB\s*1$/ })).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "TMDB" })).toHaveLength(1); + }); + it("reports an in-flight image apply to the metadata editor", () => { mocks.useItemImages.mockReturnValue({ data: { images: [], current: {} }, diff --git a/web/src/components/ImageSelectorTab.tsx b/web/src/components/ImageSelectorTab.tsx index ce10470fdd..d05f7f5b31 100644 --- a/web/src/components/ImageSelectorTab.tsx +++ b/web/src/components/ImageSelectorTab.tsx @@ -34,6 +34,8 @@ export default function ImageSelectorTab({ const availableTabs = item.type === "season" ? SEASON_TABS : IMAGE_TABS; const [activeTab, setActiveTab] = useState("poster"); const [textlessOnly, setTextlessOnly] = useState(false); + // null shows every provider's choices. + const [providerFilter, setProviderFilter] = useState(null); const [selectedImage, setSelectedImage] = useState(null); // Track which images were applied in this session (original_url per type). const [appliedImages, setAppliedImages] = useState>({}); @@ -41,7 +43,7 @@ export default function ImageSelectorTab({ const { data, isLoading, isError } = useItemImages(item.content_id, enabled); const applyMutation = useApplyItemImage(); - const images = data?.images ?? []; + const images = useMemo(() => data?.images ?? [], [data]); useEffect(() => { onApplyPendingChange?.(applyMutation.isPending); @@ -53,13 +55,28 @@ export default function ImageSelectorTab({ const tabConfig = IMAGE_TABS.find((t) => t.key === activeTab)!; - const filteredImages = useMemo(() => { - let result = images.filter((img) => img.type === activeTab); - if (textlessOnly && activeTab !== "logo") { - result = result.filter((img) => img.language === ""); - } - return result; - }, [images, activeTab, textlessOnly]); + const { providers, visibleImages, filteredImages, activeProvider } = useMemo(() => { + const ofType = images.filter((img) => img.type === activeTab); + const visible = + textlessOnly && activeTab !== "logo" ? ofType.filter((img) => img.language === "") : ofType; + // Chips follow the server's order, so local artwork's chip comes first. + const counts = new Map(ofType.map((img) => [img.provider_id, 0])); + for (const img of visible) counts.set(img.provider_id, (counts.get(img.provider_id) ?? 0) + 1); + // A filter for a provider that is no longer listed falls back to All. + const active = providerFilter !== null && counts.has(providerFilter) ? providerFilter : null; + return { + providers: [...counts].map(([id, count]) => ({ id, count })), + visibleImages: visible, + filteredImages: + active === null ? visible : visible.filter((img) => img.provider_id === active), + activeProvider: active, + }; + }, [images, activeTab, textlessOnly, providerFilter]); + + const selectProvider = (id: string | null) => { + setProviderFilter(id); + setSelectedImage(null); + }; // The "current" image for this tab. We first check session-local applied // images (which use original_url), then fall back to the server's stored @@ -128,6 +145,7 @@ export default function ImageSelectorTab({ setActiveTab(tab.key); setSelectedImage(null); setTextlessOnly(false); + setProviderFilter(null); }} className={cn( "rounded-md px-3 py-1.5 text-[12px] font-medium transition-colors", @@ -144,7 +162,11 @@ export default function ImageSelectorTab({ {activeTab !== "logo" && ( + ))} + + )} + {/* Provider errors */} {providerErrors && Object.keys(providerErrors).length > 0 && (
From aa8b53eb45c834330d36519d957757b6b64319b8 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:40:07 -0400 Subject: [PATCH 009/196] feat(sections): split home Recently Added into movies and TV rows (#2397) --- internal/catalog/recent_tv.go | 9 +- internal/sections/defaults.go | 65 ++++++++------ internal/sections/defaults_test.go | 86 +++++++++++------- internal/sections/generated.go | 48 ++++++++-- .../sections/recently_added_shared_db_test.go | 87 ++++++++++++++++--- internal/sections/repo.go | 18 ++-- 6 files changed, 217 insertions(+), 96 deletions(-) diff --git a/internal/catalog/recent_tv.go b/internal/catalog/recent_tv.go index 18771a6120..faac024d88 100644 --- a/internal/catalog/recent_tv.go +++ b/internal/catalog/recent_tv.go @@ -91,9 +91,10 @@ func recentTVFilterTypeScope(filterType string) (explicitSeries bool, tvEligible // ResolveRecentTVLibraryIDs decides whether a recently-added section is // exclusively TV-targeted and returns the effective visible TV libraries. // filterType is the section's configured filter_type: an explicit "series" -// scope may include mixed libraries, an empty scope requires every requested -// library to be a dedicated series library, and any other type is not TV -// scoped at all because event grouping cannot honor it. +// scope may include mixed libraries and TV libraries typed with an alias such +// as "tv", an empty scope requires every requested library to be a dedicated +// series library, and any other type is not TV scoped at all because event +// grouping cannot honor it. func ResolveRecentTVLibraryIDs( ctx context.Context, pool *pgxpool.Pool, @@ -156,7 +157,7 @@ func ResolveRecentTVLibraryIDs( ids := make([]int, 0, len(byID)) for id, libraryType := range byID { - if libraryType == recentTVTypeSeries || (explicitSeries && libraryType == "mixed") { + if libraryType == recentTVTypeSeries || (explicitSeries && (librarykind.IsTV(libraryType) || librarykind.IsMixed(libraryType))) { ids = append(ids, id) } } diff --git a/internal/sections/defaults.go b/internal/sections/defaults.go index a0cc85611d..b765c8b2ff 100644 --- a/internal/sections/defaults.go +++ b/internal/sections/defaults.go @@ -15,24 +15,28 @@ const ( scopeHome = "home" scopeLibrary = "library" - titleContinueWatching = "Continue Watching" - titleContinueListening = "Continue Listening" - titleContinueReading = "Continue Reading" - titleRecentlyAdded = "Recently Added" - titleRecommendedForYou = "Recommended for You" - titleReturningShows = "Returning Shows" - titleTrendingThisWeek = "Trending This Week" - titleTopRated = "Top Rated" - titleHiddenGems = "Hidden Gems" - titleRandomPicks = "Random Picks" + titleContinueWatching = "Continue Watching" + titleContinueListening = "Continue Listening" + titleContinueReading = "Continue Reading" + titleRecentlyAdded = "Recently Added" + titleRecentlyAddedMovies = "Recently Added Movies" + titleRecentlyAddedTV = "Recently Added TV" + titleRecommendedForYou = "Recommended for You" + titleReturningShows = "Returning Shows" + titleTrendingThisWeek = "Trending This Week" + titleTopRated = "Top Rated" + titleHiddenGems = "Hidden Gems" + titleRandomPicks = "Random Picks" - idContinueWatching = "default-continue-watching" - idContinueListening = "default-continue-listening" - idContinueReading = "default-continue-reading" - idRecentlyAdded = "default-recently-added" - idRecommendedForYou = "default-recommended-for-you" - idReturningShows = "default-returning-shows" - idTrendingDiscover = "default-trending-discover" + idContinueWatching = "default-continue-watching" + idContinueListening = "default-continue-listening" + idContinueReading = "default-continue-reading" + idRecentlyAdded = "default-recently-added" + idRecentlyAddedMovies = "default-recently-added-movies" + idRecentlyAddedTV = "default-recently-added-tv" + idRecommendedForYou = "default-recommended-for-you" + idReturningShows = "default-returning-shows" + idTrendingDiscover = "default-trending-discover" ) // DefaultItemLimit is the row size of default rows and of rows an admin @@ -56,11 +60,13 @@ func libraryRowItemLimit(libraryType string) int { // DefaultHomeSections returns the canonical default sections for the home scope. // These are used for seeding fresh installs and the admin "Restore Defaults" action. // -// Movies and TV share one Recently Added row; only book-type libraries add rows -// of their own. Personalized rows (Recommended, Because You Watched, Returning -// Shows) and TMDB trending stay empty, and clients hide them, until a profile -// has history or the library has a match. Recently Added and Top Rated need -// neither, so a new profile on a new server still has rows to browse. +// Movies and TV get separate Recently Added rows, as on the library pages; +// only book-type libraries add rows of their own. Trending This Week sits +// directly below the continue rows. Personalized rows (Recommended, Because +// You Watched, Returning Shows) and TMDB trending stay empty, and clients hide +// them, until a profile has history or the library has a match. Recently +// Added and Top Rated need neither, so a new profile on a new server still +// has rows to browse. func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { result := []*PageSection{ {ID: idContinueWatching, Scope: scopeHome, SectionType: SectionContinueWatching, Title: titleContinueWatching, ItemLimit: DefaultItemLimit, Config: ContinueTypeConfig(ContinueTypeWatching), Enabled: true}, @@ -68,11 +74,15 @@ func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { if hasAudiobookLibrary(libraries) { result = append(result, &PageSection{ID: idContinueListening, Scope: scopeHome, SectionType: SectionContinueWatching, Title: titleContinueListening, ItemLimit: betaLibraryItemLimit, Config: ContinueTypeConfig(ContinueTypeListening), Enabled: true}) } - result = append(result, &PageSection{ID: idRecentlyAdded, Scope: scopeHome, SectionType: SectionRecentlyAdded, Title: titleRecentlyAdded, ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig(catalog.MediaScopeVideo), Enabled: true}) + result = append(result, + &PageSection{ID: idTrendingDiscover, Scope: scopeHome, SectionType: SectionTrendingDiscover, Title: titleTrendingThisWeek, ItemLimit: DefaultItemLimit, Config: json.RawMessage(`{"source":"tmdb","window":"week"}`), Enabled: true}, + &PageSection{ID: idRecentlyAddedMovies, Scope: scopeHome, SectionType: SectionRecentlyAdded, Title: titleRecentlyAddedMovies, ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig("movie"), Enabled: true}, + &PageSection{ID: idRecentlyAddedTV, Scope: scopeHome, SectionType: SectionRecentlyAdded, Title: titleRecentlyAddedTV, ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig("series"), Enabled: true}, + ) for _, library := range libraries { if library == nil || IsVideoLibraryType(library.Type) { - continue // video libraries share the Recently Added row + continue // video libraries are covered by the split Recently Added rows } for _, section := range generatedHomeLibraryRecentDefaults(library.ID, library.Name, library.Type) { section.ID = generatedHomeLibraryRecentID(section, library.ID) @@ -84,7 +94,6 @@ func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { &PageSection{ID: idRecommendedForYou, Scope: scopeHome, SectionType: SectionRecommendedForYou, Title: titleRecommendedForYou, ItemLimit: DefaultItemLimit, Config: json.RawMessage(`{}`), Enabled: true}, &PageSection{ID: "default-because-you-watched", Scope: scopeHome, SectionType: SectionBecauseYouWatched, Title: defaultBecauseWatchedTitle, ItemLimit: DefaultItemLimit, Config: json.RawMessage(`{}`), Enabled: true}, &PageSection{ID: idReturningShows, Scope: scopeHome, SectionType: SectionReturningShows, Title: titleReturningShows, ItemLimit: DefaultItemLimit, Config: json.RawMessage(`{}`), Enabled: true}, - &PageSection{ID: idTrendingDiscover, Scope: scopeHome, SectionType: SectionTrendingDiscover, Title: titleTrendingThisWeek, ItemLimit: DefaultItemLimit, Config: json.RawMessage(`{"source":"tmdb","window":"week"}`), Enabled: true}, &PageSection{ID: "default-top-rated", Scope: scopeHome, SectionType: SectionCriticallyAcclaimed, Title: titleTopRated, ItemLimit: DefaultItemLimit, Config: json.RawMessage(`{}`), Enabled: true}, &PageSection{ID: "default-hidden-gems", Scope: scopeHome, SectionType: SectionHiddenGems, Title: titleHiddenGems, ItemLimit: DefaultItemLimit, Config: json.RawMessage(`{"min_rating":7.5}`), Enabled: true}, &PageSection{ @@ -107,7 +116,7 @@ func DefaultHomeSections(libraries []*models.MediaFolder) []*PageSection { } // IsVideoLibraryType reports whether a library holds movies or TV, the -// libraries the shared home Recently Added row covers. +// libraries the split home Recently Added rows cover. func IsVideoLibraryType(libraryType string) bool { return librarykind.IsMovie(libraryType) || librarykind.IsTV(libraryType) || librarykind.IsMixed(libraryType) } @@ -223,7 +232,7 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe case "movies", "movie": return []*PageSection{ {ID: idContinueWatching, Scope: scopeLibrary, LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: titleContinueWatching, ItemLimit: DefaultItemLimit, Config: continueWatchingCfg, Enabled: true}, - {ID: "default-recently-added-movies", Scope: scopeLibrary, LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added Movies", ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig("movie"), Enabled: true}, + {ID: idRecentlyAddedMovies, Scope: scopeLibrary, LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: titleRecentlyAddedMovies, ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig("movie"), Enabled: true}, {ID: "default-recently-released-movies", Scope: scopeLibrary, LibraryID: libraryID, Position: 2, SectionType: SectionRecentlyReleased, Title: "Recently Released Movies", ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig("movie"), Enabled: true}, {ID: "default-top-rated-movies", Scope: scopeLibrary, LibraryID: libraryID, Position: 3, SectionType: SectionCustomFilter, Title: "Top Rated Movies", ItemLimit: DefaultItemLimit, Config: defaultTopRatedConfig("movie"), Enabled: true}, {ID: idRecommendedForYou, Scope: scopeLibrary, LibraryID: libraryID, Position: 4, SectionType: SectionRecommendedForYou, Title: titleRecommendedForYou, ItemLimit: DefaultItemLimit, Config: emptyCfg, Enabled: true}, @@ -232,7 +241,7 @@ func DefaultLibrarySectionsForType(libraryID *int, libraryType string) []*PageSe case "series": return []*PageSection{ {ID: idContinueWatching, Scope: scopeLibrary, LibraryID: libraryID, Position: 0, SectionType: SectionContinueWatching, Title: titleContinueWatching, ItemLimit: DefaultItemLimit, Config: continueWatchingCfg, Enabled: true}, - {ID: "default-recently-added-tv", Scope: scopeLibrary, LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: "Recently Added TV", ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig("series"), Enabled: true}, + {ID: idRecentlyAddedTV, Scope: scopeLibrary, LibraryID: libraryID, Position: 1, SectionType: SectionRecentlyAdded, Title: titleRecentlyAddedTV, ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig("series"), Enabled: true}, {ID: idReturningShows, Scope: scopeLibrary, LibraryID: libraryID, Position: 2, SectionType: SectionReturningShows, Title: titleReturningShows, ItemLimit: DefaultItemLimit, Config: emptyCfg, Enabled: true}, {ID: "default-recently-released-episodes", Scope: scopeLibrary, LibraryID: libraryID, Position: 3, SectionType: SectionCustomFilter, Title: "Recently Released Episodes", ItemLimit: DefaultItemLimit, Config: defaultRecentEpisodesConfig(), Enabled: true}, {ID: "default-top-rated-tv", Scope: scopeLibrary, LibraryID: libraryID, Position: 4, SectionType: SectionCustomFilter, Title: "Top Rated TV", ItemLimit: DefaultItemLimit, Config: defaultTopRatedConfig("series"), Enabled: true}, diff --git a/internal/sections/defaults_test.go b/internal/sections/defaults_test.go index 6824b03a18..205f9b624b 100644 --- a/internal/sections/defaults_test.go +++ b/internal/sections/defaults_test.go @@ -14,12 +14,19 @@ var sharedHomeDefaultTail = []string{ "default-recommended-for-you", "default-because-you-watched", "default-returning-shows", - "default-trending-discover", "default-top-rated", "default-hidden-gems", "default-seasonal", } +// splitVideoHomeRows are the default home rows below Continue Watching: +// Trending This Week, then the split Recently Added rows. +var splitVideoHomeRows = []string{ + "default-trending-discover", + "default-recently-added-movies", + "default-recently-added-tv", +} + func assertHomeSectionIDs(t *testing.T, got []*PageSection, want []string) { t.Helper() ids := make([]string, len(got)) @@ -39,7 +46,7 @@ func assertHomeSectionIDs(t *testing.T, got []*PageSection, want []string) { func TestDefaultHomeSectionsWithoutLibraries(t *testing.T) { got := DefaultHomeSections(nil) - assertHomeSectionIDs(t, got, append([]string{"default-continue-watching", "default-recently-added"}, sharedHomeDefaultTail...)) + assertHomeSectionIDs(t, got, append(append([]string{"default-continue-watching"}, splitVideoHomeRows...), sharedHomeDefaultTail...)) assertContinueType(t, got[0].Config, ContinueTypeWatching) for _, section := range got { if section.ItemLimit != DefaultItemLimit { @@ -48,7 +55,7 @@ func TestDefaultHomeSectionsWithoutLibraries(t *testing.T) { } } -func TestDefaultHomeSectionsShareRecentlyAddedAcrossVideoLibraries(t *testing.T) { +func TestDefaultHomeSectionsSplitRecentlyAddedAcrossVideoLibraries(t *testing.T) { libraries := []*models.MediaFolder{ {ID: 7, Name: "Movies", Type: "movies", SortOrder: 1}, {ID: 9, Name: "Shows", Type: "series", SortOrder: 2}, @@ -56,23 +63,35 @@ func TestDefaultHomeSectionsShareRecentlyAddedAcrossVideoLibraries(t *testing.T) } got := DefaultHomeSections(libraries) - assertHomeSectionIDs(t, got, append([]string{"default-continue-watching", "default-recently-added"}, sharedHomeDefaultTail...)) + assertHomeSectionIDs(t, got, append(append([]string{"default-continue-watching"}, splitVideoHomeRows...), sharedHomeDefaultTail...)) - recent := got[1] - if recent.SectionType != SectionRecentlyAdded || recent.Title != "Recently Added" { - t.Fatalf("shared row = %s %q, want recently_added \"Recently Added\"", recent.SectionType, recent.Title) + recentMovies, recentTV := got[2], got[3] + if recentMovies.SectionType != SectionRecentlyAdded || recentMovies.Title != "Recently Added Movies" { + t.Fatalf("movies row = %s %q, want recently_added \"Recently Added Movies\"", recentMovies.SectionType, recentMovies.Title) + } + if recentTV.SectionType != SectionRecentlyAdded || recentTV.Title != "Recently Added TV" { + t.Fatalf("TV row = %s %q, want recently_added \"Recently Added TV\"", recentTV.SectionType, recentTV.Title) + } + if !homeRecentlyAddedCoversItemType(recentMovies, "movie") || homeRecentlyAddedCoversItemType(recentMovies, "series") { + t.Fatal("movies row does not cover movies only") } - if !IsSharedHomeRecentlyAddedSection(recent) { - t.Fatal("shared Recently Added row is not recognized as shared") + if !homeRecentlyAddedCoversItemType(recentTV, "series") || homeRecentlyAddedCoversItemType(recentTV, "movie") { + t.Fatal("TV row does not cover series only") } - assertQueryDefinition(t, recent.Config, catalog.QueryDefinition{ - MediaScope: catalog.MediaScopeVideo, + assertQueryDefinition(t, recentMovies.Config, catalog.QueryDefinition{ + MediaScope: "movie", + Match: "all", + Groups: []catalog.QueryGroup{}, + Sort: catalog.QuerySort{Field: "added_at", Order: "desc"}, + }) + assertQueryDefinition(t, recentTV.Config, catalog.QueryDefinition{ + MediaScope: "series", Match: "all", Groups: []catalog.QueryGroup{}, Sort: catalog.QuerySort{Field: "added_at", Order: "desc"}, }) - trending := got[slices.IndexFunc(got, func(s *PageSection) bool { return s.ID == "default-trending-discover" })] + trending := got[1] if trending.SectionType != SectionTrendingDiscover { t.Fatalf("trending type = %s, want %s", trending.SectionType, SectionTrendingDiscover) } @@ -88,20 +107,20 @@ func TestDefaultHomeSectionsKeepBookLibraryRows(t *testing.T) { } got := DefaultHomeSections(libraries) - assertHomeSectionIDs(t, got, append([]string{ + assertHomeSectionIDs(t, got, append(append([]string{ "default-continue-watching", "default-continue-listening", - "default-recently-added", + }, splitVideoHomeRows...), append([]string{ "default-home-recently_added-library-10", "default-home-recently_released-library-10", - }, sharedHomeDefaultTail...)) + }, sharedHomeDefaultTail...)...)) assertContinueType(t, got[0].Config, ContinueTypeWatching) assertContinueType(t, got[1].Config, ContinueTypeListening) - if got[3].Title != "Recently Added in Books" { - t.Fatalf("book row title = %q, want %q", got[3].Title, "Recently Added in Books") + if got[5].Title != "Recently Added in Books" { + t.Fatalf("book row title = %q, want %q", got[5].Title, "Recently Added in Books") } - if libraryID, ok := ParseGeneratedHomeLibraryRecentConfig(got[3].Config); !ok || libraryID != 10 { + if libraryID, ok := ParseGeneratedHomeLibraryRecentConfig(got[5].Config); !ok || libraryID != 10 { t.Fatalf("book row generated library = %d ok = %v, want 10 true", libraryID, ok) } } @@ -149,25 +168,30 @@ func TestDefaultRowLimitsKeepBetaLibrariesAtOldSize(t *testing.T) { } } -func TestIsSharedHomeRecentlyAddedSection(t *testing.T) { +func TestHomeRecentlyAddedCoversItemType(t *testing.T) { seven := 7 tests := []struct { - name string - section *PageSection - want bool + name string + section *PageSection + itemType string + want bool }{ - {"video scope", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("video")}, true}, - {"unscoped", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: json.RawMessage(`{}`)}, true}, - {"movie scope", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("movie")}, false}, - {"one library", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: json.RawMessage(`{"filter_library_ids":[7]}`)}, false}, - {"generated", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: GeneratedHomeLibraryRecentConfig(7)}, false}, - {"library page", &PageSection{Scope: "library", LibraryID: &seven, SectionType: SectionRecentlyAdded, Config: json.RawMessage(`{}`)}, false}, - {"other type", &PageSection{Scope: "home", SectionType: SectionRecentlyReleased, Config: json.RawMessage(`{}`)}, false}, + {"video scope covers movie", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("video")}, "movie", true}, + {"video scope covers series", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("video")}, "series", true}, + {"unscoped covers movie", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: json.RawMessage(`{}`)}, "movie", true}, + {"movie scope covers movie", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("movie")}, "movie", true}, + {"movie scope misses series", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("movie")}, "series", false}, + {"series scope covers series", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("series")}, "series", true}, + {"series scope misses movie", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("series")}, "movie", false}, + {"one library", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: json.RawMessage(`{"filter_library_ids":[7]}`)}, "movie", false}, + {"generated", &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: GeneratedHomeLibraryRecentConfig(7)}, "movie", false}, + {"library page", &PageSection{Scope: "library", LibraryID: &seven, SectionType: SectionRecentlyAdded, Config: json.RawMessage(`{}`)}, "movie", false}, + {"other type", &PageSection{Scope: "home", SectionType: SectionRecentlyReleased, Config: json.RawMessage(`{}`)}, "movie", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := IsSharedHomeRecentlyAddedSection(tt.section); got != tt.want { - t.Fatalf("IsSharedHomeRecentlyAddedSection = %v, want %v", got, tt.want) + if got := homeRecentlyAddedCoversItemType(tt.section, tt.itemType); got != tt.want { + t.Fatalf("homeRecentlyAddedCoversItemType(%q) = %v, want %v", tt.itemType, got, tt.want) } }) } diff --git a/internal/sections/generated.go b/internal/sections/generated.go index da822bccf1..b7f05be706 100644 --- a/internal/sections/generated.go +++ b/internal/sections/generated.go @@ -3,9 +3,11 @@ package sections import ( "encoding/json" "fmt" + "slices" "strings" "github.com/Silo-Server/silo-server/internal/catalog" + "github.com/Silo-Server/silo-server/internal/librarykind" ) const GeneratedHomeLibraryRecentSource = "home_library_recent" @@ -189,10 +191,12 @@ func GeneratedHomeLibraryRecentSyncedTitle(s *PageSection, libraryName string) s return generatedHomeLibraryRecentTitle(kind, s.SectionType, libraryName) } -// IsSharedHomeRecentlyAddedSection reports whether s is a home Recently Added -// row that covers every video library: no library filter, no single-type -// filter, and a media scope of video or none. -func IsSharedHomeRecentlyAddedSection(s *PageSection) bool { +// homeRecentlyAddedCoversItemType reports whether s is a home Recently +// Added row that shows itemType ("movie" or "series") from every library: +// no library filter and no narrower type filter. A combined row from older +// defaults (media scope video or none) covers both types; the split +// default rows cover one each. +func homeRecentlyAddedCoversItemType(s *PageSection, itemType string) bool { if s == nil || s.Scope != "home" || s.LibraryID != nil || s.SectionType != SectionRecentlyAdded { return false } @@ -200,9 +204,41 @@ func IsSharedHomeRecentlyAddedSection(s *PageSection) bool { return false } filters := recentlyAddedConfigFilters(s.Config) - if filters.FilterType != "" || len(filters.LibraryIDs()) > 0 { + if len(filters.LibraryIDs()) > 0 { return false } + if filters.FilterType != "" { + return filters.FilterType == itemType + } def, err := ParseQueryDefinition(s.Config) - return err != nil || def.MediaScope == "" || def.MediaScope == catalog.MediaScopeVideo + if err != nil { + return true + } + return catalog.MediaScopeMatchesItemType(def.MediaScope, itemType) +} + +// hasEnabledHomeRecentlyAddedCovering reports whether home has enabled +// Recently Added rows covering every item type a library of the given type +// holds, so the library needs no per-library rows of its own. A mixed +// library needs both types covered. +func hasEnabledHomeRecentlyAddedCovering(home []*PageSection, libraryType string) bool { + kinds := librarykind.Of(libraryType) + var needed []string + if kinds.Movie || kinds.Mixed { + needed = append(needed, "movie") + } + if kinds.TV || kinds.Mixed { + needed = append(needed, "series") + } + if len(needed) == 0 { + return false + } + for _, itemType := range needed { + if !slices.ContainsFunc(home, func(s *PageSection) bool { + return s.Enabled && homeRecentlyAddedCoversItemType(s, itemType) + }) { + return false + } + } + return true } diff --git a/internal/sections/recently_added_shared_db_test.go b/internal/sections/recently_added_shared_db_test.go index aa844a33e2..0e1e12e193 100644 --- a/internal/sections/recently_added_shared_db_test.go +++ b/internal/sections/recently_added_shared_db_test.go @@ -137,8 +137,8 @@ func TestGeneratedHomeRowsSkipVideoLibrariesWithSharedRowDB(t *testing.T) { if err != nil { t.Fatal(err) } - if !hasEnabledSharedHomeRecentlyAdded(home) { - // Older homes have no shared row, and a disabled shared row shows + if !hasEnabledHomeRecentlyAddedCovering(home, "movies") { + // Older homes have no covering row, and a disabled covering row shows // nothing; both keep per-library rows. disabled, err := f.repo.Create(ctx, &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Title: f.app + "-disabled", ItemLimit: DefaultItemLimit, Config: defaultMediaScopeConfig(catalog.MediaScopeVideo), Enabled: false}) if err != nil { @@ -184,24 +184,33 @@ func TestGeneratedHomeRowsSkipVideoLibrariesWithSharedRowDB(t *testing.T) { } } -func TestHasEnabledSharedHomeRecentlyAdded(t *testing.T) { - shared := func(enabled bool) *PageSection { - return &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig(catalog.MediaScopeVideo), Enabled: enabled} +func TestHasEnabledHomeRecentlyAddedCovering(t *testing.T) { + row := func(scope string, enabled bool) *PageSection { + return &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig(scope), Enabled: enabled} } - movieOnly := &PageSection{Scope: "home", SectionType: SectionRecentlyAdded, Config: defaultMediaScopeConfig("movie"), Enabled: true} + shared := func(enabled bool) *PageSection { return row(catalog.MediaScopeVideo, enabled) } + movieOnly := row("movie", true) + seriesOnly := row("series", true) tests := []struct { - name string - home []*PageSection - want bool + name string + home []*PageSection + libraryType string + want bool }{ - {"enabled shared row", []*PageSection{movieOnly, shared(true)}, true}, - {"disabled shared row", []*PageSection{movieOnly, shared(false)}, false}, - {"no shared row", []*PageSection{movieOnly}, false}, + {"enabled shared row covers movies", []*PageSection{movieOnly, shared(true)}, "movies", true}, + {"enabled shared row covers mixed", []*PageSection{shared(true)}, "mixed", true}, + {"disabled shared row covers nothing", []*PageSection{shared(false)}, "series", false}, + {"movie row covers movies", []*PageSection{movieOnly}, "movies", true}, + {"movie row misses series", []*PageSection{movieOnly}, "series", false}, + {"split rows cover mixed", []*PageSection{movieOnly, seriesOnly}, "mixed", true}, + {"movie row alone misses mixed", []*PageSection{movieOnly}, "mixed", false}, + {"disabled split row misses mixed", []*PageSection{movieOnly, row("series", false)}, "mixed", false}, + {"no rows", nil, "movies", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := hasEnabledSharedHomeRecentlyAdded(tt.home); got != tt.want { - t.Fatalf("hasEnabledSharedHomeRecentlyAdded = %v, want %v", got, tt.want) + if got := hasEnabledHomeRecentlyAddedCovering(tt.home, tt.libraryType); got != tt.want { + t.Fatalf("hasEnabledHomeRecentlyAddedCovering(%q) = %v, want %v", tt.libraryType, got, tt.want) } }) } @@ -276,3 +285,53 @@ func TestScopeTVLibraries(t *testing.T) { }) } } + +// The default home Recently Added TV row is scoped to series. A TV library +// typed with an alias such as "tv" counts as covered by that row, so the row +// has to show it. +func TestSeriesRecentlyAddedIncludesTVAliasLibrariesDB(t *testing.T) { + pool := openRecentlyAddedTestPool(t) + ctx := t.Context() + suffix := time.Now().UnixNano() + exec := func(sql string, args ...any) { + t.Helper() + if _, err := pool.Exec(ctx, sql, args...); err != nil { + t.Fatalf("%s: %v", sql, err) + } + } + var showsID int + if err := pool.QueryRow(ctx, `INSERT INTO media_folders (type, name, enabled) VALUES ('tv', $1, true) RETURNING id`, fmt.Sprintf("alias-recent-tv-%d", suffix)).Scan(&showsID); err != nil { + t.Fatalf("seed tv folder: %v", err) + } + base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + seriesID := fmt.Sprintf("alias-recent-series-%d", suffix) + episodeID := fmt.Sprintf("alias-recent-episode-%d", suffix) + runID := fmt.Sprintf("alias-recent-run-%d", suffix) + t.Cleanup(func() { + bg := context.Background() + _, _ = pool.Exec(bg, `DELETE FROM media_items WHERE content_id = $1`, seriesID) + _, _ = pool.Exec(bg, `DELETE FROM media_folders WHERE id = $1`, showsID) + }) + exec(`INSERT INTO media_items (content_id, type, title, status, genres, created_at) VALUES ($1, 'series', 'Alias Show', 'matched', '{}'::text[], $2)`, seriesID, base) + exec(`INSERT INTO media_item_libraries (content_id, media_folder_id, first_seen_at) VALUES ($1, $2, $3)`, seriesID, showsID, base) + exec(`INSERT INTO scan_runs (id, media_folder_id, mode, status) VALUES ($1, $2, 'library', 'completed')`, runID, showsID) + exec(`INSERT INTO episodes (content_id, series_id, season_number, episode_number, title, overview, runtime, rating_imdb) VALUES ($1, $2, 1, 1, 'Pilot', '', 45, 0)`, episodeID, seriesID) + exec(`INSERT INTO episode_libraries (episode_id, media_folder_id, first_seen_at, first_seen_scan_run_id) VALUES ($1, $2, $3, $4)`, episodeID, showsID, base.Add(time.Minute), runID) + exec(`INSERT INTO media_files (episode_id, media_folder_id, file_path) VALUES ($1, $2, $3)`, episodeID, showsID, "/alias-recent/"+episodeID+".mkv") + + items, _, err := NewFetcher(pool).fetchRecentlyAdded(ctx, ResolvedSection{ + SectionType: SectionRecentlyAdded, + ItemLimit: 20, + Config: defaultMediaScopeConfig("series"), + }, nil, []int{showsID}, catalog.AccessFilter{}) + if err != nil { + t.Fatalf("fetch series row: %v", err) + } + got := make([]string, len(items)) + for i, item := range items { + got[i] = item.Type + ":" + item.ContentID + } + if want := []string{"episode:" + episodeID}; !slices.Equal(got, want) { + t.Fatalf("series row over a tv-typed library = %v, want %v", got, want) + } +} diff --git a/internal/sections/repo.go b/internal/sections/repo.go index eb0ed8a008..ad328e013d 100644 --- a/internal/sections/repo.go +++ b/internal/sections/repo.go @@ -5,7 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "slices" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" @@ -496,24 +495,17 @@ func (r *Repository) nextHomePosition(ctx context.Context) (int, error) { return maxPosition + 1, nil } -// hasEnabledSharedHomeRecentlyAdded reports whether home has an enabled -// Recently Added row that covers every video library. -func hasEnabledSharedHomeRecentlyAdded(home []*PageSection) bool { - return slices.ContainsFunc(home, func(s *PageSection) bool { - return s.Enabled && IsSharedHomeRecentlyAddedSection(s) - }) -} - func (r *Repository) createGeneratedHomeLibraryRecentSections(ctx context.Context, libraryID int, libraryName, libraryType string) ([]*PageSection, error) { if IsVideoLibraryType(libraryType) { home, err := r.ListByScopeAll(ctx, "home", nil) if err != nil { return nil, fmt.Errorf("listing home sections: %w", err) } - // An enabled shared Recently Added row already shows the new library. - // Homes from older defaults, or whose admin disabled the shared row, - // still get per-library rows. - if hasEnabledSharedHomeRecentlyAdded(home) { + // Enabled home Recently Added rows covering this library's types + // (the split default rows, or a combined row from older defaults) + // already show the new library. Homes without them, or whose admin + // disabled them, still get per-library rows. + if hasEnabledHomeRecentlyAddedCovering(home, libraryType) { return nil, nil } } From 3bbd6d3312a3089e6f04fc1c9133c8182c07b7d0 Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 13 Oct 2026 16:16:01 +0200 Subject: [PATCH 010/196] fix(plugins): time out stalled catalog and package fetches (#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 --- internal/plugins/catalog_service.go | 2 +- internal/plugins/installer.go | 2 +- internal/plugins/package_error.go | 19 ++- internal/plugins/package_fetch.go | 88 ++++++++++ internal/plugins/package_fetch_test.go | 223 +++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 5 deletions(-) create mode 100644 internal/plugins/package_fetch.go create mode 100644 internal/plugins/package_fetch_test.go diff --git a/internal/plugins/catalog_service.go b/internal/plugins/catalog_service.go index 648d3a5847..c6b6b9c38c 100644 --- a/internal/plugins/catalog_service.go +++ b/internal/plugins/catalog_service.go @@ -103,7 +103,7 @@ type CatalogService struct { func NewCatalogService(repositories *RepositoryStore, opts CatalogServiceOptions) *CatalogService { httpClient := opts.HTTPClient if httpClient == nil { - httpClient = http.DefaultClient + httpClient = newPackageFetchClient(defaultPackageFetchTimeouts) } apiVersion := opts.SiloAPIVersion diff --git a/internal/plugins/installer.go b/internal/plugins/installer.go index 955dd8c481..87731dd24e 100644 --- a/internal/plugins/installer.go +++ b/internal/plugins/installer.go @@ -72,7 +72,7 @@ type Installer struct { func NewInstaller(installations installationStore, opts InstallerOptions) *Installer { httpClient := opts.HTTPClient if httpClient == nil { - httpClient = http.DefaultClient + httpClient = newPackageFetchClient(defaultPackageFetchTimeouts) } baseDir := opts.BaseDir if baseDir == "" { diff --git a/internal/plugins/package_error.go b/internal/plugins/package_error.go index f8ce425057..d457b3ceae 100644 --- a/internal/plugins/package_error.go +++ b/internal/plugins/package_error.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "time" ) // PackageError is an install or update refused because of the plugin package @@ -52,9 +53,16 @@ func AsPackageError(err error) (*PackageError, bool) { // download. subject names it for the admin, such as "the plugin". An // unreachable host, 429, 5xx or a cut-off body is a transient PackageError; // an address that isn't a valid http or https link, or any other status, is -// a permanent one. A canceled ctx returns its own error, not a PackageError. +// a permanent one. A body that sends nothing for packageFetchReadIdleTimeout +// counts as cut off. A canceled ctx returns its own error, not a PackageError. func fetchPackageResource(ctx context.Context, client *http.Client, rawURL, subject string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + return fetchPackageResourceWithIdle(ctx, client, rawURL, subject, packageFetchReadIdleTimeout) +} + +func fetchPackageResourceWithIdle(ctx context.Context, client *http.Client, rawURL, subject string, readIdle time.Duration) ([]byte, error) { + fetchCtx, cancel := context.WithCancelCause(ctx) + defer cancel(nil) + req, err := http.NewRequestWithContext(fetchCtx, http.MethodGet, rawURL, nil) if err != nil { return nil, invalidPackageAddressError(fmt.Errorf("build request for %q: %w", rawURL, err), subject) } @@ -79,11 +87,16 @@ func fetchPackageResource(ctx context.Context, client *http.Client, rawURL, subj } return nil, packageError(statusErr, "Silo couldn't download %s: its host answered HTTP %d.", subject, resp.StatusCode) } - data, err := io.ReadAll(resp.Body) + body := newIdleTimeoutReader(resp.Body, readIdle, cancel) + defer body.stop() + data, err := io.ReadAll(body) if err != nil { if ctx.Err() != nil { return nil, fmt.Errorf("read %q: %w", rawURL, ctx.Err()) } + if cause := context.Cause(fetchCtx); errors.Is(cause, errPackageReadStalled) { + err = cause + } return nil, transientPackageError(fmt.Errorf("read %q: %w", rawURL, err), "The download of %s was interrupted. Try again.", subject) } return data, nil diff --git a/internal/plugins/package_fetch.go b/internal/plugins/package_fetch.go new file mode 100644 index 0000000000..f8de37d4b1 --- /dev/null +++ b/internal/plugins/package_fetch.go @@ -0,0 +1,88 @@ +package plugins + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "time" +) + +// Plugin catalogs, checksum files and packages are fetched without a +// whole-request deadline, because a package can be up to 256 MiB and a slow +// but healthy download must still finish. Each phase that can hang is bounded +// instead: connecting, the TLS handshake, waiting for response headers, and +// any single wait for more of the body. +const ( + packageFetchDialTimeout = 10 * time.Second + packageFetchTLSHandshakeTimeout = 10 * time.Second + packageFetchResponseHeaderTimeout = 30 * time.Second + packageFetchReadIdleTimeout = 60 * time.Second + // packageFetchMaxRead caps one Read of the body. An HTTP/1.1 chunked body + // keeps reading from the connection until the caller's buffer is full, and + // io.ReadAll's buffer grows to megabytes, so without a cap one Read could + // wait on megabytes of a healthy download. With it, a body counts as + // stalled only when it sends less than this within the idle timeout. + packageFetchMaxRead = 32 << 10 +) + +// errPackageReadStalled is the cause recorded when one capped Read of a body +// waits longer than the read-idle timeout. +var errPackageReadStalled = errors.New("download stalled: too little data received within the idle timeout") + +// packageFetchTimeouts bounds the transport phases of a package fetch. +type packageFetchTimeouts struct { + dial time.Duration + tlsHandshake time.Duration + responseHeader time.Duration +} + +var defaultPackageFetchTimeouts = packageFetchTimeouts{ + dial: packageFetchDialTimeout, + tlsHandshake: packageFetchTLSHandshakeTimeout, + responseHeader: packageFetchResponseHeaderTimeout, +} + +// newPackageFetchClient returns the client used when the catalog service or +// installer is given none. It keeps the default transport's proxy and +// connection-pool settings and redirect handling. +func newPackageFetchClient(timeouts packageFetchTimeouts) *http.Client { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + base = &http.Transport{Proxy: http.ProxyFromEnvironment} + } + transport := base.Clone() + transport.DialContext = (&net.Dialer{Timeout: timeouts.dial, KeepAlive: 30 * time.Second}).DialContext + transport.TLSHandshakeTimeout = timeouts.tlsHandshake + transport.ResponseHeaderTimeout = timeouts.responseHeader + return &http.Client{Transport: transport} +} + +// idleTimeoutReader cancels the fetch when one Read waits longer than idle. +// Only time blocked in Read counts, and each Read asks for at most +// packageFetchMaxRead bytes, so a body is cut off only when it sends less than +// that within idle (about 546 B/s at the default 60s), however steadily. +type idleTimeoutReader struct { + r io.Reader + idle time.Duration + timer *time.Timer +} + +func newIdleTimeoutReader(r io.Reader, idle time.Duration, cancel context.CancelCauseFunc) *idleTimeoutReader { + timer := time.AfterFunc(idle, func() { cancel(errPackageReadStalled) }) + timer.Stop() + return &idleTimeoutReader{r: r, idle: idle, timer: timer} +} + +func (r *idleTimeoutReader) Read(p []byte) (int, error) { + if len(p) > packageFetchMaxRead { + p = p[:packageFetchMaxRead] + } + r.timer.Reset(r.idle) + n, err := r.r.Read(p) + r.timer.Stop() + return n, err +} + +func (r *idleTimeoutReader) stop() { r.timer.Stop() } diff --git a/internal/plugins/package_fetch_test.go b/internal/plugins/package_fetch_test.go new file mode 100644 index 0000000000..36dc33a6a5 --- /dev/null +++ b/internal/plugins/package_fetch_test.go @@ -0,0 +1,223 @@ +package plugins + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// fetchDeadline is how long a test waits for a fetch that should give up on +// its own. It only guards against a hang; the fetch fails long before it. +const fetchDeadline = 10 * time.Second + +var shortPackageFetchTimeouts = packageFetchTimeouts{ + dial: time.Second, + tlsHandshake: time.Second, + responseHeader: 100 * time.Millisecond, +} + +// stallingServer runs handler, which may block on release until the test ends. +func stallingServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, release <-chan struct{})) *httptest.Server { + t.Helper() + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler(w, r, release) + })) + t.Cleanup(server.Close) + t.Cleanup(func() { close(release) }) + return server +} + +func fetchWithDeadline(t *testing.T, fetch func() ([]byte, error)) ([]byte, error) { + t.Helper() + type result struct { + data []byte + err error + } + done := make(chan result, 1) + go func() { + data, err := fetch() + done <- result{data, err} + }() + select { + case r := <-done: + return r.data, r.err + case <-time.After(fetchDeadline): + t.Fatalf("fetch did not finish within %s", fetchDeadline) + return nil, nil + } +} + +func TestPackageFetchDefaultClientHasTimeouts(t *testing.T) { + for name, client := range map[string]*http.Client{ + "catalog": NewCatalogService(nil, CatalogServiceOptions{}).httpClient, + "installer": NewInstaller(nil, InstallerOptions{}).httpClient, + } { + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatalf("%s client transport = %T, want *http.Transport", name, client.Transport) + } + if transport.ResponseHeaderTimeout != packageFetchResponseHeaderTimeout || + transport.TLSHandshakeTimeout != packageFetchTLSHandshakeTimeout { + t.Fatalf("%s transport timeouts = header %s, tls %s", name, transport.ResponseHeaderTimeout, transport.TLSHandshakeTimeout) + } + if client.Timeout != 0 { + t.Fatalf("%s client has a whole-request timeout %s; slow downloads would be cut off", name, client.Timeout) + } + } +} + +// A host that accepts the connection and never answers is reported as +// unreachable instead of holding the fetch open. +func TestFetchPackageResourceTimesOutWaitingForHeaders(t *testing.T) { + server := stallingServer(t, func(_ http.ResponseWriter, r *http.Request, release <-chan struct{}) { + select { + case <-release: + case <-r.Context().Done(): + } + }) + client := newPackageFetchClient(shortPackageFetchTimeouts) + _, err := fetchWithDeadline(t, func() ([]byte, error) { + return fetchPackageResource(context.Background(), client, server.URL, "the plugin") + }) + requirePackageError(t, err, "couldn't reach the host for the plugin") + requireTransient(t, err, true) +} + +// A host that sends headers and then stops sending the body is treated as an +// interrupted download. +func TestFetchPackageResourceTimesOutStalledBody(t *testing.T) { + server := stallingServer(t, func(w http.ResponseWriter, r *http.Request, release <-chan struct{}) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("partial")) + w.(http.Flusher).Flush() + select { + case <-release: + case <-r.Context().Done(): + } + }) + client := newPackageFetchClient(shortPackageFetchTimeouts) + _, err := fetchWithDeadline(t, func() ([]byte, error) { + return fetchPackageResourceWithIdle(context.Background(), client, server.URL, "the plugin", 100*time.Millisecond) + }) + requirePackageError(t, err, "download of the plugin was interrupted") + requireTransient(t, err, true) + if !errors.Is(err, errPackageReadStalled) { + t.Fatalf("stalled body error = %v, want errPackageReadStalled", err) + } +} + +// Only a wait for data counts against the idle timeout: a body that takes +// longer than the timeout in total but keeps sending still completes. +func TestFetchPackageResourceAllowsSlowProgressingBody(t *testing.T) { + const ( + idle = 500 * time.Millisecond + chunks = 20 + gap = 50 * time.Millisecond + ) + server := stallingServer(t, func(w http.ResponseWriter, _ *http.Request, _ <-chan struct{}) { + w.WriteHeader(http.StatusOK) + for range chunks { + _, _ = w.Write([]byte("x")) + w.(http.Flusher).Flush() + time.Sleep(gap) + } + }) + client := newPackageFetchClient(shortPackageFetchTimeouts) + start := time.Now() + data, err := fetchWithDeadline(t, func() ([]byte, error) { + return fetchPackageResourceWithIdle(context.Background(), client, server.URL, "the plugin", idle) + }) + if err != nil { + t.Fatalf("slow download failed: %v", err) + } + if !bytes.Equal(data, bytes.Repeat([]byte("x"), chunks)) { + t.Fatalf("slow download = %q", data) + } + if elapsed := time.Since(start); elapsed <= idle { + t.Fatalf("download took %s, want longer than the %s idle timeout for the test to mean anything", elapsed, idle) + } +} + +// Canceling the caller's context during a stalled body still returns the +// caller's cancellation, not a stall. +func TestFetchPackageResourceCancelDuringStalledBody(t *testing.T) { + bodyStarted := make(chan struct{}) + server := stallingServer(t, func(w http.ResponseWriter, r *http.Request, release <-chan struct{}) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("partial")) + w.(http.Flusher).Flush() + close(bodyStarted) + select { + case <-release: + case <-r.Context().Done(): + } + }) + client := newPackageFetchClient(shortPackageFetchTimeouts) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { + <-bodyStarted + cancel() + }() + _, err := fetchWithDeadline(t, func() ([]byte, error) { + return fetchPackageResourceWithIdle(ctx, client, server.URL, "the plugin", time.Hour) + }) + if _, ok := AsPackageError(err); ok || !errors.Is(err, context.Canceled) { + t.Fatalf("canceled fetch = %v, want context.Canceled and no PackageError", err) + } +} + +// A large package sent as one HTTP/1.1 chunk arrives in many socket reads +// inside a single body Read. A steady download must not count as stalled +// just because io.ReadAll asks for a large buffer. +func TestFetchPackageResourceAllowsSlowSingleChunkBody(t *testing.T) { + const ( + idle = 100 * time.Millisecond + total = 2 << 20 + piece = 16 << 10 + gap = 10 * time.Millisecond + ) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer func() { _ = conn.Close() }() + if _, err := http.ReadRequest(bufio.NewReader(conn)); err != nil { + return + } + _, _ = fmt.Fprintf(conn, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n%x\r\n", total) + data := bytes.Repeat([]byte("x"), piece) + for sent := 0; sent < total; sent += piece { + if _, err := conn.Write(data); err != nil { + return + } + time.Sleep(gap) + } + _, _ = io.WriteString(conn, "\r\n0\r\n\r\n") + }() + client := newPackageFetchClient(shortPackageFetchTimeouts) + data, err := fetchWithDeadline(t, func() ([]byte, error) { + return fetchPackageResourceWithIdle(context.Background(), client, "http://"+listener.Addr().String(), "the plugin", idle) + }) + if err != nil { + t.Fatalf("steady single-chunk download failed: %v", err) + } + if len(data) != total { + t.Fatalf("downloaded %d bytes, want %d", len(data), total) + } +} From 11a197d7ad8e20ef6d80e2745c332f5bff05bd0f Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 13 Oct 2026 16:16:12 +0200 Subject: [PATCH 011/196] fix(metadata): match the personal collection key when merging duplicate items (#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 #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. --- internal/metadata/identify_sticky_db_test.go | 1 - internal/metadata/provider_id_integrity.go | 20 +- .../metadata/provider_id_integrity_db_test.go | 181 ++++++++++++++++++ 3 files changed, 193 insertions(+), 9 deletions(-) diff --git a/internal/metadata/identify_sticky_db_test.go b/internal/metadata/identify_sticky_db_test.go index fc530b446e..e24277a2d9 100644 --- a/internal/metadata/identify_sticky_db_test.go +++ b/internal/metadata/identify_sticky_db_test.go @@ -165,7 +165,6 @@ func TestIdentifyRenameLeavesTheMarkToTheIdentityWritePostgres(t *testing.T) { // owns the chosen IDs, and only on it: the generic merge may keep the item // being identified, which still has its old IDs. func TestIdentifyConflictMergeMarksTheSurvivorPostgres(t *testing.T) { - t.Skip("duplicate merges fail at the personal collection step until #2299 is fixed") pool := chainBuiltinTestPool(t) ctx := context.Background() t.Run("into the owner", func(t *testing.T) { diff --git a/internal/metadata/provider_id_integrity.go b/internal/metadata/provider_id_integrity.go index ebc818b9a9..0d484bf829 100644 --- a/internal/metadata/provider_id_integrity.go +++ b/internal/metadata/provider_id_integrity.go @@ -656,12 +656,15 @@ func mergeStepArgs(sql, sourceID, canonicalID string) []any { } var mediaItemMergeSteps = []mediaItemMergeStep{ + // A provider ID is unique per (provider, provider_id, item_type), so the + // source row itself blocks inserting a copy for the canonical item. Move + // the rows for providers the canonical item has no ID for instead; the + // canonical item keeps its own ID for the rest. {"merge provider ids", ` - INSERT INTO media_item_provider_ids (content_id, item_type, provider, provider_id, created_at, updated_at) - SELECT $2, item_type, provider, provider_id, created_at, NOW() - FROM media_item_provider_ids + UPDATE media_item_provider_ids + SET content_id = $2, updated_at = NOW() WHERE content_id = $1 - ON CONFLICT DO NOTHING`}, + AND provider NOT IN (SELECT provider FROM media_item_provider_ids WHERE content_id = $2)`}, {"drop source provider ids", `DELETE FROM media_item_provider_ids WHERE content_id = $1`}, {"merge legacy provider columns", ` UPDATE media_items dest @@ -698,12 +701,12 @@ var mediaItemMergeSteps = []mediaItemMergeStep{ updated_at = NOW()`}, {"delete source collection items", `DELETE FROM library_collection_items WHERE media_item_id = $1`}, {"merge personal collection items", ` - INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, position, added_at) - SELECT user_id, collection_id, $2, MIN(position), MIN(added_at) + INSERT INTO user_personal_collection_items (user_id, collection_id, media_item_id, sub_item_id, position, added_at) + SELECT user_id, collection_id, $2, sub_item_id, MIN(position), MIN(added_at) FROM user_personal_collection_items WHERE media_item_id = $1 - GROUP BY user_id, collection_id - ON CONFLICT (user_id, collection_id, media_item_id) DO UPDATE + GROUP BY user_id, collection_id, sub_item_id + ON CONFLICT (user_id, collection_id, media_item_id, sub_item_id) DO UPDATE SET position = LEAST(user_personal_collection_items.position, EXCLUDED.position), added_at = LEAST(user_personal_collection_items.added_at, EXCLUDED.added_at)`}, {"delete source personal collection items", `DELETE FROM user_personal_collection_items WHERE media_item_id = $1`}, @@ -768,6 +771,7 @@ var mediaItemMergeSteps = []mediaItemMergeStep{ progress = CASE WHEN EXCLUDED.updated_at >= ebook_reader_progress.updated_at THEN EXCLUDED.progress ELSE ebook_reader_progress.progress END, updated_at = GREATEST(ebook_reader_progress.updated_at, EXCLUDED.updated_at)`}, {"delete source ebook reader progress", `DELETE FROM ebook_reader_progress WHERE content_id = $1`}, + {"move ebook reader annotations", `UPDATE ebook_reader_annotations SET content_id = $2 WHERE content_id = $1`}, {"move history", `UPDATE user_watch_history SET media_item_id = $2 WHERE media_item_id = $1`}, {"merge hidden history", ` INSERT INTO user_history_hidden_items (user_id, profile_id, media_item_id, hidden_before, updated_at) diff --git a/internal/metadata/provider_id_integrity_db_test.go b/internal/metadata/provider_id_integrity_db_test.go index 170a5b8048..9732c8ba98 100644 --- a/internal/metadata/provider_id_integrity_db_test.go +++ b/internal/metadata/provider_id_integrity_db_test.go @@ -1,10 +1,15 @@ package metadata import ( + "context" + "os" + "reflect" "strings" "testing" + "time" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/Silo-Server/silo-server/internal/webhooksync/webhooksynctest" ) @@ -36,3 +41,179 @@ func TestSyncMergeStepsAfterPlexTableDropPostgres(t *testing.T) { t.Fatalf("webhook state = %s, %v", id, position) } } + +// beginMergeTestTx opens a transaction on SILO_TEST_DATABASE_URL that rolls +// back when the test ends, and seeds a movie source and canonical item. +func beginMergeTestTx(t *testing.T) (tx pgx.Tx, source, canonical string) { + t.Helper() + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + conn, err := pgx.Connect(t.Context(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close(context.Background()) }) + tx, err = conn.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = tx.Rollback(context.Background()) }) + suffix := uuid.NewString() + source, canonical = "merge-test-source-"+suffix, "merge-test-canonical-"+suffix + if _, err := tx.Exec(t.Context(), `INSERT INTO media_items(content_id,type,title,status) VALUES ($1,'movie','Source','pending'), ($2,'movie','Canonical','pending')`, source, canonical); err != nil { + t.Fatal(err) + } + return tx, source, canonical +} + +// The whole merge runs every step against the migrated schema, so a conflict +// target that no longer matches a table's key fails here instead of in +// production. +func TestCanonicalizeMediaItemReferencesWithoutCollectionRowsPostgres(t *testing.T) { + tx, source, canonical := beginMergeTestTx(t) + if err := canonicalizeMediaItemReferencesTx(t.Context(), tx, source, canonical); err != nil { + t.Fatalf("merge: %v", err) + } +} + +func TestCanonicalizeMediaItemReferencesMergesPersonalCollectionItemsPostgres(t *testing.T) { + tx, source, canonical := beginMergeTestTx(t) + var userID int + if err := tx.QueryRow(t.Context(), `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, "merge-test-"+uuid.NewString()).Scan(&userID); err != nil { + t.Fatal(err) + } + early := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + late := early.Add(24 * time.Hour) + // "shared" holds both items as whole-item entries, and the canonical item + // also as an episode entry. "moved" holds only the source item, as a + // whole-item entry and an episode entry. + seed := []struct { + collection, item, sub string + position int + addedAt time.Time + }{ + {"shared", source, "", 2, early}, + {"shared", canonical, "", 5, late}, + {"shared", canonical, "ep-1", 7, late}, + {"shared", source, "ep-2", 8, early}, + {"moved", source, "", 3, late}, + {"moved", source, "ep-1", 4, early}, + } + for _, row := range seed { + if _, err := tx.Exec(t.Context(), `INSERT INTO user_personal_collection_items(user_id,collection_id,media_item_id,sub_item_id,position,added_at) VALUES($1,$2,$3,$4,$5,$6)`, + userID, row.collection, row.item, row.sub, row.position, row.addedAt); err != nil { + t.Fatal(err) + } + } + + if err := canonicalizeMediaItemReferencesTx(t.Context(), tx, source, canonical); err != nil { + t.Fatalf("merge: %v", err) + } + + rows, err := tx.Query(t.Context(), ` + SELECT collection_id, media_item_id, sub_item_id, position, added_at + FROM user_personal_collection_items + WHERE user_id = $1 + ORDER BY collection_id, sub_item_id`, userID) + if err != nil { + t.Fatal(err) + } + type item struct { + collection, item, sub string + position int + addedAt time.Time + } + got, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (item, error) { + var it item + err := row.Scan(&it.collection, &it.item, &it.sub, &it.position, &it.addedAt) + it.addedAt = it.addedAt.UTC() + return it, err + }) + if err != nil { + t.Fatal(err) + } + want := []item{ + {"moved", canonical, "", 3, late}, + {"moved", canonical, "ep-1", 4, early}, + {"shared", canonical, "", 2, early}, + {"shared", canonical, "ep-1", 7, late}, + {"shared", canonical, "ep-2", 8, early}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("collection items after merge:\n got %+v\nwant %+v", got, want) + } +} + +// A merge keeps the source's provider IDs for providers the canonical item has +// no ID for, and keeps the canonical item's own ID for the rest. +func TestCanonicalizeMediaItemReferencesMovesProviderIDsPostgres(t *testing.T) { + tx, source, canonical := beginMergeTestTx(t) + suffix := uuid.NewString() + seed := []struct{ item, provider, id string }{ + {source, "tmdb", "src-tmdb-" + suffix}, + {source, "imdb", "src-imdb-" + suffix}, + {canonical, "tmdb", "canon-tmdb-" + suffix}, + } + for _, row := range seed { + if _, err := tx.Exec(t.Context(), `INSERT INTO media_item_provider_ids(content_id,item_type,provider,provider_id) VALUES($1,'movie',$2,$3)`, + row.item, row.provider, row.id); err != nil { + t.Fatal(err) + } + } + + if err := canonicalizeMediaItemReferencesTx(t.Context(), tx, source, canonical); err != nil { + t.Fatalf("merge: %v", err) + } + + rows, err := tx.Query(t.Context(), ` + SELECT content_id || ' ' || provider || ' ' || provider_id + FROM media_item_provider_ids + WHERE content_id = ANY($1::text[]) + ORDER BY provider`, []string{source, canonical}) + if err != nil { + t.Fatal(err) + } + got, err := pgx.CollectRows(rows, pgx.RowTo[string]) + if err != nil { + t.Fatal(err) + } + want := []string{ + canonical + " imdb src-imdb-" + suffix, + canonical + " tmdb canon-tmdb-" + suffix, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("provider IDs after merge:\n got %q\nwant %q", got, want) + } +} + +// Deleting the source item cascades to its ebook annotations, so the merge +// must move them first. +func TestCanonicalizeMediaItemReferencesMovesEbookAnnotationsPostgres(t *testing.T) { + tx, source, canonical := beginMergeTestTx(t) + var userID int + if err := tx.QueryRow(t.Context(), `INSERT INTO users(username,role) VALUES($1,'user') RETURNING id`, "merge-test-"+uuid.NewString()).Scan(&userID); err != nil { + t.Fatal(err) + } + annotationID := "merge-test-annotation-" + uuid.NewString() + if _, err := tx.Exec(t.Context(), `INSERT INTO ebook_reader_annotations(id,user_id,profile_id,content_id,kind,location) VALUES($1,$2,'p1',$3,'bookmark','loc-1')`, + annotationID, userID, source); err != nil { + t.Fatal(err) + } + + if err := canonicalizeMediaItemReferencesTx(t.Context(), tx, source, canonical); err != nil { + t.Fatalf("merge: %v", err) + } + if _, err := tx.Exec(t.Context(), `DELETE FROM media_items WHERE content_id = $1`, source); err != nil { + t.Fatal(err) + } + + var owner string + if err := tx.QueryRow(t.Context(), `SELECT content_id FROM ebook_reader_annotations WHERE id = $1`, annotationID).Scan(&owner); err != nil { + t.Fatalf("annotation after merge: %v", err) + } + if owner != canonical { + t.Fatalf("annotation belongs to %s, want %s", owner, canonical) + } +} From aeaffac45202cdc07b44c131f6040d8a1e102be2 Mon Sep 17 00:00:00 2001 From: Suspense Date: Tue, 13 Oct 2026 16:16:35 +0200 Subject: [PATCH 012/196] fix(web): keep Continue Watching landscape when it holds only movies (#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 #2347 --- web/src/components/SectionRow.test.tsx | 85 ++++++++++++++++++++++++++ web/src/components/SectionRow.tsx | 13 ++-- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/web/src/components/SectionRow.test.tsx b/web/src/components/SectionRow.test.tsx index be52f6dfca..4916a7a4af 100644 --- a/web/src/components/SectionRow.test.tsx +++ b/web/src/components/SectionRow.test.tsx @@ -210,6 +210,91 @@ describe("SectionRow", () => { expect(markup).not.toContain("aspect-video"); }); + function continueSection(items: ResolvedSection["items"]): ResolvedSection { + return { + id: "continue-watching", + section_type: "continue_watching", + title: "Continue Watching", + featured: false, + item_limit: 10, + total_count: items.length, + is_custom: false, + customized: false, + items, + }; + } + + function progressItem( + contentId: string, + type: ResolvedSection["items"][number]["type"], + art: { poster_url?: string; backdrop_url?: string }, + ): ResolvedSection["items"][number] { + return { + content_id: contentId, + type, + title: contentId, + position_seconds: 600, + duration_seconds: 6000, + year: 2020, + genres: [], + status: "matched", + rating_imdb: null, + overview: "", + poster_url: art.poster_url ?? "", + poster_thumbhash: "", + backdrop_url: art.backdrop_url ?? "", + backdrop_thumbhash: "", + logo_url: "", + }; + } + + function renderRow(section: ResolvedSection) { + return renderToStaticMarkup( + + + + + , + ); + } + + it("keeps all-movie continue sections as wide backdrop cards", () => { + const markup = renderRow( + continueSection([ + progressItem("movie-1", "movie", { + poster_url: "/movie-poster.jpg", + backdrop_url: "/movie-backdrop.jpg", + }), + ]), + ); + + expect(markup).toContain("aspect-video"); + expect(markup).not.toContain("aspect-[2/3]"); + expect(markup).toContain('src="/movie-backdrop.jpg"'); + }); + + it("falls back to the poster on a wide movie card without a backdrop", () => { + const markup = renderRow( + continueSection([progressItem("movie-1", "movie", { poster_url: "/movie-poster.jpg" })]), + ); + + expect(markup).toContain("aspect-video"); + expect(markup).toContain('src="/movie-poster.jpg"'); + }); + + it("keeps continue sections that mix movies and books wide", () => { + const markup = renderRow( + continueSection([ + progressItem("movie-1", "movie", { backdrop_url: "/movie-backdrop.jpg" }), + progressItem("book-1", "audiobook", { poster_url: "/book-cover.jpg" }), + ]), + ); + + expect(markup).toContain("aspect-video"); + expect(markup).not.toContain("aspect-square"); + expect(markup).not.toContain("aspect-[2/3]"); + }); + it("routes supported home section view-all actions to home browse destinations", () => { const queryClient = new QueryClient(); const section: ResolvedSection = { diff --git a/web/src/components/SectionRow.tsx b/web/src/components/SectionRow.tsx index 1de22f7767..8cacc59868 100644 --- a/web/src/components/SectionRow.tsx +++ b/web/src/components/SectionRow.tsx @@ -65,16 +65,15 @@ export default memo(function SectionRow({ section, libraryId, priority = false } // One spoiler lookup per row rather than one per card. const { hideImages, hideOverviews } = useEpisodeSpoilerPrefs({ enabled: isProgressRow }); const spoilerPrefs = useMemo(() => ({ hideImages, hideOverviews }), [hideImages, hideOverviews]); - // Continue Watching with only movies, audiobooks, and/or ebooks looks better - // as upright covers (audiobook covers render square within the poster - // variant; ebook covers are naturally 2:3). Episodes (incl. Next Up) keep the - // wide variant so stills are shown with their natural 16:9 framing. + // Progress rows stay wide (16:9) whenever they hold any video, matching the + // native apps, so Continue Watching keeps its shape as movies and episodes + // come and go. Only a Continue Watching row made up entirely of books uses + // the poster variant, where audiobook covers render square and ebook covers + // upright 2:3. const progressCardVariant: "wide" | "poster" = section.section_type === "continue_watching" && section.items.length > 0 && - section.items.every( - (it) => it.type === "movie" || it.type === "audiobook" || it.type === "ebook", - ) + section.items.every((it) => it.type === "audiobook" || it.type === "ebook") ? "poster" : "wide"; const slideSize = isProgressRow && progressCardVariant === "wide" ? "wide" : posterSize; From 4b2e921c5a70c6a1dde61ca24e3b2b07e3ccdc22 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 13 Oct 2026 11:11:26 -0400 Subject: [PATCH 013/196] fix(playback): keep web subtitles in sync after a seek on offset files (#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 #2400 Co-Authored-By: Claude Opus 5.5 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- docs/architecture/playback-protocol-v3.md | 13 ++ internal/playback/subtitle_stream.go | 42 +++-- internal/playback/subtitle_stream_test.go | 56 +++++- .../player/components/VideoPlayer.test.tsx | 161 +++++++++++++++++- web/src/player/components/VideoPlayer.tsx | 132 ++++++++++---- web/src/player/hooks/useWatchProgress.test.ts | 18 ++ web/src/player/hooks/useWatchProgress.ts | 4 +- 7 files changed, 374 insertions(+), 52 deletions(-) diff --git a/docs/architecture/playback-protocol-v3.md b/docs/architecture/playback-protocol-v3.md index 1ee7dc8df3..d24b41c06c 100644 --- a/docs/architecture/playback-protocol-v3.md +++ b/docs/architecture/playback-protocol-v3.md @@ -894,6 +894,19 @@ Three shapes exist: position, `can_seek_anywhere` is true when the runtime is known, and `seek_restoration` is `player_position` — the client seeks locally. +Source positions count from the start of the file: the container's first +timestamp is position 0. Most files start at timestamp zero, but MPEG-TS +recordings and files remuxed from them can start seconds (or more) in. Plan positions, playlist time, +subtitle tracks, and subtitle windows (`?position=&duration=`) are all on this +container-relative timeline. Encoded HLS segments can still carry the source's +own timestamps, so place them by playlist time, not by their PTS. +A direct-play engine may keep the container's own timestamps instead: +Chrome does for Matroska, reporting `currentTime` equal to the container start +time at its first frame. Such a client must subtract that origin as part of its +timeline offset, or subtitles, seeks, and reported positions drift by it. The +web player reads the origin from `currentTime` at `loadedmetadata`, before it +seeks to `player_start`. + **Copy remux over HLS** is served from FFmpeg's live, still-growing playlist, which starts at the preceding keyframe selected by FFmpeg's input seek. For HEVC HDR copy packaging, the frozen plan also controls the sample entry: a diff --git a/internal/playback/subtitle_stream.go b/internal/playback/subtitle_stream.go index 503126a243..85322a5d48 100644 --- a/internal/playback/subtitle_stream.go +++ b/internal/playback/subtitle_stream.go @@ -49,16 +49,15 @@ type StreamExtractOpts struct { // renderer receives the complete script and event timeline. AllowWindow bool // InputIsExtractedSup marks InputPath as a cached full-track .sup - // elementary stream (a previous full extract, produced with -copyts so - // its timestamps are absolute source PTS) rather than the original + // elementary stream (a previous unseeked full extract, so its + // timestamps are on the playback timeline) rather than the original // media container. The input format is forced with `-f sup` — the // headerless stream is probeable via its "PG" magic, but an explicit // format is robust against probe-size edge cases — and the stream // mapping is forced to `0:s:0`: a .sup holds exactly one stream, so // TrackIndex (which names the ordinal in the *original* container) no - // longer applies. Seeking such an input with -copyts re-emits the same - // absolute timestamps, so windowed output is byte-compatible with a - // window cut from the original file. + // longer applies. Seeking such an input with -copyts re-emits its + // stored timestamps. InputIsExtractedSup bool // FFmpegPath overrides the ffmpeg binary lookup. FFmpegPath string @@ -152,8 +151,8 @@ func streamExtractArgs(opts StreamExtractOpts) []string { // stream exactly once and consumes it whole needs the complete track // from offset 0 — windowing would silently drop every cue outside // the window. Clients that manage their own sliding window opt in - // via AllowWindow; -copyts below keeps the windowed output on - // absolute source timestamps so cues stay in sync. The same logic + // via AllowWindow; -copyts below keeps the windowed output on the + // playback timeline so cues stay in sync. The same logic // governs the -t duration cap below. windowable := !IsASS(opts.SourceCodec) && (!IsPGS(opts.SourceCodec) || opts.AllowWindow) seekApplied := opts.SeekSeconds > 0 && windowable @@ -174,17 +173,30 @@ func streamExtractArgs(opts StreamExtractOpts) []string { "-c:s", outCodec, ) - // When we seek the input, preserve the absolute source timestamps - // in the output. Without this ffmpeg rebases cues to start at 0, - // which makes every cue play `opts.SeekSeconds` earlier than it - // should — the symptom is subtitles that look "out of sync" with - // the video the player is showing at the same media time. + // When we seek the input, keep the cues on the source timeline. + // Without -copyts ffmpeg rebases cues to start at 0, which makes + // every cue play `opts.SeekSeconds` earlier than it should — the + // symptom is subtitles that look "out of sync" with the video the + // player is showing at the same media time. + // + // The playback timeline starts at the container start, which is how + // an unseeked extract comes out (ffmpeg subtracts the input start + // time). -start_at_zero makes a seeked extract subtract it too, so + // windows taken after a seek stay on the same clock as the first one + // instead of running late by the container's start time (seconds + // for TS remuxes). A cached .sup was already rebased when it was + // extracted, and its own start time is its first cue, so it must + // keep its timestamps as they are. if seekApplied { - args = append(args, "-copyts", "-avoid_negative_ts", "disabled") + args = append(args, "-copyts") + if !opts.InputIsExtractedSup { + args = append(args, "-start_at_zero") + } + args = append(args, "-avoid_negative_ts", "disabled") } // Input -t does not bound subtitle-only output: ffmpeg keeps demuxing - // and emitting cues to EOF. Limit the output instead, using the absolute - // window end because -copyts preserves the source timeline after a seek. + // and emitting cues to EOF. Limit the output instead, using the window's + // end on the playback timeline, which -copyts keeps after a seek. // Using only the duration here would end resumed windows too early (or // emit no cues when the seek already exceeds that duration). if opts.DurationSeconds > 0 && windowable { diff --git a/internal/playback/subtitle_stream_test.go b/internal/playback/subtitle_stream_test.go index e2dec056f0..6d48f1e195 100644 --- a/internal/playback/subtitle_stream_test.go +++ b/internal/playback/subtitle_stream_test.go @@ -34,7 +34,50 @@ func TestStreamExtractSubtitleBoundsWindow(t *testing.T) { if got := output.String(); !strings.Contains(got, "Inside window") || !strings.Contains(got, "13:20.000 --> 13:25.000") || strings.Contains(got, "Before window") || strings.Contains(got, "After window") { - t.Fatalf("expected only the in-window cue at its absolute timestamp, got %q", got) + t.Fatalf("expected only the in-window cue at its source timestamp, got %q", got) + } +} + +// The playback timeline starts at the container start. A window cut after a +// seek must put each cue where the unseeked extract does, even when the +// container's first timestamp is not zero (TS remuxes), or subtitles run late +// by that start time after every seek. +func TestStreamExtractSubtitleSeekKeepsContainerRelativeTimeline(t *testing.T) { + bin, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("ffmpeg is required to verify subtitle window timestamps") + } + dir := t.TempDir() + srt := filepath.Join(dir, "captions.srt") + if err := os.WriteFile(srt, []byte("1\n00:00:40,000 --> 00:00:42,000\nForty seconds in\n"), 0o600); err != nil { + t.Fatal(err) + } + source := filepath.Join(dir, "offset.mkv") + mux := exec.CommandContext(t.Context(), bin, "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "color=c=black:s=64x64:r=5:d=60", "-i", srt, + "-map", "0:v", "-map", "1:s", "-c:v", "mpeg4", "-c:s", "srt", + "-output_ts_offset", "30", source) + if out, err := mux.CombinedOutput(); err != nil { + t.Fatalf("build fixture: %v: %s", err, out) + } + + extract := func(seek float64) string { + t.Helper() + var output bytes.Buffer + if err := StreamExtractSubtitle(t.Context(), StreamExtractOpts{ + InputPath: source, SourceCodec: "subrip", SeekSeconds: seek, DurationSeconds: 600, + FFmpegPath: bin, Writer: &output, + }); err != nil { + t.Fatal(err) + } + return output.String() + } + const cue = "00:40.000 --> 00:42.000" + if got := extract(0); !strings.Contains(got, cue) { + t.Fatalf("unseeked extract should place the cue at %s, got %q", cue, got) + } + if got := extract(35); !strings.Contains(got, cue) { + t.Fatalf("seeked window should place the cue at %s like the unseeked extract, got %q", cue, got) } } @@ -134,7 +177,7 @@ func TestStreamExtractArgs_WholeTrackCodecsIgnoreWindow(t *testing.T) { } // A client that opts in via AllowWindow gets a seeked, duration-capped PGS -// extract with -copyts preserving absolute source timestamps — the -ss must +// extract with -copyts keeping cues on the playback timeline — the -ss must // be an input option (before -i) so ffmpeg uses the container index. func TestStreamExtractArgs_WindowedPGS(t *testing.T) { args := streamExtractArgs(StreamExtractOpts{ @@ -170,7 +213,7 @@ func TestStreamExtractArgs_WindowedPGS(t *testing.T) { // sup demuxer (the elementary stream has no container header to probe from // arbitrary offsets), remap to the file's sole stream regardless of the // original container's track ordinal, and still seek/window with -copyts so -// the cached stream's absolute timestamps survive into the output. +// the cached stream's stored timestamps survive into the output. func TestStreamExtractArgs_ExtractedSupInput(t *testing.T) { args := streamExtractArgs(StreamExtractOpts{ InputPath: "/transcode/subtitle-cache/abc-s3-1-2.sup", @@ -201,7 +244,12 @@ func TestStreamExtractArgs_ExtractedSupInput(t *testing.T) { t.Fatalf("-ss must be an input option (before -i): %s", joined) } if !strings.Contains(joined, "-copyts") { - t.Fatalf("cached sup extract must preserve absolute timestamps: %s", joined) + t.Fatalf("cached sup extract must preserve its stored timestamps: %s", joined) + } + // The cached .sup is already on the playback timeline and its start time + // is its first cue; rebasing it would pull every cue early by that much. + if slices.Contains(args, "-start_at_zero") { + t.Fatalf("cached sup extract must not rebase its timestamps: %s", joined) } if !strings.Contains(joined, "-c:s copy") || !strings.Contains(joined, "-f sup pipe:1") { t.Fatalf("cached sup extract should copy into a sup stream: %s", joined) diff --git a/web/src/player/components/VideoPlayer.test.tsx b/web/src/player/components/VideoPlayer.test.tsx index 964acc3341..e62e81e6bf 100644 --- a/web/src/player/components/VideoPlayer.test.tsx +++ b/web/src/player/components/VideoPlayer.test.tsx @@ -24,6 +24,7 @@ const realtimeOptions = vi.hoisted(() => ({ const controls = vi.hoisted(() => ({ current: null as null | { currentTime: number; + duration: number; onSeek: (seconds: number) => void; activeSubtitleIndex: number | null; subtitleTracks: PlayerSubtitleInfo[]; @@ -144,6 +145,7 @@ vi.mock("./PlayerControls", () => ({ PlayerControls: vi.fn( (props: { currentTime: number; + duration: number; onSeek: (seconds: number) => void; activeSubtitleIndex: number | null; subtitleTracks: PlayerSubtitleInfo[]; @@ -2245,6 +2247,7 @@ describe("VideoPlayer plan failure recovery", () => { onReanchorSeek: reanchor, }); const video = container.querySelector("video")!; + fireEvent.loadedMetadata(video); Object.defineProperty(video, "currentTime", { configurable: true, value: 100 }); Object.defineProperty(video, "seekable", { configurable: true, @@ -2276,6 +2279,7 @@ describe("VideoPlayer plan failure recovery", () => { onReanchorSeek: reanchor, }); const video = container.querySelector("video")!; + fireEvent.loadedMetadata(video); Object.defineProperty(video, "currentTime", { configurable: true, value: 100 }); Object.defineProperty(video, "seekable", { configurable: true, @@ -2308,6 +2312,7 @@ describe("VideoPlayer plan failure recovery", () => { onPlaybackTransportReady: ready, }); const video = container.querySelector("video")!; + fireEvent.loadedMetadata(video); Object.defineProperty(video, "currentTime", { configurable: true, value: 100 }); Object.defineProperty(video, "seekable", { configurable: true, @@ -2342,6 +2347,7 @@ describe("VideoPlayer plan failure recovery", () => { }), }); const video = container.querySelector("video")!; + fireEvent.loadedMetadata(video); Object.defineProperty(video, "currentTime", { configurable: true, value: 100 }); const transport = ready.mock.lastCall![0]; sendRoomMessage.mockClear(); @@ -2382,6 +2388,7 @@ describe("VideoPlayer plan failure recovery", () => { onReanchorSeek: reanchor, }); const video = container.querySelector("video")!; + fireEvent.loadedMetadata(video); Object.defineProperty(video, "currentTime", { configurable: true, value: 10 }); act(() => ready.mock.lastCall![0].skipBack()); expect(reanchor).toHaveBeenLastCalledWith(395); @@ -2824,6 +2831,7 @@ describe("VideoPlayer intro skip prompt", () => { const video = rendered.container.querySelector("video"); if (!video) throw new Error("expected video element"); + fireEvent.loadedMetadata(video); video.currentTime = 12; fireFrameTimeUpdate(video); await act(async () => Promise.resolve()); @@ -2900,7 +2908,7 @@ describe("VideoPlayer intro skip prompt", () => { }); }); -describe("VideoPlayer native HLS timeline", () => { +describe("VideoPlayer source timeline", () => { beforeEach(() => { realtimeOptions.current = null; controls.current = null; @@ -2952,6 +2960,157 @@ describe("VideoPlayer native HLS timeline", () => { expect(subtitleTimeline.assOffsetSeconds).toBe(0); }); + // Chrome keeps a Matroska file's container start time on its clock, so a + // file that starts 30 s in reports currentTime 30 at its first frame. + // Subtitle cues, the resume seek, and the reported position all have to + // map through that origin or they drift by it. + it("maps direct play onto the media timeline when the engine clock starts late", async () => { + const origin = 29.977; + const plan = fixturePlanV3({ + ...directPlan, + timeline: { ...directPlan.timeline, player_start_seconds: 1300, timeline_offset_seconds: 0 }, + }); + const { container } = renderPlayer({ plan, initialPosition: 1300 }); + const video = container.querySelector("video"); + if (!video) throw new Error("expected video element"); + + await waitFor(() => expect(video.src).toContain("/api/v1/stream/session-1")); + video.currentTime = origin; + fireEvent.loadedMetadata(video); + + expect(video.currentTime).toBeCloseTo(1300 + origin); + await waitFor(() => expect(subtitleTimeline.textOffsetSeconds).toBeCloseTo(-origin)); + expect(subtitleTimeline.assOffsetSeconds).toBeCloseTo(-origin); + fireFrameTimeUpdate(video); + expect(controls.current!.currentTime).toBeCloseTo(1300); + + playerSeek.mockClear(); + act(() => { + controls.current!.onSeek(1400); + }); + expect(playerSeek).toHaveBeenCalledWith(expect.closeTo(1400 + origin)); + }); + + // Written to the element before metadata, a seek would become the default + // start position and be read back as the clock origin. + it("holds a direct-play seek made before metadata until the origin is known", async () => { + const origin = 29.977; + const { container } = renderPlayer(); + const video = container.querySelector("video"); + if (!video) throw new Error("expected video element"); + await waitFor(() => expect(video.src).toContain("/api/v1/stream/session-1")); + + playerSeek.mockClear(); + act(() => { + controls.current!.onSeek(500); + }); + expect(playerSeek).not.toHaveBeenCalled(); + expect(controls.current!.currentTime).toBe(500); + video.currentTime = origin; + fireEvent.loadedMetadata(video); + expect(video.currentTime).toBeCloseTo(500 + origin); + await waitFor(() => expect(subtitleTimeline.textOffsetSeconds).toBeCloseTo(-origin)); + }); + + // A load queues a timeupdate at position 0. A held seek to 0 must survive it + // rather than settle on it and fall back to the resume position. + it("keeps a held seek to the start through a load's reset timeupdate", async () => { + const origin = 29.977; + const plan = fixturePlanV3({ + ...directPlan, + timeline: { ...directPlan.timeline, player_start_seconds: 1300 }, + }); + const { container } = renderPlayer({ plan, initialPosition: 1300 }); + const video = container.querySelector("video"); + if (!video) throw new Error("expected video element"); + await waitFor(() => expect(video.src).toContain("/api/v1/stream/session-1")); + + act(() => { + controls.current!.onSeek(0); + }); + fireEvent.timeUpdate(video); + video.currentTime = origin; + fireEvent.loadedMetadata(video); + expect(video.currentTime).toBeCloseTo(origin); + }); + + it("publishes direct play's duration on the media timeline", async () => { + const origin = 29.977; + const { duration_seconds: _omitted, ...source } = directPlan.source; + const plan = fixturePlanV3({ ...directPlan, source }); + const { container } = renderPlayer({ plan }); + const video = container.querySelector("video"); + if (!video) throw new Error("expected video element"); + await waitFor(() => expect(video.src).toContain("/api/v1/stream/session-1")); + + // durationchange arrives before loadedmetadata, while the origin is unknown. + Object.defineProperty(video, "duration", { configurable: true, value: 1830 }); + fireEvent.durationChange(video); + video.currentTime = origin; + fireEvent.loadedMetadata(video); + await waitFor(() => expect(controls.current!.duration).toBeCloseTo(1830 - origin)); + }); + + // A room command that runs before metadata must judge drift from where + // playback is heading, not the unloaded clock, or it can skip a needed seek. + it("applies a room pause sent before direct-play metadata", async () => { + const origin = 29.977; + const plan = fixturePlanV3({ + ...directPlan, + timeline: { ...directPlan.timeline, player_start_seconds: 1300 }, + }); + const command = { + command_id: "room-command-1", + session_id: "session-1", + selection_revision: 1, + action: "pause" as const, + position_seconds: 0, + execute_at: new Date().toISOString(), + issued_at: new Date().toISOString(), + playback_state: "paused" as const, + }; + const { container } = renderPlayer({ + plan, + initialPosition: 1300, + shouldAutoPlay: false, + watchTogetherRoomId: "room-1", + watchTogetherConnection: roomConnection({ transportCommand: command }), + }); + const video = container.querySelector("video"); + if (!video) throw new Error("expected video element"); + await waitFor(() => expect(video.src).toContain("/api/v1/stream/session-1")); + await waitFor(() => expect(controls.current!.currentTime).toBe(0)); + + video.currentTime = origin; + fireEvent.loadedMetadata(video); + expect(video.currentTime).toBeCloseTo(origin); + }); + + it("skips from the resume position before direct-play metadata", async () => { + const origin = 29.977; + const ready = vi.fn(); + const plan = fixturePlanV3({ + ...directPlan, + timeline: { ...directPlan.timeline, player_start_seconds: 1300 }, + }); + const { container } = renderPlayer({ + plan, + initialPosition: 1300, + onPlaybackTransportReady: ready, + }); + const video = container.querySelector("video"); + if (!video) throw new Error("expected video element"); + await waitFor(() => expect(video.src).toContain("/api/v1/stream/session-1")); + // A load's reset timeupdate leaves the shown position at the resume point. + fireEvent.timeUpdate(video); + expect(controls.current!.currentTime).toBe(1300); + + act(() => ready.mock.lastCall![0].skipForward()); + video.currentTime = origin; + fireEvent.loadedMetadata(video); + expect(video.currentTime).toBeCloseTo(1330 + origin); + }); + it("uses native HLS for Dolby Vision when hls.js is also available", async () => { hlsJS.supported = true; vi.stubGlobal("navigator", { diff --git a/web/src/player/components/VideoPlayer.tsx b/web/src/player/components/VideoPlayer.tsx index de850ef3bd..be9a1e7d36 100644 --- a/web/src/player/components/VideoPlayer.tsx +++ b/web/src/player/components/VideoPlayer.tsx @@ -439,6 +439,15 @@ export function VideoPlayer({ connectionStatusRef.current = connectionStatus; const transportFailedForPlanRevisionRef = useRef(null); const timelineOffsetRef = useRef(0); + // Where the direct-play engine's clock starts, in player seconds. Chrome keeps + // a Matroska file's container start time (seconds into a TS remux), so its + // currentTime runs ahead of the media timeline by that much. + const [directPlayOriginSeconds, setDirectPlayOriginSeconds] = useState(0); + const planTimelineOffsetRef = useRef(0); + // True between loading an original file and learning its clock origin. A + // seek in that window is held as pending: written to the element it would + // become the default start position and be misread as the origin. + const directPlayOriginPendingRef = useRef(false); const subtitleFetchAnchorRef = useRef(initialPosition); const backendDurationRef = useRef(propDuration ?? 0); const autoEnterPictureInPictureAttemptedRef = useRef(false); @@ -854,14 +863,18 @@ export function VideoPlayer({ const isCopyOriginalHLS = plan.delivery === "server_remux_hls"; // Keep the player-local clock mapped onto the canonical media timeline. - const timelineOffsetSeconds = plan.timeline.timeline_offset_seconds; + const planTimelineOffset = plan.timeline.timeline_offset_seconds; + planTimelineOffsetRef.current = planTimelineOffset; + const playsOriginalFile = plan.delivery === "original_http"; + const timelineOffsetSeconds = + planTimelineOffset - (playsOriginalFile ? directPlayOriginSeconds : 0); timelineOffsetRef.current = timelineOffsetSeconds; // Media-time position playback is heading to, for consumers that need a // position before the element has media loaded (when currentTime still // reads 0): an in-flight seek target, else the session's start position. subtitleFetchAnchorRef.current = - pendingSeekTime ?? toMediaTime(effectiveInitialPosition, timelineOffsetSeconds); + pendingSeekTime ?? toMediaTime(effectiveInitialPosition, planTimelineOffset); useEffect(() => { if (backendDuration > 0) { @@ -1070,6 +1083,31 @@ export function VideoPlayer({ // against the plan's timeline below. const { handleSeek } = useRemuxSeeking(videoRef); + // Where the media is on the element's clock. While an original file's clock + // origin is unknown the element has no position on the stream yet, so that + // is where playback is heading. + const elementMediaPosition = useCallback((): number | null => { + const video = videoRef.current; + if (!video) return null; + if (directPlayOriginPendingRef.current) return subtitleFetchAnchorRef.current; + return toMediaTime(video.currentTime, timelineOffsetRef.current); + }, []); + + // Publishes the element's duration on the media timeline. A direct-play + // engine's duration runs on its own clock, which starts at the origin, so + // it waits until the origin is known. + const applyElementDuration = useCallback((video: HTMLVideoElement) => { + if (directPlayOriginPendingRef.current) return; + if (!video.duration || !isFinite(video.duration)) return; + const originSeconds = planTimelineOffsetRef.current - timelineOffsetRef.current; + const mediaDuration = video.duration - originSeconds; + // For HLS EVENT playlists still being written, the element reports the + // length produced so far. The plan's source duration is the media's + // full runtime, so it wins whenever the engine reports something short. + if (backendDurationRef.current && mediaDuration < backendDurationRef.current) return; + setDuration(mediaDuration); + }, []); + const rememberPendingSeek = useCallback((seconds: number) => { pendingSeekTimeRef.current = seconds; setPendingSeekTime(seconds); @@ -1105,6 +1143,12 @@ export function VideoPlayer({ resetRoomCatchupRate(); + if (directPlayOriginPendingRef.current) { + rememberPendingSeek(seconds); + setCurrentTime(seconds); + return true; + } + const nativeSeconds = toPlayerTime(seconds, timelineOffsetRef.current); if (canSeekAnywhere || isNativePositionInRanges(video.seekable, nativeSeconds)) { rememberPendingSeek(seconds); @@ -1204,17 +1248,13 @@ export function VideoPlayer({ // scrub instead of snapping back to where the element still is. const handleSkip = useCallback( (direction: "back" | "forward") => { - const now = - pendingSeekTimeRef.current ?? - (videoRef.current - ? toMediaTime(videoRef.current.currentTime, timelineOffsetRef.current) - : currentTimeRef.current); + const now = pendingSeekTimeRef.current ?? elementMediaPosition() ?? currentTimeRef.current; const seconds = direction === "back" ? seekIntervals.back : seekIntervals.forward; handlePlayerSeek( skipTarget(now, durationRef.current, direction === "back" ? -seconds : seconds), ); }, - [handlePlayerSeek, seekIntervals.back, seekIntervals.forward], + [elementMediaPosition, handlePlayerSeek, seekIntervals.back, seekIntervals.forward], ); const handleSkipRef = useRef(handleSkip); useEffect(() => { @@ -1243,11 +1283,8 @@ export function VideoPlayer({ ); const buildExitState = useCallback((): PlaybackExitState => { - const video = videoRef.current; - const positionSeconds = toMediaTime( - video?.currentTime ?? currentTime, - timelineOffsetRef.current, - ); + const positionSeconds = + elementMediaPosition() ?? toMediaTime(currentTime, timelineOffsetRef.current); // positionSeconds is media time, so the runtime paired with it must be too. const durationSeconds = mediaDurationSeconds(backendDurationRef.current, duration); @@ -1260,7 +1297,7 @@ export function VideoPlayer({ lastCodecVideo: selectedVersion?.codec_video, lastEditionKey: selectedVersion?.edition_key, }; - }, [activeFileId, currentTime, duration, selectedVersion]); + }, [activeFileId, currentTime, duration, elementMediaPosition, selectedVersion]); useEffect(() => { if (!watchTogetherRoomId || !watchTogether.closedReason || leaveInProgressRef.current) { @@ -1948,6 +1985,7 @@ export function VideoPlayer({ let autoplayAttempts = 0; let autoplayRetryTimer: ReturnType | null = null; let nativeHLSMetadataHandler: (() => void) | null = null; + let directPlayMetadataHandler: (() => void) | null = null; mediaRecoveryAttemptsRef.current = 0; setError(null); @@ -1968,6 +2006,11 @@ export function VideoPlayer({ video.removeEventListener("loadedmetadata", nativeHLSMetadataHandler); nativeHLSMetadataHandler = null; } + if (directPlayMetadataHandler) { + video.removeEventListener("loadedmetadata", directPlayMetadataHandler); + directPlayMetadataHandler = null; + } + directPlayOriginPendingRef.current = false; }; // Settles the player into a deliberate paused state: the startup guard is @@ -2212,8 +2255,34 @@ export function VideoPlayer({ // the load algorithm that is about to seek to the resume position, and // the spec has that algorithm reject it. video.src = effectiveStreamUrl; - video.currentTime = effectiveInitialPosition; - attemptAutoplayWhenReady(); + if (playsOriginalFile) { + // The original file's clock can start at the container's start time + // instead of zero. Load without a start position so currentTime at + // loadedmetadata reports where the clock starts, fold that into the + // timeline offset, then seek to the start position on the corrected + // clock. Subtitles, seeks, and progress then share the media timeline + // the server's streams use. + directPlayOriginPendingRef.current = true; + directPlayMetadataHandler = () => { + directPlayMetadataHandler = null; + directPlayOriginPendingRef.current = false; + const origin = Number.isFinite(video.currentTime) ? Math.max(0, video.currentTime) : 0; + const planOffset = planTimelineOffsetRef.current; + timelineOffsetRef.current = planOffset - origin; + setDirectPlayOriginSeconds(origin); + // A seek made while the origin was unknown wins over the start position. + const mediaTarget = + pendingSeekTimeRef.current ?? toMediaTime(effectiveInitialPosition, planOffset); + const target = toPlayerTime(mediaTarget, timelineOffsetRef.current); + if (Math.abs(video.currentTime - target) > 0.01) video.currentTime = target; + applyElementDuration(video); + attemptAutoplayWhenReady(); + }; + video.addEventListener("loadedmetadata", directPlayMetadataHandler, { once: true }); + } else { + video.currentTime = effectiveInitialPosition; + attemptAutoplayWhenReady(); + } } } @@ -2237,11 +2306,13 @@ export function VideoPlayer({ // `planRevision` is the single signal that the transport changed: two plans // can share a stream URL and still differ in protocol, timeline, or recipe. }, [ + applyElementDuration, effectiveStreamUrl, effectiveInitialPosition, isHlsStream, isPlayerReady, planRevision, + playsOriginalFile, plannedBitrateKbps, plannedDynamicRange, reportCurrentPlanFailure, @@ -2285,9 +2356,16 @@ export function VideoPlayer({ // counting it would cancel the render: no loading overlay, a startup // guard marked started, and the new transport's first frame never seen. const hasCurrentFrame = () => video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA; + // Until an original file's clock origin is known the element has no media; + // its events are load resets. They must not settle a held seek or move the + // shown position off where playback is heading. + const resolveSeekAt = (actualTime: number) => + directPlayOriginPendingRef.current + ? { currentTime: pendingSeekTime ?? subtitleFetchAnchorRef.current, pendingSeekTime } + : resolvePendingSeekTime(actualTime, pendingSeekTime); const onTimeUpdate = () => { const nextTime = toMediaTime(video.currentTime, timelineOffsetRef.current); - const resolved = resolvePendingSeekTime(nextTime, pendingSeekTime); + const resolved = resolveSeekAt(nextTime); setCurrentTime(resolved.currentTime); if (resolved.pendingSeekTime !== pendingSeekTime) { setPendingSeekTime(resolved.pendingSeekTime); @@ -2320,10 +2398,7 @@ export function VideoPlayer({ } }; const onSeeked = () => { - const resolved = resolvePendingSeekTime( - toMediaTime(video.currentTime, timelineOffsetRef.current), - pendingSeekTime, - ); + const resolved = resolveSeekAt(toMediaTime(video.currentTime, timelineOffsetRef.current)); setCurrentTime(resolved.currentTime); setPendingSeekTime(resolved.pendingSeekTime); // Reloading a stream can finish an older native seek. It does not settle @@ -2337,15 +2412,7 @@ export function VideoPlayer({ if (hasCurrentFrame()) markPlaybackStarted(); clearBuffering(); }; - const onDurationChange = () => { - if (video.duration && isFinite(video.duration)) { - // For HLS EVENT playlists still being written, the element reports the - // length produced so far. The plan's source duration is the media's - // full runtime, so it wins whenever the engine reports something short. - if (backendDurationRef.current && video.duration < backendDurationRef.current) return; - setDuration(video.duration); - } - }; + const onDurationChange = () => applyElementDuration(video); const onProgress = () => setBuffered(video.buffered); const onVolumeChange = () => { // A room seek pre-roll mutes the element for a moment. That mute is not @@ -2459,6 +2526,7 @@ export function VideoPlayer({ // deps are intentionally narrowed to the primitives the handlers read so // room snapshot churn doesn't re-subscribe every listener. }, [ + applyElementDuration, pendingSeekTime, reportConnectionLost, reportCurrentPlanFailure, @@ -3170,7 +3238,8 @@ export function VideoPlayer({ const decision = decideRoomCatchup({ action: command.action, targetPositionSeconds, - localPositionSeconds: toMediaTime(video.currentTime, timelineOffsetRef.current), + localPositionSeconds: + elementMediaPosition() ?? toMediaTime(video.currentTime, timelineOffsetRef.current), targetLocallySeekable, }); @@ -3299,6 +3368,7 @@ export function VideoPlayer({ }; }, [ canSeekAnywhere, + elementMediaPosition, resetRoomCatchupRate, sessionId, watchTogetherRoomId, diff --git a/web/src/player/hooks/useWatchProgress.test.ts b/web/src/player/hooks/useWatchProgress.test.ts index 63301e1293..38077bcb4b 100644 --- a/web/src/player/hooks/useWatchProgress.test.ts +++ b/web/src/player/hooks/useWatchProgress.test.ts @@ -40,6 +40,7 @@ async function reportOnce(error: Error) { const onSessionEnded = vi.fn(); const video = document.createElement("video"); Object.defineProperty(video, "currentTime", { value: 30 }); + Object.defineProperty(video, "readyState", { value: HTMLMediaElement.HAVE_METADATA }); const videoRef = { current: video }; vi.useFakeTimers(); const rendered = renderHook( @@ -67,6 +68,22 @@ describe("useWatchProgress", () => { expect(onSessionEnded).toHaveBeenCalledTimes(1); }); + // An element without metadata still reads currentTime 0. Reporting that + // when the viewer leaves during startup would replace their resume point. + it("reports nothing before the element has metadata", async () => { + mocks.send.mockReset(); + mocks.send.mockResolvedValue(undefined); + const video = document.createElement("video"); + const videoRef = { current: video }; + vi.useFakeTimers(); + const rendered = renderHook(() => useWatchProgress("session-1", videoRef), { wrapper }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + rendered.unmount(); + expect(mocks.send).not.toHaveBeenCalled(); + }); + it("keeps playing through other progress failures", async () => { for (const error of [ new PlayerFetchError(404, "Playback session not found", "session_not_found"), @@ -92,6 +109,7 @@ describe("useWatchProgress", () => { const onSessionEnded = vi.fn(); const video = document.createElement("video"); Object.defineProperty(video, "currentTime", { value: 30 }); + Object.defineProperty(video, "readyState", { value: HTMLMediaElement.HAVE_METADATA }); const videoRef = { current: video }; vi.useFakeTimers(); const rendered = renderHook( diff --git a/web/src/player/hooks/useWatchProgress.ts b/web/src/player/hooks/useWatchProgress.ts index c742155b70..7dd239efc7 100644 --- a/web/src/player/hooks/useWatchProgress.ts +++ b/web/src/player/hooks/useWatchProgress.ts @@ -39,7 +39,9 @@ export function useWatchProgress( const getProgressSnapshot = useCallback(() => { const video = videoRef.current; - if (!video) return null; + // Before metadata (startup, or a transport swap) currentTime is not a + // position on this stream yet; reporting it would overwrite the resume point. + if (!video || video.readyState === HTMLMediaElement.HAVE_NOTHING) return null; return { position: toMediaTime(video.currentTime ?? 0, streamOriginRef?.current ?? 0), From 3280a88063ed158bc71f5c93ee50bf9750fd9744 Mon Sep 17 00:00:00 2001 From: rangoDJ Date: Tue, 13 Oct 2026 14:33:37 -0400 Subject: [PATCH 014/196] fix(mdblist): prevent API key leakage in transport errors (fixes #692) (#860) * fix(mdblist): prevent API key leakage in transport errors (fixes #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 * 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 #860. Co-Authored-By: Claude Opus 5.5 * 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 #860. Co-Authored-By: Claude Opus 5.5 * 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 * docs(mdblist): note which errors.As targets come back masked Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- internal/mdblist/discovery.go | 100 ++++++++++++++++++++- internal/mdblist/discovery_test.go | 135 +++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 2 deletions(-) diff --git a/internal/mdblist/discovery.go b/internal/mdblist/discovery.go index e92590445a..5a3345afc9 100644 --- a/internal/mdblist/discovery.go +++ b/internal/mdblist/discovery.go @@ -9,11 +9,14 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "strings" "sync/atomic" "time" + + "github.com/Silo-Server/silo-server/internal/logredact" ) const defaultBaseURL = "https://api.mdblist.com" @@ -110,14 +113,17 @@ func (c *Client) fetchLists(ctx context.Context, path string, q url.Values) ([]L if !c.Configured() { return nil, ErrNotConfigured } + // Redact the key this request sent: the setting can be replaced or + // cleared while it's in flight, and the error quotes the key it sent. + sentKey := q.Get("apikey") u := c.baseURL + path + "?" + q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { - return nil, fmt.Errorf("creating mdblist request: %w", err) + return nil, fmt.Errorf("creating mdblist request: %w", sanitizeAPIKeyError(err, sentKey)) } res, err := c.http.Do(req) if err != nil { - return nil, fmt.Errorf("calling mdblist: %w", err) + return nil, fmt.Errorf("calling mdblist: %w", sanitizeAPIKeyError(err, sentKey)) } defer res.Body.Close() if res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden { @@ -151,3 +157,93 @@ func canonicalListURL(user, slug string) string { } return fmt.Sprintf("https://mdblist.com/lists/%s/%s", user, slug) } + +// sanitizeAPIKeyError keeps the API key out of a request or transport +// error. logredact masks the apikey parameter in any *url.Error; the key can +// still appear elsewhere in the text (a transport or proxy quoting the whole +// request URL), so any remaining raw or query-escaped form is masked too. +func sanitizeAPIKeyError(err error, apiKey string) error { + if err == nil { + return nil + } + return maskAPIKeyError(logredact.SanitizeURLError(err), apiKey) +} + +// maskAPIKeyError wraps err in a redactedError when its message holds the key. +func maskAPIKeyError(err error, apiKey string) error { + if err == nil { + return nil + } + if msg := err.Error(); redactAPIKey(msg, apiKey) != msg { + masked := redactedError{message: redactAPIKey(msg, apiKey), cause: err, apiKey: apiKey} + // A *url.Error asks its Err directly whether it timed out, so a masked + // net.Error has to answer too. + if ne, ok := err.(net.Error); ok { //nolint:errorlint // url.Error asserts its Err directly, so only err itself counts. + return redactedNetError{redactedError: masked, net: ne} + } + return masked + } + return err +} + +// redactedError carries a masked message. It has no Unwrap, so walking the +// chain with errors.Unwrap cannot reach the unmasked text; errors.Is and +// errors.As still see the cause, which keeps sentinel matching and net.Error +// timeout classification working at any depth. What As hands out for a +// *url.Error or net.Error target is masked too, as either would otherwise +// quote the key in its own message. Any other target type gets the cause's +// own value, unmasked: a caller extracting another error type must not log +// its text. +type redactedError struct { + message string + cause error + apiKey string +} + +func (e redactedError) Error() string { return e.message } +func (e redactedError) Is(target error) bool { return errors.Is(e.cause, target) } + +func (e redactedError) As(target any) bool { + if !errors.As(e.cause, target) { + return false + } + switch found := target.(type) { + case **url.Error: + if *found != nil { + clone := **found + clone.URL = redactAPIKey(clone.URL, e.apiKey) + clone.Err = maskAPIKeyError(clone.Err, e.apiKey) + *found = &clone + } + case *net.Error: + if *found != nil && redactAPIKey((*found).Error(), e.apiKey) != (*found).Error() { + *found = redactedNetError{ + redactedError: redactedError{message: redactAPIKey((*found).Error(), e.apiKey), cause: *found, apiKey: e.apiKey}, + net: *found, + } + } + } + return true +} + +// redactedNetError is a net.Error with a masked message that still reports +// the timeout it wraps. +type redactedNetError struct { + redactedError + net net.Error +} + +func (e redactedNetError) Timeout() bool { return e.net.Timeout() } +func (e redactedNetError) Temporary() bool { return e.net.Temporary() } //nolint:staticcheck // net.Error requires it. + +// redactAPIKey masks every occurrence of the API key, raw or query-escaped. +func redactAPIKey(text, apiKey string) string { + if apiKey == "" { + return text + } + text = strings.ReplaceAll(text, apiKey, logredact.Placeholder) + if escaped := url.QueryEscape(apiKey); escaped != apiKey { + text = strings.ReplaceAll(text, escaped, logredact.Placeholder) + } + return text +} diff --git a/internal/mdblist/discovery_test.go b/internal/mdblist/discovery_test.go index 4ee9ac8a84..0739ab3ff4 100644 --- a/internal/mdblist/discovery_test.go +++ b/internal/mdblist/discovery_test.go @@ -3,10 +3,14 @@ package mdblist import ( "context" "errors" + "fmt" + "net" "net/http" "net/http/httptest" + "net/url" "strings" "testing" + "time" ) func TestSearchAttachesAPIKeyAndQuery(t *testing.T) { @@ -88,3 +92,134 @@ func TestSearchSurfacesUpstreamErrors(t *testing.T) { t.Fatal("expected unauthorized error, got nil") } } + +// assertErrorOmitsAPIKey fails when the key appears anywhere errors.Unwrap +// can reach, not only in the top-level message. It never prints the key. +func assertErrorOmitsAPIKey(t *testing.T, err error, apiKey string) { + t.Helper() + if err == nil { + t.Fatal("expected an error") + } + for e := err; e != nil; e = errors.Unwrap(e) { + if msg := e.Error(); strings.Contains(msg, apiKey) { + t.Fatalf("error chain leaks the API key: %q", strings.ReplaceAll(msg, apiKey, "[KEY]")) + } + // What errors.As hands out must be masked too. + var urlErr *url.Error + if errors.As(e, &urlErr) { + for _, text := range []string{urlErr.URL, urlErr.Error(), errorText(urlErr.Err)} { + if strings.Contains(text, apiKey) { + t.Fatalf("url.Error from errors.As leaks the API key: %q", strings.ReplaceAll(text, apiKey, "[KEY]")) + } + } + } + var netErr net.Error + if errors.As(e, &netErr) && strings.Contains(netErr.Error(), apiKey) { + t.Fatalf("net.Error from errors.As leaks the API key: %q", strings.ReplaceAll(netErr.Error(), apiKey, "[KEY]")) + } + } +} + +func TestTransportErrorOmitsAPIKey(t *testing.T) { + secretKey := "super-secret-mdblist-key-12345" + c := NewClient(secretKey, &http.Client{}) + c.baseURL = "http://127.0.0.1:0" + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := c.Search(ctx, "test") + assertErrorOmitsAPIKey(t, err, secretKey) +} + +// urlQuotingTransport fails every request with an error that quotes the full +// request URL, as some transports and proxies do. +type urlQuotingTransport struct{ cause error } + +func (t urlQuotingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("dial %s: %w", req.URL, t.cause) +} + +// urlTimeoutError is a net.Error timeout whose message quotes the request +// URL, key included, as a transport's own timeout error can. +type urlTimeoutError struct{ url string } + +func (e urlTimeoutError) Error() string { return "read " + e.url + ": i/o timeout" } +func (urlTimeoutError) Timeout() bool { return true } +func (urlTimeoutError) Temporary() bool { return true } + +type timeoutTransport struct{} + +func (timeoutTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return nil, urlTimeoutError{url: req.URL.String()} +} + +func TestTransportErrorRedactsAPIKeyInWrappedError(t *testing.T) { + secretKey := "super-secret-mdblist-key-12345" + cause := errors.New("connection refused") + c := NewClient(secretKey, &http.Client{Transport: urlQuotingTransport{cause: cause}}) + + _, err := c.Search(context.Background(), "test") + assertErrorOmitsAPIKey(t, err, secretKey) + // The transport quoted the URL in its own text, so the key is masked there. + if !strings.Contains(err.Error(), "REDACTED") { + t.Fatalf("expected REDACTED in the sanitized error: %q", strings.ReplaceAll(err.Error(), secretKey, "[KEY]")) + } + if !errors.Is(err, cause) { + t.Fatalf("sanitized error no longer matches its cause: %q", strings.ReplaceAll(err.Error(), secretKey, "[KEY]")) + } +} + +// keyChangingTransport replaces the client's key while the request is in +// flight, as a settings reload can, then fails with an error that quotes the +// key the request sent outside any URL. +type keyChangingTransport struct { + client *Client + newKey string +} + +func (t keyChangingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.client.SetAPIKey(t.newKey) + return nil, fmt.Errorf("proxy rejected apikey %s", req.URL.Query().Get("apikey")) +} + +func TestTransportErrorRedactsTheKeySentAfterASettingsReload(t *testing.T) { + sentKey := "super-secret-mdblist-key-12345" + for name, newKey := range map[string]string{"replaced": "another-mdblist-key-67890", "cleared": ""} { + t.Run(name, func(t *testing.T) { + c := NewClient(sentKey, nil) + c.http = &http.Client{Transport: keyChangingTransport{client: c, newKey: newKey}} + + _, err := c.Search(context.Background(), "test") + assertErrorOmitsAPIKey(t, err, sentKey) + }) + } +} + +func TestTransportErrorKeepsTimeoutClassification(t *testing.T) { + secretKey := "super-secret-mdblist-key-12345" + c := NewClient(secretKey, &http.Client{Transport: timeoutTransport{}}) + + _, err := c.Search(context.Background(), "test") + assertErrorOmitsAPIKey(t, err, secretKey) + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("sanitized error lost its timeout classification: %q", strings.ReplaceAll(err.Error(), secretKey, "[KEY]")) + } + // The *url.Error As hands out is masked, and still classifies its cause. + var urlErr *url.Error + if !errors.As(err, &urlErr) { + t.Fatalf("no *url.Error in %q", strings.ReplaceAll(err.Error(), secretKey, "[KEY]")) + } + assertErrorOmitsAPIKey(t, urlErr, secretKey) + if !urlErr.Timeout() || !urlErr.Temporary() { //nolint:staticcheck // Temporary is what's being preserved. + t.Fatalf("masked *url.Error: Timeout %v, Temporary %v; want both", urlErr.Timeout(), urlErr.Temporary()) //nolint:staticcheck // as above. + } +} + +func errorText(err error) string { + if err == nil { + return "" + } + return err.Error() +} From 3d80e2b29c311e2570da537ad16d31c5b8e1a99a Mon Sep 17 00:00:00 2001 From: rangoDJ Date: Tue, 13 Oct 2026 14:34:04 -0400 Subject: [PATCH 015/196] fix(catalog): add system media control artwork to WatchDetail (fixes #803) (#856) * fix(catalog): add system media control artwork to WatchDetail (fixes #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 * 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 * 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 Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- .../api/v2/fixtures/get_watch_state_ok.json | 6 +- contracts/api/v2/openapi.json | 14 ++ docs/playback-api.md | 10 ++ internal/api/handlers/items.go | 1 + internal/apiv2/watch.go | 8 ++ internal/apiv2/watch_test.go | 7 + internal/apiv2/watch_trickplay.go | 1 + internal/apiv2/watch_trickplay_test.go | 22 ++++ internal/catalog/access_filter.go | 3 + internal/catalog/detail.go | 26 ++++ internal/catalog/detail_watch_artwork_test.go | 124 ++++++++++++++++++ internal/watchtogether/selection_resolver.go | 7 +- .../watchtogether/selection_resolver_test.go | 27 ++++ scripts/ci/db-pins.txt | 5 + web/src/api/v2/schema.ts | 6 + 15 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 internal/catalog/detail_watch_artwork_test.go diff --git a/contracts/api/v2/fixtures/get_watch_state_ok.json b/contracts/api/v2/fixtures/get_watch_state_ok.json index e29539f8a4..f949a81639 100644 --- a/contracts/api/v2/fixtures/get_watch_state_ok.json +++ b/contracts/api/v2/fixtures/get_watch_state_ok.json @@ -95,5 +95,9 @@ "played": false, "last_file_id": "3" }, - "effective_subtitle_language": "eng" + "effective_subtitle_language": "eng", + "poster_url": "/api/images/poster/heat.jpg?size=medium", + "poster_thumbhash": "poster-hash", + "backdrop_url": "/api/images/backdrop/heat.jpg?size=medium", + "backdrop_thumbhash": "backdrop-hash" } diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 97481f013d..48deabf159 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -48088,6 +48088,13 @@ "WatchDetail": { "additionalProperties": false, "properties": { + "backdrop_thumbhash": { + "type": "string" + }, + "backdrop_url": { + "description": "The item's backdrop, an episode's series backdrop, or an extra's parent backdrop, sized by image_size", + "type": "string" + }, "content_id": { "examples": [ "movie:heat-1995" @@ -48141,6 +48148,13 @@ }, "type": "array" }, + "poster_thumbhash": { + "type": "string" + }, + "poster_url": { + "description": "The item's poster, an episode's still, or an extra's parent poster, sized by image_size; for system media controls", + "type": "string" + }, "preview": { "$ref": "#/components/schemas/WatchMarker" }, diff --git a/docs/playback-api.md b/docs/playback-api.md index 85382a1208..a92f85b41a 100644 --- a/docs/playback-api.md +++ b/docs/playback-api.md @@ -40,6 +40,16 @@ capabilities. A different value is `409 installation_changed`: refresh capabilities and start a new attempt. There is no admission step and no per-account enrollment. +## Watch artwork + +`GET /api/v2/watch/{id}` includes optional `poster_url`, `poster_thumbhash`, +`backdrop_url`, and `backdrop_thumbhash` for system media controls. Movies +use their own artwork, episodes use their still as the poster and their +series' backdrop, and local extras inherit their parent's artwork. URLs use +the same resolver and `image_size` query as catalog item detail. Missing +artwork is omitted; clients can retain their catalog-detail fallback. +The frozen v1 watch response does not gain these fields. + ## Marker ranges V2 watch detail includes `marker_segments` on each file version, including diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index f3a2eb72e3..737ac3ad9f 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -524,6 +524,7 @@ func (h *ItemsHandler) HandleGetWatchDetail(w http.ResponseWriter, r *http.Reque if !ok { return } + filter.OmitWatchArtwork = true detail, err := h.WatchDetail(r.Context(), apimw.GetUserID(r.Context()), requestProfileID(r), id, filter) if err != nil { diff --git a/internal/apiv2/watch.go b/internal/apiv2/watch.go index 68336657d0..92ecde6fc4 100644 --- a/internal/apiv2/watch.go +++ b/internal/apiv2/watch.go @@ -58,6 +58,10 @@ type WatchDetail struct { EffectiveVersionHDR *bool `json:"effective_version_hdr,omitempty"` EffectiveVersionCodecVideo *string `json:"effective_version_codec_video,omitempty"` EffectiveVersionEditionKey *string `json:"effective_version_edition_key,omitempty"` + PosterURL string `json:"poster_url,omitempty" doc:"The item's poster, an episode's still, or an extra's parent poster, sized by image_size; for system media controls"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + BackdropURL string `json:"backdrop_url,omitempty" doc:"The item's backdrop, an episode's series backdrop, or an extra's parent backdrop, sized by image_size"` + BackdropThumbhash string `json:"backdrop_thumbhash,omitempty"` } // WatchFileVersion is one playable file of the item. @@ -369,6 +373,10 @@ func watchDetailOf(d *catalogpkg.WatchDetail) WatchDetail { EffectiveVersionHDR: d.EffectiveVersionHDR, EffectiveVersionCodecVideo: d.EffectiveVersionCodecVideo, EffectiveVersionEditionKey: d.EffectiveVersionEditionKey, + PosterURL: d.PosterURL, + PosterThumbhash: d.PosterThumbhash, + BackdropURL: d.BackdropURL, + BackdropThumbhash: d.BackdropThumbhash, } for _, s := range d.Subtitles { out.Subtitles = append(out.Subtitles, WatchSubtitle{Source: s.Source, Language: s.Language, Codec: s.Codec, Forced: s.Forced, HearingImpaired: s.HearingImpaired, Title: s.Title}) diff --git a/internal/apiv2/watch_test.go b/internal/apiv2/watch_test.go index dcbdc47584..05705c45da 100644 --- a/internal/apiv2/watch_test.go +++ b/internal/apiv2/watch_test.go @@ -46,6 +46,8 @@ func (f *fakeWatch) WatchDetail(_ context.Context, userID int, profileID, conten detail := &catalogpkg.WatchDetail{ ContentID: contentID, Type: "movie", Title: "Heat", Year: 1995, EffectiveSubtitleLanguage: "eng", HasEffectiveSubtitleLang: true, + PosterURL: "/api/images/poster/heat.jpg?size=medium", PosterThumbhash: "poster-hash", + BackdropURL: "/api/images/backdrop/heat.jpg?size=medium", BackdropThumbhash: "backdrop-hash", Versions: []catalogpkg.FileVersion{{ FileID: 42, Resolution: "1080p", CodecVideo: "h264", CodecAudio: "eac3", Container: "mkv", FileSize: 1024, Duration: 10200, Bitrate: 8000000, AddedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), @@ -102,6 +104,11 @@ func TestGetWatchState(t *testing.T) { if body["content_id"] != "movie:heat-1995" || body["effective_subtitle_language"] != "eng" || body["effective_subtitle_mode"] != nil { t.Fatalf("body = %s", rec.Body.String()) } + // The artwork system media controls show (#803). + if body["poster_url"] != "/api/images/poster/heat.jpg?size=medium" || body["poster_thumbhash"] != "poster-hash" || + body["backdrop_url"] != "/api/images/backdrop/heat.jpg?size=medium" || body["backdrop_thumbhash"] != "backdrop-hash" { + t.Fatalf("artwork in body = %s", rec.Body.String()) + } versions := body["versions"].([]any) v := versions[0].(map[string]any) if v["file_id"] != "42" || v["duration_seconds"].(float64) != 10200 || v["added_at"] != "2026-01-02T03:04:05.000Z" || v["intro"].(map[string]any)["end_seconds"].(float64) != 90 || v["trickplay_available"] != true { diff --git a/internal/apiv2/watch_trickplay.go b/internal/apiv2/watch_trickplay.go index 0cf366c258..0502f23c65 100644 --- a/internal/apiv2/watch_trickplay.go +++ b/internal/apiv2/watch_trickplay.go @@ -79,6 +79,7 @@ func (reg *Registry) getWatchTrickplay(ctx context.Context, in *WatchTrickplayIn if err != nil { return nil, NewProblem(TypeInternalError, "An unexpected error occurred.") } + filter.OmitWatchArtwork = true detail, err := reg.deps.Watch.WatchDetail(ctx, claims.UserID, profileFrom(ctx), string(in.ID), filter) if err != nil { return nil, watchDetailProblem(err) diff --git a/internal/apiv2/watch_trickplay_test.go b/internal/apiv2/watch_trickplay_test.go index 71b4a7fc42..e218a88ec5 100644 --- a/internal/apiv2/watch_trickplay_test.go +++ b/internal/apiv2/watch_trickplay_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + catalogpkg "github.com/Silo-Server/silo-server/internal/catalog" "github.com/Silo-Server/silo-server/internal/trickplay" ) @@ -46,6 +47,27 @@ func trickplayDeps(watch *fakeWatch, tp *fakeTrickplay) Dependencies { return deps } +type watchWithUnavailableArtwork struct{ fakeWatch } + +func (f *watchWithUnavailableArtwork) WatchDetail(ctx context.Context, userID int, profileID, contentID string, filter catalogpkg.AccessFilter) (*catalogpkg.WatchDetail, error) { + if !filter.OmitWatchArtwork { + return nil, errors.New("artwork provider unavailable") + } + return f.fakeWatch.WatchDetail(ctx, userID, profileID, contentID, filter) +} + +func TestWatchTrickplayDoesNotNeedArtwork(t *testing.T) { + tp := &fakeTrickplay{} + deps := trickplayDeps(&fakeWatch{}, tp) + deps.Watch = &watchWithUnavailableArtwork{} + h := newTestHandler(t, deps) + owner := with(bearer(memberToken), "X-Profile-Id", "p-owner") + rec := do(t, h, http.MethodGet, "/api/v2/watch/movie:heat-1995/trickplay?file_id=42", "", owner) + if rec.Code != http.StatusOK || len(tp.calls) != 1 || tp.calls[0] != 42 { + t.Fatalf("seek previews = %d %s; manifest calls = %v", rec.Code, rec.Body.String(), tp.calls) + } +} + func TestGetWatchTrickplay(t *testing.T) { watch, tp := &fakeWatch{}, &fakeTrickplay{} h := newTestHandler(t, trickplayDeps(watch, tp)) diff --git a/internal/catalog/access_filter.go b/internal/catalog/access_filter.go index 1c64b823e1..9f046ab695 100644 --- a/internal/catalog/access_filter.go +++ b/internal/catalog/access_filter.go @@ -53,6 +53,9 @@ type AccessFilter struct { // size. Unset means the caller expressed no preference, and the per-context // defaults apply. See internal/imagesize. ImageSize imagesize.Size + // OmitWatchArtwork skips presentation-only artwork in watch detail for + // legacy responses and internal source selection. It does not change access. + OmitWatchArtwork bool // NamePrefix, when non-empty, restricts results to items whose // LOWER(COALESCE(NULLIF(BTRIM(sort_title),''), title)) starts with the // given (case-insensitive) prefix. Pushed into the SQL WHERE clause so diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index 140e275816..6199a00128 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -700,6 +700,11 @@ type WatchDetail struct { EffectiveVersionHDR *bool `json:"effective_version_hdr,omitempty"` EffectiveVersionCodecVideo *string `json:"effective_version_codec_video,omitempty"` EffectiveVersionEditionKey *string `json:"effective_version_edition_key,omitempty"` + // Artwork is mapped explicitly by v2; the frozen v1 JSON omits it. + PosterURL string `json:"-"` + PosterThumbhash string `json:"-"` + BackdropURL string `json:"-"` + BackdropThumbhash string `json:"-"` } func (d ItemDetail) MarshalJSON() ([]byte, error) { @@ -3241,6 +3246,12 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi item.ContentID, ) detail.Year = item.Year + if !filter.OmitWatchArtwork { + detail.PosterThumbhash = item.PosterThumbhash + detail.BackdropThumbhash = item.BackdropThumbhash + detail.PosterURL = s.PresignImageURL(ctx, item.PosterPath, "poster", string(filter.ImageSize)) + detail.BackdropURL = s.PresignImageURL(ctx, item.BackdropPath, "backdrop", string(filter.ImageSize)) + } s.effectiveSubtitleDefaults(ctx, filter, item.ContentID, files).applyToWatchDetail(detail) return detail, nil case !errors.Is(err, ErrItemNotFound): @@ -3311,6 +3322,11 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi } } + if !filter.OmitWatchArtwork { + detail.PosterThumbhash = episode.StillThumbhash + detail.PosterURL = s.PresignImageURL(ctx, episode.StillPath, "still", string(filter.ImageSize)) + } + if seriesErr == nil && series != nil { series, err = s.LocalizeItemModel(ctx, series, localizationFilter) if err != nil { @@ -3318,6 +3334,10 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi } detail.SeriesTitle = series.Title detail.Year = series.Year + if !filter.OmitWatchArtwork { + detail.BackdropThumbhash = series.BackdropThumbhash + detail.BackdropURL = s.PresignImageURL(ctx, series.BackdropPath, "backdrop", string(filter.ImageSize)) + } } return detail, nil @@ -3379,6 +3399,12 @@ func (s *DetailService) buildExtraWatchDetail(ctx context.Context, contentID str detail.SeriesTitle = parent.Title } detail.Year = parent.Year + if !filter.OmitWatchArtwork { + detail.PosterThumbhash = parent.PosterThumbhash + detail.BackdropThumbhash = parent.BackdropThumbhash + detail.PosterURL = s.PresignImageURL(ctx, parent.PosterPath, "poster", string(filter.ImageSize)) + detail.BackdropURL = s.PresignImageURL(ctx, parent.BackdropPath, "backdrop", string(filter.ImageSize)) + } } return detail, nil } diff --git a/internal/catalog/detail_watch_artwork_test.go b/internal/catalog/detail_watch_artwork_test.go new file mode 100644 index 0000000000..8f5fd7a7ac --- /dev/null +++ b/internal/catalog/detail_watch_artwork_test.go @@ -0,0 +1,124 @@ +package catalog + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestGetWatchDetailArtwork covers #803 through GetWatchDetail itself: a +// movie carries its own poster and backdrop, an episode its still as the +// poster and its series' backdrop, and a local extra its parent's artwork. +// An item without artwork leaves the fields empty. +func TestGetWatchDetailArtwork(t *testing.T) { + f := newVersionsFixture(t) + exec := func(sql string, args ...any) { + t.Helper() + if _, err := f.pool.Exec(t.Context(), sql, args...); err != nil { + t.Fatal(err) + } + } + for _, kind := range []string{"movie", "series"} { + exec(`UPDATE media_items SET poster_path=$2, poster_thumbhash=$3, backdrop_path=$4, backdrop_thumbhash=$5 WHERE content_id=$1`, + f.ids[kind], "tmdb/"+kind+"/poster.jpg", kind+"-poster-hash", "tmdb/"+kind+"/backdrop.jpg", kind+"-backdrop-hash") + } + exec(`UPDATE episodes SET still_path=$2, still_thumbhash=$3 WHERE content_id=$1`, f.ids["episode"], "tmdb/episode/still.jpg", "episode-still-hash") + + filter := AccessFilter{UserID: 1, ProfileID: "profile-1", ImageSize: "medium"} + presign := func(path, kind string) string { + return f.svc.PresignImageURL(t.Context(), path, kind, string(filter.ImageSize)) + } + cases := []struct { + name, id string + posterURL, posterHash, backURL, backHash string + }{ + {"movie", f.ids["movie"], presign("tmdb/movie/poster.jpg", "poster"), "movie-poster-hash", presign("tmdb/movie/backdrop.jpg", "backdrop"), "movie-backdrop-hash"}, + {"episode", f.ids["episode"], presign("tmdb/episode/still.jpg", "still"), "episode-still-hash", presign("tmdb/series/backdrop.jpg", "backdrop"), "series-backdrop-hash"}, + {"extra", f.ids["extra"], presign("tmdb/movie/poster.jpg", "poster"), "movie-poster-hash", presign("tmdb/movie/backdrop.jpg", "backdrop"), "movie-backdrop-hash"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + detail, err := f.svc.GetWatchDetail(t.Context(), tc.id, filter) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(detail.PosterURL, "https://images.invalid/") || !strings.HasPrefix(detail.BackdropURL, "https://images.invalid/") { + t.Fatalf("artwork URLs not resolved through the image resolver: poster=%q backdrop=%q", detail.PosterURL, detail.BackdropURL) + } + if detail.PosterURL != tc.posterURL || detail.PosterThumbhash != tc.posterHash { + t.Fatalf("poster = %q/%q, want %q/%q", detail.PosterURL, detail.PosterThumbhash, tc.posterURL, tc.posterHash) + } + if detail.BackdropURL != tc.backURL || detail.BackdropThumbhash != tc.backHash { + t.Fatalf("backdrop = %q/%q, want %q/%q", detail.BackdropURL, detail.BackdropThumbhash, tc.backURL, tc.backHash) + } + }) + } + + t.Run("no artwork", func(t *testing.T) { + exec(`UPDATE media_items SET poster_path='', poster_thumbhash='', backdrop_path='', backdrop_thumbhash='' WHERE content_id=$1`, f.ids["movie"]) + detail, err := f.svc.GetWatchDetail(t.Context(), f.ids["movie"], filter) + if err != nil { + t.Fatal(err) + } + if detail.PosterURL != "" || detail.BackdropURL != "" || detail.PosterThumbhash != "" || detail.BackdropThumbhash != "" { + t.Fatalf("artwork for an item without any: poster=%q backdrop=%q", detail.PosterURL, detail.BackdropURL) + } + }) +} + +func TestWatchDetailLegacyJSONOmitsArtwork(t *testing.T) { + detail := WatchDetail{ + ContentID: "movie:test", Title: "Test", + PosterURL: "https://images.invalid/poster.jpg", PosterThumbhash: "poster-hash", + BackdropURL: "https://images.invalid/backdrop.jpg", BackdropThumbhash: "backdrop-hash", + } + payload, err := json.Marshal(detail) + if err != nil { + t.Fatal(err) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(payload, &body); err != nil { + t.Fatal(err) + } + for _, key := range []string{"poster_url", "poster_thumbhash", "backdrop_url", "backdrop_thumbhash"} { + if _, exists := body[key]; exists { + t.Errorf("frozen v1 watch JSON includes %s", key) + } + } + if _, exists := body["content_id"]; !exists { + t.Fatal("watch JSON lost content_id") + } +} + +func TestGetWatchDetailOmitsArtworkResolution(t *testing.T) { + f := newVersionsFixture(t) + for _, kind := range []string{"movie", "series"} { + if _, err := f.pool.Exec(t.Context(), `UPDATE media_items SET poster_path=$2, backdrop_path=$3 WHERE content_id=$1`, + f.ids[kind], "provider://poster", "provider://backdrop"); err != nil { + t.Fatal(err) + } + } + if _, err := f.pool.Exec(t.Context(), `UPDATE episodes SET still_path=$2 WHERE content_id=$1`, f.ids["episode"], "provider://still"); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"movie", "episode", "extra"} { + t.Run(kind, func(t *testing.T) { + f.images.calls.Store(0) + detail, err := f.svc.GetWatchDetail(t.Context(), f.ids[kind], AccessFilter{ + UserID: 1, ProfileID: "profile-1", OmitWatchArtwork: true, + }) + if err != nil { + t.Fatal(err) + } + if calls := f.images.calls.Load(); calls != 0 { + t.Fatalf("source lookup made %d artwork resolver calls", calls) + } + if len(detail.Versions) == 0 { + t.Fatal("source lookup lost playable versions") + } + if detail.PosterURL != "" || detail.BackdropURL != "" { + t.Fatal("source lookup included artwork") + } + }) + } +} diff --git a/internal/watchtogether/selection_resolver.go b/internal/watchtogether/selection_resolver.go index 4b42a78448..185afa8508 100644 --- a/internal/watchtogether/selection_resolver.go +++ b/internal/watchtogether/selection_resolver.go @@ -77,9 +77,10 @@ func preferredRoomVersion(versions []catalog.FileVersion) catalog.FileVersion { func (r *CatalogSelectionResolver) watchDetail(ctx context.Context, userID int, profileID string, input SelectItemInput) (*catalog.WatchDetail, error) { filter := catalog.AccessFilter{ - UserID: userID, - ProfileID: profileID, - SelectedFileID: 0, + UserID: userID, + ProfileID: profileID, + SelectedFileID: 0, + OmitWatchArtwork: true, } if scope, ok := access.GetScope(ctx); ok { filter.AllowedLibraryIDs = scope.AllowedLibraryIDs diff --git a/internal/watchtogether/selection_resolver_test.go b/internal/watchtogether/selection_resolver_test.go index cc60b36680..8ede85f5ee 100644 --- a/internal/watchtogether/selection_resolver_test.go +++ b/internal/watchtogether/selection_resolver_test.go @@ -2,6 +2,7 @@ package watchtogether import ( "context" + "errors" "testing" "github.com/Silo-Server/silo-server/internal/catalog" @@ -11,6 +12,32 @@ type roomWatchDetail struct { versions []catalog.FileVersion } +type unavailableArtworkWatchDetail struct{} + +func (unavailableArtworkWatchDetail) GetWatchDetail(_ context.Context, _ string, filter catalog.AccessFilter) (*catalog.WatchDetail, error) { + if !filter.OmitWatchArtwork { + return nil, errors.New("artwork provider unavailable") + } + return &catalog.WatchDetail{ContentID: "movie-1", Type: "movie", Versions: []catalog.FileVersion{ + {FileID: 1, Resolution: "2160p", EditionKey: "theatrical"}, + {FileID: 2, Resolution: "1080p", EditionKey: "theatrical"}, + }}, nil +} + +func TestRoomSourceSelectionDoesNotNeedArtwork(t *testing.T) { + resolver := NewCatalogSelectionResolver(unavailableArtworkWatchDetail{}) + selected, err := resolver.ResolveSelection(t.Context(), 7, "host", SelectItemInput{ContentID: "movie-1"}) + if err != nil || selected.FileID == nil || *selected.FileID != 1 { + t.Fatalf("selection = %+v, error = %v", selected, err) + } + selected, err = resolver.ResolveSourceFallback(t.Context(), 7, "host", SelectItemInput{ + ContentID: "movie-1", FileID: new(1), + }, fallbackReasonLowerResolution) + if err != nil || selected.FileID == nil || *selected.FileID != 2 { + t.Fatalf("fallback = %+v, error = %v", selected, err) + } +} + func (d roomWatchDetail) GetWatchDetail(context.Context, string, catalog.AccessFilter) (*catalog.WatchDetail, error) { return &catalog.WatchDetail{ContentID: "movie-1", Type: "movie", Versions: d.versions}, nil } diff --git a/scripts/ci/db-pins.txt b/scripts/ci/db-pins.txt index dacc66e19b..4bb5105f0b 100644 --- a/scripts/ci/db-pins.txt +++ b/scripts/ci/db-pins.txt @@ -53,6 +53,11 @@ # re-id can rename an item whatever child rows it has (#933). ./migrations TestContentIDFamilyFKsCascadeOnUpdatePostgres +# Watch detail artwork: movies, episodes and extras carry the poster and +# backdrop clients publish to system media controls (#803). +./internal/catalog TestGetWatchDetailArtwork +./internal/catalog TestGetWatchDetailOmitsArtworkResolution + # Detail and versions: localization and folder reads per detail build, and the # versions endpoint issuing fewer queries than full detail with no image work. ./internal/catalog TestBuildMediaItemDetailSharesLocalization diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index 382d1934f0..b1da85b7be 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -30989,6 +30989,9 @@ export interface components { title: string; }; WatchDetail: { + backdrop_thumbhash?: string; + /** @description The item's backdrop, an episode's series backdrop, or an extra's parent backdrop, sized by image_size */ + backdrop_url?: string; /** @example movie:heat-1995 */ content_id: string; credits?: components["schemas"]["WatchMarker"]; @@ -31009,6 +31012,9 @@ export interface components { overview?: string; /** @description Logical watch choices, each spanning one or more ordered parts */ playback_variants?: components["schemas"]["WatchPlaybackVariant"][]; + poster_thumbhash?: string; + /** @description The item's poster, an episode's still, or an extra's parent poster, sized by image_size; for system media controls */ + poster_url?: string; preview?: components["schemas"]["WatchMarker"]; recap?: components["schemas"]["WatchMarker"]; /** Format: int64 */ From 2359f6cfc88e56c5490d4295bbbd56f1e36e6046 Mon Sep 17 00:00:00 2001 From: rangoDJ Date: Tue, 13 Oct 2026 14:34:34 -0400 Subject: [PATCH 016/196] chore(hooks): catch gofmt and golangci-lint issues in pre-commit (#865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(hooks): catch gofmt and golangci-lint issues in pre-commit PR #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 * 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 #865. Co-Authored-By: Claude Opus 5.5 * 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 #865. Co-Authored-By: Claude Opus 5.5 * fix(hooks): count a staged rename's source package as touched Co-Authored-By: Claude Opus 5.5 * fix(hooks): validate staged Go inputs without worktree bypasses --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- .githooks/pre-commit | 5 +- scripts/check-go-lint.sh | 119 ++++++++++++++++++++++++ scripts/test_check_go_lint.py | 168 ++++++++++++++++++++++++++++++++++ scripts/test_lint_changed.py | 3 + 4 files changed, 294 insertions(+), 1 deletion(-) create mode 100755 scripts/check-go-lint.sh create mode 100644 scripts/test_check_go_lint.py diff --git a/.githooks/pre-commit b/.githooks/pre-commit index d22600a2e3..1f2d1325c8 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -3,4 +3,7 @@ set -euo pipefail # Resolve via git toplevel so the hook works from any cwd (e.g., when # git invokes hooks from within a non-root subdirectory). -"$(git rev-parse --show-toplevel)/scripts/check-local-path-leaks.sh" --cached +repo_root="$(git rev-parse --show-toplevel)" + +"$repo_root/scripts/check-local-path-leaks.sh" --cached +"$repo_root/scripts/check-go-lint.sh" --cached diff --git a/scripts/check-go-lint.sh b/scripts/check-go-lint.sh new file mode 100755 index 0000000000..7c278c4ff7 --- /dev/null +++ b/scripts/check-go-lint.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + printf 'usage: %s [--cached]\n' "${0##*/}" >&2 +} + +cached=0 +case "${1:-}" in + "") + ;; + --cached) + cached=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + usage + exit 2 + ;; +esac + +repo_root=$(git rev-parse --show-toplevel) +cd "$repo_root" +scratch=$(mktemp -d "${TMPDIR:-/tmp}/silo-check-go-lint.XXXXXX") +trap 'rm -rf "$scratch"' EXIT + +diff_args=(--name-only -z) +if [[ "$cached" -eq 1 ]]; then + diff_args=(--cached "${diff_args[@]}") +fi + +# NUL-delimited, so names git would quote (an accented one, say) come through +# literally. A read loop rather than mapfile, which macOS's Bash 3.2 lacks; +# "${array[@]+...}" below for the same reason, as Bash before 4.4 treats an +# empty array as unset under set -u. R keeps an edited rename in the check, +# and T includes regular files replaced by symlinks (or the reverse). +# Materialize Git output so command failures cannot masquerade as no changes. +git diff "${diff_args[@]}" --diff-filter=ACMRT -- '*.go' >"$scratch/go-files" +go_files=() +while IFS= read -r -d '' file; do + go_files+=("$file") +done <"$scratch/go-files" + +# Deleting Go files can break the code that's left, so deletions alone still +# run the lint gate; there's nothing of theirs to format. +deleted=0 +git diff "${diff_args[@]}" --diff-filter=D -- '*.go' >"$scratch/deleted-go" +while IFS= read -r -d '' _; do + deleted=1 +done <"$scratch/deleted-go" + +if [[ ${#go_files[@]} -eq 0 && "$deleted" -eq 0 ]]; then + exit 0 +fi + +failed=0 + +if [[ "$cached" -eq 1 ]]; then + # The commit contains the staged blobs, so format-check those rather than + # the working tree, which may hold unstaged edits. + unformatted="" + # A blob that can't be read fails the check rather than passing as empty + # input (pipefail carries git show's failure). + for file in ${go_files[@]+"${go_files[@]}"}; do + if ! out=$(git show ":$file" | gofmt -l 2>&1) || [[ -n "$out" ]]; then + unformatted+="$file"$'\n' + fi + done +elif [[ ${#go_files[@]} -gt 0 ]]; then + unformatted=$(gofmt -l "${go_files[@]}" 2>&1) || true +else + unformatted="" +fi +if [[ -n "$unformatted" ]]; then + printf '%s\n' "gofmt is required on:" >&2 + printf '%s\n' "$unformatted" >&2 + printf '%s\n' "Fix with: gofmt -w " >&2 + failed=1 +fi + +if command -v golangci-lint >/dev/null 2>&1; then + lint_env=() + # The delegate includes the whole branch and reads dependencies, module + # files, lint configuration and embedded inputs from the working tree. + # Staged package directories alone cannot establish that it checks the + # commit. Require all tracked and nonignored inputs to match the index. + if [[ "$cached" -eq 1 ]]; then + git diff --name-only --no-renames -z -- >"$scratch/unstaged" + git ls-files --others --exclude-standard -z >"$scratch/untracked" + # Developer-local workspaces are ignored by Git and absent from CI. + lint_env=(GOWORK=off) + fi + if [[ "$cached" -eq 1 ]] && [[ -s "$scratch/unstaged" || -s "$scratch/untracked" ]]; then + # With the working tree differing from the commit, lint would pass a + # broken commit or block a good one, so refuse rather than guess. + printf '%s\n' "The working tree has unstaged or untracked files; the lint gate cannot check the commit as staged." >&2 + printf '%s\n' "Stage them or move them aside (git stash push --keep-index --include-untracked) and commit again." >&2 + failed=1 + elif ! git rev-parse --verify --quiet "${BASE_REF:-origin/main}" >/dev/null; then + # lint-changed.sh diffs against this ref; without it the lint output + # would not name the real cause. + printf '%s\n' "Cannot lint the commit: base ref '${BASE_REF:-origin/main}' is missing. Run: git fetch origin main (or set BASE_REF)." >&2 + failed=1 + elif ! env ${lint_env[@]+"${lint_env[@]}"} "$repo_root/scripts/lint-changed.sh"; then + # lint-changed.sh reports CI's changed-line findings for the touched + # packages. A type-check failure is a real finding and fails too. + failed=1 + fi +else + printf '%s\n' "skipping golangci-lint: not installed. Install with:" >&2 + printf '%s\n' " go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2" >&2 +fi + +if [[ "$failed" -ne 0 ]]; then + exit 1 +fi diff --git a/scripts/test_check_go_lint.py b/scripts/test_check_go_lint.py new file mode 100644 index 0000000000..b04426d504 --- /dev/null +++ b/scripts/test_check_go_lint.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Exercise the pre-commit Go gate against real Git indexes and gofmt.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("check-go-lint.sh") +GIT = shutil.which("git") +BASH = os.environ.get("TEST_BASH", shutil.which("bash")) + + +class CheckGoLintTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.repo = self.root / "repo" + self.repo.mkdir() + self.bin = self.root / "bin" + self.bin.mkdir() + self.temp = self.root / "tmp" + self.temp.mkdir() + self.output = self.root / "lint.json" + self.env = os.environ.copy() + self.env.update(PATH=str(self.bin), BASE_REF="base", TMPDIR=str(self.temp)) + for tool in ("git", "gofmt", "mktemp", "rm", "env", "bash"): + (self.bin / tool).symlink_to(shutil.which(tool)) + self.git("init", "-q") + self.git("config", "user.name", "CI test") + self.git("config", "user.email", "ci-test@example.invalid") + self.git("config", "core.hooksPath", "/dev/null") + self.write("a/code.go", "package a\n\nconst Value = 1\n") + self.write("b/code.go", "package b\n\nconst Value = 1\n") + self.write("go.mod", "module example.invalid/gate\n\ngo 1.26.4\n") + self.write(".gitignore", "go.work\ngo.work.sum\n") + delegate = self.write("scripts/lint-changed.sh", "#!/usr/bin/env bash\nexec golangci-lint run\n") + delegate.chmod(0o755) + self.git("add", "--all") + self.git("commit", "-qm", "fixture") + self.git("branch", "base") + + def git(self, *args): + return subprocess.check_output([GIT, *args], cwd=self.repo, text=True) + + def write(self, name, text): + path = self.repo / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + def linter(self, status=0): + path = self.bin / "golangci-lint" + path.write_text( + f"#!{sys.executable}\n" + "import json, os\n" + "from pathlib import Path\n" + f"Path({str(self.output)!r}).write_text(json.dumps({{'gowork': os.environ.get('GOWORK')}}))\n" + f"raise SystemExit({status})\n" + ) + path.chmod(0o755) + + def stage_go(self): + self.write("a/code.go", "package a\n\nconst Value = 2\n") + self.git("add", "a/code.go") + + def gate(self, *args): + result = subprocess.run( + [BASH, str(SCRIPT), *args], cwd=self.repo, env=self.env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + self.assertEqual(list(self.temp.iterdir()), [], "temporary inputs leaked") + return result + + def test_no_go_changes_skip_lint(self): + self.linter(status=1) + result = self.gate("--cached") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.output.exists()) + + def test_formatting_reads_the_index_with_literal_names(self): + self.write("café.go", "package a;const X=1\n") + self.git("add", "café.go") + self.write("café.go", "package a\n\nconst X = 1\n") + result = self.gate("--cached") + self.assertEqual(result.returncode, 1) + self.assertIn("café.go", result.stderr) + + def test_dangling_symlink_type_change_is_checked(self): + path = self.repo / "a/code.go" + path.unlink() + path.symlink_to("missing.go") + self.git("add", "a/code.go") + self.assertIn("T\ta/code.go", self.git("diff", "--cached", "--name-status")) + result = self.gate("--cached") + self.assertEqual(result.returncode, 1) + self.assertIn("gofmt is required", result.stderr) + + def test_git_discovery_failure_cannot_pass_as_no_changes(self): + self.stage_go() + self.git("config", "diff.orderFile", "missing-order-file") + result = self.gate("--cached") + self.assertNotEqual(result.returncode, 0) + self.assertIn("failed to read orderfile", result.stderr) + + def test_dependency_module_and_untracked_inputs_refuse_lint(self): + self.linter() + self.stage_go() + for name in ("b/code.go", "go.mod", "other/new.go", "notes.txt"): + with self.subTest(name=name): + path = self.repo / name + original = path.read_text() if path.exists() else None + self.write(name, "unstaged input\n") + result = self.gate("--cached") + self.assertEqual(result.returncode, 1) + self.assertIn("unstaged or untracked", result.stderr) + self.assertIn("--include-untracked", result.stderr) + self.assertFalse(self.output.exists()) + if original is None: + path.unlink() + else: + path.write_text(original) + + def test_deleted_file_restored_on_disk_refuses_lint(self): + self.linter() + self.git("rm", "--cached", "b/code.go") + result = self.gate("--cached") + self.assertEqual(result.returncode, 1) + self.assertFalse(self.output.exists()) + + def test_deletion_only_commit_reaches_lint_and_preserves_failure(self): + self.linter(status=1) + self.git("rm", "b/code.go") + result = self.gate("--cached") + self.assertEqual(result.returncode, 1) + self.assertTrue(self.output.exists()) + + def test_clean_staged_commit_disables_ignored_workspaces(self): + self.linter() + self.stage_go() + self.write("go.work", "invalid ignored workspace\n") + result = self.gate("--cached") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(self.output.read_text())["gowork"], "off") + + def test_noncached_mode_keeps_local_workspace(self): + self.linter() + self.stage_go() + self.write("a/code.go", "package a\n\nconst Value = 3\n") + self.env["GOWORK"] = "auto" + result = self.gate() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(self.output.read_text())["gowork"], "auto") + + def test_missing_linter_warns_and_keeps_formatting_gate(self): + self.stage_go() + result = self.gate("--cached") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("skipping golangci-lint", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_lint_changed.py b/scripts/test_lint_changed.py index 65c2f837e4..c9a14b0b19 100644 --- a/scripts/test_lint_changed.py +++ b/scripts/test_lint_changed.py @@ -307,4 +307,7 @@ def test_linter_failure_propagates_and_cleans_patch(self): if __name__ == "__main__": + # The repository CI already runs this entry point for lint helper checks. + from test_check_go_lint import CheckGoLintTests + unittest.main() From 2d746e858f48f928c8f519ee248a28aad48eb9d4 Mon Sep 17 00:00:00 2001 From: rangoDJ Date: Tue, 13 Oct 2026 14:36:06 -0400 Subject: [PATCH 017/196] perf(subtitles): reuse cached embedded extracts for AI translation sources (#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 #1099 Co-Authored-By: Claude Opus 5.5 * 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 * 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 * 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 #1436. Co-Authored-By: Claude Opus 5.5 * test(subtitles): release the first fill only once the second finds it busy Co-Authored-By: Claude Opus 5.5 * 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 --------- Co-authored-by: Claude Opus 5.5 --- internal/api/router.go | 3 + internal/playback/subtitle_cache.go | 55 ++++++- .../playback/subtitle_cache_inflight_test.go | 152 +++++++++++++++++ internal/playback/subtitle_text_batch.go | 3 + internal/subtitles/ai/service.go | 32 +++- internal/subtitles/ai/source_cache_test.go | 155 ++++++++++++++++++ 6 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 internal/playback/subtitle_cache_inflight_test.go create mode 100644 internal/subtitles/ai/source_cache_test.go diff --git a/internal/api/router.go b/internal/api/router.go index 29b5c96a76..4b1a182acb 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1880,6 +1880,9 @@ func newChiRouter(deps Dependencies) chi.Router { aiSem, ) aiService.SetExternalTimings(subtitleRepo) + if streamHandler != nil && streamHandler.SubtitleCache != nil { + aiService.SetSubtitleCache(streamHandler.SubtitleCache) + } aiService.Recover() if deps.OnConfigChange != nil { deps.OnConfigChange(func(_, updated *config.Config) { diff --git a/internal/playback/subtitle_cache.go b/internal/playback/subtitle_cache.go index aec1614aae..c267e6227d 100644 --- a/internal/playback/subtitle_cache.go +++ b/internal/playback/subtitle_cache.go @@ -372,6 +372,19 @@ func (c *SubtitleCache) LookupText(inputPath string, trackIndex int, format stri return data, err == nil } +// LookupWebVTT returns the complete WebVTT rendition native playback cached +// for a text track (ServeExtract stores SubRip and forced-VTT extracts under +// the vtt key), so another reader can reuse it instead of demuxing the source. +func (c *SubtitleCache) LookupWebVTT(inputPath string, trackIndex int) ([]byte, bool) { + f, _, ok := c.lookup(inputPath, trackIndex, SubtitleFormatVTTV3) + if !ok { + return nil, false + } + defer func() { _ = f.Close() }() + data, err := io.ReadAll(f) + return data, err == nil +} + // ExtractText reuses a complete cached text track or extracts it once for this // request. The fill captures source identity before extraction, so a replaced // source cannot publish an old extract under the new file's cache key. @@ -383,10 +396,38 @@ func (c *SubtitleCache) ExtractText(ctx context.Context, inputPath string, track if format == "" { return nil, fmt.Errorf("unsupported cached subtitle format") } + // Share another request's extraction rather than demuxing the source a + // second time: look the track up, else reserve the fill, else wait for + // whoever holds it and look again. A fill that commits between a busy + // reservation and the wait is found by the next lookup, and one that + // commits during the last wait by the lookup after the loop. The rounds + // are bounded, so a cache that can't take a fill (disabled, unwritable) + // falls through to extracting without one. + var fill *SubtitleCacheFill + for round := 0; round < textFillRounds && fill == nil; round++ { + if data, ok := c.LookupText(inputPath, trackIndex, format); ok { + return data, nil + } + if fill = c.beginFill(inputPath, trackIndex, format); fill != nil { + break + } + if afterBusyTextFill != nil { + afterBusyTextFill() + } + c.waitForTextFill(ctx, inputPath, TextSubtitleTrack{Ordinal: trackIndex, Format: format}) + if err := ctx.Err(); err != nil { + return nil, err + } + } + // Another filler may have committed since the last lookup: between it + // and this reservation, or during the last round's wait. Reuse its entry + // rather than extracting again. if data, ok := c.LookupText(inputPath, trackIndex, format); ok { + if fill != nil { + fill.Discard() + } return data, nil } - fill := c.beginFill(inputPath, trackIndex, format) data, err := extract(ctx) if err != nil { if fill != nil { @@ -404,6 +445,18 @@ func (c *SubtitleCache) ExtractText(ctx context.Context, inputPath string, track return data, nil } +// textFillRounds bounds ExtractText's lookup, reserve and wait rounds. +const textFillRounds = 3 + +// afterBusyTextFill runs when ExtractText finds another fill in progress, +// before it waits; tests use it to finish that fill in between. +var afterBusyTextFill func() + +// afterTextFillWaitCapture runs once waitForTextFill holds an in-flight +// fill's done channel, before it blocks on it; tests use it to act while a +// request is waiting. +var afterTextFillWaitCapture func() + func normalizeCachedTextSubtitleFormat(format string) string { switch strings.ToLower(strings.TrimPrefix(strings.TrimSpace(format), ".")) { case subtitleFormatSRT, subtitleCodecSubRip: diff --git a/internal/playback/subtitle_cache_inflight_test.go b/internal/playback/subtitle_cache_inflight_test.go new file mode 100644 index 0000000000..4ec15a3431 --- /dev/null +++ b/internal/playback/subtitle_cache_inflight_test.go @@ -0,0 +1,152 @@ +package playback + +import ( + "context" + "io" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" +) + +// TestExtractTextSharesInFlightFill: a request for a track another request is +// already extracting waits for that fill instead of demuxing the source again. +func TestExtractTextSharesInFlightFill(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "movie.mkv") + if err := os.WriteFile(source, []byte("source"), 0o644); err != nil { + t.Fatal(err) + } + cache := NewSubtitleCache(func() string { return filepath.Join(dir, "transcode") }) + var calls atomic.Int32 + started := make(chan struct{}) + release := make(chan struct{}) + extract := func(context.Context) ([]byte, error) { + if calls.Add(1) == 1 { + close(started) + <-release + } + return []byte("1\n00:00:01,000 --> 00:00:02,000\nhello\n"), nil + } + + var wg sync.WaitGroup + results := make([][]byte, 2) + errs := make([]error, 2) + wg.Add(1) + go func() { + defer wg.Done() + results[0], errs[0] = cache.ExtractText(context.Background(), source, 0, "srt", extract) + }() + <-started + // Release the first extraction only once the second request is waiting + // on it, so the test exercises the shared fill, not a later cache hit. + busy := make(chan struct{}) + var busyOnce sync.Once + afterTextFillWaitCapture = func() { busyOnce.Do(func() { close(busy) }) } + t.Cleanup(func() { afterTextFillWaitCapture = nil }) + wg.Add(1) + go func() { + defer wg.Done() + results[1], errs[1] = cache.ExtractText(context.Background(), source, 0, "srt", extract) + }() + <-busy + close(release) + wg.Wait() + + for i := range errs { + if errs[i] != nil { + t.Fatalf("request %d: %v", i, errs[i]) + } + if string(results[i]) != string(results[0]) { + t.Fatalf("request %d read %q, want %q", i, results[i], results[0]) + } + } + if got := calls.Load(); got != 1 { + t.Fatalf("extract ran %d times, want 1", got) + } +} + +// A fill that commits after ExtractText finds it in progress, but before +// ExtractText waits for it, is still reused: the next lookup finds it, and +// the source isn't demuxed again. +func TestExtractTextReusesAFillThatCommitsBeforeTheWait(t *testing.T) { + cache, source := newTestCache(t) + holder := cache.beginFill(source, 0, "srt") + if holder == nil { + t.Fatal("couldn't reserve the fill") + } + if _, err := holder.Tee(io.Discard).Write([]byte("SHARED")); err != nil { + t.Fatal(err) + } + afterBusyTextFill = func() { + afterBusyTextFill = nil + if err := holder.Commit(); err != nil { + t.Error(err) + } + } + t.Cleanup(func() { afterBusyTextFill = nil }) + + extracts := 0 + data, err := cache.ExtractText(t.Context(), source, 0, "srt", func(context.Context) ([]byte, error) { + extracts++ + return []byte("AGAIN"), nil + }) + if err != nil || string(data) != "SHARED" || extracts != 0 { + t.Fatalf("ExtractText = %q, %v after %d extracts; want the committed fill and none", data, err, extracts) + } +} + +// A fill that commits during the last round's wait is reused too: the rounds +// run out with no reservation, and the lookup after them finds the entry. +func TestExtractTextReusesAFillThatCommitsDuringTheLastWait(t *testing.T) { + cache, source := newTestCache(t) + reserve := func() *SubtitleCacheFill { + holder := cache.beginFill(source, 0, "srt") + if holder == nil { + t.Fatal("couldn't reserve the fill") + } + if _, err := holder.Tee(io.Discard).Write([]byte("SHARED")); err != nil { + t.Fatal(err) + } + return holder + } + holder := reserve() + waits := 0 + afterTextFillWaitCapture = func() { + waits++ + if waits < textFillRounds { + // Each earlier filler gives up and another takes over. + holder.Discard() + holder = reserve() + return + } + if err := holder.Commit(); err != nil { + t.Error(err) + } + } + t.Cleanup(func() { afterTextFillWaitCapture = nil }) + + extracts := 0 + data, err := cache.ExtractText(t.Context(), source, 0, "srt", func(context.Context) ([]byte, error) { + extracts++ + return []byte("AGAIN"), nil + }) + if err != nil || string(data) != "SHARED" || extracts != 0 || waits != textFillRounds { + t.Fatalf("ExtractText = %q, %v after %d extracts and %d waits; want the committed fill, no extract, %d waits", + data, err, extracts, waits, textFillRounds) + } +} + +// Without a cache directory there's no fill to share: ExtractText extracts. +func TestExtractTextWithoutACacheExtracts(t *testing.T) { + cache := NewSubtitleCache(func() string { return "" }) + extracts := 0 + data, err := cache.ExtractText(t.Context(), "/media/movie.mkv", 0, "srt", func(context.Context) ([]byte, error) { + extracts++ + return []byte("DATA"), nil + }) + if err != nil || string(data) != "DATA" || extracts != 1 { + t.Fatalf("ExtractText = %q, %v after %d extracts; want one extract", data, err, extracts) + } +} diff --git a/internal/playback/subtitle_text_batch.go b/internal/playback/subtitle_text_batch.go index 34c96c6889..7d61130d43 100644 --- a/internal/playback/subtitle_text_batch.go +++ b/internal/playback/subtitle_text_batch.go @@ -271,6 +271,9 @@ func (c *SubtitleCache) waitForTextFill(ctx context.Context, inputPath string, t if done == nil { return false } + if afterTextFillWaitCapture != nil { + afterTextFillWaitCapture() + } select { case <-done: return true diff --git a/internal/subtitles/ai/service.go b/internal/subtitles/ai/service.go index 8c23467436..3d5a4f7964 100644 --- a/internal/subtitles/ai/service.go +++ b/internal/subtitles/ai/service.go @@ -78,6 +78,13 @@ type Service struct { ffmpegPath string logger *slog.Logger runner *jobrunner.Runner + // subtitleCache, when set, holds complete embedded text extracts shared + // with subtitle delivery, so a source track is demuxed once rather than + // on every translation of it. Optional; nil extracts every time. + subtitleCache *playback.SubtitleCache + // extractEmbedded demuxes one embedded subtitle stream (ffmpeg's 0:s:N) + // as SRT. Nil uses playback.ExtractSubtitle; tests replace it. + extractEmbedded func(ctx context.Context, filePath string, ordinal int) ([]byte, error) } // SetExternalTimings makes a sidecar source carry its timing correction, as @@ -86,6 +93,12 @@ func (s *Service) SetExternalTimings(timings subtitles.ExternalTimingLookup) { s.externalTimings = timings } +// SetSubtitleCache shares the node's subtitle extract cache with translation +// source loading. Call it before Recover or the first Enqueue. +func (s *Service) SetSubtitleCache(cache *playback.SubtitleCache) { + s.subtitleCache = cache +} + // UpdateConfig swaps the service config. Safe for concurrent use; running // jobs finish with the config they started with. func (s *Service) UpdateConfig(cfg Config) { @@ -715,7 +728,24 @@ func (s *Service) loadSource(ctx context.Context, job *Job) ([]SubtitleCue, stri if playback.NeedsBurnIn(track.Codec) { return nil, "", fmt.Errorf("%w: bitmap track", ErrSourceUnsupported) } - data, _, err := playback.ExtractSubtitle(ctx, file.FilePath, embeddedIndex, s.ffmpegPath) + extract := func(ctx context.Context) ([]byte, error) { + if s.extractEmbedded != nil { + return s.extractEmbedded(ctx, file.FilePath, embeddedIndex) + } + data, _, err := playback.ExtractSubtitle(ctx, file.FilePath, embeddedIndex, s.ffmpegPath) + return data, err + } + var data []byte + if cached, ok := s.subtitleCache.LookupWebVTT(file.FilePath, embeddedIndex); ok { + // Native playback already demuxed this track as WebVTT; ParseCues + // reads it, so the source is not demuxed again. + data = cached + } else if s.subtitleCache != nil { + // ExtractSubtitle writes SRT for ffmpeg's 0:s:N, the cache's key. + data, err = s.subtitleCache.ExtractText(ctx, file.FilePath, embeddedIndex, "srt", extract) + } else { + data, err = extract(ctx) + } if err != nil { return nil, "", fmt.Errorf("extract embedded subtitle: %w", err) } diff --git a/internal/subtitles/ai/source_cache_test.go b/internal/subtitles/ai/source_cache_test.go new file mode 100644 index 0000000000..62951b0b54 --- /dev/null +++ b/internal/subtitles/ai/source_cache_test.go @@ -0,0 +1,155 @@ +package ai + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/playback" +) + +const cachedSourceSRT = `1 +00:00:01,000 --> 00:00:02,000 +Bonjour + +2 +00:00:03,000 --> 00:00:04,000 +Au revoir +` + +// TestLoadSourceReusesCachedEmbeddedSubtitle covers #1099: translating an +// embedded text track reads the node's complete subtitle extract instead of +// demuxing the source again. The service's ffmpeg path does not exist, so any +// extraction attempt fails the test. +func TestLoadSourceReusesCachedEmbeddedSubtitle(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "movie.mkv") + if err := os.WriteFile(source, []byte("not a real container"), 0o644); err != nil { + t.Fatal(err) + } + cache := playback.NewSubtitleCache(func() string { return filepath.Join(dir, "transcode") }) + if _, err := cache.ExtractText(context.Background(), source, 1, "srt", func(context.Context) ([]byte, error) { + return []byte(cachedSourceSRT), nil + }); err != nil { + t.Fatal(err) + } + + file := &models.MediaFile{ID: 7, FilePath: source, SubtitleTracks: []models.SubtitleTrack{ + {Language: "eng", Codec: "subrip"}, + {Language: "fre", Codec: "subrip"}, + }} + svc := NewService(context.Background(), Config{}, nil, nil, nil, nil, nil, + runTranscribeFileResolver{file: file}, nil, filepath.Join(dir, "missing-ffmpeg"), nil, nil) + svc.SetSubtitleCache(cache) + + cues, language, err := svc.loadSource(context.Background(), &Job{MediaFileID: file.ID, SourceIndex: 1}) + if err != nil { + t.Fatalf("loadSource extracted instead of reusing the cache: %v", err) + } + if language != "fre" || len(cues) != 2 || cues[0].Lines[0] != "Bonjour" { + t.Fatalf("language=%q cues=%+v", language, cues) + } + + // Without the cache the same request has to demux, which fails here. + svc.SetSubtitleCache(nil) + if _, _, err := svc.loadSource(context.Background(), &Job{MediaFileID: file.ID, SourceIndex: 1}); err == nil { + t.Fatal("uncached load did not attempt extraction") + } else if errors.Is(err, ErrSourceUnsupported) { + t.Fatalf("unexpected unsupported-source error: %v", err) + } +} + +// TestLoadSourceFillsCacheOnMiss: the first translation of an uncached track +// extracts it once and publishes the extract, so a later translation, even +// from another service sharing the cache, reads it without extracting. +func TestLoadSourceFillsCacheOnMiss(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "movie.mkv") + if err := os.WriteFile(source, []byte("not a real container"), 0o644); err != nil { + t.Fatal(err) + } + cache := playback.NewSubtitleCache(func() string { return filepath.Join(dir, "transcode") }) + file := &models.MediaFile{ID: 7, FilePath: source, SubtitleTracks: []models.SubtitleTrack{ + {Language: "eng", Codec: "subrip"}, + {Language: "fre", Codec: "subrip"}, + }} + newService := func(extract func(context.Context, string, int) ([]byte, error)) *Service { + svc := NewService(context.Background(), Config{}, nil, nil, nil, nil, nil, + runTranscribeFileResolver{file: file}, nil, "", nil, nil) + svc.SetSubtitleCache(cache) + svc.extractEmbedded = extract + return svc + } + + calls := 0 + first := newService(func(_ context.Context, path string, ordinal int) ([]byte, error) { + calls++ + if path != source || ordinal != 1 { + t.Fatalf("extracted %s track %d, want %s track 1", path, ordinal, source) + } + return []byte(cachedSourceSRT), nil + }) + for range 2 { + cues, language, err := first.loadSource(context.Background(), &Job{MediaFileID: file.ID, SourceIndex: 1}) + if err != nil || language != "fre" || len(cues) != 2 { + t.Fatalf("language=%q cues=%d err=%v", language, len(cues), err) + } + } + if calls != 1 { + t.Fatalf("extracted %d times, want 1", calls) + } + + second := newService(func(context.Context, string, int) ([]byte, error) { + return nil, errors.New("extracted again despite a cached source") + }) + if _, _, err := second.loadSource(context.Background(), &Job{MediaFileID: file.ID, SourceIndex: 1}); err != nil { + t.Fatal(err) + } +} + +// TestLoadSourceReusesPlaybackWebVTT covers the producer/consumer path: native +// playback's ServeExtract caches a SubRip track as WebVTT, and the first AI +// translation of that track reads it instead of demuxing the source again. +func TestLoadSourceReusesPlaybackWebVTT(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "movie.mkv") + if err := os.WriteFile(source, []byte("not a real container"), 0o644); err != nil { + t.Fatal(err) + } + cache := playback.NewSubtitleCache(func() string { return filepath.Join(dir, "transcode") }) + + const vtt = "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nBonjour\n\n00:00:03.000 --> 00:00:04.000\nAu revoir\n" + req := httptest.NewRequest(http.MethodGet, "/subtitles/1", nil) + opts := playback.StreamExtractOpts{InputPath: source, TrackIndex: 1, SourceCodec: "subrip"} + if err := cache.ServeExtract(httptest.NewRecorder(), req, opts, func(_ context.Context, o playback.StreamExtractOpts) error { + _, err := io.WriteString(o.Writer, vtt) + return err + }); err != nil { + t.Fatal(err) + } + + file := &models.MediaFile{ID: 7, FilePath: source, SubtitleTracks: []models.SubtitleTrack{ + {Language: "eng", Codec: "subrip"}, + {Language: "fre", Codec: "subrip"}, + }} + svc := NewService(context.Background(), Config{}, nil, nil, nil, nil, nil, + runTranscribeFileResolver{file: file}, nil, "", nil, nil) + svc.SetSubtitleCache(cache) + svc.extractEmbedded = func(context.Context, string, int) ([]byte, error) { + return nil, errors.New("demuxed the source despite playback's cached WebVTT") + } + + cues, language, err := svc.loadSource(context.Background(), &Job{MediaFileID: file.ID, SourceIndex: 1}) + if err != nil { + t.Fatal(err) + } + if language != "fre" || len(cues) != 2 || cues[0].Lines[0] != "Bonjour" { + t.Fatalf("language=%q cues=%+v", language, cues) + } +} From e21aaedbf8f976d4e4361996d3aea0047aec9052 Mon Sep 17 00:00:00 2001 From: rangoDJ Date: Tue, 13 Oct 2026 14:36:24 -0400 Subject: [PATCH 018/196] fix(web): scope realtime query invalidation by item (#857) * fix(web): scope realtime query invalidation to itemId to prevent buffering (fixes #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 * 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 * docs(web): note that watched keys match their whole subtree Co-Authored-By: Claude Opus 5.5 * test(web): cover catalog-shape itemId narrowing on media refresh Co-Authored-By: Claude Opus 5.5 * 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 * 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 * 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 #857. Co-Authored-By: Claude Opus 5.5 * 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 #857. Co-Authored-By: Claude Opus 5.5 * 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 * 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 * 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 * fix(web): preserve dependent caches in scoped realtime refreshes --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- .../realtimeCatalogInvalidation.test.ts | 22 ++ .../components/realtimeCatalogInvalidation.ts | 13 +- .../hooks/queries/mediaSurfaceRefresh.test.ts | 316 ++++++++++++++++++ web/src/hooks/queries/mediaSurfaceRefresh.ts | 266 ++++++++++++++- 4 files changed, 610 insertions(+), 7 deletions(-) diff --git a/web/src/components/realtimeCatalogInvalidation.test.ts b/web/src/components/realtimeCatalogInvalidation.test.ts index a9325447ae..0ede3a8b00 100644 --- a/web/src/components/realtimeCatalogInvalidation.test.ts +++ b/web/src/components/realtimeCatalogInvalidation.test.ts @@ -121,6 +121,28 @@ describe("createCatalogInvalidationScheduler", () => { expect(invalidatedLibraries(queryClient, [3])).toEqual([3]); }); + it("keeps a library-wide event broad when coalesced with a single item", async () => { + const queryClient = new QueryClient(); + const otherDetailKey = catalogKeys.itemDetail("item-2"); + queryClient.setQueryData(otherDetailKey, { content_id: "item-2" }); + const scheduler = createCatalogInvalidationScheduler(queryClient, WINDOW_MS); + + try { + scheduler.schedule({ itemId: "item-1", libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(0); + expect(queryClient.getQueryState(otherDetailKey)?.isInvalidated).toBe(false); + + scheduler.schedule({ itemId: "item-1", libraryId: 3, allowDashboardRefetch: false }); + scheduler.schedule({ libraryId: 3, allowDashboardRefetch: false }); + await vi.advanceTimersByTimeAsync(WINDOW_MS); + + expect(queryClient.getQueryState(otherDetailKey)?.isInvalidated).toBe(true); + } finally { + scheduler.cancel(); + queryClient.clear(); + } + }); + it("marks home section data stale (without refetching) on a library-scoped sweep", async () => { const queryClient = new QueryClient(); const homeLayoutKey = sectionKeys.homeLayout(); diff --git a/web/src/components/realtimeCatalogInvalidation.ts b/web/src/components/realtimeCatalogInvalidation.ts index 117af15f6c..85b508d765 100644 --- a/web/src/components/realtimeCatalogInvalidation.ts +++ b/web/src/components/realtimeCatalogInvalidation.ts @@ -33,9 +33,11 @@ export function invalidateCatalogState( if (libraryId !== undefined) { void queryClient.invalidateQueries({ queryKey: sectionKeys.home(), refetchType: "none" }); } - void invalidateMediaSurfaceQueries(queryClient, { itemId, libraryId }).then(() => { - bumpHomeRefreshSignal(queryClient); - }); + void invalidateMediaSurfaceQueries(queryClient, { itemId, libraryId, itemMayBeNew: true }).then( + () => { + bumpHomeRefreshSignal(queryClient); + }, + ); if (includeLibraryLists) { void queryClient.invalidateQueries({ queryKey: adminKeys.libraries(), @@ -88,6 +90,7 @@ interface PendingCatalogInvalidation { itemIds: Set; libraryIds: Set; hasUnscopedEvent: boolean; + hasItemUnscopedEvent: boolean; allowDashboardRefetch: boolean; includeLibraryLists: boolean; } @@ -151,11 +154,13 @@ function mergeCatalogInvalidation( itemIds: new Set(), libraryIds: new Set(), hasUnscopedEvent: false, + hasItemUnscopedEvent: false, allowDashboardRefetch: false, includeLibraryLists: false, }; if (options.itemId) next.itemIds.add(options.itemId); + else next.hasItemUnscopedEvent = true; if (options.libraryId === undefined) { next.hasUnscopedEvent = true; } else { @@ -171,7 +176,7 @@ function resolveBatch(batch: PendingCatalogInvalidation): CatalogInvalidationOpt const [onlyItemId] = batch.itemIds; const [onlyLibraryId] = batch.libraryIds; return { - itemId: batch.itemIds.size === 1 ? onlyItemId : undefined, + itemId: !batch.hasItemUnscopedEvent && batch.itemIds.size === 1 ? onlyItemId : undefined, libraryId: !batch.hasUnscopedEvent && batch.libraryIds.size === 1 ? onlyLibraryId : undefined, allowDashboardRefetch: batch.allowDashboardRefetch, includeLibraryLists: batch.includeLibraryLists, diff --git a/web/src/hooks/queries/mediaSurfaceRefresh.test.ts b/web/src/hooks/queries/mediaSurfaceRefresh.test.ts index 4b6e43c161..5666d70e59 100644 --- a/web/src/hooks/queries/mediaSurfaceRefresh.test.ts +++ b/web/src/hooks/queries/mediaSurfaceRefresh.test.ts @@ -9,13 +9,16 @@ import { mediaSurfaceKeys, personKeys, progressKeys, + ratingKeys, recKeys, sectionKeys, watchlistKeys, } from "./keys"; import { cancelItemDetailQueries, + getQueryKeyItemId, invalidateMediaSurfaceQueries, + relatedItemIds, removeItemFromHomeSectionCaches, scheduleMediaSurfaceInvalidation, } from "./mediaSurfaceRefresh"; @@ -382,4 +385,317 @@ describe("invalidateMediaSurfaceQueries", () => { }, }); }); + + it("does not invalidate detail queries for a different item ID when itemId is specified", async () => { + const queryClient = new QueryClient(); + const item1DetailKey = itemKeys.detail("item-1"); + const item2DetailKey = itemKeys.detail("item-2"); + const item1WatchKey = itemKeys.watchDetail("item-1"); + const item2WatchKey = itemKeys.watchDetail("item-2"); + + queryClient.setQueryData(item1DetailKey, { content_id: "item-1" }); + queryClient.setQueryData(item2DetailKey, { content_id: "item-2" }); + queryClient.setQueryData(item1WatchKey, { content_id: "item-1" }); + queryClient.setQueryData(item2WatchKey, { content_id: "item-2" }); + // Catalog keys pass activeCatalogQueryMatchesLibrary, so only the itemId + // gate narrows them; this is the shape catalog.item.changed drives (#796). + const item1CatalogKey = catalogKeys.itemDetail("item-1"); + const item2CatalogKey = catalogKeys.itemDetail("item-2"); + queryClient.setQueryData(item1CatalogKey, { content_id: "item-1" }); + queryClient.setQueryData(item2CatalogKey, { content_id: "item-2" }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "item-1" }); + + expect(queryClient.getQueryState(item1DetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(item1WatchKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(item2DetailKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(item2WatchKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(item1CatalogKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(item2CatalogKey)?.isInvalidated).toBe(false); + }); + + it("does not read the ratings list key as an item ID", () => { + // ["ratings", "list"] shares the ["ratings", id] shape; an item-scoped + // refresh must not treat it as another item's query. + expect(getQueryKeyItemId(ratingKeys.list())).toBeUndefined(); + expect(getQueryKeyItemId(ratingKeys.item("item-2"))).toBe("item-2"); + }); + + it("refreshes an episode's season and series queries for its event", async () => { + const queryClient = new QueryClient(); + const seasonEpisodesKey = catalogKeys.itemEpisodes("season-1"); + const seasonsKey = catalogKeys.seriesSeasons("series-1"); + const seriesDetailKey = catalogKeys.itemDetail("series-1", 7); + const episodeDetailKey = catalogKeys.itemDetail("episode-1"); + const otherDetailKey = catalogKeys.itemDetail("item-2"); + queryClient.setQueryData(seasonEpisodesKey, { + episodes: [{ content_id: "episode-1" }, { content_id: "episode-2" }], + }); + queryClient.setQueryData(seasonsKey, { seasons: [{ content_id: "season-1" }] }); + queryClient.setQueryData(seriesDetailKey, { content_id: "series-1" }); + queryClient.setQueryData(episodeDetailKey, { content_id: "episode-1" }); + queryClient.setQueryData(otherDetailKey, { content_id: "item-2" }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-1" }); + + expect(queryClient.getQueryState(seasonEpisodesKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(seriesDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(episodeDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherDetailKey)?.isInvalidated).toBe(false); + }); + + it("finds an episode's series from its cached detail", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(catalogKeys.itemDetail("episode-1"), { + content_id: "episode-1", + series_id: "series-1", + }); + + expect(relatedItemIds(queryClient, "episode-1")).toEqual(new Set(["episode-1", "series-1"])); + }); + + it("refreshes open episode grids for an item the cache doesn't know yet", async () => { + // A scan adds an episode: its event names only the new episode, which no + // cached query mentions, so the season grid that should list it refreshes. + const queryClient = new QueryClient(); + const seasonEpisodesKey = catalogKeys.itemEpisodes("season-1"); + queryClient.setQueryData(seasonEpisodesKey, { episodes: [{ content_id: "episode-1" }] }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-new", itemMayBeNew: true }); + + expect(queryClient.getQueryState(seasonEpisodesKey)?.isInvalidated).toBe(true); + }); + + it("keeps other items' detail out of an unknown new item's refresh", async () => { + // An episode is playing while a scan adds another: its watch detail and + // series lists' detail aren't about the new episode. + const queryClient = new QueryClient(); + const playingKey = itemKeys.watchDetail("episode-1"); + const seasonsKey = catalogKeys.seriesSeasons("series-1"); + queryClient.setQueryData(playingKey, { content_id: "episode-1" }); + queryClient.setQueryData(seasonsKey, { seasons: [] }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-new", itemMayBeNew: true }); + + expect(queryClient.getQueryState(playingKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(seasonsKey)?.isInvalidated).toBe(true); + }); + + it("refreshes an open season's detail with its list for a new episode", async () => { + // The season's episode count changes with its episode list. + const queryClient = new QueryClient(); + const seasonDetailKey = catalogKeys.itemDetail("season-1"); + const seasonEpisodesKey = catalogKeys.itemEpisodes("season-1"); + const otherDetailKey = catalogKeys.itemDetail("movie-1"); + queryClient.setQueryData(seasonDetailKey, { content_id: "season-1", episode_count: 1 }); + queryClient.setQueryData(seasonEpisodesKey, { episodes: [{ content_id: "episode-1" }] }); + queryClient.setQueryData(otherDetailKey, { content_id: "movie-1" }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-new", itemMayBeNew: true }); + + expect(queryClient.getQueryState(seasonDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherDetailKey)?.isInvalidated).toBe(false); + }); + + it("links a season detail cached under its series to the season", async () => { + const queryClient = new QueryClient(); + const seasonDetailKey = catalogKeys.seasonDetail("series-1", 1); + queryClient.setQueryData(seasonDetailKey, { season: { content_id: "season-1" } }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "season-1" }); + + expect(queryClient.getQueryState(seasonDetailKey)?.isInvalidated).toBe(true); + }); + + it("follows a cached watch detail to its series", async () => { + // Only the player's watch detail knows the episode's series. + const queryClient = new QueryClient(); + queryClient.setQueryData(itemKeys.watchDetail("episode-1"), { + content_id: "episode-1", + series_id: "series-1", + }); + const seasonsKey = catalogKeys.seriesSeasons("series-1"); + queryClient.setQueryData(seasonsKey, { seasons: [] }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-1" }); + + expect(queryClient.getQueryState(seasonsKey)?.isInvalidated).toBe(true); + }); + + it("stays narrowed for a progress event about an uncached item", async () => { + // Progress on another client: the item exists, it just isn't open here. + const queryClient = new QueryClient(); + const playingKey = itemKeys.watchDetail("episode-1"); + const seasonEpisodesKey = catalogKeys.itemEpisodes("season-1"); + queryClient.setQueryData(playingKey, { content_id: "episode-1" }); + queryClient.setQueryData(seasonEpisodesKey, { episodes: [{ content_id: "episode-1" }] }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-elsewhere" }); + + expect(queryClient.getQueryState(playingKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(seasonEpisodesKey)?.isInvalidated).toBe(false); + }); + + it("narrows series-keyed queries to the event's series", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(catalogKeys.itemDetail("episode-1"), { + content_id: "episode-1", + series_id: "series-1", + }); + const ownSeasonsKey = catalogKeys.seriesSeasons("series-1"); + const otherSeasonsKey = catalogKeys.seriesSeasons("series-9"); + const otherEpisodesKey = catalogKeys.seasonEpisodes("series-9", 1); + queryClient.setQueryData(ownSeasonsKey, { seasons: [] }); + queryClient.setQueryData(otherSeasonsKey, { seasons: [] }); + queryClient.setQueryData(otherEpisodesKey, { episodes: [] }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-1" }); + + expect(queryClient.getQueryState(ownSeasonsKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherSeasonsKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(otherEpisodesKey)?.isInvalidated).toBe(false); + }); + + it("refreshes a directly opened season when its series changes", async () => { + const queryClient = new QueryClient(); + // A season page opened by URL caches its detail and episodes, but not the + // series' season list. + const seasonDetailKey = catalogKeys.itemDetail("season-1"); + const seasonEpisodesKey = catalogKeys.itemEpisodes("season-1"); + const otherDetailKey = catalogKeys.itemDetail("season-9"); + queryClient.setQueryData(seasonDetailKey, { content_id: "season-1", series_id: "series-1" }); + queryClient.setQueryData(seasonEpisodesKey, { episodes: [{ content_id: "episode-1" }] }); + queryClient.setQueryData(otherDetailKey, { content_id: "season-9", series_id: "series-9" }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "series-1" }); + + expect(queryClient.getQueryState(seasonDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(seasonEpisodesKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherDetailKey)?.isInvalidated).toBe(false); + }); + + it("refreshes a marked season's cached episode details", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(catalogKeys.itemEpisodes("season-1"), { + episodes: [{ content_id: "episode-1" }], + }); + const episodeDetailKey = catalogKeys.itemDetail("episode-1"); + const episodeWatchKey = itemKeys.watchDetail("episode-1"); + const otherWatchKey = itemKeys.watchDetail("item-2"); + queryClient.setQueryData(episodeDetailKey, { content_id: "episode-1" }); + queryClient.setQueryData(episodeWatchKey, { content_id: "episode-1" }); + queryClient.setQueryData(otherWatchKey, { content_id: "item-2" }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "season-1" }); + + expect(queryClient.getQueryState(episodeDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(episodeWatchKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherWatchKey)?.isInvalidated).toBe(false); + }); + + it("refreshes episode details when a series-keyed season is marked watched", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(catalogKeys.seasonDetail("series-1", 1), { + season: { content_id: "season-1", season_number: 1 }, + }); + queryClient.setQueryData(catalogKeys.seasonEpisodes("series-1", 1), { + episodes: [{ content_id: "episode-1" }], + }); + const episodeDetailKey = catalogKeys.itemDetail("episode-1"); + const episodeWatchKey = itemKeys.watchDetail("episode-1"); + const otherWatchKey = itemKeys.watchDetail("episode-2"); + queryClient.setQueryData(episodeDetailKey, { content_id: "episode-1" }); + queryClient.setQueryData(episodeWatchKey, { content_id: "episode-1" }); + queryClient.setQueryData(otherWatchKey, { content_id: "episode-2" }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "season-1" }); + + expect(queryClient.getQueryState(episodeDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(episodeWatchKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherWatchKey)?.isInvalidated).toBe(false); + }); + + it("finds a numbered season's episode children from its cached season list", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(catalogKeys.seasonEpisodes("series-1", 0), { + episodes: [{ content_id: "special-1" }], + }); + // The season list may enter the cache after the episode list. + queryClient.setQueryData(catalogKeys.seriesSeasons("series-1"), { + seasons: [{ content_id: "specials", season_number: 0 }], + }); + + expect(relatedItemIds(queryClient, "specials")).toEqual( + new Set(["specials", "series-1", "special-1"]), + ); + }); + + it("finds an episode's season even when the cached episode list omits it", async () => { + const queryClient = new QueryClient(); + const seasonDetailKey = catalogKeys.itemDetail("season-1"); + const seasonEpisodesKey = catalogKeys.itemEpisodes("season-1"); + queryClient.setQueryData(seasonDetailKey, { + content_id: "season-1", + type: "season", + series_id: "series-1", + season_number: 1, + }); + queryClient.setQueryData(seasonEpisodesKey, { episodes: [] }); + queryClient.setQueryData(itemKeys.watchDetail("episode-1"), { + content_id: "episode-1", + type: "episode", + series_id: "series-1", + season_number: 1, + }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-1" }); + + expect(queryClient.getQueryState(seasonDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(seasonEpisodesKey)?.isInvalidated).toBe(true); + }); + + it("refreshes new-item lists even when an item detail query has no data", async () => { + const queryClient = new QueryClient(); + const seasonEpisodesKey = catalogKeys.itemEpisodes("season-1"); + queryClient.setQueryData(seasonEpisodesKey, { episodes: [{ content_id: "episode-1" }] }); + queryClient.getQueryCache().build(queryClient, { + queryKey: catalogKeys.itemDetail("episode-new"), + }); + + await invalidateMediaSurfaceQueries(queryClient, { itemId: "episode-new", itemMayBeNew: true }); + + expect(queryClient.getQueryState(seasonEpisodesKey)?.isInvalidated).toBe(true); + }); + + it("matches a watched item key in any library", async () => { + const queryClient = new QueryClient(); + const libraryDetailKey = catalogKeys.itemDetail("series-1", 7); + const otherDetailKey = catalogKeys.itemDetail("item-2", 7); + queryClient.setQueryData(libraryDetailKey, { content_id: "series-1" }); + queryClient.setQueryData(otherDetailKey, { content_id: "item-2" }); + + await invalidateMediaSurfaceQueries(queryClient, { + itemId: "episode-1", + watchedKeys: [catalogKeys.itemDetail("series-1")], + }); + + expect(queryClient.getQueryState(libraryDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherDetailKey)?.isInvalidated).toBe(false); + }); + + it("still invalidates explicitly watched keys that belong to another item", async () => { + const queryClient = new QueryClient(); + const seriesDetailKey = itemKeys.detail("series-1"); + const otherDetailKey = itemKeys.detail("item-2"); + queryClient.setQueryData(seriesDetailKey, { content_id: "series-1" }); + queryClient.setQueryData(otherDetailKey, { content_id: "item-2" }); + + await invalidateMediaSurfaceQueries(queryClient, { + itemId: "episode-1", + watchedKeys: [seriesDetailKey], + }); + + expect(queryClient.getQueryState(seriesDetailKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherDetailKey)?.isInvalidated).toBe(false); + }); }); diff --git a/web/src/hooks/queries/mediaSurfaceRefresh.ts b/web/src/hooks/queries/mediaSurfaceRefresh.ts index 2e674f1fdc..a7c35fa493 100644 --- a/web/src/hooks/queries/mediaSurfaceRefresh.ts +++ b/web/src/hooks/queries/mediaSurfaceRefresh.ts @@ -1,5 +1,10 @@ import type { Query, QueryClient } from "@tanstack/react-query"; -import type { HomeSectionItemsResponse, ItemDetail } from "@/api/types"; +import type { + HomeSectionItemsResponse, + ItemDetail, + SeasonDetailResponse, + SeasonsResponse, +} from "@/api/types"; import { adminKeys, catalogKeys, @@ -30,6 +35,15 @@ interface InvalidateMediaSurfaceOptions { */ skipItemQueries?: boolean; skipSimilarItems?: boolean; + // The item may be one this client has never seen, such as an episode a + // scan just added. Only catalog events can say so; progress, favorite and + // watchlist events are about items that already exist. + itemMayBeNew?: boolean; + // Set internally for such an item when nothing in the cache names it, + // with the items owning the cached lists, whose detail (a season's + // episode count) changes with them. + refreshItemKeyedLists?: boolean; + listOwnerIds?: ReadonlySet; } const MEDIA_SURFACE_REFRESH_DELAY_MS = 600; @@ -120,6 +134,10 @@ export function removeItemFromHomeSectionCaches( ); } +function isWatchDetailQueryKey(queryKey: readonly unknown[]) { + return queryKey[0] === "items" && queryKey[1] === "watchDetail"; +} + export function isItemDetailQueryKey(queryKey: unknown, itemId: string) { return ( Array.isArray(queryKey) && @@ -150,9 +168,169 @@ function queryKeyStartsWith(queryKey: readonly unknown[], prefix: readonly unkno ); } +// Only these shapes are narrowed by itemId. episodeKeys.byItem is left out on +// purpose: it is invalidated only through watchedKeys, and mapping it here +// would change which episode lists a refresh touches. +export function getQueryKeyItemId(queryKey: readonly unknown[]): string | undefined { + if (!Array.isArray(queryKey) || queryKey.length < 2) return undefined; + if ( + queryKey[0] === "items" && + (queryKey[1] === "detail" || queryKey[1] === "watchDetail" || queryKey[1] === "markers") && + typeof queryKey[2] === "string" + ) { + return queryKey[2]; + } + // A series' season list, season detail and episodes are keyed by series. + if ( + queryKey[0] === "catalog" && + (queryKey[1] === "items" || queryKey[1] === "series") && + typeof queryKey[2] === "string" + ) { + return queryKey[2]; + } + // ratingKeys.list() shares the ["ratings", x] shape; "list" is not an item ID. + if (queryKey[0] === "ratings" && typeof queryKey[1] === "string" && queryKey[1] !== "list") { + return queryKey[1]; + } + return undefined; +} + +// The item whose episodes or seasons a cached list query holds: an episode +// list belongs to its season (or series), a season list to its series. +function listOwnerId(queryKey: readonly unknown[]): string | undefined { + if (queryKey[0] === "catalog" && queryKey[1] === "series" && typeof queryKey[2] === "string") { + return queryKey[2]; + } + return getQueryKeyItemId(queryKey); +} + +function listedChildIds(data: unknown): string[] { + if (typeof data !== "object" || data === null) return []; + const ids: string[] = []; + for (const field of ["episodes", "seasons"] as const) { + const list = (data as Record)[field]; + if (!Array.isArray(list)) continue; + for (const entry of list) { + const id = (entry as { content_id?: unknown } | null)?.content_id; + if (typeof id === "string") ids.push(id); + } + } + return ids; +} + +// relatedItemIds is itemId plus the items whose cached state derives from it, +// as far as the cache shows: its parents (the series named by its detail, and +// the season or series whose cached list holds it, and theirs) and its +// children (the episodes or seasons its own cached lists hold, the cached +// details that name it as their series, and theirs). +// Realtime events and watched marks name only the item itself, so narrowing +// to that one ID would leave an open season grid, series watched totals or a +// marked season's episodes stale. +export function relatedItemIds(queryClient: QueryClient, itemId: string): Set { + // Index season identities before the links, then walk them, so a + // series with many cached seasons and episodes costs two cache scans, + // not one per item. + const childrenOf = new Map(); + const parentsOf = new Map(); + const queries = queryClient.getQueryCache().getAll(); + const seasonIds = new Map>(); + const rememberSeason = ( + seriesId: string, + season: Pick | undefined, + ) => { + if (!season?.content_id || season.season_number == null) return; + let seasons = seasonIds.get(seriesId); + if (!seasons) { + seasons = new Map(); + seasonIds.set(seriesId, seasons); + } + seasons.set(season.season_number, season.content_id); + }; + for (const query of queries) { + const owner = listOwnerId(query.queryKey); + if (!owner) continue; + if (query.queryKey[0] === "catalog" && query.queryKey[1] === "series") { + for (const season of (query.state.data as SeasonsResponse | undefined)?.seasons ?? []) { + rememberSeason(owner, season); + } + rememberSeason(owner, (query.state.data as SeasonDetailResponse | undefined)?.season); + } + if (isItemDetailQueryKey(query.queryKey, owner)) { + const detail = query.state.data as ItemDetail | undefined; + if (detail?.type === "season" && detail.series_id) { + rememberSeason(detail.series_id, detail); + } + } + } + const add = (edges: Map, from: string, to: string) => { + const list = edges.get(from); + if (list) list.push(to); + else edges.set(from, [to]); + }; + const link = (parent: string, child: string) => { + if (parent === child) return; + add(childrenOf, parent, child); + add(parentsOf, child, parent); + }; + for (const query of queries) { + const owner = listOwnerId(query.queryKey); + if (!owner) continue; + // Numbered season episode keys name the series, while their members + // belong to the season whose identity the first pass found. + const numberedSeason = + query.queryKey[0] === "catalog" && + query.queryKey[1] === "series" && + query.queryKey[5] === "episodes" && + typeof query.queryKey[4] === "number" + ? seasonIds.get(owner)?.get(query.queryKey[4]) + : undefined; + if (numberedSeason) link(owner, numberedSeason); + for (const child of listedChildIds(query.state.data)) link(numberedSeason ?? owner, child); + // A season's detail is cached under its series, so its own ID is only + // in the data. + const seasonId = (query.state.data as { season?: { content_id?: unknown } } | undefined)?.season + ?.content_id; + if (typeof seasonId === "string") link(owner, seasonId); + if (isItemDetailQueryKey(query.queryKey, owner) || isWatchDetailQueryKey(query.queryKey)) { + const detail = query.state.data as ItemDetail | undefined; + const seriesId = detail?.series_id; + // A detail names its series, and derives its series title, backdrop + // and credits from it. A season page opened directly has no cached + // season list, so this edge is how a series change reaches it. A + // playing episode's watch detail names it too, so its progress + // reaches the series' watched summary. + if (seriesId) { + link(seriesId, owner); + const seasonId = + detail.season_number == null + ? undefined + : seasonIds.get(seriesId)?.get(detail.season_number); + if (seasonId) link(seasonId, owner); + } + } + } + + const related = new Set([itemId]); + const walk = (edges: Map) => { + const pending = [itemId]; + while (pending.length > 0) { + for (const next of edges.get(pending.pop() as string) ?? []) { + if (!related.has(next)) { + related.add(next); + pending.push(next); + } + } + } + }; + walk(parentsOf); + walk(childrenOf); + return related; +} + function shouldInvalidateMediaSurfaceQuery( queryKey: readonly unknown[], options: InvalidateMediaSurfaceOptions, + relatedIds?: ReadonlySet, ) { if (options.skipItemDetail && options.itemId && isItemDetailQueryKey(queryKey, options.itemId)) { return false; @@ -161,6 +339,26 @@ function shouldInvalidateMediaSurfaceQuery( return false; } + if (options.itemId) { + const targetItemId = getQueryKeyItemId(queryKey); + // Keys for related items (see relatedItemIds) or named in watchedKeys (a + // watched episode's season or series, say) belong to other item IDs by + // design; don't narrow them away. + if ( + targetItemId && + targetItemId !== options.itemId && + !relatedIds?.has(targetItemId) && + !isWatchedKey(queryKey, options) && + !( + options.refreshItemKeyedLists && + (isItemKeyedListQuery(queryKey) || + (options.listOwnerIds?.has(targetItemId) && isItemDetailQueryKey(queryKey, targetItemId))) + ) + ) { + return false; + } + } + if (queryKeyStartsWith(queryKey, catalogKeys.all)) { return activeCatalogQueryMatchesLibrary(queryKey, options.libraryId); } @@ -178,7 +376,31 @@ function shouldInvalidateMediaSurfaceQuery( return true; } - return (options.watchedKeys ?? []).some((key) => queryKeyStartsWith(queryKey, key)); + return isWatchedKey(queryKey, options); +} + +// isItemKeyedListQuery reports a list of a series' or season's children, keyed +// by the parent: a season list, a season's detail and episodes, or an item's +// episodes. A new child can't be traced to its parent's lists from the cache. +function isItemKeyedListQuery(queryKey: readonly unknown[]): boolean { + return queryKey[0] === "catalog" && (queryKey[1] === "series" || queryKey[3] === "episodes"); +} + +// isWatchedKey is a prefix match on purpose: everything under a watched key +// (a watched season's or episode's detail variants) changes with it, so it is +// exempt from the itemId narrowing too. Don't tighten it to an exact match. +// An item key also matches a watched key for the same item in any library: +// watched keys carry the "default" library segment, while an item opened +// through its library caches under that library's ID. +function isWatchedKey(queryKey: readonly unknown[], options: InvalidateMediaSurfaceOptions) { + const targetItemId = getQueryKeyItemId(queryKey); + return (options.watchedKeys ?? []).some( + (key) => + queryKeyStartsWith(queryKey, key) || + (targetItemId !== undefined && + getQueryKeyItemId(key) === targetItemId && + queryKeyStartsWith(queryKey, key.slice(0, 2))), + ); } export async function invalidateMediaSurfaceQueries( @@ -189,13 +411,51 @@ export async function invalidateMediaSurfaceQueries( // `cancelRefetch: false` — reusing an in-flight request would let a response // that predates the mutation satisfy the invalidation and land in the cache // as fresh. - const matches = (query: Query) => shouldInvalidateMediaSurfaceQuery(query.queryKey, options); + const relatedIds = options.itemId ? relatedItemIds(queryClient, options.itemId) : undefined; + // A new item nothing in the cache knows about, such as an episode a scan + // just added, can't be traced to the season or series lists that should now + // show it, so its event refreshes every such list. Other items' detail + // stays narrowed away. A caller that names the keys it changed + // (watchedKeys) has scoped it already. + const scoped = + options.itemMayBeNew && + options.itemId && + !options.watchedKeys?.length && + relatedIds?.size === 1 && + !cacheHasItemDetail(queryClient, options.itemId) + ? { ...options, refreshItemKeyedLists: true, listOwnerIds: itemKeyedListOwners(queryClient) } + : options; + const matches = (query: Query) => + shouldInvalidateMediaSurfaceQuery(query.queryKey, scoped, relatedIds); const runningLetterIndexes = refetchLetterIndexesWhenDone(queryClient, matches); await queryClient.invalidateQueries({ predicate: (query) => matches(query) && !runningLetterIndexes.has(query), }); } +// itemKeyedListOwners is the items whose children a cached list holds. +function itemKeyedListOwners(queryClient: QueryClient): Set { + const owners = new Set(); + for (const query of queryClient.getQueryCache().getAll()) { + const owner = isItemKeyedListQuery(query.queryKey) ? listOwnerId(query.queryKey) : undefined; + if (owner) owners.add(owner); + } + return owners; +} + +// A pending query or a markers-only read does not establish item ancestry. +function cacheHasItemDetail(queryClient: QueryClient, itemId: string): boolean { + return queryClient + .getQueryCache() + .getAll() + .some( + (query) => + getQueryKeyItemId(query.queryKey) === itemId && + (isItemDetailQueryKey(query.queryKey, itemId) || isWatchDetailQueryKey(query.queryKey)) && + query.state.data != null, + ); +} + // A letter index sorts the whole result, which on a large library can outlast // a scan's invalidation window, so cancelling and restarting it on every sweep // would keep it from ever finishing. A running read finishes instead, and one From a044982ac01b96d35a94b74e4b4ade691c7378d8 Mon Sep 17 00:00:00 2001 From: rangoDJ Date: Tue, 13 Oct 2026 14:36:46 -0400 Subject: [PATCH 019/196] feat(playback): serve copy remux from a complete keyframe playlist (#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 (#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 * 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 (#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 * style(playback): name the restarting reason and check cache assertions Co-Authored-By: Claude Opus 5.5 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * 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 * chore(apiv2): drop the breaking approvals #1788 merged with The invitation approvals came in with #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 * 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 #1756. Co-Authored-By: Claude Opus 5.5 * 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 * refactor(keyframes): keep children's doc comment on children Co-Authored-By: Claude Opus 5.5 * fix(keyframes): reject a cue point with a malformed track position Co-Authored-By: Claude Opus 5.5 * fix(keyframes): reject an explicit zero TimestampScale Co-Authored-By: Claude Opus 5.5 * 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 * 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 #1760. Co-Authored-By: Claude Opus 5.5 * 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 #1770. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- contracts/api/v2/openapi.json | 6 + internal/api/handlers/playback_v3.go | 10 +- internal/config/admin_settings.go | 10 + internal/config/config.go | 3 + internal/config/db_loader.go | 5 + internal/jellycompat/handlers_playback.go | 10 + internal/jellycompat/playback_sessions.go | 4 + internal/jellycompat/streams.go | 3 + internal/jellycompat/streams_test.go | 49 ++ internal/playback/copy_keyframe_plan.go | 243 ++++++++ internal/playback/copy_keyframe_plan_test.go | 415 ++++++++++++++ internal/playback/keyframes/cache.go | 237 ++++++++ internal/playback/keyframes/cache_test.go | 133 +++++ internal/playback/keyframes/coverage.go | 354 ++++++++++++ internal/playback/keyframes/ffmpeg_test.go | 269 +++++++++ internal/playback/keyframes/matroska.go | 538 ++++++++++++++++++ internal/playback/keyframes/matroska_test.go | 474 +++++++++++++++ internal/playback/keyframes/plan.go | 53 ++ internal/playback/keyframes/plan_test.go | 48 ++ internal/playback/recipecard.go | 5 + internal/playback/recipecard_test.go | 13 + internal/playback/transcode.go | 55 +- internal/playback/transcode_manager.go | 1 + internal/streamtoken/token.go | 1 + internal/transcodenode/server.go | 23 + internal/transcodenode/server_test.go | 15 + .../admin-settings/PlaybackSettings.test.tsx | 1 + .../pages/admin-settings/PlaybackSettings.tsx | 9 + 28 files changed, 2981 insertions(+), 6 deletions(-) create mode 100644 internal/playback/copy_keyframe_plan.go create mode 100644 internal/playback/copy_keyframe_plan_test.go create mode 100644 internal/playback/keyframes/cache.go create mode 100644 internal/playback/keyframes/cache_test.go create mode 100644 internal/playback/keyframes/coverage.go create mode 100644 internal/playback/keyframes/ffmpeg_test.go create mode 100644 internal/playback/keyframes/matroska.go create mode 100644 internal/playback/keyframes/matroska_test.go create mode 100644 internal/playback/keyframes/plan.go create mode 100644 internal/playback/keyframes/plan_test.go diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 48deabf159..dde691501b 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -219080,6 +219080,9 @@ "input_path": { "type": "string" }, + "keyframe_playlist": { + "type": "boolean" + }, "require_ready": { "type": "boolean" }, @@ -219216,6 +219219,9 @@ "hw_accel": { "type": "string" }, + "keyframe_playlist": { + "type": "boolean" + }, "session_id": { "type": "string" }, diff --git a/internal/api/handlers/playback_v3.go b/internal/api/handlers/playback_v3.go index 081b0b3a6f..11c2af8a14 100644 --- a/internal/api/handlers/playback_v3.go +++ b/internal/api/handlers/playback_v3.go @@ -3785,7 +3785,7 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play sourceMetadata := sourceExecutionMetadataV3(file, result) sourceProfile, sourceBitDepth := sourceVideoTranscodeFactsV3(file, result) unlock := h.tm.LockSessionLifecycle(session.ID) - opts := playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SourceAudioChannels: result.SourceAudioChannels, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, ToneMapPolicy: result.ToneMapPolicy, ToneMapMode: result.ToneMapMode, ToneMapSourceKind: result.ToneMapSourceKind, ToneMapRecipeVersion: result.ToneMapRecipeVersion, ToneMapPreflightRequired: result.ToneMapPreflightRequired, ToneMapSourceRevision: result.ToneMapSourceRevision, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), VideoSampleEntry: videoSampleEntryForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: playback.DefaultSegmentDuration, SegmentRetentionSeconds: cfg.SegmentRetentionSeconds, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink} + opts := playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SourceAudioChannels: result.SourceAudioChannels, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, ToneMapPolicy: result.ToneMapPolicy, ToneMapMode: result.ToneMapMode, ToneMapSourceKind: result.ToneMapSourceKind, ToneMapRecipeVersion: result.ToneMapRecipeVersion, ToneMapPreflightRequired: result.ToneMapPreflightRequired, ToneMapSourceRevision: result.ToneMapSourceRevision, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), VideoSampleEntry: videoSampleEntryForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: playback.DefaultSegmentDuration, KeyframePlaylist: cfg.KeyframePlaylist && videoCodec == "copy", SegmentRetentionSeconds: cfg.SegmentRetentionSeconds, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink} if opts.ToneMapMode != "" { opts.ToneMapDVConfigPresent = sourceMetadata.ToneMapDVConfigPresent opts.ToneMapDVBLCompatIDPresent = sourceMetadata.ToneMapDVBLCompatIDPresent @@ -4015,7 +4015,7 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla hwAccel = playback.HWAccelNone } } - req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SourceAudioChannels: result.SourceAudioChannels, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, ToneMapPolicy: result.ToneMapPolicy, ToneMapMode: result.ToneMapMode, ToneMapSourceKind: result.ToneMapSourceKind, ToneMapRecipeVersion: result.ToneMapRecipeVersion, ToneMapPreflightRequired: result.ToneMapPreflightRequired, ToneMapSourceRevision: result.ToneMapSourceRevision, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), VideoSampleEntry: videoSampleEntryForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: playback.DefaultSegmentDuration, HWAccel: hwAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, RequireReady: true} + req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SourceAudioChannels: result.SourceAudioChannels, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, ToneMapPolicy: result.ToneMapPolicy, ToneMapMode: result.ToneMapMode, ToneMapSourceKind: result.ToneMapSourceKind, ToneMapRecipeVersion: result.ToneMapRecipeVersion, ToneMapPreflightRequired: result.ToneMapPreflightRequired, ToneMapSourceRevision: result.ToneMapSourceRevision, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), VideoSampleEntry: videoSampleEntryForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: playback.DefaultSegmentDuration, KeyframePlaylist: h.playbackConfig().KeyframePlaylist && videoCodec == "copy", HWAccel: hwAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, RequireReady: true} req.ThrottleSeconds = playback.ConfiguredTranscodeThrottleSeconds(r.Context(), h.SettingsRepo) if strings.EqualFold(videoCodec, "copy") { req.CopyFMP4RecipeVersion = playback.CopyFMP4RecipeVersion @@ -4081,6 +4081,10 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla h.tm.StopRemoteTranscode(transportID, node.URL) return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "The selected transcode node did not confirm the throttle policy.", retryable: true, cause: err} } + if err := transcodenode.ValidateKeyframePlaylistAttestation(req, nodeResp); err != nil { + h.tm.StopRemoteTranscode(transportID, node.URL) + return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "The selected transcode node did not confirm the keyframe playlist.", retryable: true, cause: err} + } if req.ToneMapMode != "" && nodeResp.ToneMapMode != req.ToneMapMode { h.tm.StopRemoteTranscode(transportID, node.URL) return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "The selected transcode node did not confirm the tone-map recipe.", retryable: true} @@ -4220,7 +4224,7 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla // not part of the node's request/response contract, so the caller supplies it. func remoteTranscodeRecipeCardV3(session *playback.Session, file *models.MediaFile, nodeURL, transportID string, req transcodenode.TranscodeStartRequest, nodeResp transcodenode.TranscodeStartResponse, toneMapFilter string) playback.RecipeCard { hw := firstNonEmptyHandlerV3(strings.TrimSpace(nodeResp.HWAccel), strings.TrimSpace(req.HWAccel)) - card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, nodeURL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SourceAudioChannels: req.SourceAudioChannels, SoftwareVideoDecode: req.SoftwareVideoDecode || nodeResp.SoftwareVideoDecode, ToneMapPolicy: req.ToneMapPolicy, ToneMapMode: req.ToneMapMode, ToneMapSourceKind: req.ToneMapSourceKind, ToneMapFilter: toneMapFilter, ToneMapRecipeVersion: req.ToneMapRecipeVersion, ToneMapPreflightRequired: req.ToneMapPreflightRequired, ToneMapSourceRevision: req.ToneMapSourceRevision, VideoBitstreamFilter: req.VideoBitstreamFilter, VideoSampleEntry: req.VideoSampleEntry, SeekSeconds: req.SeekSeconds, StreamOriginSeconds: req.StreamOriginSeconds, CopySeekAnchorResolved: req.CopySeekAnchorResolved, StartSegmentNumber: req.StartSegmentNumber, TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, TargetCodecAudio: req.TargetCodecAudio, TargetAudioChannels: req.TargetAudioChannels, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration, ThrottleSeconds: req.ThrottleSeconds}) + card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, nodeURL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SourceAudioChannels: req.SourceAudioChannels, SoftwareVideoDecode: req.SoftwareVideoDecode || nodeResp.SoftwareVideoDecode, ToneMapPolicy: req.ToneMapPolicy, ToneMapMode: req.ToneMapMode, ToneMapSourceKind: req.ToneMapSourceKind, ToneMapFilter: toneMapFilter, ToneMapRecipeVersion: req.ToneMapRecipeVersion, ToneMapPreflightRequired: req.ToneMapPreflightRequired, ToneMapSourceRevision: req.ToneMapSourceRevision, VideoBitstreamFilter: req.VideoBitstreamFilter, VideoSampleEntry: req.VideoSampleEntry, SeekSeconds: req.SeekSeconds, StreamOriginSeconds: req.StreamOriginSeconds, CopySeekAnchorResolved: req.CopySeekAnchorResolved, StartSegmentNumber: req.StartSegmentNumber, TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, TargetCodecAudio: req.TargetCodecAudio, TargetAudioChannels: req.TargetAudioChannels, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, KeyframePlaylist: req.KeyframePlaylist, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration, ThrottleSeconds: req.ThrottleSeconds}) card.ToneMapDVConfigPresent = req.ToneMapDVConfigPresent if encoderHWAccel := strings.TrimSpace(nodeResp.EncoderHWAccel); encoderHWAccel != "" { card.EncoderHWAccel = encoderHWAccel diff --git a/internal/config/admin_settings.go b/internal/config/admin_settings.go index 89db041c3f..4581bf63ed 100644 --- a/internal/config/admin_settings.go +++ b/internal/config/admin_settings.go @@ -65,6 +65,13 @@ const ( AccessUnratedContentAllow = "allow" ) +// PlaybackKeyframePlaylistSettingKey serves copy-remux HLS as one complete +// playlist planned from the source's keyframes, instead of FFmpeg's growing +// one (#1466). It is off while restarts in the middle of such a stream are +// still being built; a planned stream that has to restart starts again from +// the beginning. +const PlaybackKeyframePlaylistSettingKey = "playback.copy_keyframe_playlist" + // CatalogExtraRatingSourcesSettingKey lists, comma-separated, the rating // sources clients show in addition to IMDb and TMDB, which are always shown: // sources metadata plugins declare, such as rt_critic. Empty, the @@ -471,6 +478,8 @@ var adminSettingDefaults = map[string]string{ "catalog.search.meilisearch.semantic_ratio": "0.5", "catalog.search.meilisearch.embedder": "silo_recommendations", "catalog.search.meilisearch.binary_quantized": "false", + + PlaybackKeyframePlaylistSettingKey: "false", } var legacyAdminSettingFallbacks = []struct { @@ -587,6 +596,7 @@ func NormalizeAdminSetting(key, raw string) (string, error) { switch key { case "metadata.cache_images", "playback.transcode_enabled", PlaybackAllowHEVCEncodingSettingKey, + PlaybackKeyframePlaylistSettingKey, ChapterThumbnailSoftwareToneMapSettingKey, PlaybackTranscodeHardwareToneMapSettingKey, PlaybackTranscodeSoftwareToneMapSettingKey, CatalogScopeVersionsToLibrarySettingKey, Allow4KTranscodeSettingKey, playback.TranscodeThrottleEnabledSettingKey, "audiobookshelf_compat.enabled", diff --git a/internal/config/config.go b/internal/config/config.go index 22bd42a087..398877520a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -199,6 +199,9 @@ type PlaybackConfig struct { SubtitleSyncNodeCapacity int `yaml:"-"` TranscodeEnabled bool `yaml:"transcode_enabled"` Routing PlaybackRoutingPolicy `yaml:"-"` + // KeyframePlaylist is playback.copy_keyframe_playlist: copy-remux HLS + // from one complete keyframe-planned playlist (#1466). + KeyframePlaylist bool `yaml:"-"` } // RedisConfig holds Redis connection settings. diff --git a/internal/config/db_loader.go b/internal/config/db_loader.go index e94438429f..9bf6ddf4a7 100644 --- a/internal/config/db_loader.go +++ b/internal/config/db_loader.go @@ -357,6 +357,11 @@ func LoadFromDB(m map[string]string) (*Config, error) { return nil, fmt.Errorf("%s must be 0 or between 120 and 86400", playbackSegmentRetentionSettingKey) } cfg.Playback.SegmentRetentionSeconds = segmentRetentionSeconds + keyframePlaylist, err := boolOr(m, PlaybackKeyframePlaylistSettingKey, false) + if err != nil { + return nil, err + } + cfg.Playback.KeyframePlaylist = keyframePlaylist cfg.Playback.HWAccel = stringOr(m, "playback.hw_accel", "auto") cfg.Playback.HWDevice = stringOr(m, "playback.hw_device", "") chapterThumbnailWorkers, err := intOr(m, "playback.chapter_thumbnail_workers", 1) diff --git a/internal/jellycompat/handlers_playback.go b/internal/jellycompat/handlers_playback.go index de2e9a1fbe..bb1b0c5dca 100644 --- a/internal/jellycompat/handlers_playback.go +++ b/internal/jellycompat/handlers_playback.go @@ -1451,6 +1451,7 @@ func (h *PlaybackHandler) buildProxyRedirectURL( claims.PlayMethod = streamtoken.PlayMethodCopyFMP4Transcode claims.CopyFMP4RecipeVersion = playback.CopyFMP4RecipeVersion claims.CopyVideoMPEGTS = source.HLSRemuxMPEGTS + claims.KeyframePlaylist = source.KeyframePlaylist } if compatSession != nil { claims.UserID = compatSession.StreamAppUserID @@ -1752,6 +1753,7 @@ func (h *PlaybackHandler) startRemoteTranscodeWithToneMapMode( reqBody.TargetCodecVideo = compatCopyCodec reqBody.VideoSampleEntry, reqBody.VideoBitstreamFilter = compatCopyVideoRecipe(source, file.PrimaryDVProfile()) reqBody.CopyVideoMPEGTS = source.HLSRemuxMPEGTS + reqBody.KeyframePlaylist = source.KeyframePlaylist reqBody.CopyFMP4RecipeVersion = playback.CopyFMP4RecipeVersion } if !compatHLSTranscodesAudio(source) { @@ -1902,6 +1904,10 @@ func (h *PlaybackHandler) startRemoteTranscodeWithToneMapMode( h.tm.StopRemoteTranscode(upstreamSessionID, transcodeNodeURL) return fmt.Errorf("%w: %w", errRemoteTranscodeStartFailed, err) } + if err := transcodenode.ValidateKeyframePlaylistAttestation(reqBody, nodeResponse); err != nil { + h.tm.StopRemoteTranscode(upstreamSessionID, transcodeNodeURL) + return fmt.Errorf("%w: %w", errRemoteTranscodeStartFailed, err) + } if reqBody.ToneMapMode != "" && nodeResponse.ToneMapMode != reqBody.ToneMapMode { h.tm.StopRemoteTranscode(upstreamSessionID, transcodeNodeURL) err := errors.New("remote transcode node did not confirm tone-map mode") @@ -1967,6 +1973,7 @@ func (h *PlaybackHandler) startRemoteTranscodeWithToneMapMode( VideoSampleEntry: reqBody.VideoSampleEntry, VideoBitstreamFilter: reqBody.VideoBitstreamFilter, CopyVideoMPEGTS: reqBody.CopyVideoMPEGTS, + KeyframePlaylist: reqBody.KeyframePlaylist, SegmentDuration: reqBody.SegmentDuration, AudioTrackIndex: reqBody.AudioTrackIndex, SourceAudioChannels: reqBody.SourceAudioChannels, @@ -2330,6 +2337,9 @@ func (h *PlaybackHandler) HandlePlaybackInfo(w http.ResponseWriter, r *http.Requ source.HLSRemuxMPEGTS = compatWebOSDVMPEGTS(r.UserAgent(), source) applyCompatSubtitleDelivery(&source, profile, req.AlwaysBurnInSubtitleWhenTranscoding) applyCompatDownloadedSubtitleDelivery(&source, profile, downloaded) + // Only a copy stream is served from a keyframe playlist; subtitle + // delivery above can turn a copy into a transcode. + source.KeyframePlaylist = compatHLSCopiesVideo(source) && h.PlaybackConfig != nil && h.PlaybackConfig().KeyframePlaylist if source.SupportsTranscoding && !compatHLSCopiesVideo(source) && compatVersionRequiresToneMap(version) { if !toneMapPolicyLoaded { var policyErr error diff --git a/internal/jellycompat/playback_sessions.go b/internal/jellycompat/playback_sessions.go index 9ae7876fd2..786f969c39 100644 --- a/internal/jellycompat/playback_sessions.go +++ b/internal/jellycompat/playback_sessions.go @@ -126,6 +126,10 @@ type PlaybackMediaSource struct { // clients whose Dolby Vision decoder requires MPEG-TS. HLSRemux bool HLSRemuxMPEGTS bool + // KeyframePlaylist records, for the life of the play session, that an + // HLS copy stream is served from a keyframe-planned playlist + // (playback.copy_keyframe_playlist when the session started). + KeyframePlaylist bool // DOVIVariant marks a copy remux of Dolby Vision without a compatible base // layer (HEVC profile 5, AV1 profile 10) for a client whose device profile // explicitly lists DOVI. As in Jellyfin 12, the fMP4 master playlist then diff --git a/internal/jellycompat/streams.go b/internal/jellycompat/streams.go index 7295839782..0a15deb477 100644 --- a/internal/jellycompat/streams.go +++ b/internal/jellycompat/streams.go @@ -382,6 +382,7 @@ func compatRecipeMatchesSource(recipe *playback.RecipeCard, source PlaybackMedia recipe.AudioTrackIndex == compatAudioTrackIndexOrDefault(source) && recipe.SourceAudioChannels == compatHLSRecipeSourceAudioChannels(source) && recipe.CopyVideoMPEGTS == source.HLSRemuxMPEGTS && + (!compatHLSCopiesVideo(source) || recipe.KeyframePlaylist == source.KeyframePlaylist) && recipe.SubtitleBurnIn == source.SubtitleBurnIn && (!source.SubtitleBurnIn || (recipe.SubtitleTrackIndex == source.SubtitleTrackIndex && recipe.SubtitleCodec == source.SubtitleCodec)) && (source.TargetBitrateKbps == 0 || recipe.TargetBitrateKbps == source.TargetBitrateKbps) && (source.TargetResolution == "" || recipe.TargetResolution == source.TargetResolution) @@ -2901,6 +2902,7 @@ func (h *PlaybackHandler) ensureTranscodeSessionWithToneMapMode( opts.TargetCodecVideo = compatCopyCodec opts.VideoSampleEntry, opts.VideoBitstreamFilter = compatCopyVideoRecipe(source, file.PrimaryDVProfile()) opts.CopyVideoMPEGTS = source.HLSRemuxMPEGTS + opts.KeyframePlaylist = source.KeyframePlaylist } if !compatHLSTranscodesAudio(source) { opts.TargetCodecAudio = compatCopyCodec @@ -3176,6 +3178,7 @@ func compatLiveTranscodeMatchesAudioSource(transcodeSession *playback.TranscodeS return opts.AudioTrackIndex == compatAudioTrackIndexOrDefault(source) && opts.SourceAudioChannels == compatHLSRecipeSourceAudioChannels(source) && opts.CopyVideoMPEGTS == source.HLSRemuxMPEGTS && + (!compatHLSCopiesVideo(source) || opts.KeyframePlaylist == source.KeyframePlaylist) && opts.SubtitleBurnIn == source.SubtitleBurnIn && (!source.SubtitleBurnIn || (opts.SubtitleTrackIndex == source.SubtitleTrackIndex && opts.SubtitleCodec == source.SubtitleCodec)) && (source.TargetBitrateKbps == 0 || opts.TargetBitrateKbps == source.TargetBitrateKbps) && (source.TargetResolution == "" || opts.TargetResolution == source.TargetResolution) diff --git a/internal/jellycompat/streams_test.go b/internal/jellycompat/streams_test.go index e5fd75c59c..26991366ed 100644 --- a/internal/jellycompat/streams_test.go +++ b/internal/jellycompat/streams_test.go @@ -630,6 +630,55 @@ func TestBuildProxyRedirectURLCarriesMPEGTSForRemoteCopyRecipe(t *testing.T) { } } +// The keyframe playlist only applies to copy streams; an encoded stream's +// recipe and live session must match whatever the flag says. +func TestKeyframePlaylistOnlyComparedForCopyStreams(t *testing.T) { + encoded := testRemoteTranscodeSource() + encoded.KeyframePlaylist = true + recipe := playback.NewRecipeCard(7, "profile", encoded.FileID, "", playback.TranscodeOpts{ + TargetCodecVideo: compatTargetVideoCodec, + TargetCodecAudio: compatTargetAudioCodec, + AudioTrackIndex: compatAudioTrackIndexOrDefault(encoded), + }) + if !compatRecipeMatchesSource(&recipe, encoded) { + t.Fatal("encoded source rejected its recipe over the keyframe playlist flag") + } + + copySource := encoded + copySource.HLSRemux = true + copyRecipe := recipe + copyRecipe.TargetCodecVideo = compatCopyCodec + if compatRecipeMatchesSource(©Recipe, copySource) { + t.Fatal("copy source accepted a recipe without its keyframe playlist") + } + copyRecipe.KeyframePlaylist = true + if !compatRecipeMatchesSource(©Recipe, copySource) { + t.Fatal("copy source rejected its keyframe playlist recipe") + } +} + +// A remote copy session rebuilt from the proxy token keeps the keyframe +// playlist the client already has. +func TestBuildProxyRedirectURLCarriesKeyframePlaylistForRemoteCopyRecipe(t *testing.T) { + h := &PlaybackHandler{JWTSecret: "test-secret"} + source := PlaybackMediaSource{HLSRemux: true, KeyframePlaylist: true} + redirectURL, err := h.buildProxyRedirectURL( + "play-1", "upstream-1", string(playback.PlayTranscode), + &models.MediaFile{FilePath: "/media/movie.mkv"}, source, nil, time.Time{}, + "http://transcode-1", 0, &nodepool.Node{URL: "http://proxy-1"}, netaccess.Path{}, + ) + if err != nil { + t.Fatal(err) + } + claims, err := streamtoken.Verify(proxyTokenFromRedirect(t, redirectURL, string(playback.PlayTranscode)), h.JWTSecret) + if err != nil { + t.Fatal(err) + } + if !claims.KeyframePlaylist { + t.Fatalf("copy proxy token omitted the keyframe playlist: %#v", claims) + } +} + func TestBuildProxyRedirectURLMarksToneMapForOldReaderRejection(t *testing.T) { h := &PlaybackHandler{JWTSecret: "test-secret"} source := PlaybackMediaSource{Version: catalog.FileVersion{HDR: true, VideoTracks: []models.VideoTrack{{VideoRangeType: "HDR10", ColorTransfer: "smpte2084"}}}} diff --git a/internal/playback/copy_keyframe_plan.go b/internal/playback/copy_keyframe_plan.go new file mode 100644 index 0000000000..02abd756c2 --- /dev/null +++ b/internal/playback/copy_keyframe_plan.go @@ -0,0 +1,243 @@ +package playback + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Silo-Server/silo-server/internal/playback/keyframes" +) + +// copySegmentPlan is a copy-video session's complete segment list, worked out +// from the source's keyframe index before FFmpeg starts (#1466). The session +// serves it as a VOD playlist instead of FFmpeg's growing one, so a player +// never sees a partial playlist: Safari's native HLS can fall back to one and +// stop mid-stream. +// +// FFmpeg's HLS muxer, run from the start of the file, cuts copied video at +// the planned keyframes (keyframes.PlanSegments), so its seg_NNNNN files are +// the planned segments. A seek restart would cut at different keyframes, so +// a planned session restarts from the beginning instead (see restart). +type copySegmentPlan struct { + durations []float64 +} + +// loadKeyframeIndex reads a source's keyframe index, and +// loadVerifiedKeyframeIndex waits for its full check, stopping when ctx is +// done; tests replace both. +var ( + loadKeyframeIndex = keyframes.Load + loadVerifiedKeyframeIndex = keyframes.LoadVerified +) + +// planCopySegments returns the segment plan for a session, or nil when the +// session keeps FFmpeg's real playlist: the setting is off, the video isn't +// copied, the stream doesn't start at the beginning, or the source has no +// keyframe index. +func planCopySegments(ctx context.Context, opts TranscodeOpts) *copySegmentPlan { + if !opts.KeyframePlaylist || + !strings.EqualFold(opts.TargetCodecVideo, "copy") || + opts.SeekSeconds > 0 || opts.StartSegmentNumber > 0 || + !CanGenerateSyntheticManifest(opts.TotalDuration, opts.SegmentDuration) { + return nil + } + load := loadKeyframeIndex + if opts.Reconstructed { + load = func(path string) (keyframes.Index, error) { return loadVerifiedKeyframeIndex(ctx, path) } + // A rebuilt session that lost its plan record (a clean shutdown + // removes the output directory) may already have served the planned + // playlist. The plan follows from the verified index alone, so wait + // for the check here rather than fall back to FFmpeg's playlist, + // which a player holding the planned one can't follow. Once the + // check has run, the index is cached. The check stops if the + // rebuild's request goes away (see resolveCopyPlan). + } + idx, err := load(opts.InputPath) + if err != nil { + if !errors.Is(err, keyframes.ErrNoIndex) { + log.Printf("playback: read keyframe index for %s: %v; using FFmpeg's playlist", opts.SessionID, err) + } + return nil + } + segmentSeconds := float64(opts.SegmentDuration) + if segmentSeconds <= 0 { + segmentSeconds = defaultSegmentDuration + } + starts := keyframes.PlanSegments(idx.Keyframes, segmentSeconds) + if len(starts) == 0 || len(starts) > maxSyntheticManifestSegments { + return nil + } + // FFmpeg's last segment ends with the copied video, even when an audio + // or subtitle track runs on: the container's duration can be longer. + if idx.VideoEnd <= idx.Keyframes[len(idx.Keyframes)-1] { + // The video's end is unknown, so the plan can't describe the last + // segment. + return nil + } + durations := keyframes.SegmentDurations(starts, idx.VideoEnd) + return ©SegmentPlan{durations: durations} +} + +// copyPlanRecordFile keeps a session's planning decision in its output +// directory, which a session rebuilt from its recipe reuses. +const copyPlanRecordFile = "keyframe-plan.json" + +type copyPlanRecord struct { + Planned bool `json:"planned"` + Durations []float64 `json:"durations,omitempty"` +} + +// resolveCopyPlan returns the plan a session serves. The first start decides +// and records the decision; a rebuilt session follows the record, so it +// serves the playlist its player already has. Deciding afresh could differ: +// a file's index that was still being checked may be verified by then. +// +// The record lives in the output directory, which a clean shutdown removes. +// A rebuilt session without one plans again from the verified index (see +// planCopySegments), which gives the same plan as the first start. If that +// start had fallen back to FFmpeg's playlist, the rebuilt session now plans: +// a planned session copies from the beginning, where FFmpeg cuts the same +// segments, so the player's earlier segments still match. +func resolveCopyPlan(ctx context.Context, opts TranscodeOpts) *copySegmentPlan { + if !opts.KeyframePlaylist || opts.OutputDir == "" { + return planCopySegments(ctx, opts) + } + path := filepath.Join(opts.OutputDir, copyPlanRecordFile) + if data, err := os.ReadFile(path); err == nil { + var rec copyPlanRecord + if err := json.Unmarshal(data, &rec); err == nil { + if !rec.Planned { + return nil + } + if plan := rec.plan(); plan != nil { + return plan + } + } + log.Printf("playback: session %s has an unreadable keyframe plan record; using FFmpeg's playlist", opts.SessionID) + return nil + } + + // Record the decision whatever the start's fate: StartTranscode launches + // FFmpeg even if its caller has gone, and that session serves this + // playlist. + plan := planCopySegments(ctx, opts) + if plan == nil && opts.Reconstructed && ctx.Err() != nil { + // The rebuild's request went away during the keyframe check. Its + // outcome says nothing about the file, so don't record it: + // StartTranscode abandons this rebuild, and the next one plans. + return nil + } + rec := copyPlanRecord{Planned: plan != nil} + if plan != nil { + rec.Durations = plan.durations + } + data, err := json.Marshal(rec) + if err == nil { + tmp := path + ".tmp" + if err = os.WriteFile(tmp, data, 0o644); err == nil { + err = os.Rename(tmp, path) + } + } + if err != nil { + // Without a record a rebuilt session can't promise the same + // playlist, so don't serve a planned one. + log.Printf("playback: record keyframe plan for %s: %v; using FFmpeg's playlist", opts.SessionID, err) + return nil + } + return plan +} + +// plan rebuilds a recorded plan, or nil if the record is inconsistent. +func (r copyPlanRecord) plan() *copySegmentPlan { + if len(r.Durations) == 0 { + return nil + } + for _, d := range r.Durations { + if !(d > 0) || math.IsInf(d, 0) { + return nil + } + } + return ©SegmentPlan{durations: r.Durations} +} + +// plannedSegmentWait is how long a request for a segment FFmpeg hasn't +// reached yet waits. A planned stream doesn't seek-restart forward, so a far +// seek waits for FFmpeg to copy up to it; stream copy runs many times faster +// than playback, and a missing segment can end Safari's native playback. +const plannedSegmentWait = 30 * time.Second + +// plannedSegmentRecoveryDecision handles a missing segment in a planned +// session. One FFmpeg hasn't reached is waited for, and recorded as the +// player's position so the throttler doesn't hold FFmpeg back from it. One +// that is gone (pruned behind the player) or will never come (FFmpeg stopped) +// regenerates the stream from the beginning. +func (s *TranscodeSession) plannedSegmentRecoveryDecision(segNum int, decision SegmentRecoveryDecision) SegmentRecoveryDecision { + progress := decision.Progress + switch { + case progress.Restarting: + decision.Wait = true + decision.WaitTimeout = activeSegmentWait + decision.RestartOnTimeout = false + decision.Reason = segmentReasonRestarting + case progress.Running && segNum > progress.ProducedHead: + s.mu.Lock() + if segNum > s.lastRequestedSegment { + s.lastRequestedSegment = segNum + } + s.mu.Unlock() + decision.Wait = true + decision.WaitTimeout = plannedSegmentWait + decision.RestartOnTimeout = false + decision.Reason = "planned_segment_ahead" + default: + decision.Wait = false + decision.RestartOnTimeout = true + decision.Reason = "planned_segment_regenerate" + } + return decision +} + +// manifest returns the plan as a complete VOD playlist for the session's +// segment files, in the same shape as GenerateFullManifest. +func (p *copySegmentPlan) manifest(opts TranscodeOpts, segPrefix, rawQuery string) []byte { + queryDefinition, suffix, queryVersion := syntheticManifestQuery(len(p.durations), rawQuery) + segExt := hlsSegmentExtension(opts) + fmp4 := videoUsesFMP4(opts) + hlsVersion := 3 + if fmp4 { + hlsVersion = 7 + } + hlsVersion = max(hlsVersion, queryVersion) + + // EXT-X-TARGETDURATION must cover every segment's rounded duration. A + // keyframe gap longer than the segment length makes a longer segment. + target := 1 + for _, d := range p.durations { + target = max(target, int(math.Round(d))) + } + + var buf bytes.Buffer + buf.WriteString("#EXTM3U\n") + fmt.Fprintf(&buf, "#EXT-X-VERSION:%d\n", hlsVersion) + buf.WriteString(queryDefinition) + fmt.Fprintf(&buf, "#EXT-X-TARGETDURATION:%d\n", target) + buf.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n") + buf.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n") + if fmp4 { + fmt.Fprintf(&buf, "#EXT-X-MAP:URI=\"%sinit.mp4%s\"\n", segPrefix, suffix) + } + for i, d := range p.durations { + fmt.Fprintf(&buf, "#EXTINF:%.6f,\n", d) + fmt.Fprintf(&buf, "%sseg_%05d%s%s\n", segPrefix, i, segExt, suffix) + } + buf.WriteString("#EXT-X-ENDLIST\n") + return buf.Bytes() +} diff --git a/internal/playback/copy_keyframe_plan_test.go b/internal/playback/copy_keyframe_plan_test.go new file mode 100644 index 0000000000..398e732ac9 --- /dev/null +++ b/internal/playback/copy_keyframe_plan_test.go @@ -0,0 +1,415 @@ +package playback + +import ( + "bufio" + "bytes" + "context" + "errors" + "math" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + "time" + + "github.com/Silo-Server/silo-server/internal/playback/keyframes" +) + +func stubKeyframeIndex(t *testing.T, idx keyframes.Index, err error) { + t.Helper() + previous := loadKeyframeIndex + loadKeyframeIndex = func(string) (keyframes.Index, error) { return idx, err } + t.Cleanup(func() { loadKeyframeIndex = previous }) +} + +func plannedOpts() TranscodeOpts { + return TranscodeOpts{ + InputPath: "/media/episode.mkv", + SessionID: "planned", + TargetCodecVideo: "copy", + TargetCodecAudio: "copy", + SegmentDuration: 2, + TotalDuration: 10, + KeyframePlaylist: true, + } +} + +func TestPlanCopySegments(t *testing.T) { + // The container runs to 12s, but FFmpeg's last segment ends with the + // video at 10s. + stubKeyframeIndex(t, keyframes.Index{Keyframes: []float64{0, 1, 2.6, 7.0, 7.4, 9.0}, Duration: 12, VideoEnd: 10}, nil) + + plan := planCopySegments(t.Context(), plannedOpts()) + if plan == nil { + t.Fatal("no plan for an eligible session") + } + // Starts 0, 2.6, 7.0, 7.4 (catching up after the long gap), 9.0; the + // last segment runs to the video's end. + want := []float64{2.6, 4.4, 0.4, 1.6, 1.0} + if len(plan.durations) != len(want) { + t.Fatalf("durations = %v, want %v", plan.durations, want) + } + for i := range want { + if math.Abs(plan.durations[i]-want[i]) > 1e-9 { + t.Fatalf("durations = %v, want %v", plan.durations, want) + } + } +} + +// A source whose timestamps start later than zero plans the same segments. +func TestPlanCopySegmentsForALaterStart(t *testing.T) { + stubKeyframeIndex(t, keyframes.Index{Keyframes: []float64{7, 8, 9.6, 14.0, 14.4, 16.0}, VideoEnd: 17}, nil) + plan := planCopySegments(t.Context(), plannedOpts()) + if plan == nil { + t.Fatal("no plan for a source starting at 7s") + } + want := []float64{2.6, 4.4, 0.4, 1.6, 1.0} + for i := range want { + if math.Abs(plan.durations[i]-want[i]) > 1e-9 { + t.Fatalf("durations = %v, want %v", plan.durations, want) + } + } +} + +func TestPlanCopySegmentsKeepsFFmpegPlaylist(t *testing.T) { + stubKeyframeIndex(t, keyframes.Index{Keyframes: []float64{0, 2, 4, 6, 8}, VideoEnd: 10}, nil) + for name, change := range map[string]func(*TranscodeOpts){ + "setting off": func(o *TranscodeOpts) { o.KeyframePlaylist = false }, + "encoded video": func(o *TranscodeOpts) { o.TargetCodecVideo = "h264" }, + "starts at a seek": func(o *TranscodeOpts) { o.SeekSeconds = 4 }, + "starts mid-stream": func(o *TranscodeOpts) { o.StartSegmentNumber = 2 }, + "unknown duration": func(o *TranscodeOpts) { o.TotalDuration = 0 }, + } { + t.Run(name, func(t *testing.T) { + opts := plannedOpts() + change(&opts) + if plan := planCopySegments(t.Context(), opts); plan != nil { + t.Fatalf("plan = %v, want none", plan.durations) + } + }) + } + + t.Run("unknown video end", func(t *testing.T) { + stubKeyframeIndex(t, keyframes.Index{Keyframes: []float64{0, 2, 4, 6, 8}}, nil) + if plan := planCopySegments(t.Context(), plannedOpts()); plan != nil { + t.Fatalf("plan = %v, want none", plan.durations) + } + }) + t.Run("no index", func(t *testing.T) { + stubKeyframeIndex(t, keyframes.Index{}, keyframes.ErrNoIndex) + if plan := planCopySegments(t.Context(), plannedOpts()); plan != nil { + t.Fatalf("plan = %v, want none", plan.durations) + } + }) + t.Run("unreadable source", func(t *testing.T) { + stubKeyframeIndex(t, keyframes.Index{}, errors.New("permission denied")) + if plan := planCopySegments(t.Context(), plannedOpts()); plan != nil { + t.Fatalf("plan = %v, want none", plan.durations) + } + }) +} + +// A session rebuilt from its recipe serves the playlist its player has, even +// if the file's index became available since it started. +func TestResolveCopyPlanFollowsTheRecordedDecision(t *testing.T) { + opts := plannedOpts() + opts.OutputDir = t.TempDir() + + stubKeyframeIndex(t, keyframes.Index{}, keyframes.ErrUnverified) + if plan := resolveCopyPlan(t.Context(), opts); plan != nil { + t.Fatal("planned while the index is being checked") + } + verified := keyframes.Index{Keyframes: []float64{0, 1, 2.6, 7.0, 7.4, 9.0}, VideoEnd: 10} + stubKeyframeIndex(t, verified, nil) + if plan := resolveCopyPlan(t.Context(), opts); plan != nil { + t.Fatal("a rebuilt session switched to a keyframe playlist") + } + + planned := plannedOpts() + planned.OutputDir = t.TempDir() + first := resolveCopyPlan(t.Context(), planned) + if first == nil { + t.Fatal("no plan with a verified index") + } + // The record stands in for the index, which a cold process may not have + // verified yet. + stubKeyframeIndex(t, keyframes.Index{}, keyframes.ErrUnverified) + again := resolveCopyPlan(t.Context(), planned) + if again == nil || !slices.Equal(again.durations, first.durations) { + t.Fatalf("rebuilt plan = %+v, want the recorded %+v", again, first) + } +} + +// A clean shutdown removes the output directory and its plan record. The +// rebuilt session plans again from the verified index, waiting for the check +// a cold process hasn't run yet, and gets the same plan. +func TestResolveCopyPlanReplansAfterTheRecordIsGone(t *testing.T) { + verified := keyframes.Index{Keyframes: []float64{0, 1, 2.6, 7.0, 7.4, 9.0}, VideoEnd: 10} + stubKeyframeIndex(t, verified, nil) + first := plannedOpts() + first.OutputDir = t.TempDir() + original := resolveCopyPlan(t.Context(), first) + if original == nil { + t.Fatal("no plan with a verified index") + } + + // A cold process: the cache has no result yet, and the full check gives + // the same index. + stubKeyframeIndex(t, keyframes.Index{}, keyframes.ErrUnverified) + previous := loadVerifiedKeyframeIndex + verifiedLoads := 0 + loadVerifiedKeyframeIndex = func(context.Context, string) (keyframes.Index, error) { verifiedLoads++; return verified, nil } + t.Cleanup(func() { loadVerifiedKeyframeIndex = previous }) + + rebuilt := plannedOpts() + rebuilt.OutputDir = t.TempDir() + rebuilt.Reconstructed = true + again := resolveCopyPlan(t.Context(), rebuilt) + if again == nil || !slices.Equal(again.durations, original.durations) || verifiedLoads != 1 { + t.Fatalf("rebuilt plan = %+v after %d verified loads, want the original %+v", again, verifiedLoads, original) + } + + // A first start still doesn't wait for the check. + fresh := plannedOpts() + fresh.OutputDir = t.TempDir() + if plan := resolveCopyPlan(t.Context(), fresh); plan != nil || verifiedLoads != 1 { + t.Fatalf("a first start planned (%v) or waited for the check (%d loads)", plan != nil, verifiedLoads) + } +} + +// A rebuild whose request goes away during the keyframe check records nothing, +// so the next rebuild plans instead of following a fallback. +func TestResolveCopyPlanLeavesNoRecordWhenTheRebuildIsCanceled(t *testing.T) { + verified := keyframes.Index{Keyframes: []float64{0, 1, 2.6, 7.0, 7.4, 9.0}, VideoEnd: 10} + stubKeyframeIndex(t, keyframes.Index{}, keyframes.ErrUnverified) + previous := loadVerifiedKeyframeIndex + loadVerifiedKeyframeIndex = func(ctx context.Context, _ string) (keyframes.Index, error) { + if err := ctx.Err(); err != nil { + return keyframes.Index{}, err + } + return verified, nil + } + t.Cleanup(func() { loadVerifiedKeyframeIndex = previous }) + + rebuilt := plannedOpts() + rebuilt.OutputDir = t.TempDir() + rebuilt.Reconstructed = true + gone, cancel := context.WithCancel(t.Context()) + cancel() + if plan := resolveCopyPlan(gone, rebuilt); plan != nil { + t.Fatal("planned without the keyframe check") + } + if _, err := os.Stat(filepath.Join(rebuilt.OutputDir, copyPlanRecordFile)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a canceled rebuild left a plan record: %v", err) + } + if plan := resolveCopyPlan(t.Context(), rebuilt); plan == nil { + t.Fatal("the next rebuild didn't plan") + } +} + +func TestCopySegmentPlanManifest(t *testing.T) { + plan := ©SegmentPlan{durations: []float64{2.6, 5.2, 0.4}} + got := string(plan.manifest(plannedOpts(), "segment/", "token=abc")) + want := "#EXTM3U\n" + + "#EXT-X-VERSION:7\n" + + // The 5.2s segment, from a long keyframe gap, sets the target. + "#EXT-X-TARGETDURATION:5\n" + + "#EXT-X-MEDIA-SEQUENCE:0\n" + + "#EXT-X-PLAYLIST-TYPE:VOD\n" + + "#EXT-X-MAP:URI=\"segment/init.mp4?token=abc\"\n" + + "#EXTINF:2.600000,\nsegment/seg_00000.m4s?token=abc\n" + + "#EXTINF:5.200000,\nsegment/seg_00001.m4s?token=abc\n" + + "#EXTINF:0.400000,\nsegment/seg_00002.m4s?token=abc\n" + + "#EXT-X-ENDLIST\n" + if got != want { + t.Fatalf("manifest:\n%s\nwant:\n%s", got, want) + } +} + +func TestPlannedSessionServesPlanForEveryManifest(t *testing.T) { + plan := ©SegmentPlan{durations: []float64{2, 2, 1}} + s := &TranscodeSession{opts: plannedOpts(), copyPlan: plan} + want := plan.manifest(s.opts, "", "") + for name, build := range map[string]func(string, string) ([]byte, error){ + "playback": s.BuildPlaybackManifest, + "source aligned": s.BuildSourceAlignedPlaybackManifest, + } { + got, err := build("", "") + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("%s manifest = %q, %v; want the plan", name, got, err) + } + } +} + +func TestPlannedSegmentRecoveryDecision(t *testing.T) { + s := &TranscodeSession{opts: plannedOpts(), copyPlan: ©SegmentPlan{durations: []float64{2}}, lastRequestedSegment: 3} + decide := func(segNum int, progress SegmentProgress) SegmentRecoveryDecision { + return s.plannedSegmentRecoveryDecision(segNum, SegmentRecoveryDecision{RestartOnTimeout: true, Progress: progress}) + } + + // A segment FFmpeg hasn't reached is waited for, never seek-restarted, + // and becomes the throttler's target. + ahead := decide(400, SegmentProgress{Running: true, ProducedHead: 20}) + if !ahead.Wait || ahead.RestartOnTimeout || ahead.WaitTimeout != plannedSegmentWait { + t.Fatalf("segment ahead: %+v, want a %s wait without restart", ahead, plannedSegmentWait) + } + if got := s.LastRequestedSegment(); got != 400 { + t.Fatalf("last requested segment = %d, want 400", got) + } + + for name, progress := range map[string]SegmentProgress{ + "pruned behind the head": {Running: true, ProducedHead: 500}, + "FFmpeg stopped": {Running: false, ProducedHead: 20}, + } { + got := decide(10, progress) + if got.Wait || !got.RestartOnTimeout { + t.Fatalf("%s: %+v, want a restart", name, got) + } + } + + restarting := decide(10, SegmentProgress{Restarting: true}) + if !restarting.Wait || restarting.RestartOnTimeout { + t.Fatalf("restarting: %+v, want a wait", restarting) + } +} + +func TestPlannedSegmentRecoveryTargetRestartsFromTheBeginning(t *testing.T) { + s := &TranscodeSession{opts: plannedOpts(), copyPlan: ©SegmentPlan{durations: []float64{2}}} + target, ok, err := s.ResolveSegmentRecoveryTarget(context.Background(), 40) + if err != nil || !ok { + t.Fatalf("ResolveSegmentRecoveryTarget = %v, %v", ok, err) + } + if target.SeekSeconds != 0 || target.StreamOriginSeconds != 0 || !target.CopySeekAnchorResolved { + t.Fatalf("target = %+v, want the beginning", target) + } +} + +// A planned session's FFmpeg must write exactly the segments its playlist +// lists, from the start and after a restart. +func TestPlannedCopySessionMatchesFFmpeg(t *testing.T) { + if testing.Short() { + t.Skip("real FFmpeg integration test") + } + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("ffmpeg is not installed") + } + ctx, cancel := context.WithTimeout(t.Context(), 2*time.Minute) + defer cancel() + + dir := t.TempDir() + source := filepath.Join(dir, "irregular.mkv") + // A second audio track the session doesn't select runs 20s past the + // video, so the container's duration does too. + out, err := exec.CommandContext(ctx, ffmpeg, "-v", "error", + "-f", "lavfi", "-i", "testsrc2=size=96x54:rate=25:duration=40", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=40", + "-f", "lavfi", "-i", "sine=frequency=660:sample_rate=48000:duration=60", + "-map", "0", "-map", "1", "-map", "2", + "-c:v", "libx264", "-preset", "ultrafast", "-g", "1000", "-bf", "2", + "-sc_threshold", "0", "-force_key_frames", "0,0.9,2.2,2.6,5.2,7.2,7.6,8.9,12.6,13,15,17.6,18.5,25,26.1,31,33.3,38", + "-c:a", "aac", source).CombinedOutput() + if err != nil { + t.Skipf("ffmpeg can't make the test file (%v): %s", err, out) + } + + // Planned sessions use a fully checked index; check it now rather than + // in the background. + if _, err := keyframes.LoadVerified(t.Context(), source); err != nil { + t.Fatal(err) + } + opts := TranscodeOpts{ + InputPath: source, + OutputDir: filepath.Join(dir, "out"), + SessionID: "planned-integration", + SourceVideoCodec: "h264", + TargetCodecVideo: "copy", + TargetCodecAudio: "copy", + SegmentDuration: 2, + TotalDuration: 60, + KeyframePlaylist: true, + FFmpegPath: ffmpeg, + HWAccel: HWAccelNone, + } + s, err := StartTranscode(ctx, opts) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + if s.copyPlan == nil { + t.Fatal("no plan for a Matroska source with Cues") + } + manifest, err := s.BuildPlaybackManifest("", "") + if err != nil { + t.Fatal(err) + } + planned := extinfDurations(t, manifest) + + checkWritten := func(label string) { + t.Helper() + select { + case <-s.Done(): + case <-ctx.Done(): + t.Fatalf("%s: FFmpeg didn't finish", label) + } + written, err := os.ReadFile(filepath.Join(opts.OutputDir, "stream.m3u8")) + if err != nil { + t.Fatal(err) + } + got := extinfDurations(t, written) + if len(got) != len(planned) { + t.Fatalf("%s: FFmpeg wrote %d segments %v, playlist lists %d %v", label, len(got), got, len(planned), planned) + } + for i := range got { + tolerance := 0.002 + if i == len(got)-1 { + // FFmpeg measures the last segment to a late packet, + // within a frame of the video's end. + tolerance = 0.041 + } + if math.Abs(got[i]-planned[i]) > tolerance { + t.Fatalf("%s: segment %d is %.3fs, playlist says %.3fs", label, i, got[i], planned[i]) + } + } + // Per-segment tolerances could hide drift that adds up. + var wrote, listed float64 + for i := range got { + wrote += got[i] + listed += planned[i] + } + if math.Abs(wrote-listed) > 0.041 { + t.Fatalf("%s: FFmpeg wrote %.3fs in all, the playlist lists %.3fs", label, wrote, listed) + } + } + checkWritten("from the start") + + if _, ok, err := s.RestartSegment(ctx, 9); err != nil || !ok { + t.Fatalf("RestartSegment = %v, %v", ok, err) + } + if o := s.Opts(); o.SeekSeconds != 0 || o.StartSegmentNumber != 0 { + t.Fatalf("restart seek %.3f at segment %d, want the beginning", o.SeekSeconds, o.StartSegmentNumber) + } + if got := s.LastRequestedSegment(); got != 9 { + t.Fatalf("last requested segment after restart = %d, want 9", got) + } + checkWritten("after a restart") +} + +func extinfDurations(t *testing.T, playlist []byte) []float64 { + t.Helper() + var durations []float64 + scanner := bufio.NewScanner(bytes.NewReader(playlist)) + for scanner.Scan() { + if v, ok := strings.CutPrefix(scanner.Text(), "#EXTINF:"); ok { + d, err := strconv.ParseFloat(strings.TrimSuffix(v, ","), 64) + if err != nil { + t.Fatal(err) + } + durations = append(durations, d) + } + } + return durations +} diff --git a/internal/playback/keyframes/cache.go b/internal/playback/keyframes/cache.go new file mode 100644 index 0000000000..76b80706d4 --- /dev/null +++ b/internal/playback/keyframes/cache.go @@ -0,0 +1,237 @@ +package keyframes + +import ( + "container/list" + "context" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "sync" + "time" +) + +// ErrUnverified means the file's index passed the quick check and is being +// checked against every cluster in the background; until that finishes it +// isn't used. It is reported as ErrNoIndex. +var ErrUnverified = fmt.Errorf("%w: keyframe index not verified yet", ErrNoIndex) + +const ( + // maxCachedFiles bounds the process-wide result cache. An index is a few + // kilobytes to a few hundred, one float per keyframe. + maxCachedFiles = 256 + // maxQueuedChecks bounds the full checks waiting or running. A file that + // finds the queue full is checked on a later playback. + maxQueuedChecks = 16 +) + +type cacheKey struct { + path string + size int64 + modTime time.Time +} + +type cacheEntry struct { + key cacheKey + index Index + err error +} + +// indexCache holds checked results, including files without a usable index, +// so a later playback or a second viewer doesn't read the file again. An +// entry is keyed by the file's size and modification time, so a replaced file +// is read afresh. Checks in progress are tracked apart from the results, so +// evicting results never loses track of one. +var indexCache = struct { + sync.Mutex + order *list.List + entries map[cacheKey]*list.Element + inFlight map[cacheKey]struct{} +}{order: list.New(), entries: map[cacheKey]*list.Element{}, inFlight: map[cacheKey]struct{}{}} + +// verifySlots runs one full check at a time: each reads every block header +// of a file. +var verifySlots = make(chan struct{}, 1) + +// Load returns the keyframe index of the media file at path once it has been +// checked against every cluster. The first call for a file runs the quick +// check and, if it passes, queues the full check in the background and +// returns ErrUnverified; calls after it finishes get the result from the +// cache. A file without a usable index, including any that isn't Matroska or +// WebM or is malformed, gets ErrNoIndex. +func Load(path string) (Index, error) { + return load(context.Background(), path, false) +} + +// LoadVerified is Load that runs the full check itself instead of in the +// background. The check stops when ctx is done, and a stopped check isn't +// cached. +func LoadVerified(ctx context.Context, path string) (Index, error) { + return load(ctx, path, true) +} + +func load(ctx context.Context, path string, wait bool) (Index, error) { + f, err := os.Open(path) + if err != nil { + return Index{}, err + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + return Index{}, err + } + key := cacheKey{path: path, size: info.Size(), modTime: info.ModTime()} + + if idx, err, ok := cached(key); ok && (!wait || !errors.Is(err, ErrUnverified)) { + return idx, err + } + + if wait { + idx, err := VerifyMatroska(contextReaderAt{ctx: ctx, r: f}, info.Size()) + if err != nil && transient(err) { + return Index{}, err + } + idx, err = settle(idx, err) + store(key, idx, err) + return idx, err + } + + if idx, err := ReadMatroska(f, info.Size()); err != nil { + if transient(err) { + return Index{}, err + } + idx, err = settle(idx, err) + store(key, idx, err) + return idx, err + } + if startCheck(key) { + go verifyInBackground(key) + return Index{}, ErrUnverified + } + // Not queued: a check may have finished since the lookup above, so + // return its result rather than ErrUnverified. + if idx, err, ok := cached(key); ok { + return idx, err + } + return Index{}, ErrUnverified +} + +// transient reports an error reading the file, which says nothing lasting +// about it: a later playback tries again. Anything else describes the file. +func transient(err error) bool { + return errors.As(err, new(*fs.PathError)) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +// contextReaderAt stops a full check once its caller has gone: every read +// fails with the context's error. +type contextReaderAt struct { + ctx context.Context + r io.ReaderAt +} + +func (c contextReaderAt) ReadAt(p []byte, off int64) (int, error) { + if err := c.ctx.Err(); err != nil { + return 0, err + } + return c.r.ReadAt(p, off) +} + +// settle turns a lasting failure into ErrNoIndex: the file isn't Matroska, is +// malformed, or has no complete index. +func settle(idx Index, err error) (Index, error) { + if err == nil || errors.Is(err, ErrNoIndex) { + return idx, err + } + return Index{}, fmt.Errorf("%w: %w", ErrNoIndex, err) +} + +// startCheck records a full check for key, unless one is already queued or +// the queue is full. +func startCheck(key cacheKey) bool { + indexCache.Lock() + defer indexCache.Unlock() + if _, ok := indexCache.inFlight[key]; ok || len(indexCache.inFlight) >= maxQueuedChecks { + return false + } + if _, ok := indexCache.entries[key]; ok { + return false // a check finished meanwhile + } + indexCache.inFlight[key] = struct{}{} + return true +} + +func verifyInBackground(key cacheKey) { + verifySlots <- struct{}{} + defer func() { <-verifySlots }() + + idx, err := func() (Index, error) { + f, err := os.Open(key.path) + if err != nil { + return Index{}, err + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + return Index{}, err + } + if info.Size() != key.size || !info.ModTime().Equal(key.modTime) { + return Index{}, &fs.PathError{Op: "verify", Path: key.path, Err: errors.New("file changed during the check")} + } + return VerifyMatroska(f, info.Size()) + }() + + if err != nil && transient(err) { + slog.Warn("keyframe index: check file", "component", "playback", "path", key.path, "error", err) + indexCache.Lock() + delete(indexCache.inFlight, key) // a later playback tries again + indexCache.Unlock() + return + } + idx, err = settle(idx, err) + // Publish the result and end the check together, so no Load sees + // neither and queues the file again. + indexCache.Lock() + storeLocked(key, idx, err) + delete(indexCache.inFlight, key) + indexCache.Unlock() +} + +func cached(key cacheKey) (Index, error, bool) { + indexCache.Lock() + defer indexCache.Unlock() + if _, ok := indexCache.inFlight[key]; ok { + return Index{}, ErrUnverified, true + } + el, ok := indexCache.entries[key] + if !ok { + return Index{}, nil, false + } + indexCache.order.MoveToFront(el) + entry, _ := el.Value.(*cacheEntry) + return entry.index, entry.err, true +} + +func store(key cacheKey, idx Index, err error) { + indexCache.Lock() + defer indexCache.Unlock() + storeLocked(key, idx, err) +} + +func storeLocked(key cacheKey, idx Index, err error) { + if el, ok := indexCache.entries[key]; ok { + entry, _ := el.Value.(*cacheEntry) + entry.index, entry.err = idx, err + indexCache.order.MoveToFront(el) + return + } + indexCache.entries[key] = indexCache.order.PushFront(&cacheEntry{key: key, index: idx, err: err}) + for indexCache.order.Len() > maxCachedFiles { + oldest := indexCache.order.Back() + indexCache.order.Remove(oldest) + if entry, ok := oldest.Value.(*cacheEntry); ok { + delete(indexCache.entries, entry.key) + } + } +} diff --git a/internal/playback/keyframes/cache_test.go b/internal/playback/keyframes/cache_test.go new file mode 100644 index 0000000000..aee0d61611 --- /dev/null +++ b/internal/playback/keyframes/cache_test.go @@ -0,0 +1,133 @@ +package keyframes + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" + "time" +) + +func TestLoadCachesUntilTheFileChanges(t *testing.T) { + path := filepath.Join(t.TempDir(), "a.mkv") + contents := file(layout{}) + if err := os.WriteFile(path, contents, 0o644); err != nil { + t.Fatal(err) + } + stamp := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + got, err := LoadVerified(t.Context(), path) + wantIndex(t, got, err) + + // Same size and time with different bytes: still the cached index. + damaged := slices.Clone(contents) + for i := range damaged[:8] { + damaged[i] = 0 + } + if err := os.WriteFile(path, damaged, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + got, err = Load(path) + wantIndex(t, got, err) + + // A new modification time reads the file again. + later := stamp.Add(time.Hour) + if err := os.Chtimes(path, later, later); err != nil { + t.Fatal(err) + } + if _, err := Load(path); !errors.Is(err, ErrNoIndex) { + t.Fatalf("err = %v, want ErrNoIndex for the changed file", err) + } +} + +func TestLoadReportsOtherFilesAsUnindexed(t *testing.T) { + path := filepath.Join(t.TempDir(), "a.mp4") + if err := os.WriteFile(path, []byte("\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); !errors.Is(err, ErrNoIndex) { + t.Fatalf("err = %v, want ErrNoIndex", err) + } + if _, err := Load(filepath.Join(t.TempDir(), "missing.mkv")); err == nil || errors.Is(err, ErrNoIndex) { + t.Fatalf("missing file err = %v, want a read error", err) + } +} + +// The first Load starts the full check and doesn't use the index until it +// passes. +func TestLoadVerifiesInTheBackground(t *testing.T) { + path := filepath.Join(t.TempDir(), "b.mkv") + if err := os.WriteFile(path, file(layout{}), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); !errors.Is(err, ErrUnverified) || !errors.Is(err, ErrNoIndex) { + t.Fatalf("first Load err = %v, want ErrUnverified (an ErrNoIndex)", err) + } + deadline := time.Now().Add(5 * time.Second) + for { + got, err := Load(path) + if !errors.Is(err, ErrUnverified) { + wantIndex(t, got, err) + return + } + if time.Now().After(deadline) { + t.Fatal("the background check didn't finish") + } + time.Sleep(10 * time.Millisecond) + } +} + +// A malformed file is a lasting fact, cached like a missing index, not +// checked again on every playback. +func TestLoadCachesMalformedFiles(t *testing.T) { + path := filepath.Join(t.TempDir(), "broken.mkv") + // Cues pointing at an element that isn't a cluster: with the Cues first, + // cluster B ends the file. + contents := file(layout{cuesFirst: true}) + broken := slices.Clone(contents) + copy(broken[len(broken)-len(clusterB):], bytes.Repeat([]byte{0xEC}, 8)) // overwrite cluster B's header with Void + if err := os.WriteFile(path, broken, 0o644); err != nil { + t.Fatal(err) + } + stamp := time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC) + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + if _, err := LoadVerified(t.Context(), path); !errors.Is(err, ErrNoIndex) { + t.Fatalf("malformed file err = %v, want ErrNoIndex", err) + } + // Repaired in place with the same size and time: still the cached result. + if err := os.WriteFile(path, contents, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, stamp, stamp); err != nil { + t.Fatal(err) + } + if _, err := Load(path); !errors.Is(err, ErrNoIndex) || errors.Is(err, ErrUnverified) { + t.Fatalf("second load err = %v, want the cached ErrNoIndex", err) + } +} + +// A full check whose caller has gone stops with the caller's error, which +// isn't cached: the next check reads the file. +func TestLoadVerifiedStopsWhenCanceled(t *testing.T) { + path := filepath.Join(t.TempDir(), "canceled.mkv") + if err := os.WriteFile(path, file(layout{}), 0o644); err != nil { + t.Fatal(err) + } + gone, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := LoadVerified(gone, path); !errors.Is(err, context.Canceled) || errors.Is(err, ErrNoIndex) { + t.Fatalf("canceled check: err = %v, want context.Canceled and not ErrNoIndex", err) + } + got, err := LoadVerified(t.Context(), path) + wantIndex(t, got, err) +} diff --git a/internal/playback/keyframes/coverage.go b/internal/playback/keyframes/coverage.go new file mode 100644 index 0000000000..a931369d16 --- /dev/null +++ b/internal/playback/keyframes/coverage.go @@ -0,0 +1,354 @@ +package keyframes + +import ( + "encoding/binary" + "fmt" + "io" + "math" + "sort" +) + +// ErrIncompleteIndex means the file's Cues leave out some video keyframes. +// Matroska recommends a cue for every video keyframe but doesn't require +// one, and a plan built on a partial list would cut segments where FFmpeg +// doesn't. It is reported as ErrNoIndex. +var ErrIncompleteIndex = fmt.Errorf("%w: cues leave out video keyframes", ErrNoIndex) + +const ( + idCueClusterPosition = 0xF1 + idClusterTimestamp = 0xE7 + idSimpleBlock = 0xA3 + idBlockGroup = 0xA0 + idBlock = 0xA1 + idReferenceBlock = 0xFB + idBlockDuration = 0x9B + + // coverageSamples is how many cued clusters, spread through the file, have + // their keyframes checked against the Cues, each with the cluster after + // it. Muxers that cue sparsely leave keyframes out of most clusters, or + // cue only some clusters, so a few samples catch them while reading only + // block headers. + coverageSamples = 4 + // maxClusterChildren bounds the elements read from one cluster. + maxClusterChildren = 20_000 +) + +// cuePoint is one video cue: its timestamp in ticks and the position of the +// cluster holding the keyframe, relative to the segment's data. +type cuePoint struct { + ticks uint64 + cluster int64 +} + +// checkCueCoverage reads the video keyframes of a few clusters spread +// through the segment, and of the cluster after each, and reports +// ErrIncompleteIndex if any keyframe has no cue. +func checkCueCoverage(r io.ReaderAt, segStart, segEnd int64, video uint64, points []cuePoint) error { + cued := make(map[uint64]struct{}, len(points)) + var clusters []int64 + seen := map[int64]struct{}{} + for _, p := range points { + cued[p.ticks] = struct{}{} + if p.cluster < 0 { + continue + } + if _, ok := seen[p.cluster]; !ok { + seen[p.cluster] = struct{}{} + clusters = append(clusters, p.cluster) + } + } + if len(clusters) == 0 { + // CueClusterPosition is mandatory; without it the Cues can't be + // checked, so they aren't trusted. + return ErrIncompleteIndex + } + sort.Slice(clusters, func(i, j int) bool { return clusters[i] < clusters[j] }) + + samples := min(coverageSamples, len(clusters)) + for i := range samples { + at := segStart + clusters[i*(len(clusters)-1)/max(samples-1, 1)] + // The sampled cluster, then the one after it, which a muxer that + // cues only some clusters leaves without any cue. + for range 2 { + keys, next, err := clusterKeyframes(r, at, segEnd, video, nil) + if err != nil { + return fmt.Errorf("keyframes: check cluster at %d: %w", at, err) + } + for _, k := range keys { + if _, ok := cued[k]; !ok { + return ErrIncompleteIndex + } + } + if id, _, _, err := readElementHeader(r, next); next >= segEnd || err != nil || id != idCluster { + break + } + at = next + } + } + return nil +} + +// clusterKeyframes returns the timestamps, in ticks, of the video track's +// keyframes in the cluster at off, and where the next element starts. It +// reads element headers and the first bytes of each block, never the frames. +func clusterKeyframes(r io.ReaderAt, off, segEnd int64, video uint64, last *videoEnd) ([]uint64, int64, error) { + id, size, headerLen, err := readElementHeader(r, off) + if err != nil { + return nil, 0, err + } + if id != idCluster { + return nil, 0, fmt.Errorf("cue points at element %#x, not a cluster", id) + } + end := segEnd + if size != unknownSize && off+headerLen+size < end { + end = off + headerLen + size + } + + var ( + clusterTicks uint64 + haveTicks bool + keys []int64 + latest videoEnd // relative to the cluster + ) + at := off + headerLen + n := 0 + for ; at < end && n < maxClusterChildren; n++ { + id, size, headerLen, err := readElementHeader(r, at) + if err != nil { + if isShortRead(err) { + break + } + return nil, 0, err + } + // An unknown-size cluster ends where the next top-level element + // starts. + if id == idCluster || id == idCues || size == unknownSize { + end = at + break + } + data := at + headerLen + switch id { + case idClusterTimestamp: + b, err := readElementData(r, data, size, 8) + if err != nil { + return nil, 0, err + } + clusterTicks, haveTicks = readUint(b), true + case idSimpleBlock: + track, rel, flags, err := readBlockHeader(r, data, size) + if err != nil { + return nil, 0, err + } + if track == video { + latest.add(rel, 0) + if flags&0x80 != 0 { + keys = append(keys, rel) + } + } + case idBlockGroup: + track, rel, key, duration, err := readBlockGroup(r, data, size) + if err != nil { + return nil, 0, err + } + if track == video { + latest.add(rel, duration) + if key { + keys = append(keys, rel) + } + } + } + at = data + size + } + if n == maxClusterChildren && at < end { + // Not every block was read, so the cluster can't vouch for the Cues. + return nil, 0, ErrIncompleteIndex + } + if !haveTicks { + return nil, 0, fmt.Errorf("cluster at %d has no timestamp", off) + } + if last != nil { + if latest.n > 0 { + last.add(int64(clusterTicks)+latest.last, latest.lastDuration) + } + if latest.n > 1 { + last.add(int64(clusterTicks)+latest.prev, 0) + } + } + ticks := make([]uint64, 0, len(keys)) + for _, rel := range keys { + if t := int64(clusterTicks) + rel; t >= 0 { + ticks = append(ticks, uint64(t)) + } + } + return ticks, end, nil +} + +// readBlockHeader reads a (Simple)Block's track number, timestamp relative +// to its cluster, and flags. +func readBlockHeader(r io.ReaderAt, off, size int64) (track uint64, rel int64, flags byte, err error) { + var buf [12]byte + n := min(int64(len(buf)), size) + if _, err := r.ReadAt(buf[:n], off); err != nil { + return 0, 0, 0, err + } + track, trackLen, ok := readVint(buf[:n], false) + if !ok || int64(trackLen+3) > n { + return 0, 0, 0, fmt.Errorf("short block header at %d", off) + } + rel = int64(int16(binary.BigEndian.Uint16(buf[trackLen:]))) + return track, rel, buf[trackLen+2], nil +} + +// readBlockGroup reads a BlockGroup's block header, whether it is a keyframe +// (a block without a ReferenceBlock references no other frame), and its +// BlockDuration in ticks, or 0 when it has none. +func readBlockGroup(r io.ReaderAt, off, size int64) (track uint64, rel int64, key bool, duration int64, err error) { + end := off + size + key = true + haveBlock := false + for at := off; at < end; { + id, childSize, headerLen, err := readElementHeader(r, at) + if err != nil { + return 0, 0, false, 0, err + } + if childSize == unknownSize { + return 0, 0, false, 0, fmt.Errorf("unknown-size element in block group at %d", at) + } + switch id { + case idBlock: + track, rel, _, err = readBlockHeader(r, at+headerLen, childSize) + if err != nil { + return 0, 0, false, 0, err + } + haveBlock = true + case idReferenceBlock: + key = false + case idBlockDuration: + b, err := readElementData(r, at+headerLen, childSize, 8) + if err != nil { + return 0, 0, false, 0, err + } + if d := readUint(b); d <= math.MaxInt64 { + duration = int64(d) + } + } + at += headerLen + childSize + } + if !haveBlock { + return 0, 0, false, 0, nil + } + return track, rel, key, duration, nil +} + +// VerifyMatroska reads a Matroska file's index like ReadMatroska and then +// checks it against every cluster: each video keyframe must have a cue, and +// each video cue must be a keyframe. Only then does the index list exactly +// the keyframes FFmpeg cuts at. It reads every block's header, which on a +// large file can take as long as reading the file, so callers run it once per +// file in the background (see Load). +func VerifyMatroska(r io.ReaderAt, size int64) (Index, error) { + m, err := readMatroska(r, size) + if err != nil { + return Index{}, err + } + end, err := verifyAllClusters(r, m) + if err != nil { + return Index{}, err + } + m.VideoEnd = end.seconds(m.timescale, m.frameSeconds) + return m.Index, nil +} + +// videoEnd keeps the two latest distinct timestamps of a track's frames, and +// the latest frame's own duration when its block gives one. +type videoEnd struct { + last, prev int64 + lastDuration int64 // in ticks; 0 when the block doesn't say + n int // how many of last and prev are set +} + +// add records a frame at t ticks; duration is its BlockDuration, or 0. +func (v *videoEnd) add(t, duration int64) { + switch { + case v.n > 0 && t == v.last: + // Blocks sharing the last timestamp end with the longest. + v.lastDuration = max(v.lastDuration, duration) + return + case v.n > 1 && t == v.prev: + return + case v.n == 0 || t > v.last: + v.prev, v.last, v.lastDuration = v.last, t, duration + case v.n == 1 || t > v.prev: + v.prev = t + default: + return + } + v.n = min(v.n+1, 2) +} + +// seconds returns when the last frame ends: its timestamp plus its block's +// duration, else the track's default frame duration, else the gap to the +// frame before. It returns 0 when that can't be worked out. +func (v videoEnd) seconds(timescale int64, frame float64) float64 { + if v.n == 0 { + return 0 + } + scale := float64(timescale) / 1e9 + if v.lastDuration > 0 { + frame = float64(v.lastDuration) * scale + } + if frame <= 0 { + if v.n < 2 { + return 0 + } + frame = float64(v.last-v.prev) * scale + } + return float64(v.last)*scale + frame +} + +func verifyAllClusters(r io.ReaderAt, m matroskaIndex) (videoEnd, error) { + var end videoEnd + cued := make(map[uint64]struct{}, len(m.points)) + for _, p := range m.points { + cued[p.ticks] = struct{}{} + } + found := make(map[uint64]struct{}, len(cued)) + for at := m.segStart; at < m.segEnd; { + id, size, headerLen, err := readElementHeader(r, at) + if err != nil { + if isShortRead(err) { + break + } + return end, err + } + if id == idCluster { + keys, next, err := clusterKeyframes(r, at, m.segEnd, m.video, &end) + if err != nil { + return end, err + } + for _, k := range keys { + if _, ok := cued[k]; !ok { + return end, ErrIncompleteIndex + } + found[k] = struct{}{} + } + if next <= at { + return end, ErrIncompleteIndex + } + at = next + continue + } + if size == unknownSize { + // An unknown-size element other than a cluster can't be + // skipped, so the rest of the file can't be checked. + return end, ErrIncompleteIndex + } + at += headerLen + size + } + if len(found) != len(cued) { + // A cue on a frame that isn't a keyframe would put a planned + // boundary where FFmpeg can't cut. + return end, ErrIncompleteIndex + } + return end, nil +} diff --git a/internal/playback/keyframes/ffmpeg_test.go b/internal/playback/keyframes/ffmpeg_test.go new file mode 100644 index 0000000000..38e378c5c1 --- /dev/null +++ b/internal/playback/keyframes/ffmpeg_test.go @@ -0,0 +1,269 @@ +package keyframes + +import ( + "bufio" + "encoding/json" + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// These tests check the reader and planner against FFmpeg itself, on a file +// with irregular keyframes. They skip when ffmpeg or ffprobe isn't installed. + +func requireFFmpeg(t *testing.T) (ffmpeg, ffprobe string) { + t.Helper() + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("ffmpeg not installed") + } + ffprobe, err = exec.LookPath("ffprobe") + if err != nil { + t.Skip("ffprobe not installed") + } + return ffmpeg, ffprobe +} + +// irregularKeyframes are the forced keyframe times of the test file, with +// gaps both shorter and longer than the 2s segment length. +var irregularKeyframes = []float64{0, 0.9, 2.2, 2.6, 5.2, 7.2, 7.6, 8.9, 12.6, 13.0, 15.0, 17.6, 18.5} + +func makeTestMKV(t *testing.T, ffmpeg string) string { + t.Helper() + times := make([]string, len(irregularKeyframes)) + for i, k := range irregularKeyframes { + times[i] = strconv.FormatFloat(k, 'f', -1, 64) + } + path := filepath.Join(t.TempDir(), "in.mkv") + out, err := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=160x90:rate=25", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", + "-t", "20", "-c:v", "libx264", "-preset", "ultrafast", + "-g", "1000", "-sc_threshold", "0", "-bf", "0", + "-force_key_frames", strings.Join(times, ","), + "-c:a", "aac", path).CombinedOutput() + if err != nil { + t.Skipf("ffmpeg can't make the test file (%v): %s", err, out) + } + return path +} + +func readIndex(t *testing.T, path string) Index { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + idx, err := ReadMatroska(f, info.Size()) + if err != nil { + t.Fatal(err) + } + return idx +} + +func TestReadMatroskaMatchesFFprobe(t *testing.T) { + ffmpeg, ffprobe := requireFFmpeg(t) + path := makeTestMKV(t, ffmpeg) + + out, err := exec.Command(ffprobe, "-v", "error", "-select_streams", "v:0", + "-show_entries", "packet=pts_time,flags", "-of", "json", path).Output() + if err != nil { + t.Fatal(err) + } + var probe struct { + Packets []struct { + PTS string `json:"pts_time"` + Flags string `json:"flags"` + } `json:"packets"` + } + if err := json.Unmarshal(out, &probe); err != nil { + t.Fatal(err) + } + var want []float64 + for _, p := range probe.Packets { + if strings.Contains(p.Flags, "K") { + v, _ := strconv.ParseFloat(p.PTS, 64) + want = append(want, v) + } + } + + got := readIndex(t, path).Keyframes + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + if _, err := VerifyMatroska(f, info.Size()); err != nil { + t.Fatalf("FFmpeg's Cues failed the full check: %v", err) + } + if len(got) != len(want) { + t.Fatalf("keyframes = %v, ffprobe = %v", got, want) + } + for i := range want { + if math.Abs(got[i]-want[i]) > 0.001 { + t.Fatalf("keyframes = %v, ffprobe = %v", got, want) + } + } +} + +// The plan must match the segments FFmpeg's HLS muxer writes for copied +// video, or the complete playlist would list durations the segments don't +// have. +func TestPlanSegmentsMatchesFFmpegHLS(t *testing.T) { + ffmpeg, _ := requireFFmpeg(t) + // Regular 2s keyframes on a timeline that starts at 0.28s land exactly + // on every target, which float error in the planner must not skip. + // A second audio track FFmpeg doesn't select runs 10s past the video, so + // the container's duration does too; the plan must still end with the + // video. + regular := filepath.Join(t.TempDir(), "regular.mkv") + if out, err := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=160x90:rate=25:duration=10", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=10", + "-f", "lavfi", "-i", "sine=frequency=660:sample_rate=48000:duration=20", + "-map", "0", "-map", "1", "-map", "2", + "-c:v", "libx264", "-preset", "ultrafast", + "-g", "50", "-sc_threshold", "0", "-bf", "0", + "-c:a", "aac", "-output_ts_offset", "0.28", regular).CombinedOutput(); err != nil { + t.Skipf("ffmpeg can't make the test file (%v): %s", err, out) + } + for name, path := range map[string]string{"irregular": makeTestMKV(t, ffmpeg), "regular with an offset": regular} { + t.Run(name, func(t *testing.T) { checkPlanMatchesFFmpegHLS(t, ffmpeg, path) }) + } +} + +func checkPlanMatchesFFmpegHLS(t *testing.T, ffmpeg, path string) { + dir := t.TempDir() + out, err := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", + "-i", path, "-map", "0:v:0", "-map", "0:a:0", + "-copyts", "-avoid_negative_ts", "make_non_negative", "-start_at_zero", + "-c:v", "copy", "-c:a", "copy", + "-f", "hls", "-hls_time", "2", "-hls_list_size", "0", + "-hls_segment_type", "fmp4", + "-hls_segment_filename", filepath.Join(dir, "seg_%05d.m4s"), + filepath.Join(dir, "stream.m3u8")).CombinedOutput() + if err != nil { + t.Fatalf("ffmpeg: %v: %s", err, out) + } + written := playlistDurations(t, filepath.Join(dir, "stream.m3u8")) + + // Plan as a session does: from the verified index, to the video's end. + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + idx, err := VerifyMatroska(f, info.Size()) + if err != nil { + t.Fatal(err) + } + planned := SegmentDurations(PlanSegments(idx.Keyframes, 2), idx.VideoEnd) + if len(planned) != len(written) { + t.Fatalf("planned %d segments %v, FFmpeg wrote %d %v", len(planned), planned, len(written), written) + } + var plannedTotal, writtenTotal float64 + for i := range written { + tolerance := 0.002 + if i == len(written)-1 { + // FFmpeg measures the last segment to a late packet, within a + // frame of the video's end. + tolerance = 0.041 + } + if math.Abs(planned[i]-written[i]) > tolerance { + t.Fatalf("segment %d: planned %.3fs, FFmpeg wrote %.3fs (planned %v, written %v)", + i, planned[i], written[i], planned, written) + } + plannedTotal += planned[i] + writtenTotal += written[i] + } + // Per-segment tolerances could hide drift that adds up. + if math.Abs(plannedTotal-writtenTotal) > 0.041 { + t.Fatalf("planned %.3fs in all, FFmpeg wrote %.3fs", plannedTotal, writtenTotal) + } +} + +func playlistDurations(t *testing.T, path string) []float64 { + t.Helper() + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + var durations []float64 + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if v, ok := strings.CutPrefix(line, "#EXTINF:"); ok { + d, err := strconv.ParseFloat(strings.TrimSuffix(v, ","), 64) + if err != nil { + t.Fatalf("parse %q: %v", line, err) + } + durations = append(durations, d) + } + } + return durations +} + +// VerifyMatroska's video end is where ffprobe's last video frame ends, and +// a longer audio track doesn't move it. +func TestVerifyMatroskaVideoEndMatchesFFprobe(t *testing.T) { + ffmpeg, ffprobe := requireFFmpeg(t) + path := filepath.Join(t.TempDir(), "longer-audio.mkv") + out, err := exec.Command(ffmpeg, "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc2=size=160x90:rate=30000/1001:duration=9.5", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=15", + "-c:v", "libx264", "-preset", "ultrafast", "-g", "60", "-bf", "2", + "-c:a", "aac", path).CombinedOutput() + if err != nil { + t.Skipf("ffmpeg can't make the test file (%v): %s", err, out) + } + out, err = exec.Command(ffprobe, "-v", "error", "-select_streams", "v:0", + "-show_entries", "packet=pts_time,duration_time", "-of", "csv=p=0", path).Output() + if err != nil { + t.Fatal(err) + } + var want float64 + for line := range strings.FieldsSeq(string(out)) { + pts, duration, _ := strings.Cut(strings.TrimSuffix(line, ","), ",") + p, err1 := strconv.ParseFloat(pts, 64) + d, err2 := strconv.ParseFloat(duration, 64) + if err1 != nil || err2 != nil { + t.Fatalf("ffprobe packet %q", line) + } + want = max(want, p+d) + } + + f, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = f.Close() }() + info, err := f.Stat() + if err != nil { + t.Fatal(err) + } + idx, err := VerifyMatroska(f, info.Size()) + if err != nil { + t.Fatal(err) + } + if math.Abs(idx.VideoEnd-want) > 0.001 || idx.Duration < 14 { + t.Fatalf("video end %.3fs (container %.3fs), ffprobe's last frame ends at %.3fs", idx.VideoEnd, idx.Duration, want) + } +} diff --git a/internal/playback/keyframes/matroska.go b/internal/playback/keyframes/matroska.go new file mode 100644 index 0000000000..cf1f243cfc --- /dev/null +++ b/internal/playback/keyframes/matroska.go @@ -0,0 +1,538 @@ +// Package keyframes reads a media file's video keyframe times from its +// container index, without reading the media itself, and plans HLS segment +// boundaries on them. +package keyframes + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "sort" +) + +// ErrNoIndex means the file has no usable keyframe index: no Cues, or none +// for its video track. +var ErrNoIndex = errors.New("keyframes: no keyframe index") + +// notMatroskaError reports a file that isn't Matroska or WebM: a stable fact +// about the file, unlike a read failure. +type notMatroskaError struct{} + +func (notMatroskaError) Error() string { return "keyframes: not a Matroska file" } + +func isShortRead(err error) bool { + return errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) +} + +// Index is a file's video keyframe times, in seconds on the container's +// timeline, ascending and without duplicates. +type Index struct { + Keyframes []float64 + // Duration is the container's duration in seconds, or 0 when unknown. + Duration float64 + // VideoEnd is when the video track's last frame ends, in seconds on the + // same timeline as Keyframes, or 0 when unknown. Only VerifyMatroska, + // which reads every block, sets it. Other tracks can run longer, so it + // can be earlier than Duration. + VideoEnd float64 +} + +// idTrackTimestampScale is a track's deprecated extra timestamp scale. +const idTrackTimestampScale = 0x23314F + +// Matroska element IDs, from the Matroska specification. +const ( + idEBML = 0x1A45DFA3 + idSegment = 0x18538067 + idSeekHead = 0x114D9B74 + idSeek = 0x4DBB + idSeekID = 0x53AB + idSeekPosition = 0x53AC + idInfo = 0x1549A966 + idTimestampScale = 0x2AD7B1 + idDuration = 0x4489 + idTracks = 0x1654AE6B + idTrackEntry = 0xAE + idTrackNumber = 0xD7 + idTrackType = 0x83 + idDefaultDuration = 0x23E383 + idCues = 0x1C53BB6B + idCuePoint = 0xBB + idCueTime = 0xB3 + idCueTrackPos = 0xB7 + idCueTrack = 0xF7 + idCluster = 0x1F43B675 + trackTypeVideo = 1 + defaultTimescale = 1_000_000 // nanoseconds per timestamp tick + unknownSize int64 = -1 +) + +// Upper bounds on what the reader loads into memory. Cues for a long film +// with every keyframe indexed are a few megabytes; the metadata elements are +// small. Anything larger is treated as a damaged file. +const ( + maxCuesSize = 64 << 20 + maxMetadataSize = 16 << 20 +) + +// ReadMatroska returns the keyframe index of a Matroska or WebM file from its +// Cues element. It reads the header, the seek head, segment info, tracks and +// cues, following the seek head to Cues stored after the clusters. As a quick +// check it reads the block headers of a few clusters (checkCueCoverage); +// VerifyMatroska checks them all. +func ReadMatroska(r io.ReaderAt, size int64) (Index, error) { + m, err := readMatroska(r, size) + return m.Index, err +} + +// matroskaIndex is an Index with what checking it against the clusters +// needs (VerifyMatroska). +type matroskaIndex struct { + Index + segStart, segEnd int64 + video uint64 + points []cuePoint + timescale int64 + // frameSeconds is the video track's default frame duration, or 0. + frameSeconds float64 +} + +func readMatroska(r io.ReaderAt, size int64) (matroskaIndex, error) { + id, dataSize, headerLen, err := readElementHeader(r, 0) + if err != nil { + if isShortRead(err) { + // Empty, or not starting with an EBML element at all. + return matroskaIndex{}, notMatroskaError{} + } + return matroskaIndex{}, fmt.Errorf("keyframes: read EBML header: %w", err) + } + if id != idEBML { + return matroskaIndex{}, notMatroskaError{} + } + pos := headerLen + dataSize + + id, segSize, headerLen, err := readElementHeader(r, pos) + if err != nil { + if isShortRead(err) { + return matroskaIndex{}, notMatroskaError{} + } + return matroskaIndex{}, fmt.Errorf("keyframes: read segment: %w", err) + } + if id != idSegment { + return matroskaIndex{}, notMatroskaError{} + } + segStart := pos + headerLen + segEnd := size + if segSize != unknownSize && segStart+segSize < segEnd { + segEnd = segStart + segSize + } + + var ( + timescale int64 = defaultTimescale + duration float64 + video uint64 + frameNS uint64 + cuesAt int64 = -1 + infoAt int64 = -1 + tracksAt int64 = -1 + cues []byte + haveInfo bool + haveTrack bool + ) + for at := segStart; at < segEnd; { + id, dataSize, headerLen, err := readElementHeader(r, at) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + break + } + return matroskaIndex{}, fmt.Errorf("keyframes: read element at %d: %w", at, err) + } + dataAt := at + headerLen + switch id { + case idSeekHead: + data, err := readElementData(r, dataAt, dataSize, maxMetadataSize) + if err != nil { + return matroskaIndex{}, err + } + if p, ok := seekPosition(data, idCues); ok { + cuesAt = segStart + p + } + if p, ok := seekPosition(data, idInfo); ok { + infoAt = segStart + p + } + if p, ok := seekPosition(data, idTracks); ok { + tracksAt = segStart + p + } + case idInfo: + data, err := readElementData(r, dataAt, dataSize, maxMetadataSize) + if err != nil { + return matroskaIndex{}, err + } + var ok bool + if timescale, duration, ok = parseInfo(data); !ok { + return matroskaIndex{}, ErrNoIndex + } + haveInfo = true + case idTracks: + data, err := readElementData(r, dataAt, dataSize, maxMetadataSize) + if err != nil { + return matroskaIndex{}, err + } + var scaled bool + video, frameNS, scaled, haveTrack = firstVideoTrack(data) + if scaled { + // The track's timestamps are scaled again, a deprecated + // feature the times here don't apply; don't plan from them. + return matroskaIndex{}, ErrNoIndex + } + case idCues: + cues, err = readElementData(r, dataAt, dataSize, maxCuesSize) + if err != nil { + return matroskaIndex{}, err + } + } + if cues != nil && haveInfo && haveTrack { + break + } + // Clusters hold the media. Stop the linear scan at the first one and + // jump to the Cues the seek head points at, if they come later. + if id == idCluster || dataSize == unknownSize { + break + } + at = dataAt + dataSize + } + + if cues == nil && cuesAt >= 0 { + id, dataSize, headerLen, err := readElementHeader(r, cuesAt) + if err != nil { + return matroskaIndex{}, fmt.Errorf("keyframes: read cues at %d: %w", cuesAt, err) + } + if id != idCues { + return matroskaIndex{}, fmt.Errorf("keyframes: seek head points at element %#x, not Cues", id) + } + cues, err = readElementData(r, cuesAt+headerLen, dataSize, maxCuesSize) + if err != nil { + return matroskaIndex{}, err + } + } + // Info and Tracks may also come after the clusters, found through the + // seek head. Info's timestamp scale converts every cue time, so an index + // without it isn't trusted. + if !haveInfo && infoAt >= 0 { + data, err := readSeekTarget(r, infoAt, idInfo) + if err != nil { + return matroskaIndex{}, err + } + var ok bool + if timescale, duration, ok = parseInfo(data); !ok { + return matroskaIndex{}, ErrNoIndex + } + haveInfo = true + } + if !haveTrack && tracksAt >= 0 { + data, err := readSeekTarget(r, tracksAt, idTracks) + if err != nil { + return matroskaIndex{}, err + } + var scaled bool + video, frameNS, scaled, haveTrack = firstVideoTrack(data) + if scaled { + return matroskaIndex{}, ErrNoIndex + } + } + if cues == nil || !haveTrack || !haveInfo { + return matroskaIndex{}, ErrNoIndex + } + + points, complete := cuePoints(cues, video) + if !complete || len(points) == 0 { + // A malformed cue point ends the list early; the points read so + // far would leave out later keyframes. + return matroskaIndex{}, ErrNoIndex + } + if err := checkCueCoverage(r, segStart, segEnd, video, points); err != nil { + return matroskaIndex{}, err + } + ticks := make([]uint64, len(points)) + for i, p := range points { + ticks[i] = p.ticks + } + sort.Slice(ticks, func(i, j int) bool { return ticks[i] < ticks[j] }) + idx := Index{Keyframes: make([]float64, 0, len(ticks))} + for i, t := range ticks { + if i > 0 && t == ticks[i-1] { + continue + } + idx.Keyframes = append(idx.Keyframes, float64(t)*float64(timescale)/1e9) + } + if duration > 0 { + idx.Duration = duration * float64(timescale) / 1e9 + } + return matroskaIndex{ + Index: idx, segStart: segStart, segEnd: segEnd, video: video, points: points, + timescale: timescale, frameSeconds: float64(frameNS) / 1e9, + }, nil +} + +// readElementHeader reads an element's ID and data size at off, returning the +// header's length. A data size of unknownSize means the element runs to the +// end of its parent. +func readElementHeader(r io.ReaderAt, off int64) (id uint64, size int64, headerLen int64, err error) { + var buf [12]byte + n, err := r.ReadAt(buf[:], off) + if n == 0 { + if err == nil { + err = io.EOF + } + return 0, 0, 0, err + } + b := buf[:n] + id, idLen, ok := readVint(b, true) + if !ok { + return 0, 0, 0, io.ErrUnexpectedEOF + } + raw, sizeLen, ok := readVint(b[idLen:], false) + if !ok { + return 0, 0, 0, io.ErrUnexpectedEOF + } + size = int64(raw) + if raw == (uint64(1)<<(7*sizeLen))-1 { + size = unknownSize + } + return id, size, int64(idLen + sizeLen), nil +} + +func readElementData(r io.ReaderAt, off, size, limit int64) ([]byte, error) { + if size == unknownSize || size < 0 || size > limit { + return nil, fmt.Errorf("keyframes: element at %d has unsupported size %d", off, size) + } + data := make([]byte, size) + if _, err := r.ReadAt(data, off); err != nil { + return nil, fmt.Errorf("keyframes: read element at %d: %w", off, err) + } + return data, nil +} + +// readVint reads an EBML variable-length integer. IDs keep their length +// marker bit; sizes drop it. +func readVint(b []byte, keepMarker bool) (value uint64, length int, ok bool) { + if len(b) == 0 || b[0] == 0 { + return 0, 0, false + } + length = 1 + for mask := byte(0x80); b[0]&mask == 0; mask >>= 1 { + length++ + } + if length > 8 || len(b) < length { + return 0, 0, false + } + value = uint64(b[0]) + if !keepMarker { + value &= uint64(0xFF >> length) + } + for _, c := range b[1:length] { + value = value<<8 | uint64(c) + } + return value, length, true +} + +// readSeekTarget reads the element a seek head points at, which must be want. +func readSeekTarget(r io.ReaderAt, at int64, want uint64) ([]byte, error) { + id, dataSize, headerLen, err := readElementHeader(r, at) + if err != nil { + return nil, fmt.Errorf("keyframes: read element at %d: %w", at, err) + } + if id != want { + return nil, fmt.Errorf("keyframes: seek head points at element %#x, not %#x", id, want) + } + return readElementData(r, at+headerLen, dataSize, maxMetadataSize) +} + +// childrenComplete is children, reporting whether every child was well +// formed: false when malformed data stopped the walk early. +func childrenComplete(data []byte, fn func(id uint64, payload []byte)) bool { + for len(data) > 0 { + id, idLen, ok := readVint(data, true) + if !ok { + return false + } + raw, sizeLen, ok := readVint(data[idLen:], false) + if !ok { + return false + } + start := idLen + sizeLen + if raw > uint64(len(data)-start) { + return false + } + end := start + int(raw) + fn(id, data[start:end]) + data = data[end:] + } + return true +} + +// children calls fn for each child element in a master element's data. It +// stops at the first malformed child. +func children(data []byte, fn func(id uint64, payload []byte)) { + for len(data) > 0 { + id, idLen, ok := readVint(data, true) + if !ok { + return + } + raw, sizeLen, ok := readVint(data[idLen:], false) + if !ok { + return + } + start := idLen + sizeLen + if raw > uint64(len(data)-start) { + return + } + end := start + int(raw) + fn(id, data[start:end]) + data = data[end:] + } +} + +func readUint(b []byte) uint64 { + var v uint64 + for _, c := range b { + v = v<<8 | uint64(c) + } + return v +} + +func readFloat(b []byte) float64 { + switch len(b) { + case 4: + return float64(math.Float32frombits(binary.BigEndian.Uint32(b))) + case 8: + return math.Float64frombits(binary.BigEndian.Uint64(b)) + } + return 0 +} + +func seekPosition(seekHead []byte, target uint64) (int64, bool) { + var ( + pos int64 + found bool + ) + children(seekHead, func(id uint64, seek []byte) { + if id != idSeek || found { + return + } + var seekID uint64 + var seekPos int64 = -1 + children(seek, func(id uint64, v []byte) { + switch id { + case idSeekID: + seekID = readUint(v) + case idSeekPosition: + seekPos = int64(readUint(v)) + } + }) + if seekID == target && seekPos >= 0 { + pos, found = seekPos, true + } + }) + return pos, found +} + +// parseInfo reads Info's timestamp scale, defaulting when it is absent, and +// duration. ok is false for an explicit scale Matroska doesn't allow (zero) +// or that doesn't fit: cue times scaled by a guess would plan wrong segments. +func parseInfo(info []byte) (timescale int64, duration float64, ok bool) { + timescale, ok = defaultTimescale, true + children(info, func(id uint64, v []byte) { + switch id { + case idTimestampScale: + if s := int64(readUint(v)); s > 0 { + timescale = s + } else { + ok = false + } + case idDuration: + duration = readFloat(v) + } + }) + return timescale, duration, ok +} + +func firstVideoTrack(tracks []byte) (number, frameNS uint64, scaled, found bool) { + children(tracks, func(id uint64, entry []byte) { + if id != idTrackEntry || found { + return + } + var n, kind, frame uint64 + trackScale := 1.0 + children(entry, func(id uint64, v []byte) { + switch id { + case idTrackNumber: + n = readUint(v) + case idTrackType: + kind = readUint(v) + case idDefaultDuration: + frame = readUint(v) + case idTrackTimestampScale: + trackScale = readFloat(v) + } + }) + if kind == trackTypeVideo && n > 0 { + number, frameNS, scaled, found = n, frame, trackScale != 1, true + } + }) + return number, frameNS, scaled, found +} + +// cuePoints returns every cue point that indexes the video track, with the +// position of the cluster holding it (-1 when the cue doesn't say). Matroska +// muxers index video keyframes, so the times are keyframe times; whether +// every keyframe has one is checked separately (checkCueCoverage). +// It reports false when a malformed cue point stopped the walk early. +func cuePoints(cues []byte, video uint64) ([]cuePoint, bool) { + var points []cuePoint + complete := true + complete = childrenComplete(cues, func(id uint64, point []byte) { + if id != idCuePoint { + return + } + var ( + t uint64 + hasTime bool + isVideo bool + cluster int64 = -1 + ) + if !childrenComplete(point, func(id uint64, v []byte) { + switch id { + case idCueTime: + t, hasTime = readUint(v), true + case idCueTrackPos: + var ( + track uint64 + pos int64 = -1 + ) + // Malformed data here could hide the video track's entry. + if !childrenComplete(v, func(id uint64, v []byte) { + switch id { + case idCueTrack: + track = readUint(v) + case idCueClusterPosition: + pos = int64(readUint(v)) + } + }) { + complete = false + } + if track == video { + isVideo, cluster = true, pos + } + } + }) { + complete = false + } + if hasTime && isVideo { + points = append(points, cuePoint{ticks: t, cluster: cluster}) + } + }) && complete + return points, complete +} diff --git a/internal/playback/keyframes/matroska_test.go b/internal/playback/keyframes/matroska_test.go new file mode 100644 index 0000000000..88fa8c40cd --- /dev/null +++ b/internal/playback/keyframes/matroska_test.go @@ -0,0 +1,474 @@ +package keyframes + +import ( + "bytes" + "encoding/binary" + "errors" + "math" + "slices" + "testing" +) + +// el encodes one EBML element with an 8-byte size, so an element's length +// never depends on its values and cluster positions can be computed up front. +func el(id uint64, parts ...[]byte) []byte { + data := bytes.Join(parts, nil) + var out []byte + switch { + case id > 0xFFFFFF: + out = append(out, byte(id>>24), byte(id>>16), byte(id>>8), byte(id)) + case id > 0xFFFF: + out = append(out, byte(id>>16), byte(id>>8), byte(id)) + case id > 0xFF: + out = append(out, byte(id>>8), byte(id)) + default: + out = append(out, byte(id)) + } + var size [8]byte + binary.BigEndian.PutUint64(size[:], uint64(len(data))) + size[0] = 0x01 + return append(append(out, size[:]...), data...) +} + +func uintEl(id, v uint64) []byte { + var b [8]byte + binary.BigEndian.PutUint64(b[:], v) + return el(id, b[:]) +} + +func floatEl(id uint64, v float64) []byte { + var b [8]byte + binary.BigEndian.PutUint64(b[:], math.Float64bits(v)) + return el(id, b[:]) +} + +const ( + audioTrack = 1 + videoTrack = 2 +) + +// block encodes a SimpleBlock at a timestamp relative to its cluster. +func block(track uint64, rel int16, key bool) []byte { + var flags byte + if key { + flags = 0x80 + } + var ts [2]byte + binary.BigEndian.PutUint16(ts[:], uint16(rel)) + return el(idSimpleBlock, []byte{0x80 | byte(track)}, ts[:], []byte{flags}, bytes.Repeat([]byte{0xAA}, 16)) +} + +// groupBlock encodes a BlockGroup; a referencing block is not a keyframe. +func groupBlock(track uint64, rel int16, references bool) []byte { + var ts [2]byte + binary.BigEndian.PutUint16(ts[:], uint16(rel)) + parts := [][]byte{el(idBlock, []byte{0x80 | byte(track)}, ts[:], []byte{0}, bytes.Repeat([]byte{0xBB}, 16))} + if references { + parts = append(parts, uintEl(idReferenceBlock, 40)) + } + return el(idBlockGroup, parts...) +} + +func cluster(ticks uint64, blocks ...[]byte) []byte { + return el(idCluster, append([][]byte{uintEl(idClusterTimestamp, ticks)}, blocks...)...) +} + +var ( + ebmlHeader = el(idEBML, el(0x4282, []byte("matroska"))) + info = el(idInfo, uintEl(idTimestampScale, 1_000_000), floatEl(idDuration, 12_500)) + tracks = el(idTracks, + el(idTrackEntry, uintEl(idTrackNumber, audioTrack), uintEl(idTrackType, 2)), + el(idTrackEntry, uintEl(idTrackNumber, videoTrack), uintEl(idTrackType, trackTypeVideo)), + ) + // Video keyframes at 0, 2.216s (a BlockGroup) and 4.218s; non-keyframes + // and audio between them. + clusterA = cluster(0, + block(videoTrack, 0, true), + block(audioTrack, 10, true), + block(videoTrack, 40, false), + groupBlock(videoTrack, 80, true), + groupBlock(videoTrack, 2216, false), + ) + clusterB = cluster(4218, + block(videoTrack, 0, true), + block(videoTrack, 40, false), + ) +) + +type layout struct { + cuesFirst bool // Cues before the clusters, found by the linear scan + unknownSize bool // a live-style segment with no size + dropCue *uint64 // a video keyframe left out of the Cues + noPositions bool +} + +// file builds a Matroska file whose Cues index the fixture's keyframes, plus +// an audio cue and a duplicate, pointing at the clusters that hold them. +func file(l layout) []byte { + seekHead := func(pos uint64) []byte { + return el(idSeekHead, el(idSeek, uintEl(idSeekID, idCues), uintEl(idSeekPosition, pos))) + } + head := slices.Concat(seekHead(0), info, tracks) + cuesLen := len(cuesFor(l, 0, 0)) + posA := uint64(len(head)) + if l.cuesFirst { + posA += uint64(cuesLen) + } + posB := posA + uint64(len(clusterA)) + cues := cuesFor(l, posA, posB) + + var body []byte + if l.cuesFirst { + body = slices.Concat(seekHead(uint64(len(head))), info, tracks, cues, clusterA, clusterB) + } else { + body = slices.Concat(seekHead(posB+uint64(len(clusterB))), info, tracks, clusterA, clusterB, cues) + } + if l.unknownSize { + return slices.Concat(ebmlHeader, []byte{0x18, 0x53, 0x80, 0x67, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, body) + } + return slices.Concat(ebmlHeader, el(idSegment, body)) +} + +func cuesFor(l layout, posA, posB uint64) []byte { + point := func(ticks, track, pos uint64) []byte { + if l.dropCue != nil && ticks == *l.dropCue && track == videoTrack { + return nil + } + trackPos := [][]byte{uintEl(idCueTrack, track)} + if !l.noPositions { + trackPos = append(trackPos, uintEl(idCueClusterPosition, pos)) + } + return el(idCuePoint, uintEl(idCueTime, ticks), el(idCueTrackPos, trackPos...)) + } + return el(idCues, + point(4218, videoTrack, posB), + point(0, videoTrack, posA), + point(10, audioTrack, posA), // audio: not a video keyframe + point(2216, videoTrack, posA), + point(2216, videoTrack, posA), // duplicate + ) +} + +func ptr[T any](v T) *T { return &v } + +func read(f []byte) (Index, error) { + return ReadMatroska(bytes.NewReader(f), int64(len(f))) +} + +func wantIndex(t *testing.T, got Index, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } + if want := []float64{0, 2.216, 4.218}; !slices.Equal(got.Keyframes, want) { + t.Fatalf("keyframes = %v, want %v", got.Keyframes, want) + } + if got.Duration != 12.5 { + t.Fatalf("duration = %v, want 12.5", got.Duration) + } +} + +func TestReadMatroskaCuesBeforeClusters(t *testing.T) { + got, err := read(file(layout{cuesFirst: true})) + wantIndex(t, got, err) +} + +// Most muxers write the Cues after the clusters and point to them from the +// seek head; the reader follows it instead of reading the clusters. +func TestReadMatroskaCuesAfterClustersThroughSeekHead(t *testing.T) { + got, err := read(file(layout{})) + wantIndex(t, got, err) +} + +// Info may come after the clusters too. Its timestamp scale, here 2ms per +// tick, must still convert the cue times. +func TestReadMatroskaInfoAfterClustersThroughSeekHead(t *testing.T) { + scaledInfo := el(idInfo, uintEl(idTimestampScale, 2_000_000), floatEl(idDuration, 6_250)) + seekHead := func(cuesPos, infoPos uint64) []byte { + return el(idSeekHead, + el(idSeek, uintEl(idSeekID, idCues), uintEl(idSeekPosition, cuesPos)), + el(idSeek, uintEl(idSeekID, idInfo), uintEl(idSeekPosition, infoPos))) + } + posA := uint64(len(seekHead(0, 0)) + len(tracks)) + posB := posA + uint64(len(clusterA)) + cues := cuesFor(layout{}, posA, posB) + cuesPos := posB + uint64(len(clusterB)) + infoPos := cuesPos + uint64(len(cues)) + segment := el(idSegment, seekHead(cuesPos, infoPos), tracks, clusterA, clusterB, cues, scaledInfo) + got, err := read(slices.Concat(ebmlHeader, segment)) + if err != nil { + t.Fatal(err) + } + if want := []float64{0, 4.432, 8.436}; !slices.Equal(got.Keyframes, want) || got.Duration != 12.5 { + t.Fatalf("keyframes %v, duration %v; want %v and 12.5", got.Keyframes, got.Duration, want) + } +} + +// Tracks may come after the clusters too, found through the seek head. +func TestReadMatroskaTracksAfterClustersThroughSeekHead(t *testing.T) { + seekHead := func(cuesPos, tracksPos uint64) []byte { + return el(idSeekHead, + el(idSeek, uintEl(idSeekID, idCues), uintEl(idSeekPosition, cuesPos)), + el(idSeek, uintEl(idSeekID, idTracks), uintEl(idSeekPosition, tracksPos))) + } + posA := uint64(len(seekHead(0, 0)) + len(info)) + posB := posA + uint64(len(clusterA)) + cues := cuesFor(layout{}, posA, posB) + cuesPos := posB + uint64(len(clusterB)) + tracksPos := cuesPos + uint64(len(cues)) + segment := el(idSegment, seekHead(cuesPos, tracksPos), info, clusterA, clusterB, cues, tracks) + got, err := read(slices.Concat(ebmlHeader, segment)) + wantIndex(t, got, err) +} + +// Malformed data in a cue point makes the index incomplete, even when the +// points read around it happen to cover every keyframe: here the broken one +// is a duplicate. +func TestReadMatroskaRejectsMalformedCues(t *testing.T) { + seekHead := func(pos uint64) []byte { + return el(idSeekHead, el(idSeek, uintEl(idSeekID, idCues), uintEl(idSeekPosition, pos))) + } + posA := uint64(len(seekHead(0)) + len(info) + len(tracks)) + posB := posA + uint64(len(clusterA)) + payload := cuesFor(layout{}, posA, posB)[12:] // past the Cues ID and its 8-byte size + // A duplicate cue point whose track position holds a cut-off CueTrack. + truncatedTrack := el(idCuePoint, uintEl(idCueTime, 2216), + el(idCueTrackPos, uintEl(idCueTrack, videoTrack)[:5], uintEl(idCueClusterPosition, posA))) + for name, malformed := range map[string][]byte{ + "truncated cue point": el(idCues, payload[:len(payload)-3]), + "truncated track position": el(idCues, payload, truncatedTrack), + } { + t.Run(name, func(t *testing.T) { + segment := el(idSegment, seekHead(posB+uint64(len(clusterB))), info, tracks, clusterA, clusterB, malformed) + _, err := read(slices.Concat(ebmlHeader, segment)) + if !errors.Is(err, ErrNoIndex) { + t.Fatalf("err = %v, want ErrNoIndex", err) + } + }) + } +} + +func TestReadMatroskaUnknownSizeSegment(t *testing.T) { + got, err := read(file(layout{unknownSize: true})) + wantIndex(t, got, err) +} + +// A plan built on Cues that skip keyframes would cut segments where FFmpeg +// doesn't, so such a file has no usable index. +func TestReadMatroskaRejectsCuesMissingKeyframes(t *testing.T) { + for name, l := range map[string]layout{ + "keyframe without a cue (Block)": {dropCue: ptr(uint64(2216))}, + "keyframe without a cue (SimpleBlock)": {dropCue: ptr(uint64(4218)), cuesFirst: true}, + "cues without cluster positions": {noPositions: true}, + } { + t.Run(name, func(t *testing.T) { + _, err := read(file(l)) + if !errors.Is(err, ErrIncompleteIndex) || !errors.Is(err, ErrNoIndex) { + t.Fatalf("err = %v, want ErrIncompleteIndex (an ErrNoIndex)", err) + } + }) + } +} + +func TestReadMatroskaWithoutIndex(t *testing.T) { + audioOnlyTracks := el(idTracks, el(idTrackEntry, uintEl(idTrackNumber, audioTrack), uintEl(idTrackType, 2))) + for name, segment := range map[string][]byte{ + "no cues": el(idSegment, info, tracks, clusterA), + "no video cue": el(idSegment, info, tracks, el(idCues, el(idCuePoint, uintEl(idCueTime, 0), el(idCueTrackPos, uintEl(idCueTrack, audioTrack))))), + "no video track": el(idSegment, info, audioOnlyTracks, clusterA), + // Without Info the cue times can't be converted. + "no info": el(idSegment, tracks, cuesFor(layout{}, 0, 0), clusterA), + // An explicit zero scale isn't the default; Matroska requires at least 1. + "zero timestamp scale": el(idSegment, el(idInfo, uintEl(idTimestampScale, 0), floatEl(idDuration, 12_500)), tracks, cuesFor(layout{}, 0, 0), clusterA), + } { + t.Run(name, func(t *testing.T) { + _, err := read(slices.Concat(ebmlHeader, segment)) + if !errors.Is(err, ErrNoIndex) { + t.Fatalf("err = %v, want ErrNoIndex", err) + } + }) + } +} + +func TestReadMatroskaRejectsOtherFiles(t *testing.T) { + for name, f := range map[string][]byte{ + "empty": nil, + "mp4": []byte("\x00\x00\x00\x18ftypisom\x00\x00\x02\x00isomiso2"), + "truncated": ebmlHeader[:5], + } { + t.Run(name, func(t *testing.T) { + if _, err := read(f); err == nil { + t.Fatal("read succeeded, want an error") + } + }) + } +} + +// A Cues element larger than the reader loads is a damaged file, not a +// multi-gigabyte allocation. +func TestReadMatroskaRejectsOversizedCues(t *testing.T) { + huge := []byte{0x1C, 0x53, 0xBB, 0x6B, 0x01, 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00} + // An unknown-size segment, so the reader reaches the Cues header. + f := slices.Concat( + ebmlHeader, + []byte{0x18, 0x53, 0x80, 0x67, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}, + info, tracks, huge, + ) + if _, err := read(f); err == nil || errors.Is(err, ErrNoIndex) { + t.Fatalf("err = %v, want a size error", err) + } +} + +// manyClusters builds a file with ten clusters, each holding two video +// keyframes, and Cues for all of them except dropTicks. The sampled check +// reads clusters 0, 3, 6 and 9 and the ones after them; cluster 2 is only +// read by the full check. +func manyClusters(dropTicks uint64, extraCue uint64) []byte { + head := slices.Concat(info, tracks) + var clusters [][]byte + for c := range 10 { + base := uint64(c) * 4000 + clusters = append(clusters, cluster(base, block(videoTrack, 0, true), block(videoTrack, 40, false), block(videoTrack, 2000, true))) + } + cuesFor := func(positions []uint64) []byte { + var points [][]byte + point := func(ticks, pos uint64) []byte { + return el(idCuePoint, uintEl(idCueTime, ticks), + el(idCueTrackPos, uintEl(idCueTrack, videoTrack), uintEl(idCueClusterPosition, pos))) + } + for c := range 10 { + for _, rel := range []uint64{0, 2000} { + if ticks := uint64(c)*4000 + rel; ticks != dropTicks { + points = append(points, point(ticks, positions[c])) + } + } + } + if extraCue != 0 { + points = append(points, point(extraCue, positions[extraCue/4000])) + } + return el(idCues, points...) + } + // The Cues come before the clusters; their length doesn't depend on the + // positions they hold. + positions := make([]uint64, 10) + pos := uint64(len(head) + len(cuesFor(positions))) + for c, cl := range clusters { + positions[c] = pos + pos += uint64(len(cl)) + } + body := slices.Concat(head, cuesFor(positions), slices.Concat(clusters...)) + return slices.Concat(ebmlHeader, el(idSegment, body)) +} + +func verify(f []byte) (Index, error) { + return VerifyMatroska(bytes.NewReader(f), int64(len(f))) +} + +func TestVerifyMatroskaChecksEveryCluster(t *testing.T) { + complete := manyClusters(1<<62, 0) + if idx, err := verify(complete); err != nil || len(idx.Keyframes) != 20 { + t.Fatalf("complete cues: %d keyframes, %v; want 20 and no error", len(idx.Keyframes), err) + } + + // A keyframe in cluster 2 without a cue gets past the sampled check but + // not the full one. + missing := manyClusters(2*4000+2000, 0) + if _, err := read(missing); err != nil { + t.Fatalf("sampled check: %v, want it to miss cluster 2", err) + } + if _, err := verify(missing); !errors.Is(err, ErrIncompleteIndex) { + t.Fatalf("full check err = %v, want ErrIncompleteIndex", err) + } + + // A cue on a frame that isn't a keyframe would plan a boundary FFmpeg + // can't cut at. + if _, err := verify(manyClusters(1<<62, 2*4000+40)); !errors.Is(err, ErrIncompleteIndex) { + t.Fatalf("cue on a non-keyframe err = %v, want ErrIncompleteIndex", err) + } +} + +func TestVideoEndSeconds(t *testing.T) { + var end videoEnd + if got := end.seconds(defaultTimescale, 0.04); got != 0 { + t.Fatalf("no frames: %v, want 0", got) + } + end.add(0, 0) + if got := end.seconds(defaultTimescale, 0); got != 0 { + t.Fatalf("one frame without a frame duration: %v, want 0", got) + } + // Presentation order differs from storage order with B-frames. + for _, ms := range []int64{120, 80, 40, 100, 120} { + end.add(ms, 0) + } + if got := end.seconds(defaultTimescale, 0); math.Abs(got-0.14) > 1e-9 { + t.Fatalf("from the last gap: %v, want 0.14", got) + } + if got := end.seconds(defaultTimescale, 0.033); math.Abs(got-0.153) > 1e-9 { + t.Fatalf("from the frame duration: %v, want 0.153", got) + } + // The last frame's own BlockDuration wins over both. + end.add(120, 70) + if got := end.seconds(defaultTimescale, 0.033); math.Abs(got-0.19) > 1e-9 { + t.Fatalf("from the block duration: %v, want 0.19", got) + } + // Blocks sharing the last timestamp end with the longest, in either order. + for _, durations := range [][2]int64{{70, 30}, {30, 70}} { + var shared videoEnd + shared.add(80, 0) + shared.add(120, durations[0]) + shared.add(120, durations[1]) + if got := shared.seconds(defaultTimescale, 0.033); math.Abs(got-0.19) > 1e-9 { + t.Fatalf("blocks of %v ms at the end: %v, want 0.19", durations, got) + } + } + // A later frame without one goes back to the track's duration. + end.add(160, 0) + if got := end.seconds(defaultTimescale, 0.033); math.Abs(got-0.193) > 1e-9 { + t.Fatalf("after a later frame: %v, want 0.193", got) + } +} + +func TestReadBlockGroupDuration(t *testing.T) { + var ts [2]byte + binary.BigEndian.PutUint16(ts[:], 40) + group := el(idBlockGroup, + el(idBlock, []byte{0x80 | byte(videoTrack)}, ts[:], []byte{0}, bytes.Repeat([]byte{0xBB}, 16)), + uintEl(idBlockDuration, 70)) + r := bytes.NewReader(group) + _, size, headerLen, err := readElementHeader(r, 0) + if err != nil { + t.Fatal(err) + } + track, rel, key, duration, err := readBlockGroup(r, headerLen, size) + if err != nil || track != videoTrack || rel != 40 || !key || duration != 70 { + t.Fatalf("readBlockGroup = %d, %d, %v, %d, %v; want video at 40, a keyframe of 70 ticks", track, rel, key, duration, err) + } +} + +// The fixture's last video frame is at 4.258s, 40ms after the one before. +func TestVerifyMatroskaVideoEnd(t *testing.T) { + idx, err := verify(file(layout{})) + if err != nil { + t.Fatal(err) + } + if math.Abs(idx.VideoEnd-4.298) > 1e-9 { + t.Fatalf("VideoEnd = %v, want 4.298", idx.VideoEnd) + } +} + +// A video track whose timestamps carry a TrackTimestampScale isn't planned +// from: the reader's times don't apply it. +func TestFirstVideoTrackReportsATrackTimestampScale(t *testing.T) { + entry := func(scale ...[]byte) []byte { + return el(idTrackEntry, append([][]byte{uintEl(idTrackNumber, videoTrack), uintEl(idTrackType, trackTypeVideo)}, scale...)...) + } + if _, _, scaled, found := firstVideoTrack(entry()); !found || scaled { + t.Fatalf("unscaled track: scaled=%v found=%v", scaled, found) + } + if _, _, scaled, found := firstVideoTrack(entry(floatEl(idTrackTimestampScale, 1))); !found || scaled { + t.Fatalf("scale 1: scaled=%v found=%v", scaled, found) + } + if _, _, scaled, found := firstVideoTrack(entry(floatEl(idTrackTimestampScale, 2))); !found || !scaled { + t.Fatalf("scale 2: scaled=%v found=%v", scaled, found) + } +} diff --git a/internal/playback/keyframes/plan.go b/internal/playback/keyframes/plan.go new file mode 100644 index 0000000000..75df2fc4ae --- /dev/null +++ b/internal/playback/keyframes/plan.go @@ -0,0 +1,53 @@ +package keyframes + +// PlanSegments returns the start time of each HLS segment for a stream copied +// from a file with these keyframes, cut the way FFmpeg's HLS muxer cuts +// copied video: a segment ends at the first keyframe at or after its target, +// and each cut moves the target on by segmentSeconds. Targets count from the +// first keyframe, where FFmpeg's output starts. Jellyfin's +// DynamicHlsPlaylistGenerator.ComputeSegments uses the same rule. +// +// When keyframes are further apart than segmentSeconds the targets fall +// behind, and the following keyframes each start a segment until they catch +// up, as FFmpeg's muxer does. +func PlanSegments(keyframes []float64, segmentSeconds float64) []float64 { + if len(keyframes) == 0 || segmentSeconds <= 0 { + return nil + } + starts := []float64{keyframes[0]} + // Targets are measured from the first keyframe, and a keyframe within + // targetSlack of one counts as reaching it: FFmpeg compares integer + // timestamps, where a keyframe exactly on a target reaches it, but a + // nonzero origin leaves float error ("2.28 - 0.28" isn't quite 2). + target := segmentSeconds + for _, k := range keyframes[1:] { + if k-keyframes[0] >= target-targetSlack { + starts = append(starts, k) + target += segmentSeconds + } + } + return starts +} + +// targetSlack is the float error PlanSegments allows when comparing a +// keyframe with a target: a microsecond, FFmpeg's finest time base and well +// under any frame's duration. +const targetSlack = 1e-6 + +// SegmentDurations returns each planned segment's duration: up to the next +// segment's start, and for the last one up to the media's end. A last segment +// that would be empty or negative, from an end before the last start, is +// given zero. +func SegmentDurations(starts []float64, end float64) []float64 { + durations := make([]float64, len(starts)) + for i, start := range starts { + next := end + if i+1 < len(starts) { + next = starts[i+1] + } + if next > start { + durations[i] = next - start + } + } + return durations +} diff --git a/internal/playback/keyframes/plan_test.go b/internal/playback/keyframes/plan_test.go new file mode 100644 index 0000000000..023b151e89 --- /dev/null +++ b/internal/playback/keyframes/plan_test.go @@ -0,0 +1,48 @@ +package keyframes + +import ( + "slices" + "testing" +) + +func TestPlanSegments(t *testing.T) { + for _, tc := range []struct { + name string + keyframes []float64 + want []float64 + }{ + {name: "no keyframes", keyframes: nil, want: nil}, + {name: "one keyframe", keyframes: []float64{0}, want: []float64{0}}, + // Regular 1s keyframes: one segment every 2s. + {name: "regular", keyframes: []float64{0, 1, 2, 3, 4, 5, 6}, want: []float64{0, 2, 4, 6}}, + // A keyframe after the target ends the segment there. + {name: "late keyframe", keyframes: []float64{0, 1.3, 2.6, 3.9, 5.2}, want: []float64{0, 2.6, 5.2}}, + // After a long gap the targets lag behind, and the next keyframes each + // start a segment until they catch up, as FFmpeg's muxer does. + {name: "catching up", keyframes: []float64{0, 7.0, 7.4, 7.8, 9.9}, want: []float64{0, 7.0, 7.4, 7.8, 9.9}}, + // Targets count from the first keyframe, where FFmpeg's output starts. + {name: "late start", keyframes: []float64{0.5, 1.5, 2.5, 3.5}, want: []float64{0.5, 2.5}}, + // A keyframe exactly on a target cuts there, though a nonzero origin + // leaves float error in the subtraction. + {name: "exact targets after a nonzero origin", keyframes: []float64{0.28, 2.28, 4.28, 6.28, 8.28}, want: []float64{0.28, 2.28, 4.28, 6.28, 8.28}}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := PlanSegments(tc.keyframes, 2); !slices.Equal(got, tc.want) { + t.Fatalf("PlanSegments = %v, want %v", got, tc.want) + } + }) + } +} + +func TestSegmentDurations(t *testing.T) { + got := SegmentDurations([]float64{0, 2.6, 5.2}, 6) + want := []float64{2.6, 2.6, 0.8} + for i := range want { + if diff := got[i] - want[i]; diff > 1e-9 || diff < -1e-9 { + t.Fatalf("SegmentDurations = %v, want %v", got, want) + } + } + if got := SegmentDurations([]float64{0, 5}, 4); got[1] != 0 { + t.Fatalf("last duration past the end = %v, want 0", got[1]) + } +} diff --git a/internal/playback/recipecard.go b/internal/playback/recipecard.go index cff0bb5391..433681ea96 100644 --- a/internal/playback/recipecard.go +++ b/internal/playback/recipecard.go @@ -97,6 +97,7 @@ type RecipeCard struct { VideoBitstreamFilter string `json:"video_bitstream_filter,omitempty"` VideoSampleEntry string `json:"video_sample_entry,omitempty"` CopyVideoMPEGTS bool `json:"copy_video_mpegts,omitempty"` + KeyframePlaylist bool `json:"keyframe_playlist,omitempty"` SeekSeconds float64 `json:"seek_seconds"` StreamOriginSeconds float64 `json:"stream_origin_seconds,omitempty"` CopySeekAnchorResolved bool `json:"copy_seek_anchor_resolved,omitempty"` @@ -201,6 +202,7 @@ func NewRecipeCard(userID int, profileID string, mediaFileID int, transcodeNodeU VideoBitstreamFilter: opts.VideoBitstreamFilter, VideoSampleEntry: opts.VideoSampleEntry, CopyVideoMPEGTS: opts.CopyVideoMPEGTS, + KeyframePlaylist: opts.KeyframePlaylist, SeekSeconds: opts.SeekSeconds, StreamOriginSeconds: opts.StreamOriginSeconds, CopySeekAnchorResolved: opts.CopySeekAnchorResolved, @@ -303,6 +305,7 @@ func (c RecipeCard) TranscodeOpts(outputDir, ffmpegPath string, logSink FFmpegLo VideoBitstreamFilter: c.VideoBitstreamFilter, VideoSampleEntry: c.VideoSampleEntry, CopyVideoMPEGTS: c.CopyVideoMPEGTS, + KeyframePlaylist: c.KeyframePlaylist, SeekSeconds: c.SeekSeconds, StreamOriginSeconds: c.StreamOriginSeconds, CopySeekAnchorResolved: c.CopySeekAnchorResolved, @@ -415,6 +418,7 @@ func (c RecipeCard) ToClaims() streamtoken.Claims { VideoBitstreamFilter: c.VideoBitstreamFilter, VideoSampleEntry: c.VideoSampleEntry, CopyVideoMPEGTS: c.CopyVideoMPEGTS, + KeyframePlaylist: c.KeyframePlaylist, SeekSeconds: c.SeekSeconds, StreamOriginSeconds: c.StreamOriginSeconds, CopySeekAnchorResolved: c.CopySeekAnchorResolved, @@ -501,6 +505,7 @@ func RecipeCardFromClaims(c *streamtoken.Claims) RecipeCard { VideoBitstreamFilter: c.VideoBitstreamFilter, VideoSampleEntry: c.VideoSampleEntry, CopyVideoMPEGTS: c.CopyVideoMPEGTS, + KeyframePlaylist: c.KeyframePlaylist, SeekSeconds: c.SeekSeconds, StreamOriginSeconds: c.StreamOriginSeconds, CopySeekAnchorResolved: c.CopySeekAnchorResolved, diff --git a/internal/playback/recipecard_test.go b/internal/playback/recipecard_test.go index ed87805838..3eb7ca08e0 100644 --- a/internal/playback/recipecard_test.go +++ b/internal/playback/recipecard_test.go @@ -159,6 +159,19 @@ func TestRecipeCardPreservesCopyVideoMPEGTS(t *testing.T) { } } +// A rebuilt session must keep serving the keyframe playlist the player has. +func TestRecipeCardPreservesKeyframePlaylist(t *testing.T) { + card := NewRecipeCard(42, "profile-1", 77, "", TranscodeOpts{ + SessionID: "planned", TargetCodecVideo: "copy", TargetCodecAudio: "copy", KeyframePlaylist: true, + }) + if !card.TranscodeOpts(t.TempDir(), "/usr/bin/ffmpeg", nil).KeyframePlaylist { + t.Fatal("stored recipe lost the keyframe playlist") + } + if back := RecipeCardFromClaims(ptr(card.ToClaims())); !back.KeyframePlaylist { + t.Fatal("stream-token recipe lost the keyframe playlist") + } +} + func TestRecipeCardNetworkRouteSurvivesRecovery(t *testing.T) { for _, provider := range []*string{nil, new(""), new("tailscale")} { card := NewDirectRecipeCard("network-route", 42, "profile-1", 77) diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index e04bc6979b..6a108929e4 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -52,7 +52,16 @@ type TranscodeOpts struct { // CopyVideoMPEGTS packages copied video in MPEG-TS instead of fMP4. It is // durable because the segment extension and bytes must survive restarts. CopyVideoMPEGTS bool - SeekSeconds float64 + // KeyframePlaylist serves a copy-video stream that starts at the + // beginning as one complete VOD playlist planned from the source's + // keyframe index, instead of FFmpeg's growing playlist (#1466). It is + // durable because a reconstructed session must keep serving the playlist + // the player already has. + KeyframePlaylist bool + // Reconstructed marks a session rebuilt from its recipe after its server + // or node lost it. It isn't durable: each rebuild sets it. + Reconstructed bool + SeekSeconds float64 // StreamOriginSeconds is the keyframe timestamp at which a copy-video // stream actually begins. SeekSeconds remains the client-requested -ss so // FFmpeg performs exactly one demuxer seek; this origin keeps response and @@ -255,6 +264,9 @@ type TranscodeSession struct { // reacquires this same device rather than re-running selection, so a restart // keeps its GPU affinity and stays visible in per-device reporting. hwWorkloadDevice string + // copyPlan is the planned segment list served instead of FFmpeg's + // playlist, or nil. It is fixed when the session starts. + copyPlan *copySegmentPlan } // NewTranscodeSessionForTest exposes only the output directory needed by tests @@ -302,6 +314,9 @@ type SegmentRecoveryDecision struct { Progress SegmentProgress } +// segmentReasonRestarting is the recovery reason while a restart is in flight. +const segmentReasonRestarting = "transcode_restarting" + // defaultSegmentDuration is the segment length when not specified. Short // segments (2s) allow the player to start quickly while still maintaining // efficient HTTP delivery. This matches the approach used by Plex. @@ -408,6 +423,16 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession, releaseHWDevice() return nil, err } + copyPlan := resolveCopyPlan(ctx, opts) + if copyPlan == nil && opts.Reconstructed && opts.KeyframePlaylist && ctx.Err() != nil { + // Rebuilding without the plan would serve FFmpeg's playlist to a + // player that may hold the planned one; a later request rebuilds. + releaseHWDevice() + return nil, fmt.Errorf("plan rebuilt keyframe playlist: %w", ctx.Err()) + } + if copyPlan != nil { + log.Printf("playback: session %s serves a %d-segment keyframe playlist", opts.SessionID, len(copyPlan.durations)) + } // The synchronous source guard above is bounded by the caller's startup // context. Once it succeeds, keep the established behavior where the @@ -426,6 +451,7 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession, lastPruneHighWater: opts.StartSegmentNumber - 1, segmentIncarnation: uuid.NewString(), hwWorkloadDevice: hwWorkloadDevice, + copyPlan: copyPlan, } args := buildFFmpegArgs(opts) @@ -2165,6 +2191,9 @@ func (s *TranscodeSession) waitForManifest(ctx context.Context, timeout time.Dur // longer media uses FFmpeg's real sliding playlist. func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([]byte, error) { opts := s.Opts() + if s.copyPlan != nil { + return s.copyPlan.manifest(opts, segPrefix, rawQuery), nil + } if strings.EqualFold(opts.TargetCodecVideo, "copy") || !CanGenerateSyntheticManifest(opts.TotalDuration, opts.SegmentDuration) { // Copy-video, unknown-duration, or oversized sessions must use FFmpeg's @@ -2245,6 +2274,11 @@ const SourceTimelineQueryParam = "source_timeline" // first produced segment retains its source-time position. Synthetic manifests // already cover the full source timeline and need no adjustment. func (s *TranscodeSession) BuildSourceAlignedPlaybackManifest(segPrefix, rawQuery string) ([]byte, error) { + if s.copyPlan != nil { + // A planned playlist starts at the source's beginning and doesn't + // depend on FFmpeg's progress or restarts. + return s.BuildPlaybackManifest(segPrefix, rawQuery) + } s.mu.Lock() opts, generation, restarting := s.opts, s.segmentGeneration, s.restarting != nil s.mu.Unlock() @@ -2727,6 +2761,9 @@ func (s *TranscodeSession) SegmentRecoveryDecision(segNum int, now time.Time) Se RestartOnTimeout: true, Progress: progress, } + if s.copyPlan != nil { + return s.plannedSegmentRecoveryDecision(segNum, decision) + } switch { // Restarting must be checked before Running: the restart window runs @@ -2738,7 +2775,7 @@ func (s *TranscodeSession) SegmentRecoveryDecision(segNum int, now time.Time) Se decision.Wait = true decision.WaitTimeout = activeSegmentWait decision.RestartOnTimeout = false - decision.Reason = "transcode_restarting" + decision.Reason = segmentReasonRestarting case !progress.Running: decision.Reason = "transcode_not_running" case segNum < progress.StartSegmentNumber: @@ -3197,6 +3234,14 @@ func (s *TranscodeSession) restart( <-done } + // A planned stream's segments only match its playlist when FFmpeg cuts + // them from the beginning, so it always restarts there. The requested + // segment stays the throttler's target, so FFmpeg runs on to reach it. + requestedSegment := startSegment + if s.copyPlan != nil { + seekSeconds, startSegment, streamOriginSeconds, copySeekAnchorResolved = 0, 0, 0, true + } + s.mu.Lock() s.running = false s.waitErr = nil @@ -3307,7 +3352,7 @@ func (s *TranscodeSession) restart( s.running = true s.restarting = nil s.stdinPipe = stdinPipe - s.lastRequestedSegment = startSegment + s.lastRequestedSegment = requestedSegment s.lastCompletedSegment = startSegment - 1 s.generationStartedAt = startedAt s.inheritedManifest = inheritedManifest @@ -3686,6 +3731,10 @@ type SegmentRecoveryTarget struct { // ResolveSegmentRecoveryTarget preserves the existing manifest's // URI-to-source-time mapping across a missing-segment restart. func (s *TranscodeSession) ResolveSegmentRecoveryTarget(ctx context.Context, segNum int) (SegmentRecoveryTarget, bool, error) { + if s.copyPlan != nil { + // restart regenerates a planned stream from the beginning. + return SegmentRecoveryTarget{StartSegmentNumber: segNum, CopySeekAnchorResolved: true}, true, nil + } seekSeconds, ok, err := s.RestartSeekTarget(segNum) if err != nil || !ok { return SegmentRecoveryTarget{}, ok, err diff --git a/internal/playback/transcode_manager.go b/internal/playback/transcode_manager.go index c5072d94d2..c473cd6a16 100644 --- a/internal/playback/transcode_manager.go +++ b/internal/playback/transcode_manager.go @@ -897,6 +897,7 @@ func (m *TranscodeManager) doReconstructTranscode(ctx context.Context, sessionID cfg := m.runtimeConfig() outputDir := reconstructionOutputDir(cfg.TranscodeDir, sessionID, card.OutputSubdir) opts := card.TranscodeOpts(outputDir, cfg.FFmpegPath, m.logSink()) + opts.Reconstructed = true // Recipe cards preserve the original launch tuning, but a reconstruction is // not a fresh generation: restore the conservative manifest lead so recovery // never exposes a hardware encoder after only one fragment. diff --git a/internal/streamtoken/token.go b/internal/streamtoken/token.go index ef36934445..7d4b2d2dbf 100644 --- a/internal/streamtoken/token.go +++ b/internal/streamtoken/token.go @@ -128,6 +128,7 @@ type Claims struct { VideoBitstreamFilter string `json:"vbsf,omitempty"` VideoSampleEntry string `json:"vse,omitempty"` CopyVideoMPEGTS bool `json:"cvts,omitempty"` + KeyframePlaylist bool `json:"kfp,omitempty"` OutputSubdir string `json:"osd,omitempty"` SeekSeconds float64 `json:"seek,omitempty"` StreamOriginSeconds float64 `json:"origin,omitempty"` diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index c862e53eb9..6de9db4f47 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -66,6 +66,7 @@ type TranscodeStartRequest struct { VideoBitstreamFilter string `json:"video_bitstream_filter,omitempty"` VideoSampleEntry string `json:"video_sample_entry,omitempty"` CopyVideoMPEGTS bool `json:"copy_video_mpegts,omitempty"` + KeyframePlaylist bool `json:"keyframe_playlist,omitempty"` SeekSeconds float64 `json:"seek_seconds"` StreamOriginSeconds float64 `json:"stream_origin_seconds,omitempty"` CopySeekAnchorResolved bool `json:"copy_seek_anchor_resolved,omitempty"` @@ -117,12 +118,21 @@ type TranscodeStartResponse struct { // decoder failed to start. Older nodes omit it, so callers OR it with the // requested value. SoftwareVideoDecode bool `json:"software_video_decode,omitempty"` + // KeyframePlaylist attests that the executor understood a requested + // keyframe playlist. Older nodes omit it and are rejected before they + // serve FFmpeg's growing playlist under a recipe that records the planned + // one. + KeyframePlaylist bool `json:"keyframe_playlist,omitempty"` } var ErrAudioRecipeAttestationMismatch = errors.New("transcode node audio recipe attestation mismatch") var ErrCopyFMP4RecipeAttestationMismatch = errors.New("transcode node copy-fmp4 recipe attestation mismatch") var ErrThrottleAttestationMismatch = errors.New("transcode node throttle attestation mismatch") +// ErrKeyframePlaylistAttestationMismatch reports an executor that ignored a +// requested keyframe playlist. +var ErrKeyframePlaylistAttestationMismatch = errors.New("transcode node keyframe playlist attestation mismatch") + func validateAudioRecipeRequest(req TranscodeStartRequest) error { if req.SourceAudioChannels == 0 && req.AudioRecipeVersion == "" { return nil @@ -175,6 +185,16 @@ func ValidateCopyFMP4RecipeAttestation(req TranscodeStartRequest, response Trans return nil } +// ValidateKeyframePlaylistAttestation rejects an executor that silently +// ignored a requested keyframe playlist. Requests without one stay compatible +// with older nodes, which omit the field. +func ValidateKeyframePlaylistAttestation(req TranscodeStartRequest, response TranscodeStartResponse) error { + if req.KeyframePlaylist && !response.KeyframePlaylist { + return ErrKeyframePlaylistAttestationMismatch + } + return nil +} + // ValidateThrottleAttestation rejects an executor that silently ignored an // enabled throttle policy. Disabled requests remain compatible with older // nodes whose response omits the field. @@ -1658,6 +1678,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { VideoBitstreamFilter: req.VideoBitstreamFilter, VideoSampleEntry: req.VideoSampleEntry, CopyVideoMPEGTS: req.CopyVideoMPEGTS, + KeyframePlaylist: req.KeyframePlaylist, SeekSeconds: req.SeekSeconds, StreamOriginSeconds: req.StreamOriginSeconds, CopySeekAnchorResolved: req.CopySeekAnchorResolved, @@ -1834,6 +1855,7 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { CopyFMP4RecipeVersion: req.CopyFMP4RecipeVersion, ThrottleSeconds: req.ThrottleSeconds, SoftwareVideoDecode: session.Opts().SoftwareVideoDecode, + KeyframePlaylist: session.Opts().KeyframePlaylist, }) } @@ -2000,6 +2022,7 @@ func (s *Server) spawnReconstruct(r *http.Request, sessionID string, requestedSe outputDir := s.sessionOutputDir(sessionID) opts := card.TranscodeOpts(outputDir, cfg.Playback.FFmpegPath, s.ffmpegSink) opts.SessionID = sessionID + opts.Reconstructed = true // Recipe cards preserve the original launch tuning, but reconstruction must // retain the normal manifest cushion rather than the fresh-start fast path. opts.FastStart = false diff --git a/internal/transcodenode/server_test.go b/internal/transcodenode/server_test.go index fb85f6080f..1deef4d2ff 100644 --- a/internal/transcodenode/server_test.go +++ b/internal/transcodenode/server_test.go @@ -1393,6 +1393,21 @@ func TestValidateThrottleAttestation(t *testing.T) { } } +// A node that ignores a requested keyframe playlist would serve FFmpeg's +// growing playlist under a recipe that records the planned one. +func TestValidateKeyframePlaylistAttestation(t *testing.T) { + request := TranscodeStartRequest{KeyframePlaylist: true} + if err := ValidateKeyframePlaylistAttestation(request, TranscodeStartResponse{KeyframePlaylist: true}); err != nil { + t.Fatalf("matching attestation rejected: %v", err) + } + if err := ValidateKeyframePlaylistAttestation(request, TranscodeStartResponse{}); !errors.Is(err, ErrKeyframePlaylistAttestationMismatch) { + t.Fatalf("missing attestation error = %v, want %v", err, ErrKeyframePlaylistAttestationMismatch) + } + if err := ValidateKeyframePlaylistAttestation(TranscodeStartRequest{}, TranscodeStartResponse{}); err != nil { + t.Fatalf("request without a keyframe playlist rejected a legacy node response: %v", err) + } +} + func TestHandleStartRejectsUnversionedSourceAudioRecipeBeforeExecution(t *testing.T) { server := newTestServer(t) body, err := json.Marshal(TranscodeStartRequest{ diff --git a/web/src/pages/admin-settings/PlaybackSettings.test.tsx b/web/src/pages/admin-settings/PlaybackSettings.test.tsx index 4b97f02eb1..03ead25f42 100644 --- a/web/src/pages/admin-settings/PlaybackSettings.test.tsx +++ b/web/src/pages/admin-settings/PlaybackSettings.test.tsx @@ -119,6 +119,7 @@ describe("PlaybackSettings layout", () => { expect(keys).toContain("playback.allow_hevc_encoding"); expect(keys).toContain("playback.routing.video_transcode_egress"); expect(keys).toContain("playback.watched_threshold"); + expect(keys).toContain("playback.copy_keyframe_playlist"); expect(keys.some((key) => key.startsWith("download."))).toBe(false); // Hidden tier: still saved and readable through the API, no UI. expect(keys).not.toContain("playback.chapter_thumbnail_node_capacity"); diff --git a/web/src/pages/admin-settings/PlaybackSettings.tsx b/web/src/pages/admin-settings/PlaybackSettings.tsx index 0000c3597b..a2d73f604b 100644 --- a/web/src/pages/admin-settings/PlaybackSettings.tsx +++ b/web/src/pages/admin-settings/PlaybackSettings.tsx @@ -52,6 +52,7 @@ const TRANSCODING_ADVANCED_KEYS = [ "playback.ffmpeg_path", "playback.transcode_dir", "playback.segment_retention_seconds", + "playback.copy_keyframe_playlist", "playback.hw_device", "playback.transcode_hardware_tone_map_enabled", "playback.transcode_software_tone_map_enabled", @@ -530,6 +531,14 @@ export default function PlaybackSettings() { onChange={(v) => form.setValue("playback.transcode_software_tone_map_enabled", v)} restartRequired={restartKeys.has("playback.transcode_software_tone_map_enabled")} /> + form.setValue("playback.copy_keyframe_playlist", v)} + restartRequired={restartKeys.has("playback.copy_keyframe_playlist")} + /> Date: Tue, 13 Oct 2026 14:59:47 -0400 Subject: [PATCH 020/196] fix(access): remove deleted libraries from access groups and accounts (#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 "#", 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 #2395 Co-authored-by: Claude Opus 5.5 --- docs/admin-users-api.md | 15 + docs/invitations-api.md | 6 +- internal/apiv2/admin_access_groups.go | 8 + internal/apiv2/admin_access_groups_test.go | 31 +++ internal/apiv2/admin_accounts.go | 4 +- internal/apiv2/admin_accounts_test.go | 5 + internal/apiv2/invitations.go | 3 + internal/apiv2/invitations_test.go | 20 ++ internal/apiv2/profiles.go | 22 +- .../prune_deleted_library_access_test.go | 263 ++++++++++++++++++ ...rune_deleted_library_access_references.sql | 116 ++++++++ scripts/ci/db-contracts.txt | 7 + 12 files changed, 487 insertions(+), 13 deletions(-) create mode 100644 migrations/prune_deleted_library_access_test.go create mode 100644 migrations/sql/20261013021242_prune_deleted_library_access_references.sql diff --git a/docs/admin-users-api.md b/docs/admin-users-api.md index 1ae352e20b..e415dff7c7 100644 --- a/docs/admin-users-api.md +++ b/docs/admin-users-api.md @@ -104,6 +104,21 @@ clients show where a default comes from without keeping their own copy. The values come from the server build. Account capabilities advertise `policy_defaults`. Existing `admin:users` keys may use this read. +## Library lists + +An account's or access group's `library_ids` is `null` for every library and an +array for exactly the listed ones, so `[]` allows none. Create and update return +`422 validation_failed` at `body.library_ids` when the array names a library that +does not exist; a guarded update checks its precondition first. Whichever API +writes these lists, and the [invitation](invitations-api.md) lists too, the +database keeps only libraries that exist, and a write racing a library delete +waits for it as it would behind a foreign key. + +Deleting a library removes its ID from every account, access group and +invitation in the same transaction. A list left with no IDs stays `[]` and never +widens to `null`. The edited accounts and groups get new revisions, so an editor +holding the old ETag gets 412 instead of writing the deleted library back. + ## Passwords Account editor and list rows carry `password_login` and `password_change_required`. diff --git a/docs/invitations-api.md b/docs/invitations-api.md index 06c77bfa2f..59883c2488 100644 --- a/docs/invitations-api.md +++ b/docs/invitations-api.md @@ -60,8 +60,10 @@ Administrator creation takes optional `delivery`, `email`, optional `role` (default `user`), optional string `access_group_id`, optional string-ID array `library_ids`, optional `create_profile` and `show_tour` (both default true), and optional `note`. -Omitted library IDs inherit access; `[]` is an explicit empty override. Omit -optional members instead of sending null. Explicit false must remain false. +Omitted library IDs inherit access; `[]` is an explicit empty override. An ID +that names no library returns `422 validation_failed` at `body.library_ids`, and +deleting a library removes its ID from stored invitations. Omit optional members +instead of sending null. Explicit false must remain false. Default-profile requests are refused before effects when unsupported. Only the server Owner may create or resend an invitation with role `admin`; another administrator receives `403 permission_denied`. diff --git a/internal/apiv2/admin_access_groups.go b/internal/apiv2/admin_access_groups.go index b8eef81f08..dbf9ae3188 100644 --- a/internal/apiv2/admin_access_groups.go +++ b/internal/apiv2/admin_access_groups.go @@ -226,6 +226,9 @@ func registerAdminAccessGroups(reg *Registry) { if update.Name == nil { return nil, NewProblem(TypeValidationFailed, "Access group name is required.") } + if p = reg.rejectUnknownLibraries(ctx, update.LibraryIDs, locationLibraryIDs); p != nil { + return nil, p + } body := access.CreateGroupInput{Name: *update.Name, DownloadAllowed: true, DownloadTranscodeAllowed: true, TranscodeAllowed: true, AudioTranscodeAllowed: true, RequestsAllowed: true} applyGroupCreate(&body, update) g, err := svc.CreateAdminAccessGroup(ctx, body) @@ -255,6 +258,11 @@ func registerAdminAccessGroups(reg *Registry) { if p != nil { return nil, p } + // After the guard: an editor opened before a library was deleted holds + // the old ETag and gets 412, which tells it to reload. + if p = reg.rejectUnknownLibraries(ctx, body.LibraryIDs, locationLibraryIDs); p != nil { + return nil, p + } g, err = svc.UpdateAdminAccessGroup(ctx, id, body, guard) if err != nil { return nil, groupProblem(ctx, err) diff --git a/internal/apiv2/admin_access_groups_test.go b/internal/apiv2/admin_access_groups_test.go index ac3245af48..04b675106b 100644 --- a/internal/apiv2/admin_access_groups_test.go +++ b/internal/apiv2/admin_access_groups_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "net/http" + "net/http/httptest" + "slices" "strings" "testing" @@ -175,3 +177,32 @@ func TestAdminAccessGroupAuthenticationPolicy(t *testing.T) { }) } } + +// A group's library list is an integer array no foreign key covers, so an id +// no library carries is refused before the store, on create and update alike. +func TestAdminAccessGroupRejectsUnknownLibraries(t *testing.T) { + f := fixtureAdminAccessGroups() + deps := requestDeps(fixtureRequests()) // knows libraries 1-4 + deps.AdminAccessGroups = f + h := NewHandler(deps) + path := Prefix + "/admin/access-groups/7" + for _, rec := range []*httptest.ResponseRecorder{ + do(t, h, http.MethodPost, Prefix+"/admin/access-groups", `{"name":"New","library_ids":["1","9"]}`, actingRequestAdmin), + do(t, h, http.MethodPut, path, `{"library_ids":["9"]}`, with(actingRequestAdmin, "If-Match", "*")), + } { + p := requireProblem(t, rec, TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != locationLibraryIDs || p.Errors[0].Detail != "unknown library identifier: 9" { + t.Fatalf("errors = %+v", p.Errors) + } + } + // An editor opened before the library was deleted holds an old ETag: 412 + // tells it to reload, rather than 422 for a library it no longer lists. + requireProblem(t, do(t, h, http.MethodPut, path, `{"library_ids":["9"]}`, with(actingRequestAdmin, "If-Match", `"stale"`)), TypePreconditionFailed) + if f.writes != 0 { + t.Fatal("unknown library reached the store") + } + saved := do(t, h, http.MethodPut, path, `{"library_ids":["2","1"]}`, with(actingRequestAdmin, "If-Match", "*")) + if saved.Code != 200 || f.last.LibraryIDs == nil || !slices.Equal(*f.last.LibraryIDs, []int{2, 1}) { + t.Fatal(saved.Code, saved.Body.String(), f.last.LibraryIDs) + } +} diff --git a/internal/apiv2/admin_accounts.go b/internal/apiv2/admin_accounts.go index da50b979ec..272129c638 100644 --- a/internal/apiv2/admin_accounts.go +++ b/internal/apiv2/admin_accounts.go @@ -379,7 +379,7 @@ func (reg *Registry) createAdminAccount(ctx context.Context, in *AdminAccountCre if p != nil { return nil, p } - if p = reg.rejectUnknownLibraries(ctx, policy.LibraryIDs.Value); p != nil { + if p = reg.rejectUnknownLibraries(ctx, policy.LibraryIDs.Value, locationLibraryIDs); p != nil { return nil, p } b := in.Body @@ -417,7 +417,7 @@ func (reg *Registry) updateAdminAccount(ctx context.Context, in *AdminAccountUpd if p != nil { return nil, p } - if p = reg.rejectUnknownLibraries(ctx, input.LibraryIDs.Value); p != nil { + if p = reg.rejectUnknownLibraries(ctx, input.LibraryIDs.Value, locationLibraryIDs); p != nil { return nil, p } b := in.Body diff --git a/internal/apiv2/admin_accounts_test.go b/internal/apiv2/admin_accounts_test.go index 70db1cf207..d9034290e5 100644 --- a/internal/apiv2/admin_accounts_test.go +++ b/internal/apiv2/admin_accounts_test.go @@ -185,6 +185,11 @@ func TestAdminAccountCreateAndErrors(t *testing.T) { if f.create.DefaultProfile.Enabled || f.create.User.LibraryIDs == nil || f.create.User.DownloadAllowed == nil || *f.create.User.DownloadAllowed { t.Fatalf("creation lost explicit values: %+v", f.create) } + // An unknown library is named at the member the account body sends. + unknown := requireProblem(t, do(t, h, http.MethodPost, path, strings.Replace(body, `"library_ids":[]`, `"library_ids":["9"]`, 1), actingRequestAdmin), TypeValidationFailed) + if len(unknown.Errors) != 1 || unknown.Errors[0].Location != locationLibraryIDs { + t.Fatalf("unknown library errors = %+v", unknown.Errors) + } f.err = auth.ErrDuplicate requireProblem(t, do(t, h, http.MethodPost, path, body, actingRequestAdmin), TypeConflict) f.err = errors.New("storage down") diff --git a/internal/apiv2/invitations.go b/internal/apiv2/invitations.go index bb742ef8f4..85b78bec03 100644 --- a/internal/apiv2/invitations.go +++ b/internal/apiv2/invitations.go @@ -392,6 +392,9 @@ func registerInvitations(reg *Registry) { } input.LibraryIDs = append(input.LibraryIDs, n) } + if p := reg.rejectUnknownLibraries(ctx, &input.LibraryIDs, locationLibraryIDs); p != nil { + return nil, p + } } return invitationDelivery(svc.Send(ctx, input)) }) diff --git a/internal/apiv2/invitations_test.go b/internal/apiv2/invitations_test.go index 6bdf9b2ee6..35781539ab 100644 --- a/internal/apiv2/invitations_test.go +++ b/internal/apiv2/invitations_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "slices" "strings" "testing" "time" @@ -379,3 +380,22 @@ func TestResendInvitationDeliveryChoice(t *testing.T) { t.Fatal(noAddress.Body.String()) } } + +// An invitation's library list is applied verbatim when it is accepted, so an +// id no library carries is refused before the invitation is sent. +func TestInvitationRejectsUnknownLibraries(t *testing.T) { + f := fixtureInvitations() + h := invitationTestHandler(f) // knows libraries 1-4 + path := Prefix + "/admin/invitations" + p := requireProblem(t, do(t, h, http.MethodPost, path, `{"email":"a@example.invalid","library_ids":["2","9"]}`, actingRequestAdmin), TypeValidationFailed) + if len(p.Errors) != 1 || p.Errors[0].Location != locationLibraryIDs || p.Errors[0].Detail != "unknown library identifier: 9" { + t.Fatalf("errors = %+v", p.Errors) + } + if f.writes != 0 { + t.Fatal("unknown library reached the service") + } + r := do(t, h, http.MethodPost, path, `{"email":"a@example.invalid","library_ids":["2","1"]}`, actingRequestAdmin) + if r.Code != 201 || f.send == nil || !slices.Equal(f.send.LibraryIDs, []int{2, 1}) { + t.Fatal(r.Code, r.Body.String(), f.send) + } +} diff --git a/internal/apiv2/profiles.go b/internal/apiv2/profiles.go index 925d9e5b3d..0eb60f452a 100644 --- a/internal/apiv2/profiles.go +++ b/internal/apiv2/profiles.go @@ -90,9 +90,11 @@ const fieldMaxAdvisoryAge = "max_advisory_age" // check the caller did not pass (an unverified PIN-locked primary profile). const codeProfileManagement = "profile_management" -// locationAllowedLibraryIDs is the problem location of the allowlist member. +// locationAllowedLibraryIDs is the problem location of the profile allowlist +// member; locationLibraryIDs is the account, access group and invitation one. const ( locationAllowedLibraryIDs = locationBody + ".allowed_library_ids" + locationLibraryIDs = locationBody + ".library_ids" locationPIN = locationBody + ".pin" ) @@ -691,7 +693,7 @@ func (reg *Registry) createProfile(ctx context.Context, in *ProfileCreateInput) return nil, p } if len(req.AllowedLibraryIDs) > 0 { - if p := reg.rejectUnknownLibraries(ctx, &req.AllowedLibraryIDs); p != nil { + if p := reg.rejectUnknownLibraries(ctx, &req.AllowedLibraryIDs, locationAllowedLibraryIDs); p != nil { return nil, p } } @@ -780,7 +782,7 @@ func (reg *Registry) updateProfile(ctx context.Context, in *ProfileUpdateInput) if p != nil { return nil, p } - if p := reg.rejectUnknownLibraries(ctx, req.AllowedLibraryIDs); p != nil { + if p := reg.rejectUnknownLibraries(ctx, req.AllowedLibraryIDs, locationAllowedLibraryIDs); p != nil { return nil, p } view, err := reg.deps.Profiles.UpdateProfile(ctx, handlers.ProfileUpdateCommand{ @@ -800,11 +802,13 @@ func (reg *Registry) updateProfile(ctx context.Context, in *ProfileUpdateInput) return &ProfileOutput{Body: profile}, nil } -// rejectUnknownLibraries refuses an allowlist naming a library that does not -// exist. The store's row-by-row insert would otherwise hit the library -// foreign key (a 500) on Postgres, and persist a dangling id where no key -// is enforced. A nil or empty allowlist has nothing to check. -func (reg *Registry) rejectUnknownLibraries(ctx context.Context, ids *[]int) *Problem { +// rejectUnknownLibraries refuses a library list naming a library that does not +// exist, reporting it at location. A profile allowlist's row-by-row insert +// would otherwise hit the library foreign key (a 500) on Postgres; the +// database silently drops an unknown id from account, access group and +// invitation lists, so the client hears about it here. A nil or empty list +// has nothing to check. +func (reg *Registry) rejectUnknownLibraries(ctx context.Context, ids *[]int, location string) *Problem { if ids == nil || len(*ids) == 0 { return nil } @@ -822,7 +826,7 @@ func (reg *Registry) rejectUnknownLibraries(ctx context.Context, ids *[]int) *Pr for _, id := range *ids { if !known[id] { return NewProblem(TypeValidationFailed, "The request did not pass validation; see errors."). - WithErrors(ProblemError{Location: locationAllowedLibraryIDs, Code: codeInvalid, Detail: "unknown library identifier: " + strconv.Itoa(id)}) + WithErrors(ProblemError{Location: location, Code: codeInvalid, Detail: "unknown library identifier: " + strconv.Itoa(id)}) } } return nil diff --git a/migrations/prune_deleted_library_access_test.go b/migrations/prune_deleted_library_access_test.go new file mode 100644 index 0000000000..c71bcfa325 --- /dev/null +++ b/migrations/prune_deleted_library_access_test.go @@ -0,0 +1,263 @@ +package migrations + +import ( + "context" + "os" + "slices" + "testing" + + "github.com/jackc/pgx/v5" +) + +// requireLibraryIDs checks one library_ids column, keeping NULL (every +// library) distinct from an empty array (no libraries). +func requireLibraryIDs(t *testing.T, tx pgx.Tx, table string, id int64, want []int32) { + t.Helper() + var got []int32 + if err := tx.QueryRow(t.Context(), `SELECT library_ids FROM `+table+` WHERE id = $1`, id).Scan(&got); err != nil { + t.Fatal(err) + } + if (got == nil) != (want == nil) || !slices.Equal(got, want) { + t.Fatalf("%s %d library_ids = %#v, want %#v", table, id, got, want) + } +} + +// TestDeletingLibraryPrunesAccessPolicyReferencesPostgres deletes libraries +// that access groups, account overrides and invitations name, against the +// migrated schema in a transaction that is rolled back. The deleted IDs leave +// every list, the surviving ones keep their order, a list left with nothing +// stays an empty restriction rather than widening to every library, and NULL +// stays NULL. +func TestDeletingLibraryPrunesAccessPolicyReferencesPostgres(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + conn, err := pgx.Connect(t.Context(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close(context.Background()) }) + tx, err := conn.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = tx.Rollback(context.Background()) }) + + folder := func(name string) int32 { + t.Helper() + var id int32 + if err := tx.QueryRow(t.Context(), `INSERT INTO media_folders (type, name) VALUES ('movies', $1) RETURNING id`, name).Scan(&id); err != nil { + t.Fatal(err) + } + return id + } + gone, alsoGone, kept := folder("access prune gone"), folder("access prune also gone"), folder("access prune kept") + insert := func(query string, args ...any) int64 { + t.Helper() + var id int64 + if err := tx.QueryRow(t.Context(), query, args...).Scan(&id); err != nil { + t.Fatal(err) + } + return id + } + group := func(name string, ids []int32) int64 { + return insert(`INSERT INTO access_groups (name, library_ids) VALUES ($1, $2) RETURNING id`, name, ids) + } + user := func(name string, ids []int32) int64 { + return insert(`INSERT INTO users (username, role, library_ids) VALUES ($1, 'user', $2) RETURNING id`, name, ids) + } + mixedGroup := group("access prune mixed", []int32{gone, kept, alsoGone}) + onlyGoneGroup := group("access prune only gone", []int32{gone}) + allGroup := group("access prune all", nil) + untouchedGroup := group("access prune untouched", []int32{kept}) + mixedUser := user("access-prune-mixed", []int32{alsoGone, kept}) + onlyGoneUser := user("access-prune-only-gone", []int32{gone, alsoGone}) + inheritUser := user("access-prune-inherit", nil) + invitation := insert(`INSERT INTO invitations (email, token_hash, library_ids, invited_by, expires_at) + VALUES ('access-prune@example.test', 'access-prune-token', $1, $2, now() + interval '1 day') RETURNING id`, + []int32{kept, gone}, mixedUser) + + revision := func(id int64) int64 { + t.Helper() + var rev int64 + if err := tx.QueryRow(t.Context(), `SELECT configuration_revision FROM access_groups WHERE id = $1`, id).Scan(&rev); err != nil { + t.Fatal(err) + } + return rev + } + mixedRevision, untouchedRevision := revision(mixedGroup), revision(untouchedGroup) + + if _, err := tx.Exec(t.Context(), `DELETE FROM media_folders WHERE id = ANY($1)`, []int32{gone, alsoGone}); err != nil { + t.Fatal(err) + } + + requireLibraryIDs(t, tx, "access_groups", mixedGroup, []int32{kept}) + requireLibraryIDs(t, tx, "access_groups", onlyGoneGroup, []int32{}) + requireLibraryIDs(t, tx, "access_groups", allGroup, nil) + requireLibraryIDs(t, tx, "access_groups", untouchedGroup, []int32{kept}) + requireLibraryIDs(t, tx, "users", mixedUser, []int32{kept}) + requireLibraryIDs(t, tx, "users", onlyGoneUser, []int32{}) + requireLibraryIDs(t, tx, "users", inheritUser, nil) + requireLibraryIDs(t, tx, "invitations", invitation, []int32{kept}) + + // An edited group gets a new revision, so an admin form opened before the + // delete reports a conflict instead of saving the deleted library back. + if revision(mixedGroup) == mixedRevision { + t.Fatal("pruned group kept its configuration revision") + } + if revision(untouchedGroup) != untouchedRevision { + t.Fatal("group without a deleted library got a new configuration revision") + } + + // A write naming a library that does not exist, from any API, keeps only + // the libraries that do. + missing := gone + written := group("access prune written", []int32{kept, missing}) + requireLibraryIDs(t, tx, "access_groups", written, []int32{kept}) + if _, err := tx.Exec(t.Context(), `UPDATE users SET library_ids = $1 WHERE id = $2`, []int32{missing}, inheritUser); err != nil { + t.Fatal(err) + } + requireLibraryIDs(t, tx, "users", inheritUser, []int32{}) +} + +// TestLibraryDeleteRacingAccessWritePostgres commits a library delete while +// another transaction is adding that library to an access group. The write +// waits for the delete, as it would behind a foreign key, and stores only the +// libraries that still exist. +func TestLibraryDeleteRacingAccessWritePostgres(t *testing.T) { + dsn := os.Getenv("SILO_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("SILO_TEST_DATABASE_URL is not set") + } + connect := func() *pgx.Conn { + t.Helper() + conn, err := pgx.Connect(t.Context(), dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close(context.Background()) }) + return conn + } + deleter, writer := connect(), connect() + ctx := t.Context() + + var kept, doomed int32 + var groupID int64 + if err := deleter.QueryRow(ctx, `INSERT INTO media_folders (type, name) VALUES ('movies', 'access race kept') RETURNING id`).Scan(&kept); err != nil { + t.Fatal(err) + } + if err := deleter.QueryRow(ctx, `INSERT INTO media_folders (type, name) VALUES ('movies', 'access race doomed') RETURNING id`).Scan(&doomed); err != nil { + t.Fatal(err) + } + if err := deleter.QueryRow(ctx, `INSERT INTO access_groups (name, library_ids) VALUES ('access race group', $1) RETURNING id`, []int32{kept}).Scan(&groupID); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _, _ = deleter.Exec(context.Background(), `DELETE FROM access_groups WHERE id = $1`, groupID) + _, _ = deleter.Exec(context.Background(), `DELETE FROM media_folders WHERE id = ANY($1)`, []int32{kept, doomed}) + }) + + tx, err := deleter.Begin(ctx) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tx.Rollback(context.Background()) }() + if _, err := tx.Exec(ctx, `DELETE FROM media_folders WHERE id = $1`, doomed); err != nil { + t.Fatal(err) + } + + var writerPID uint32 + if err := writer.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&writerPID); err != nil { + t.Fatal(err) + } + wrote := make(chan error, 1) + go func() { + _, err := writer.Exec(ctx, `UPDATE access_groups SET library_ids = $1 WHERE id = $2`, []int32{kept, doomed}, groupID) + wrote <- err + }() + // Wait until the write is blocked on the delete's lock. + for { + var waiting bool + if err := deleter.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_locks WHERE pid = $1 AND NOT granted)`, writerPID).Scan(&waiting); err != nil { + t.Fatal(err) + } + if waiting { + break + } + select { + case err := <-wrote: + t.Fatalf("write finished before the delete committed: %v", err) + default: + } + } + if err := tx.Commit(ctx); err != nil { + t.Fatal(err) + } + if err := <-wrote; err != nil { + t.Fatal(err) + } + var got []int32 + if err := deleter.QueryRow(ctx, `SELECT library_ids FROM access_groups WHERE id = $1`, groupID).Scan(&got); err != nil { + t.Fatal(err) + } + if !slices.Equal(got, []int32{kept}) { + t.Fatalf("library_ids = %v, want [%d]", got, kept) + } +} + +const pruneDeletedLibraryAccessMigration = "20261013021242_prune_deleted_library_access_references" + +// TestPruneDeletedLibraryAccessMigrationRepairsStaleIDsPostgres runs the +// migration over references that earlier library deletes left behind: it +// drops every ID that names no library and leaves NULL lists and intact lists +// alone. The rollback removes the trigger, and running it again repairs what +// a delete left behind in between. +func TestPruneDeletedLibraryAccessMigrationRepairsStaleIDsPostgres(t *testing.T) { + tx, schema := adminMigrationFixture(t) + migrationExec(t, tx, ` +CREATE TABLE media_folders (id bigint PRIMARY KEY); +CREATE TABLE access_groups (id bigint PRIMARY KEY, library_ids integer[], updated_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE users (id bigint PRIMARY KEY, library_ids integer[], updated_at timestamptz NOT NULL DEFAULT now()); +CREATE TABLE invitations (id bigint PRIMARY KEY, library_ids integer[], updated_at timestamptz NOT NULL DEFAULT now()); +INSERT INTO media_folders VALUES (1), (2), (3); +INSERT INTO access_groups (id, library_ids) VALUES (1, '{3,20,1,23}'), (2, '{20,23}'), (3, NULL), (4, '{2,1}'); +INSERT INTO users (id, library_ids) VALUES (1, '{23,2}'), (2, '{20}'), (3, NULL), (4, '{}'); +INSERT INTO invitations (id, library_ids) VALUES (1, '{1,20}'), (2, NULL);`) + + check := func() { + t.Helper() + requireLibraryIDs(t, tx, "access_groups", 1, []int32{3, 1}) + requireLibraryIDs(t, tx, "access_groups", 2, []int32{}) + requireLibraryIDs(t, tx, "access_groups", 3, nil) + requireLibraryIDs(t, tx, "access_groups", 4, []int32{2, 1}) + requireLibraryIDs(t, tx, "users", 1, []int32{2}) + requireLibraryIDs(t, tx, "users", 2, []int32{}) + requireLibraryIDs(t, tx, "users", 3, nil) + requireLibraryIDs(t, tx, "users", 4, []int32{}) + requireLibraryIDs(t, tx, "invitations", 1, []int32{1}) + requireLibraryIDs(t, tx, "invitations", 2, nil) + } + migrationExec(t, tx, adminMigrationSQL(t, pruneDeletedLibraryAccessMigration, schema, false)) + check() + + // The triggers installed in the same schema filter a later write and prune + // a later delete. + migrationExec(t, tx, `UPDATE invitations SET library_ids = '{3,99}' WHERE id = 2`) + requireLibraryIDs(t, tx, "invitations", 2, []int32{3}) + migrationExec(t, tx, `UPDATE invitations SET library_ids = NULL WHERE id = 2`) + migrationExec(t, tx, `DELETE FROM media_folders WHERE id = 2`) + requireLibraryIDs(t, tx, "access_groups", 4, []int32{1}) + requireLibraryIDs(t, tx, "users", 1, []int32{}) + + // After the rollback a delete leaves its reference behind again, and + // re-running the migration repairs it. + migrationExec(t, tx, adminMigrationSQL(t, pruneDeletedLibraryAccessMigration, schema, true)) + migrationExec(t, tx, `DELETE FROM media_folders WHERE id = 1`) + requireLibraryIDs(t, tx, "access_groups", 4, []int32{1}) + migrationExec(t, tx, adminMigrationSQL(t, pruneDeletedLibraryAccessMigration, schema, false)) + requireLibraryIDs(t, tx, "access_groups", 1, []int32{3}) + requireLibraryIDs(t, tx, "access_groups", 4, []int32{}) + requireLibraryIDs(t, tx, "invitations", 1, []int32{}) + requireLibraryIDs(t, tx, "access_groups", 3, nil) +} diff --git a/migrations/sql/20261013021242_prune_deleted_library_access_references.sql b/migrations/sql/20261013021242_prune_deleted_library_access_references.sql new file mode 100644 index 0000000000..74601cb4f6 --- /dev/null +++ b/migrations/sql/20261013021242_prune_deleted_library_access_references.sql @@ -0,0 +1,116 @@ +-- +goose Up +-- Access groups, account overrides and invitations name libraries in integer +-- arrays, which no foreign key covers, so deleting a library used to leave its +-- ID behind: the admin pages listed it as "#20" and counted it as a library, +-- and saving the policy kept it. Profile allowlists are a join table and +-- already cascade. +-- +-- existing_library_ids keeps the IDs that still name a library, in their +-- order. A list left with none becomes empty, never NULL: NULL means every +-- library, so pruning must not widen access. +-- +goose StatementBegin +CREATE FUNCTION public.existing_library_ids(ids integer[]) +RETURNS integer[] +LANGUAGE sql +STABLE +STRICT +AS $$ + SELECT COALESCE(array_agg(listed.id ORDER BY listed.position), '{}') + FROM unnest(ids) WITH ORDINALITY AS listed(id, position) + WHERE EXISTS (SELECT 1 FROM public.media_folders f WHERE f.id = listed.id) +$$; +-- +goose StatementEnd + +-- Prune on every library delete, in the deleting statement, so no path can +-- leave a reference behind. Changing users.library_ids does not bump +-- access_policy_revision, so members stay signed in; the edited rows still get +-- a new admin or configuration revision, so an admin form opened before the +-- delete gets a conflict instead of saving the deleted library back. +-- +goose StatementBegin +CREATE FUNCTION public.prune_deleted_library_access_references() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + deleted integer[]; +BEGIN + -- library_ids is integer[], so a library past its range can't be listed. + SELECT array_agg(old_rows.id)::integer[] INTO deleted + FROM old_rows WHERE old_rows.id <= 2147483647; + IF deleted IS NULL THEN + RETURN NULL; -- nothing listable deleted, or a retry found it gone + END IF; + UPDATE public.access_groups + SET library_ids = public.existing_library_ids(library_ids), updated_at = now() + WHERE library_ids && deleted; + UPDATE public.users + SET library_ids = public.existing_library_ids(library_ids), updated_at = now() + WHERE library_ids && deleted; + UPDATE public.invitations + SET library_ids = public.existing_library_ids(library_ids), updated_at = now() + WHERE library_ids && deleted; + RETURN NULL; +END; +$$; +-- +goose StatementEnd + +CREATE TRIGGER media_folders_prune_access_references + AFTER DELETE ON public.media_folders + REFERENCING OLD TABLE AS old_rows + FOR EACH STATEMENT + EXECUTE FUNCTION public.prune_deleted_library_access_references(); + +-- Every write keeps only IDs that name a library, whichever API or node made +-- it, as a foreign key would: it locks the named libraries, so a concurrent +-- delete either waits for the write and then prunes it, or commits first and +-- its ID is dropped here. A write that only removes IDs adds nothing to check, +-- which keeps the prune above from locking the libraries it is not deleting. +-- +goose StatementBegin +CREATE FUNCTION public.keep_existing_library_ids() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND NEW.library_ids <@ OLD.library_ids THEN + RETURN NEW; + END IF; + PERFORM 1 FROM public.media_folders WHERE id = ANY (NEW.library_ids) FOR KEY SHARE; + NEW.library_ids := public.existing_library_ids(NEW.library_ids); + RETURN NEW; +END; +$$; +-- +goose StatementEnd + +CREATE TRIGGER access_groups_keep_existing_library_ids + BEFORE INSERT OR UPDATE OF library_ids ON public.access_groups + FOR EACH ROW WHEN (NEW.library_ids IS NOT NULL) + EXECUTE FUNCTION public.keep_existing_library_ids(); +CREATE TRIGGER users_keep_existing_library_ids + BEFORE INSERT OR UPDATE OF library_ids ON public.users + FOR EACH ROW WHEN (NEW.library_ids IS NOT NULL) + EXECUTE FUNCTION public.keep_existing_library_ids(); +CREATE TRIGGER invitations_keep_existing_library_ids + BEFORE INSERT OR UPDATE OF library_ids ON public.invitations + FOR EACH ROW WHEN (NEW.library_ids IS NOT NULL) + EXECUTE FUNCTION public.keep_existing_library_ids(); + +-- Repair the references earlier deletes left behind. +UPDATE public.access_groups +SET library_ids = public.existing_library_ids(library_ids), updated_at = now() +WHERE library_ids IS DISTINCT FROM public.existing_library_ids(library_ids); +UPDATE public.users +SET library_ids = public.existing_library_ids(library_ids), updated_at = now() +WHERE library_ids IS DISTINCT FROM public.existing_library_ids(library_ids); +UPDATE public.invitations +SET library_ids = public.existing_library_ids(library_ids), updated_at = now() +WHERE library_ids IS DISTINCT FROM public.existing_library_ids(library_ids); + +-- +goose Down +-- The repaired IDs named no library, so there is nothing to restore. +DROP TRIGGER IF EXISTS invitations_keep_existing_library_ids ON public.invitations; +DROP TRIGGER IF EXISTS users_keep_existing_library_ids ON public.users; +DROP TRIGGER IF EXISTS access_groups_keep_existing_library_ids ON public.access_groups; +DROP FUNCTION IF EXISTS public.keep_existing_library_ids(); +DROP TRIGGER IF EXISTS media_folders_prune_access_references ON public.media_folders; +DROP FUNCTION IF EXISTS public.prune_deleted_library_access_references(); +DROP FUNCTION IF EXISTS public.existing_library_ids(integer[]); diff --git a/scripts/ci/db-contracts.txt b/scripts/ci/db-contracts.txt index b1db769ccc..6cb31da139 100644 --- a/scripts/ci/db-contracts.txt +++ b/scripts/ci/db-contracts.txt @@ -243,3 +243,10 @@ # first position exactly where the cursor pager and a seek list it. ./internal/catalog TestLetterIndexMatchesCursorPagesPostgres ./internal/catalog TestLetterIndexEpisodesMatchCursorPagesPostgres + +# Deleting a library removes its ID from access groups, account overrides and +# invitations, without widening an emptied list; a write keeps only existing +# libraries, even racing the delete; the migration repairs earlier deletes. +./migrations TestDeletingLibraryPrunesAccessPolicyReferencesPostgres +./migrations TestLibraryDeleteRacingAccessWritePostgres +./migrations TestPruneDeletedLibraryAccessMigrationRepairsStaleIDsPostgres From 80c71c2bda65a1b26ecea4d5a614c6e5b0137ace Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:13:36 -0400 Subject: [PATCH 021/196] fix(autoscan): accept Sonarr SeriesDelete and Radarr MovieDelete webhooks (#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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- internal/autoscan/arrwebhook/parse.go | 37 ++++++++++++++++++- internal/autoscan/arrwebhook/parse_test.go | 33 +++++++++++++++++ .../testdata/radarr_movie_delete.json | 13 +++++++ .../testdata/sonarr_series_delete.json | 12 ++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 internal/autoscan/arrwebhook/testdata/radarr_movie_delete.json create mode 100644 internal/autoscan/arrwebhook/testdata/sonarr_series_delete.json diff --git a/internal/autoscan/arrwebhook/parse.go b/internal/autoscan/arrwebhook/parse.go index 1170704b23..126a39c8fa 100644 --- a/internal/autoscan/arrwebhook/parse.go +++ b/internal/autoscan/arrwebhook/parse.go @@ -7,6 +7,7 @@ package arrwebhook import ( + "bytes" "encoding/json" "errors" "strings" @@ -67,7 +68,36 @@ type payload struct { // Download/import payloads include the files replaced by an upgrade. They // must be reconciled as vanished paths even when a separate "file delete for // upgrade" notification is not enabled in arr. - DeletedFiles []fileRef `json:"deletedFiles"` + DeletedFiles []fileRef `json:"-"` + DeletedFilesRaw json.RawMessage `json:"deletedFiles"` +} + +// folderDeleteEventTypes send deletedFiles as a bool (whether arr deleted the +// folder) rather than as files. +var folderDeleteEventTypes = map[string]bool{ + "SeriesDelete": true, + "MovieDelete": true, +} + +// decodeDeletedFiles reads deletedFiles once the event type is known: the bool +// folder-delete events send names no files, and anything else that is not a +// list of files is malformed. +func decodeDeletedFiles(eventType string, raw json.RawMessage) ([]fileRef, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return nil, nil + } + if folderDeleteEventTypes[eventType] { + switch string(trimmed) { + case "true", "false": + return nil, nil + } + } + var files []fileRef + if err := json.Unmarshal(trimmed, &files); err != nil { + return nil, err + } + return files, nil } // importEventTypes are the events that carry newly imported files. @@ -97,6 +127,11 @@ func Parse(provider string, body []byte) (ParsedWebhook, error) { if eventType == "" { return ParsedWebhook{}, ErrMalformedPayload } + deletedFiles, err := decodeDeletedFiles(eventType, p.DeletedFilesRaw) + if err != nil { + return ParsedWebhook{}, ErrMalformedPayload + } + p.DeletedFiles = deletedFiles out := ParsedWebhook{EventType: eventType, Provider: resolveProvider(provider, p)} if strings.EqualFold(eventType, "Test") { diff --git a/internal/autoscan/arrwebhook/parse_test.go b/internal/autoscan/arrwebhook/parse_test.go index 920db0649d..d8e3ed0ece 100644 --- a/internal/autoscan/arrwebhook/parse_test.go +++ b/internal/autoscan/arrwebhook/parse_test.go @@ -187,6 +187,39 @@ func TestParseUnknownEventIsNoOpNotError(t *testing.T) { } } +// SeriesDelete and MovieDelete send deletedFiles as a bool, not the file list +// an upgrade's download carries; they are valid deliveries with nothing to do. +func TestParseSeriesAndMovieDeleteAreNoOps(t *testing.T) { + for _, tc := range []struct{ fixture, eventType, provider string }{ + {"sonarr_series_delete.json", "SeriesDelete", ProviderSonarr}, + {"radarr_movie_delete.json", "MovieDelete", ProviderRadarr}, + } { + parsed, err := Parse(ProviderAuto, fixture(t, tc.fixture)) + if err != nil { + t.Fatalf("%s: Parse: %v", tc.fixture, err) + } + if parsed.EventType != tc.eventType || parsed.Provider != tc.provider || parsed.Test || len(parsed.Changes) != 0 { + t.Fatalf("%s: want a no-op %s from %s, got %+v", tc.fixture, tc.eventType, tc.provider, parsed) + } + } +} + +// Outside SeriesDelete and MovieDelete, a deletedFiles value that is not a list +// of files is malformed, so a broken upgrade payload is not accepted with its +// replaced files dropped. +func TestParseMalformedDeletedFiles(t *testing.T) { + for _, deleted := range []string{`[1]`, `{"path":"/tv/x/old.mkv"}`, `"/tv/x/old.mkv"`, `1`, `true`} { + body := []byte(`{"eventType":"Download","series":{"path":"/tv/x"},"deletedFiles":` + deleted + `}`) + if _, err := Parse(ProviderAuto, body); !errors.Is(err, ErrMalformedPayload) { + t.Fatalf("deletedFiles %s: want ErrMalformedPayload, got %v", deleted, err) + } + } + body := []byte(`{"eventType":"Download","series":{"path":"/tv/x"},"episodeFile":{"path":"/tv/x/new.mkv"},"deletedFiles":null}`) + if parsed, err := Parse(ProviderAuto, body); err != nil || len(parsed.Changes) != 1 { + t.Fatalf("deletedFiles null: got %+v, %v; want the imported file only", parsed, err) + } +} + func TestParseMalformedBody(t *testing.T) { if _, err := Parse(ProviderAuto, []byte("{not json")); !errors.Is(err, ErrMalformedPayload) { t.Fatalf("want ErrMalformedPayload, got %v", err) diff --git a/internal/autoscan/arrwebhook/testdata/radarr_movie_delete.json b/internal/autoscan/arrwebhook/testdata/radarr_movie_delete.json new file mode 100644 index 0000000000..a0a6aa9a18 --- /dev/null +++ b/internal/autoscan/arrwebhook/testdata/radarr_movie_delete.json @@ -0,0 +1,13 @@ +{ + "movie": { + "id": 44, + "title": "Example Movie", + "year": 2024, + "folderPath": "/data/movies/Example Movie (2024)", + "tmdbId": 654321 + }, + "deletedFiles": false, + "movieFolderSize": 4294967296, + "eventType": "MovieDelete", + "instanceName": "Radarr" +} diff --git a/internal/autoscan/arrwebhook/testdata/sonarr_series_delete.json b/internal/autoscan/arrwebhook/testdata/sonarr_series_delete.json new file mode 100644 index 0000000000..95396adc70 --- /dev/null +++ b/internal/autoscan/arrwebhook/testdata/sonarr_series_delete.json @@ -0,0 +1,12 @@ +{ + "series": { + "id": 12, + "title": "Example Show", + "path": "/data/tv/Example Show", + "tvdbId": 123456, + "type": "standard" + }, + "deletedFiles": true, + "eventType": "SeriesDelete", + "instanceName": "Sonarr" +} From 590bfcfba813d9c87dc322332b7c6b5889236053 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:13:41 -0400 Subject: [PATCH 022/196] fix(web): name a route's Sonarr metadata language in its summary (#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 --- .../admin-settings/requestRoutingModel.test.ts | 17 +++++++++++++++++ .../pages/admin-settings/requestRoutingModel.ts | 10 +++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/web/src/pages/admin-settings/requestRoutingModel.test.ts b/web/src/pages/admin-settings/requestRoutingModel.test.ts index 9f6135a645..ff4dc5cd74 100644 --- a/web/src/pages/admin-settings/requestRoutingModel.test.ts +++ b/web/src/pages/admin-settings/requestRoutingModel.test.ts @@ -18,6 +18,7 @@ import { ruleDraft, ruleSentence, rowsToConditions, + SERVER_NAMED_OVERRIDES, splitOverrideFields, traceLine, } from "./requestRoutingModel"; @@ -166,6 +167,22 @@ describe("rule sentences", () => { ), ).toBe("Quality Ultra-HD"); }); + + it("names a Sonarr metadata language from the server's options", () => { + // A route row loads the server's options only for these keys. + expect(SERVER_NAMED_OVERRIDES.has("metadata_language")).toBe(true); + expect( + overridesSummary( + { metadata_language: 8 }, + { + options: { metadata_language: [{ value: "8", label: "Japanese" }] }, + fields: [ + { key: "metadata_language", label: "Metadata language" } as PluginAdminFormField, + ], + }, + ), + ).toBe("Metadata language Japanese"); + }); }); describe("the pass-through choice", () => { diff --git a/web/src/pages/admin-settings/requestRoutingModel.ts b/web/src/pages/admin-settings/requestRoutingModel.ts index bb6ff9029a..b7f8ab108f 100644 --- a/web/src/pages/admin-settings/requestRoutingModel.ts +++ b/web/src/pages/admin-settings/requestRoutingModel.ts @@ -457,10 +457,14 @@ const INLINE_OVERRIDES: readonly string[] = ["root_folder", "quality_profile_id" const SELF_NAMED_OVERRIDES = new Set(["root_folder"]); /** - * Override keys whose values are IDs on the server (a quality profile, tags), - * so naming them takes the server's options. + * Override keys whose values are IDs on the server (a quality profile, tags, a + * Sonarr metadata language), so naming them takes the server's options. */ -export const SERVER_NAMED_OVERRIDES: ReadonlySet = new Set(["quality_profile_id", "tags"]); +export const SERVER_NAMED_OVERRIDES: ReadonlySet = new Set([ + "quality_profile_id", + "tags", + "metadata_language", +]); export interface OverrideNames { /** The server's loaded options (root folders, quality profiles, tags), by field key. */ From 22e718980cda01f64ce2bc3fcf191faf1cccf7f5 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:53:32 -0400 Subject: [PATCH 023/196] fix(trickplay): correct HDR previews, decode on NVIDIA, and repair missed cells (#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 * 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 * 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 * 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 --------- Co-authored-by: Claude Opus 5.5 --- contracts/api/v2/openapi.json | 7 + docs/architecture/media-sampling.md | 124 +++++++-- docs/architecture/trickplay.md | 33 ++- docs/architecture/worker-http-protocol.md | 5 +- internal/mediasample/args.go | 23 +- internal/mediasample/concat.go | 11 +- internal/mediasample/ffmpeg_test.go | 68 ++++- internal/mediasample/hwdecode.go | 70 ++++- internal/mediasample/images.go | 65 ++++- internal/mediasample/reason.go | 4 + internal/mediasample/request.go | 13 + internal/mediasample/run.go | 44 ++- internal/mediasample/sheet_repair.go | 210 ++++++++++++++ internal/mediasample/sheet_repair_test.go | 259 ++++++++++++++++++ internal/mediasample/sheets.go | 151 ++++++++-- internal/mediasample/sheets_join.go | 126 ++++++--- internal/mediasample/sheets_test.go | 139 ++++++++-- internal/mediasample/sheets_tonemap_test.go | 228 +++++++++++++++ internal/playback/protocol_v3.go | 4 + internal/transcodenode/server.go | 3 +- internal/transcodenode/trickplay.go | 2 +- internal/trickplay/extract.go | 11 +- internal/trickplay/remote.go | 8 +- internal/trickplay/remote_test.go | 6 +- internal/trickplay/repository.go | 3 + internal/trickplay/service.go | 34 ++- internal/trickplay/service_test.go | 33 ++- ...ue_trickplay_hdr_and_repairable_sheets.sql | 29 ++ .../admin-settings/PlaybackSettings.test.tsx | 16 +- .../admin-settings/playbackSettings.utils.ts | 9 +- 30 files changed, 1548 insertions(+), 190 deletions(-) create mode 100644 internal/mediasample/sheet_repair.go create mode 100644 internal/mediasample/sheet_repair_test.go create mode 100644 internal/mediasample/sheets_tonemap_test.go create mode 100644 migrations/sql/20261013170245_requeue_trickplay_hdr_and_repairable_sheets.sql diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index dde691501b..ce483bef62 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -217948,6 +217948,10 @@ "filled": { "format": "int64", "type": "integer" + }, + "refined": { + "format": "int64", + "type": "integer" } }, "required": [ @@ -218087,6 +218091,9 @@ "properties": { "allow_software": { "type": "boolean" + }, + "source": { + "type": "string" } }, "type": "object" diff --git a/docs/architecture/media-sampling.md b/docs/architecture/media-sampling.md index 1ce4ec35f8..f4bde56fda 100644 --- a/docs/architecture/media-sampling.md +++ b/docs/architecture/media-sampling.md @@ -173,23 +173,30 @@ An attempt with `Hardware` set decodes the request's video on the runner's `HardwareResolver` (`hwresolver.go`), which caches the resolved backend per configured pair until the playback probe cache is invalidated, and asks again after `HardwareRetryInterval` when `auto` resolved to no hardware; credits -detection uses one. Only QSV, VAAPI, and VideoToolbox decode -here (`SupportsHardwareDecode`); NVENC does not. A hardware attempt needs a -video output (`Images` or `Stats`). Audio in the same run always decodes in -software. +detection uses one. QSV, VAAPI, and VideoToolbox decode +every video output (`SupportsHardwareDecode`); NVENC decodes only `Sheets` +(`SupportsHardwareSheets`), on CUDA, so the stats chains cached intro +fingerprints come from and the chapter-thumbnail images stay as they were. A +hardware attempt needs a video output (`Images`, `Stats`, or `Sheets`); one +whose output its accelerator does not decode fails as `unsupported` without +starting ffmpeg. Audio in the same run always decodes in software. - QSV and VAAPI decode into VAAPI surfaces on a render device. QSV initializes its VAAPI parent device the way playback does (`tonemap.QSVInitDeviceArgs`) and decodes through it. +- NVENC decodes into CUDA surfaces on the CUDA device `HWDevice` names (an + index, or the default when empty), declared as the filters' device too: + `-init_hw_device cuda=cu[:N] -filter_hw_device cu -hwaccel cuda + -hwaccel_output_format cuda`. - VideoToolbox decodes `Images` into system-memory frames, so software filters apply to them directly. `Stats` asks for VideoToolbox surfaces (`-hwaccel_output_format videotoolbox_vld`) and downloads them, because only surfaces prove the decode ran on hardware: given a stream VideoToolbox cannot decode, such as VP8, plain `-hwaccel videotoolbox` quietly decodes in software, while the download of a software frame fails the attempt. -- Each hardware attempt reserves one render device through - `playback.AcquireHWDevice`, falling back to `playback.PickRenderDevice`, - before its timeout starts, and releases it when the attempt ends, before +- Each hardware attempt reserves one device through + `playback.AcquireHWDevice`, falling back to `playback.PickRenderDevice` for + QSV and VAAPI, before its timeout starts, and releases it when the attempt ends, before the next attempt. A multi-device `Runner.HWDevice` list therefore resolves to one device per attempt. A hardware attempt that cannot be built, such as VAAPI without a render device, fails as `unsupported` without starting @@ -198,6 +205,10 @@ software. the hardware one. A failed run takes its reason from its last attempt, so once the software attempt has run, a hardware failure costs only time and cannot mark an artifact unusable. +- A GPU that cannot decode the stream makes ffmpeg log "Failed setup for + format …" or "doesn't support hardware accelerated …" and fall back to + software frames, which the GPU chain refuses; the attempt's cause is + `unsupported`. `Stats` on VAAPI surfaces (VAAPI and QSV) scales the whole picture on the GPU and converts it to 8-bit NV12 there before the download, since a 10-bit @@ -301,17 +312,50 @@ including a 90-degree display matrix, and returns the actual cell height in request also probes when this option is set. Callers publishing manifests must use the returned height and reject chunks whose geometry differs. -- HDR sources are scaled before they are tone mapped, so the software - tone-map chain of [Images](#images) only handles thumbnail-sized frames, - followed by `scale=out_range=full:out_color_matrix=bt601`. VAAPI and QSV - tone map on the GPU first (`tonemap_vaapi` needs the source's HDR - metadata), scale with `scale_vaapi`, and download. +- GPU surfaces (VAAPI, QSV, CUDA) are scaled on the GPU to four tiles' size, + or the frame's own size if smaller, downloaded as NV12 (P010 for a source + tone mapped below), and area-scaled to the tile in software: + + ```text + scale_vaapi=w=min(iw\,4W):h=min(ih\,4H):format=nv12,hwdownload,format=nv12,scale=W:H:flags=area:out_range=full:… + scale_cuda=w=min(iw\,4W):h=min(ih\,4H):interp_algo=bilinear:format=nv12,hwdownload,format=nv12,scale=W:H:flags=area:out_range=full:… + ``` + + The GPU scalers sample a few source pixels per output pixel, so scaling a + 4K frame straight to a 300-pixel tile aliases: SSIM 0.94 against the + software area scale on four 4K frames, where the two-step scale gives 0.999 + and downloads a fraction of the frame. +- HDR sources are tone mapped in software after scaling, so the tone map + only handles thumbnail-sized frames, followed by + `scale=out_range=full:out_color_matrix=bt601`. +- `ToneMap.Source` names the base signal `tonemap.ResolveSource` classified + the input as. With it, every attempt converts with the chain playback + converts that signal with in software (`tonemap.SoftwareFilter`) on a + filter the binary lists: `tonemapx`, else `tonemap`'s Hable curve, which + cannot apply a Dolby Vision RPU. GPU attempts decode and scale as above, + download 10-bit P010 (`format=p010`, `hwdownload,format=p010le`), and take + the same software chain, so every decoder gives the same colors. The GPU + tone mappers do not: on an Intel Arc, `tonemap_vaapi` crushed PQ midtones + and turned HLG teal and inverted, AMD's refuses HDR, and neither applies an + RPU. A frame keeps its Dolby Vision RPU through the GPU scale and the + download (SSIM 0.986 against the software render, 0.85 without the RPU). + An attempt the binary has no filter for, such as a Dolby Vision base + (Profile 5, compatibility id 0) without `tonemapx`, or one that does not + allow software tone mapping, fails as `unsupported` before ffmpeg starts, + so it makes no sheets rather than green and magenta ones. A base that is + already SDR only changes gamut. Without `Source` the [Images](#images) + chains apply, as best effort for sources whose catalog facts cannot be + classified; CUDA has none of those, so its attempt is `unsupported`. Images + take no `Source`. - `metadata=print` logs a frame only when it carries metadata, so `metadata=mode=add` gives every frame a `sample` entry of -1 unless its packet was tagged. The log then counts every frame on stdout, and the Nth - raw frame is the frame logged as `frame:N`. A counter that skips or - restarts, frames without log lines, log lines without frames, or bytes - after the last whole frame fail the attempt as `output`. + raw frame is the frame logged as `frame:N`. A filter graph reinit, which a + hardware decoder that renews its surfaces causes mid-list, restarts the + counter at zero after the old graph passed on every frame it logged; the + frames still line up, and the count goes on. A counter that skips or + restarts anywhere else, frames without log lines, log lines without frames, + or bytes after the last whole frame fail the attempt as `output`. - `-fps_mode passthrough` is required: a list's timestamps jump back at every entry, and rawvideo would otherwise pick a constant frame rate and drop or duplicate frames. Windows bound their duration with an input `-t`, since an @@ -361,10 +405,52 @@ must use the returned height and reject chunks whose geometry differs. fails for any reason, the attempt reports the window's failure, whose reason and log describe the latest read, and its error names the list run's. -- A sheet is JPEG-encoded in-process (`image/jpeg`, `Quality`) as soon as - frames land two sheets further on, so at most three sheets are held. - `Result.Sheets` holds them in order and `Result.SheetFrames` counts the - decoded and filled cells. +- Sheets are JPEG-encoded in-process (`image/jpeg`, `Quality`) once the run + and its repairs are done, since a repair may replace any cell. A run holds + its tiles until then, so a request whose sheets exceed 64 million tile + pixels is invalid; callers chunk longer files. `Result.Sheets` holds them + in order and `Result.SheetFrames` counts the decoded, filled, and + repaired (`Refined`) cells. + +### Repairs + +A list gives each sample the keyframe at or before its time, placed back in +the input from its frame time: `pts + start - k*span + sample` for list entry +`k`, where `start` is the list input's start time from its header, by which +ffmpeg shifted every frame. Two kinds of cell come out worse than the input +allows, and the attempt reads their samples again in a second list, after +the first run and before the sheets are encoded (`sheet_repair.go`): + +- Missing cells decoded no frame. The demuxer could not seek to a keyframe + before the sample, as in a container that flags no keyframes (VC-1 in + Matroska, whose I-frames only the decoder recognizes, lost a third of its + samples to half-second entries), or the HEVC decoder dropped the keyframe + as a duplicate picture order count. The repair lists them again, + keyframes only, each entry lasting until the next sample (the shortest gap + between the run's samples): the decoder starts afresh and finds the + keyframe before the sample, or failing that the first one after it. +- Repeated cells show the same moment as the cell before: their frames lie + within a tenth of the time between the two samples, because no keyframe + lies between them (a GOP longer than the interval), or because an open-GOP + keyframe brought a leading picture just before it that one sample kept. + The repair lists them again with `-skip_frame:v noref`, decoding every + reference frame from the keyframe on, and keeps the last frame at or before + each sample's time. + +Each repaired sample keeps its nearest frame: the last at or before its +time, else the first after it. A repair that ffmpeg finishes must line up +with its log like any run; one cut short keeps the frames it placed, which +were written and logged in order, and leaves the rest. Both repairs share one +time box, 30 seconds plus a quarter second a sample of the run, within the +attempt's timeout: on a 4K HEVC clip with a keyframe every 25 seconds and +samples every 10, software repaired 12 of 31 cells in 26 seconds, CUDA in 4. +Missing cells are repaired first, then the repeated cells, recomputed so a +recovered cell that shows its neighbor's keyframe is decoded at its own time +too. A list that decoded nothing is repaired as well, since a container +without keyframe flags can miss every short entry. A repair never fails the +run, except that the caller canceling ends it as `canceled`; the sparse check then counts what the run and its repairs +decoded, so a list whose repairs recover its missing samples no longer needs +the window fallback above. ## Argument stability diff --git a/docs/architecture/trickplay.md b/docs/architecture/trickplay.md index 723ac21681..261d442248 100644 --- a/docs/architecture/trickplay.md +++ b/docs/architecture/trickplay.md @@ -103,18 +103,34 @@ and runs each file at idle CPU and I/O priority: `k*interval + interval/2`, the last one a second inside the file, so it shows what plays in `[k*interval, (k+1)*interval)`. - The first `mediasample` Sheets request covers one sheet to learn actual - display geometry. Later requests cover at most 16 sheets and 64 million + display geometry. Later requests cover at most 16 sheets and 32 million pixels, keeping high-entropy JPEG responses within the bounded node limit; a longer file - takes several runs, which bounds each run's memory. Each run seeks to the + takes several runs, which bounds each run's memory: a run holds every tile + it decodes until its repairs are done. Each run seeks to the keyframe before each sample rather than reading the whole file (`Samples`); see [media sampling](media-sampling.md#sheets). AVI files, whose index seeks poorly, are read in one pass (`ReadThrough`), and containers without a keyframe index (MPEG-TS) always are. -- HDR and Dolby Vision sources are tone mapped (`tonemap.NeedsToneMap`), - after scaling, so software tone mapping is always allowed. +- A list run's missing and repeated cells are read again before the sheets + are encoded ([repairs](media-sampling.md#repairs)): samples a container + without keyframe flags (VC-1 in Matroska) or an HEVC picture-order + collision left without a frame, and samples a GOP longer than the interval + gave the previous sample's keyframe. `SheetFrames.Refined` counts them. +- HDR and Dolby Vision sources are tone mapped (`tonemap.NeedsToneMap`) in + software after scaling, on GPU attempts too, so software tone mapping is + always allowed. The request names the base signal `tonemap.ResolveSource` + classifies the file's catalog facts as (`ToneMap.Source`), and every + attempt converts it with the chain playback converts it with in software: + a Dolby Vision base (Profile 5, compatibility id 0) only through + `tonemapx`, which applies its RPU, HLG with the HLG transfer. A file the + facts cannot classify keeps the best-effort chains, except a legacy row + whose catalog names Dolby Vision Profile 5, which takes the Dolby Vision + signal: no chain without its RPU can show that base. - Hardware decode follows `playback.hw_accel` and `playback.hw_device` - (QSV, VAAPI, VideoToolbox), with a software attempt after a hardware - failure. Attempt timeouts grow with the samples: two minutes plus half a + (QSV, VAAPI, VideoToolbox, and NVENC's CUDA decoder), with a software + attempt after a hardware failure. A GPU that cannot decode the codec fails + its attempt as `unsupported`, logged at debug rather than as the + accelerator's first failure. Attempt timeouts grow with the samples: two minutes plus half a second a sample on hardware, two seconds a sample in software. - A file selected for local extraction that this server cannot read (an offline mount) is given back for an hour without counting a failure; @@ -180,8 +196,9 @@ the lease, and the uploads; a node only decodes and tiles [worker protocol](worker-http-protocol.md)). - A node is eligible while it is enabled, healthy, and advertises the - `trickplay_extract_v1` transport feature, so older nodes are never sent - work. The API server reserves capacity through the node planner shared + `trickplay_extract_v2` transport feature, so nodes that would ignore the + tone-map source signal (`trickplay_extract_v1` alone) are never sent + work. Nodes keep advertising v1 for older API servers. The API server reserves capacity through the node planner shared with playback and downloads and chooses among nodes by available capacity and load. Those provisional reservations belong to one API process; a node's reported load accounts for work accepted from other replicas. diff --git a/docs/architecture/worker-http-protocol.md b/docs/architecture/worker-http-protocol.md index 497afa0977..63cae07d97 100644 --- a/docs/architecture/worker-http-protocol.md +++ b/docs/architecture/worker-http-protocol.md @@ -93,7 +93,10 @@ whole run, software attempts included, so an admin re-probe is refused until the run ends. Failures use `trickplay.ExtractError` JSON; a `422` carries the sampling cause and whether it is `permanent` (a cause in the file itself). Nodes advertise the endpoint as the -`trickplay_extract_v1` transport feature. It has no replay receipt and is +`trickplay_extract_v1` transport feature, and as `trickplay_extract_v2` once +they convert HDR with the tone-map source signal a request names +(`sheets.tone_map.source`), decode on CUDA, and repair missing and repeated +cells; API servers that send the signal only use v2 nodes. It has no replay receipt and is classified `non_retryable`; the API server keeps the work's lease and moves it to another node or back to the queue. diff --git a/internal/mediasample/args.go b/internal/mediasample/args.go index ef30708eb0..44ddea8793 100644 --- a/internal/mediasample/args.go +++ b/internal/mediasample/args.go @@ -61,6 +61,9 @@ func buildArgs(req Request, attempt Attempt, hw hardwareDecode, inputStart float if req.Stats == nil { return nil, nil, errors.New("hardware decode needs a video output") } + if !SupportsHardwareDecode(hw.Accel) { + return nil, nil, fmt.Errorf("hardware decode does not support %q", hw.Accel) + } var err error if decode, err = hardwareDecodeArgs(hw, true); err != nil { return nil, nil, err @@ -81,7 +84,7 @@ func buildArgs(req Request, attempt Attempt, hw hardwareDecode, inputStart float return buildSamplesArgs(req, attempt, hw, append(args, decode...), inputStart) } if req.Window.KeyframesOnly { - args = append(args, "-skip_frame:v", "nokey") + args = append(args, skipFrameOption, keyframeSkipFrame) } args = append(args, decode...) args = append(args, @@ -157,11 +160,12 @@ func buildSamplesArgs(req Request, attempt Attempt, hw hardwareDecode, args []st // buildSheetsArgs builds the arguments of a Sheets request whose video chain // is graph (see buildSheetsGraph). A Samples request reads its list as -// buildSamplesArgs does and returns the list for stdin; a Window request, -// which a Samples request becomes for inputs the list cannot seek in, decodes -// the window's keyframes. Either way the frames go to stdout raw, and hardware +// buildSamplesArgs does, its entries lasting and its decoder skipping frames +// as list says, and returns the list for stdin; a Window request, which a +// Samples request becomes for inputs the list cannot seek in, decodes the +// window's keyframes. Either way the frames go to stdout raw, and hardware // attempts leave VideoToolbox frames in system memory, as images do. -func buildSheetsArgs(req Request, attempt Attempt, hw hardwareDecode, inputStart float64, graph, packetTimingPath string) ([]string, []byte, error) { +func buildSheetsArgs(req Request, attempt Attempt, hw hardwareDecode, inputStart float64, graph, packetTimingPath string, list sheetsList) ([]string, []byte, error) { if (req.Window == nil) == (req.Samples == nil) || req.Sheets == nil { return nil, nil, errors.New("sheets need exactly one sampling mode") } @@ -178,19 +182,20 @@ func buildSheetsArgs(req Request, attempt Attempt, hw hardwareDecode, inputStart args = append(args, "-threads", threads, "-filter_threads", threads) } if req.Samples != nil { - list, err := buildConcatListSpan(req.Input, req.Samples.Seconds, inputStart, sheetSampleSpanSeconds) + concat, err := buildConcatListSpan(req.Input, req.Samples.Seconds, inputStart, list.span) if err != nil { return nil, nil, err } args = append(args, decode...) - args = append(args, concatInputArgs...) + args = append(args, skipFrameOption, list.skip) + args = append(args, concatDemuxerArgs...) args = append(args, "-i", concatListInput) - return append(args, sheetsOutputArgs(graph)...), list, nil + return append(args, sheetsOutputArgs(graph)...), concat, nil } // The window's duration bounds the input, not the output: an output -t // drops frames after the filters logged them, and the log would no // longer count the frames on stdout. - args = append(args, "-skip_frame:v", "nokey") + args = append(args, skipFrameOption, keyframeSkipFrame) args = append(args, decode...) args = append(args, "-ss", formatSeconds(req.Window.StartSeconds), diff --git a/internal/mediasample/concat.go b/internal/mediasample/concat.go index 3ec481dc13..d00d595e4b 100644 --- a/internal/mediasample/concat.go +++ b/internal/mediasample/concat.go @@ -37,8 +37,15 @@ const sampleSpanSeconds = 0.04 // sampleMetadataKey is the packet metadata entry naming a sample's time. const sampleMetadataKey = "sample" -// concatInputArgs are the input options of a Samples request, before -i. -var concatInputArgs = []string{"-skip_frame:v", "nokey", "-protocol_whitelist", "file,pipe", "-f", "concat", "-safe", "0"} +// skipFrameOption sets which video frames the decoder skips. +const skipFrameOption = "-skip_frame:v" + +// concatDemuxerArgs open a list input; concatInputArgs are the input options +// of a Samples request, before -i. +var ( + concatDemuxerArgs = []string{"-protocol_whitelist", "file,pipe", "-f", "concat", "-safe", "0"} + concatInputArgs = append([]string{skipFrameOption, keyframeSkipFrame}, concatDemuxerArgs...) +) // concatListInput is the input a Samples request reads its list from. const concatListInput = "pipe:0" diff --git a/internal/mediasample/ffmpeg_test.go b/internal/mediasample/ffmpeg_test.go index 921439443a..b064ea9ca4 100644 --- a/internal/mediasample/ffmpeg_test.go +++ b/internal/mediasample/ffmpeg_test.go @@ -334,11 +334,12 @@ func TestRunSheetsWithRealFFmpeg(t *testing.T) { // seconds apart: 256 frames, a whole cycle of the 8-bit picture order count. // After each jump through a list the decoder computes the previous // keyframe's count again and drops the keyframe as a duplicate. With a -// keyframe every 64 frames, the window that reads the samples again decodes -// every keyframe, and every sample must show its scene. With a keyframe every -// 256 frames the keyframes collide in the window too, since skipped pictures -// do not advance the count, and the run must fail rather than repeat the -// first scene. +// keyframe every 64 frames, the repair that lists the lost samples again +// decodes every keyframe, and every sample must show its scene. With a +// keyframe every 256 frames the keyframes collide in the repair and in the +// window that reads the samples again too, since skipped pictures do not +// advance the count, and the run must fail rather than repeat the first +// scene. func TestRunSheetsOpenGOPHEVCWithRealFFmpeg(t *testing.T) { ffmpeg, caps := realFFmpeg(t) grays := []int{20, 60, 100, 140, 180, 220} @@ -379,8 +380,10 @@ func TestRunSheetsOpenGOPHEVCWithRealFFmpeg(t *testing.T) { if err != nil { t.Fatalf("run: %v", err) } - if len(result.Sheets) != 1 || result.SheetFrames != (SheetFrames{Decoded: 6}) { - t.Fatalf("got %d sheets, frames %+v; want 1 sheet of 6 decoded samples", len(result.Sheets), result.SheetFrames) + // The list loses the keyframes after its first jump, and the + // repair, which lists them again, finds every one. + if len(result.Sheets) != 1 || result.SheetFrames != (SheetFrames{Decoded: 6, Refined: 5}) { + t.Fatalf("got %d sheets, frames %+v; want 1 sheet of 6 decoded samples, 5 of them repaired", len(result.Sheets), result.SheetFrames) } img, err := jpeg.Decode(bytes.NewReader(result.Sheets[0].JPEG)) if err != nil { @@ -495,3 +498,54 @@ func TestRunSheetsToneMapsHDRWithRealFFmpeg(t *testing.T) { t.Fatalf("tone-mapped sheet has mean luma %d, want a visible picture", mean) } } + +// TestRunSheetsRepairsLongGOPWithRealFFmpeg samples a clip whose brightness +// follows the clock, with a keyframe every 25 seconds, ten seconds apart. +// The list gives several samples the same keyframe, and the repair decodes +// each of them at its own time, so every cell shows its sample's moment. +func TestRunSheetsRepairsLongGOPWithRealFFmpeg(t *testing.T) { + ffmpeg, caps := realFFmpeg(t) + clip := filepath.Join(t.TempDir(), "long-gop.mkv") + args := []string{"-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "nullsrc=s=64x36:r=24:d=60,geq=lum='clip(4*T\\,0\\,255)':cb=128:cr=128", + "-c:v", "libx264", "-threads", "1", "-g", "600", "-keyint_min", "600", "-sc_threshold", "0", "-bf", "2", clip} + if output, err := exec.Command(ffmpeg, args...).CombinedOutput(); err != nil { + t.Skipf("cannot encode the clip with libx264: %v: %s", err, output) + } + seconds := []float64{5, 15, 25, 35, 45, 55} + req := Request{ + Input: clip, + Samples: &Samples{Seconds: seconds}, + Sheets: &SheetsOutput{TileWidth: 64, TileHeight: 36, Columns: 3, Rows: 2, Quality: 90}, + Threads: 1, + } + if err := caps.Require(req); err != nil { + t.Skipf("ffmpeg cannot sample sheets: %v", err) + } + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + result, err := Runner{FFmpegPath: ffmpeg, Workload: processmetrics.Trickplay}.Run(ctx, req) + if err != nil { + t.Fatalf("run: %v", err) + } + // Keyframes at 0, 25, and 50 s serve the samples at 5, 25, and 55 s; the + // other three repeat them until repaired. + if result.SheetFrames != (SheetFrames{Decoded: 6, Refined: 3}) { + t.Fatalf("frames %+v, want 6 decoded, 3 of them repaired", result.SheetFrames) + } + img, err := jpeg.Decode(bytes.NewReader(result.Sheets[0].JPEG)) + if err != nil { + t.Fatal(err) + } + ycc := img.(*image.YCbCr) + // The moment each cell shows: its own keyframe for the samples at 5, 25, + // and 55 s, and the repaired samples' own times. + shown := []float64{0, 15, 25, 35, 45, 50} + for cell, at := range shown { + x, y := (cell%3)*64+32, (cell/3)*36+18 + // geq writes limited-range luma, which the sheet expands to full. + want := math.Max(0, (4*at-16)*255/219) + if got := float64(ycc.Y[ycc.YOffset(x, y)]); math.Abs(got-want) > 6 { + t.Errorf("cell %d: luma %g, want about %g, the frame at %g s", cell, got, want, at) + } + } +} diff --git a/internal/mediasample/hwdecode.go b/internal/mediasample/hwdecode.go index 89897798d2..2aa565b3db 100644 --- a/internal/mediasample/hwdecode.go +++ b/internal/mediasample/hwdecode.go @@ -8,15 +8,32 @@ import ( // Hardware decode backends a hardware attempt can use, named as // playback.ResolveHWAccelWithFFmpegContext resolves the configured -// accelerator. NVENC is not among them: nothing here decodes on CUDA. +// accelerator. NVENC decodes on CUDA, and only Sheets outputs use it (see +// SupportsHardwareSheets): the stats chains intro fingerprints are cached +// from, and the images of chapter thumbnails, stay as they were. const ( hwAccelQSV = "qsv" hwAccelVAAPI = "vaapi" hwAccelVideoToolbox = "videotoolbox" + hwAccelNVENC = "nvenc" ) -// hwaccelOption selects ffmpeg's hardware decoder. -const hwaccelOption = "-hwaccel" +// cudaDeviceAlias names the CUDA device a CUDA attempt declares, which its +// decoder and filters share. +const cudaDeviceAlias = "cu" + +// hwaccelOption selects ffmpeg's hardware decoder, and +// hwaccelOutputFormatOption the frames it decodes into. +// initHWDeviceOption declares a hardware device, and filterHWDeviceOption +// names the one filters use. cudaHWAccel is the CUDA decoder and surface +// format. +const ( + hwaccelOption = "-hwaccel" + hwaccelOutputFormatOption = "-hwaccel_output_format" + initHWDeviceOption = "-init_hw_device" + filterHWDeviceOption = "-filter_hw_device" + cudaHWAccel = "cuda" +) // hwDownloadFilter copies a decoded VAAPI surface to system memory as 8-bit // 4:2:0. @@ -40,10 +57,28 @@ func SupportsHardwareDecode(accel string) bool { return accel == hwAccelQSV || accel == hwAccelVAAPI || accel == hwAccelVideoToolbox } +// SupportsHardwareSheets reports whether hardware attempts of a Sheets +// request can decode on the resolved accelerator accel: every accelerator +// SupportsHardwareDecode names, and NVENC's CUDA decoder. +func SupportsHardwareSheets(accel string) bool { + return SupportsHardwareDecode(accel) || accel == hwAccelNVENC +} + +// supportsHardware reports whether a hardware attempt of req can decode on +// accel. +func (r Request) supportsHardware(accel string) bool { + if r.Sheets != nil { + return SupportsHardwareSheets(accel) + } + return SupportsHardwareDecode(accel) +} + // hardwareDecodeArgs returns the input options that decode on hw, which go // before the input's -ss and -i. QSV decodes through its VAAPI parent device, // so both leave VAAPI surfaces that filters must download (hwDownloadFilter) -// or process on the GPU first. VideoToolbox decodes into system-memory frames +// or process on the GPU first. NVENC decodes into CUDA surfaces on the CUDA +// device hw.Device names (an index, or empty for the default), declared as +// the filters' device too. VideoToolbox decodes into system-memory frames // that software filters apply to directly, unless vtSurfaces asks for its // surfaces, which filters must download (videoToolboxDownloadFilter). // @@ -58,9 +93,9 @@ func hardwareDecodeArgs(hw hardwareDecode, vtSurfaces bool) ([]string, error) { } args := tonemap.QSVInitDeviceArgs(hw.Device) return append(args, - "-filter_hw_device", "va", + filterHWDeviceOption, "va", hwaccelOption, hwAccelVAAPI, - "-hwaccel_output_format", "vaapi", + hwaccelOutputFormatOption, "vaapi", ), nil case hwAccelVAAPI: if hw.Device == "" { @@ -68,16 +103,27 @@ func hardwareDecodeArgs(hw hardwareDecode, vtSurfaces bool) ([]string, error) { } args := tonemap.VAAPIInitDeviceArgs("hw", hw.Device) return append(args, - "-filter_hw_device", "hw", + filterHWDeviceOption, "hw", hwaccelOption, hwAccelVAAPI, - "-hwaccel_output_format", "vaapi", + hwaccelOutputFormatOption, "vaapi", ), nil case hwAccelVideoToolbox: args := []string{hwaccelOption, hwAccelVideoToolbox} if vtSurfaces { - args = append(args, "-hwaccel_output_format", "videotoolbox_vld") + args = append(args, hwaccelOutputFormatOption, "videotoolbox_vld") } return args, nil + case hwAccelNVENC: + device := cudaHWAccel + "=" + cudaDeviceAlias + if hw.Device != "" { + device += ":" + hw.Device + } + return []string{ + initHWDeviceOption, device, + filterHWDeviceOption, cudaDeviceAlias, + hwaccelOption, cudaHWAccel, + hwaccelOutputFormatOption, cudaHWAccel, + }, nil default: return nil, fmt.Errorf("hardware decode does not support %q", hw.Accel) } @@ -88,3 +134,9 @@ func hardwareDecodeArgs(hw hardwareDecode, vtSurfaces bool) ([]string, error) { func framesInSystemMemory(accel string) bool { return accel == hwAccelVideoToolbox } + +// usesRenderDevice reports whether accel opens a DRI render device, which a +// hardware attempt must reserve before it starts. +func usesRenderDevice(accel string) bool { + return accel == hwAccelQSV || accel == hwAccelVAAPI +} diff --git a/internal/mediasample/images.go b/internal/mediasample/images.go index 8743f879b8..99ff5b253e 100644 --- a/internal/mediasample/images.go +++ b/internal/mediasample/images.go @@ -28,6 +28,23 @@ type ToneMap struct { // AllowSoftware lets an attempt tone map in software. Without it such an // attempt fails as unsupported before ffmpeg starts. AllowSoftware bool `json:"allow_software,omitempty"` + // Source is the base signal tonemap.ResolveSource classified the input's + // video as. Only Sheets take it. With it, every attempt, on a GPU too, + // converts in software with the chain playback converts that signal with + // in software (tonemap.SoftwareFilter): a Dolby Vision base only through + // tonemapx, which applies its RPU, an HLG source with the HLG transfer. It + // therefore needs AllowSoftware, and an attempt the binary has no filter + // for fails as unsupported before ffmpeg starts. Empty keeps the + // best-effort chains below, for sources whose catalog facts cannot + // classify them. + Source tonemap.SourceKind `json:"source,omitempty"` +} + +func (t ToneMap) validate() error { + if t.Source != "" && !tonemap.ValidSourceKind(t.Source) { + return fmt.Errorf("tone-map source %q is not a known base signal", t.Source) + } + return nil } // Image is one decoded image. @@ -79,13 +96,28 @@ func (r Request) imageToneMap() *ToneMap { // toneMapResolver picks the software tone-map chain at most once per run, when // the first attempt that needs it starts, so a hardware attempt that succeeds // never loads capabilities and a failed choice is not retried by the next -// attempt. +// attempt. A run whose request names its source's base signal picks that +// signal's chain instead (see selectSourceToneMap). type toneMapResolver struct { done bool filter string failure *AttemptError } +// source returns the software chain that converts the base signal kind, +// chosen once per run. +func (t *toneMapResolver) source(ctx context.Context, r Runner, kind tonemap.SourceKind) (string, *AttemptError) { + if !t.done { + t.filter, t.failure = r.selectSourceToneMap(ctx, kind) + t.done = true + } + if t.failure != nil { + failure := *t.failure + return "", &failure + } + return t.filter, nil +} + func (t *toneMapResolver) resolve(ctx context.Context, r Runner) (string, *AttemptError) { if !t.done { t.filter, t.failure = r.selectSoftwareToneMap(ctx) @@ -121,6 +153,34 @@ func (r Runner) selectSoftwareToneMap(ctx context.Context) (string, *AttemptErro return "", &AttemptError{Reason: ReasonUnsupported, Err: errors.New("configured FFmpeg lacks the required tonemapx or tonemap filter")} } +// selectSourceToneMap picks the chain playback converts the base signal kind +// with in software (tonemap.SoftwareFilter), on a filter the binary lists: +// Jellyfin's tonemapx, the only one that applies a Dolby Vision RPU, else the +// standard tonemap filter's Hable curve for the standard signals. A base that +// is already SDR needs no tone-map filter. Every chain needs zscale and +// sidedata. +func (r Runner) selectSourceToneMap(ctx context.Context, kind tonemap.SourceKind) (string, *AttemptError) { + load := r.Capabilities + if load == nil { + load = LoadCapabilities + } + caps, err := load(ctx, r.FFmpegPath) + if err != nil { + return "", &AttemptError{Reason: ReasonCapabilities, Err: err} + } + switch { + case !caps.HasFilter("zscale") || !caps.HasFilter("sidedata"): + return "", &AttemptError{Reason: ReasonUnsupported, Err: errors.New("configured FFmpeg lacks the required zscale or sidedata filter")} + case tonemap.IsSDRSource(kind): + return tonemap.SoftwareFilter(kind, ""), nil + case caps.HasFilter(tonemap.SoftwareFilterBT2390): + return tonemap.SoftwareFilter(kind, tonemap.SoftwareFilterBT2390), nil + case kind != tonemap.SourceDolbyVision && caps.HasFilter(tonemap.SoftwareFilterHable): + return tonemap.SoftwareFilter(kind, tonemap.SoftwareFilterHable), nil + } + return "", &AttemptError{Reason: ReasonUnsupported, Err: fmt.Errorf("configured FFmpeg has no tone-map filter that converts a %s source", kind)} +} + // softwareToneMapFilter settles, before ffmpeg starts, the software tone-map // chain an image or sheet attempt needs, or "" when it needs none. func (r Runner) softwareToneMapFilter(ctx context.Context, req Request, attempt Attempt, toneMap *toneMapResolver) (string, *AttemptError) { @@ -162,6 +222,9 @@ func buildImageArgs(req Request, attempt Attempt, hw hardwareDecode, softwareTon args = append(args, "-threads", strconv.Itoa(req.Threads)) } if attempt.Hardware { + if !SupportsHardwareDecode(hw.Accel) { + return nil, fmt.Errorf("hardware decode does not support %q", hw.Accel) + } decode, err := hardwareDecodeArgs(hw, false) if err != nil { return nil, err diff --git a/internal/mediasample/reason.go b/internal/mediasample/reason.go index 7d8f2fd98c..23182559f2 100644 --- a/internal/mediasample/reason.go +++ b/internal/mediasample/reason.go @@ -133,6 +133,10 @@ var ( "unknown decoder", "decoder not found", "requested output format", + // A hardware decoder that cannot decode the stream: ffmpeg falls + // back to software frames, which the GPU chain then refuses. + "failed setup for format", + "doesn't support hardware accelerated", } ) diff --git a/internal/mediasample/request.go b/internal/mediasample/request.go index 95790c79f5..5e46644df5 100644 --- a/internal/mediasample/request.go +++ b/internal/mediasample/request.go @@ -198,6 +198,9 @@ func (r Request) Validate() error { if err := r.Images.validate(); err != nil { return err } + if r.Images.ToneMap != nil && r.Images.ToneMap.Source != "" { + return errors.New("images keep their tone-map chains and take no source signal") + } } if r.Sheets != nil { if r.Samples == nil || r.Stats != nil { @@ -206,6 +209,16 @@ func (r Request) Validate() error { if err := r.Sheets.validate(); err != nil { return err } + run := *r.Sheets + if run.UseInputAspect { + // The probe sets the tile height, and samples checks the run + // again with it before any tile is decoded. Until then, only a + // run too large at any height is refused. + run.TileHeight = minSheetTile + } + if err := run.validateRun(len(r.Samples.Seconds)); err != nil { + return err + } } if !r.hasOutput() { return errors.New("request has no output") diff --git a/internal/mediasample/run.go b/internal/mediasample/run.go index 4cef59b749..47216be810 100644 --- a/internal/mediasample/run.go +++ b/internal/mediasample/run.go @@ -34,7 +34,7 @@ type Runner struct { // starts ffmpeg. Exec ExecFunc // Capabilities returns the binary's inventory when a run needs it, which - // only software tone mapping of images does so far. Nil means + // only software tone mapping of images and sheets does so far. Nil means // LoadCapabilities; tests replace it. Capabilities func(ctx context.Context, ffmpegPath string) (Capabilities, error) // Fallback reports whether a run moves on to its next attempt after @@ -140,7 +140,7 @@ func (r Runner) runAttempt(ctx context.Context, req Request, attempt Attempt, to // A hardware attempt reserves its device, and an image or sheet attempt // settles its filters, before its timeout starts, so neither eats into // the decode's time. - hw, release, failure := r.reserveHardware(attempt) + hw, release, failure := r.reserveHardware(req, attempt) if failure != nil { failure.Decoder = decoder return Result{}, failure @@ -181,19 +181,23 @@ func (r Runner) runAttempt(ctx context.Context, req Request, attempt Attempt, to } // reserveHardware returns the hardware attempt decodes on. A hardware -// attempt reserves one render device through playback.AcquireHWDevice, -// falling back to playback.PickRenderDevice, until release is called. A -// multi-device HWDevice therefore resolves to one device per attempt, and the -// reservation spans only this attempt. A hardware attempt the host cannot -// run, such as VAAPI without a render device, fails as unsupported without +// attempt reserves one device through playback.AcquireHWDevice, falling back +// to playback.PickRenderDevice for an accelerator that opens a render device, +// until release is called. A multi-device HWDevice therefore resolves to one +// device per attempt, and the reservation spans only this attempt. A hardware +// attempt the host cannot run, such as VAAPI without a render device, or req's +// outputs cannot run on, such as stats on CUDA, fails as unsupported without // starting ffmpeg: that is no fault of the request or the file. -func (r Runner) reserveHardware(attempt Attempt) (hardwareDecode, func(), *AttemptError) { +func (r Runner) reserveHardware(req Request, attempt Attempt) (hardwareDecode, func(), *AttemptError) { hw := hardwareDecode{Accel: r.HWAccel} if !attempt.Hardware { return hw, func() {}, nil } + if !req.supportsHardware(r.HWAccel) { + return hardwareDecode{}, nil, &AttemptError{Reason: ReasonUnsupported, Err: fmt.Errorf("hardware decode does not support %q for this output", r.HWAccel)} + } device, release := playback.AcquireHWDevice(r.HWDevice, r.HWAccel) - if device == "" { + if device == "" && usesRenderDevice(r.HWAccel) { device = playback.PickRenderDevice("") } hw.Device = device @@ -244,7 +248,7 @@ func (a attemptRun) samples(req Request) (Result, *AttemptError) { } if req.Sheets != nil && req.Sheets.UseInputAspect { out := req.Sheets.forDisplayAspect(header.info.displayAspect()) - if err := out.validate(); err != nil { + if err := errors.Join(out.validate(), out.validateRun(len(req.Samples.Seconds))); err != nil { return Result{}, &AttemptError{Decoder: a.decoder, Reason: ReasonArgs, Err: err} } req.Sheets = &out @@ -367,8 +371,9 @@ func watchDuplicatePOC(seen *bool) func(string) { } // sheets runs a Sheets request for the sample times, reading req's list -// (with inpoints offset by inputStart) or window, and tiles the frames. -// logHandlers also read ffmpeg's log. +// (with inpoints offset by inputStart) or window, repairs a list's weak cells +// (see sheet_repair.go), and tiles the frames. logHandlers also read +// ffmpeg's log. func (a attemptRun) sheets(req Request, times []float64, inputStart float64, logHandlers ...func(string)) (Result, *AttemptError) { var packetTimingPath string if req.Window != nil { @@ -379,7 +384,7 @@ func (a attemptRun) sheets(req Request, times []float64, inputStart float64, log defer func() { _ = os.RemoveAll(dir) }() packetTimingPath = filepath.Join(dir, "packets.framecrc") } - args, stdinBytes, err := buildSheetsArgs(req, a.attempt, a.hw, inputStart, a.sheetsGraph, packetTimingPath) + args, stdinBytes, err := buildSheetsArgs(req, a.attempt, a.hw, inputStart, a.sheetsGraph, packetTimingPath, sampledSheetsList) if err != nil { return Result{}, &AttemptError{Decoder: a.decoder, Reason: ReasonArgs, Err: err} } @@ -387,7 +392,8 @@ func (a attemptRun) sheets(req Request, times []float64, inputStart float64, log if req.Window != nil { offset = req.Window.StartSeconds } - assembler := newSheetAssembler(*req.Sheets, times, req.Samples != nil, offset) + assembler := newSheetAssembler(*req.Sheets, times, req.Samples != nil, offset, sampledSheetsList.span) + assembler.inputStart = inputStart if failure := a.exec(req, args, stdinBytes, assembler, append(logHandlers, assembler.line)...); failure != nil { return Result{}, failure } @@ -396,6 +402,16 @@ func (a attemptRun) sheets(req Request, times []float64, inputStart float64, log return Result{}, &AttemptError{Decoder: a.decoder, Reason: ReasonOutput, Err: fmt.Errorf("read packet timing: %w", err)} } } + if failure := assembler.close(); failure != nil { + failure.Decoder = a.decoder + return Result{}, failure + } + if req.Samples != nil { + a.repairSheets(req, assembler, inputStart) + if err := a.ctx.Err(); err != nil { + return Result{}, &AttemptError{Decoder: a.decoder, Reason: ReasonCanceled, Err: err} + } + } sheets, frames, failure := assembler.finish() if failure != nil { failure.Decoder = a.decoder diff --git a/internal/mediasample/sheet_repair.go b/internal/mediasample/sheet_repair.go new file mode 100644 index 0000000000..b7ddd7d9dc --- /dev/null +++ b/internal/mediasample/sheet_repair.go @@ -0,0 +1,210 @@ +package mediasample + +import ( + "context" + "math" + "time" +) + +// A sampled list gives each sample the keyframe at or before its time. Two +// kinds of cell come out of that run worse than the input allows, and a +// repair reads their samples again, after the run and before the sheets are +// encoded: +// +// - Missing cells decoded no frame. The demuxer could not seek to a +// keyframe before the sample, as in a container that flags no keyframes +// (VC-1 in Matroska, whose I-frames only the decoder recognizes), or the +// HEVC decoder dropped the keyframe as a duplicate picture order count +// (see watchDuplicatePOC). The repair lists them again, keyframes only, +// each entry lasting until the next sample: the decoder starts afresh +// and finds the keyframe before the sample, or failing that the first +// one after it. +// - Repeated cells show the same moment as the cell before, because no +// keyframe lies between the two samples: a long GOP. Two cells whose +// frames lie within repeatShare of the time between their samples count, +// since an open-GOP keyframe can bring a leading picture just before it +// that one sample keeps and the next does not. The repair lists them +// again decoding every reference frame (-skip_frame noref) from the +// keyframe on, and keeps the last frame at or before the sample's time, +// so the cell shows its own moment instead of a repeat. +// +// Each repaired sample keeps its nearest frame: the last at or before its +// time, else the first after it. A repair that fails or runs out of time +// keeps the frames it already placed, which ffmpeg wrote and logged in +// order, and changes nothing else; the run's own result stands. Repairs +// share one time box, so a long-GOP 4K file decoded in software adds at most +// repairBaseSeconds plus repairSecondsPerSample per sample to its attempt. +const ( + repairBaseSeconds = 30 + repairSecondsPerSample = 0.25 + // repeatShare is how close, as a share of the time between their + // samples, two cells' frames lie when they show the same moment. + repeatShare = 0.1 + // exactSkipFrame decodes the reference frames a repeated cell's moment + // may lie on, skipping the frames nothing else is predicted from. + exactSkipFrame = "noref" + // keyframeSkipFrame decodes only keyframes, as every list does. + keyframeSkipFrame = "nokey" +) + +// sheetsList is how a Sheets list reads its samples: each entry lasts span +// seconds, and the decoder skips frames as -skip_frame:v skip says. +type sheetsList struct { + span float64 + skip string +} + +// sampledSheetsList is how the first run of a sampled list reads. +var sampledSheetsList = sheetsList{span: sheetSampleSpanSeconds, skip: keyframeSkipFrame} + +// sheetCandidate is the frame a repair keeps for one sample so far. +type sheetCandidate struct { + frame []byte + source float64 + // before marks a frame at or before the sample's time. + before bool +} + +// offer keeps frame as cell's candidate when it lies nearer the sample's +// time than the candidate so far: a frame at or before the time replaces any +// earlier one, since frames arrive in presentation order, and a frame after +// it only fills an empty candidate. +func (a *sheetAssembler) offer(cell int, source float64, frame []byte) { + candidate := &a.candidates[cell] + before := !math.IsNaN(source) && source <= a.times[cell]+sampleMatchSeconds + if candidate.frame != nil && !before { + a.recycle(frame) + return + } + a.recycle(candidate.frame) + *candidate = sheetCandidate{frame: frame, source: source, before: before} +} + +// repairs returns the cells of a closed sampled run worth a second read: +// those without a frame, and those whose frame is the keyframe the cell +// before already shows. A run that decoded nothing is repaired too: a +// container that flags no keyframes can miss every short list entry. +func (a *sheetAssembler) repairs() (missing, repeated []int) { + a.mu.Lock() + defer a.mu.Unlock() + if !a.sampled { + return nil, nil + } + for cell := range a.times { + switch { + case !a.placed(cell): + missing = append(missing, cell) + case cell > 0 && a.placed(cell-1) && math.Abs(a.sources[cell]-a.sources[cell-1]) < repeatShare*(a.times[cell]-a.times[cell-1]): + repeated = append(repeated, cell) + } + } + return missing, repeated +} + +// placed reports whether cell holds a frame of its own. +func (a *sheetAssembler) placed(cell int) bool { + canvas := a.open[cell/a.perSheet] + return canvas != nil && canvas.set[cell%a.perSheet] +} + +// adopt places the candidates a repair over cells collected. A missing cell +// takes any candidate; a repeated cell only one at or before its time and +// later than the frame it shows. +func (a *sheetAssembler) adopt(cells []int, repair *sheetAssembler) { + repair.mu.Lock() + defer repair.mu.Unlock() + a.mu.Lock() + defer a.mu.Unlock() + for i, cell := range cells { + candidate := repair.candidates[i] + if candidate.frame == nil { + continue + } + missing := !a.placed(cell) + if !missing && (!candidate.before || !(candidate.source > a.sources[cell]+sampleMatchSeconds)) { + continue + } + canvas := a.canvas(cell / a.perSheet) + slot := cell % a.perSheet + canvas.write(slot, candidate.frame) + canvas.set[slot] = true + a.sources[cell] = candidate.source + if missing { + a.decoded++ + } + if !a.refinedCells[cell] { + a.refinedCells[cell] = true + a.refined++ + } + } +} + +// repairSheets runs the repairs a closed sampled run needs, within their +// time box and the attempt's. +func (a attemptRun) repairSheets(req Request, run *sheetAssembler, inputStart float64) { + missing, repeated := run.repairs() + if len(missing) == 0 && len(repeated) == 0 { + return + } + budget := time.Duration((repairBaseSeconds + repairSecondsPerSample*float64(len(run.times))) * float64(time.Second)) + ctx, cancel := context.WithTimeout(a.attemptCtx, budget) + defer cancel() + repair := a + repair.attemptCtx = ctx + if len(missing) > 0 { + repair.repair(req, run, missing, inputStart, sheetsList{span: missingRepairSpan(run.times), skip: keyframeSkipFrame}) + // A recovered cell can show the keyframe of the cell after it. + _, repeated = run.repairs() + } + if len(repeated) > 0 && ctx.Err() == nil { + repair.repair(req, run, repeated, inputStart, sheetsList{span: sheetSampleSpanSeconds, skip: exactSkipFrame}) + } +} + +// repair reads the samples of cells again as list says and adopts what it +// finds. A read that ffmpeg finishes must line up with its log, as a run's +// does; one cut short keeps the frames it placed, each written and logged in +// order before the end. +func (a attemptRun) repair(req Request, run *sheetAssembler, cells []int, inputStart float64, list sheetsList) { + times := make([]float64, len(cells)) + for i, cell := range cells { + times[i] = run.times[cell] + } + repairReq := req + repairReq.Samples = &Samples{Seconds: times} + args, stdinBytes, err := buildSheetsArgs(repairReq, a.attempt, a.hw, inputStart, a.sheetsGraph, "", list) + if err != nil { + return + } + collector := newSheetAssembler(*req.Sheets, times, true, 0, list.span) + collector.inputStart = inputStart + collector.candidates = make([]sheetCandidate, len(times)) + finished := a.exec(req, args, stdinBytes, collector, collector.line) == nil + if (finished && collector.close() != nil) || (!finished && collector.broken()) { + return + } + run.adopt(cells, collector) +} + +// broken reports whether the frames seen so far stopped lining up with the +// log. +func (a *sheetAssembler) broken() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.err != nil +} + +// missingRepairSpan is how long a missing sample's repair entry lasts: until +// the next sample, the shortest gap between the run's samples, so a frame it +// finds after the sample's time stays in the sample's own stretch. A run of +// one sample takes a second. +func missingRepairSpan(times []float64) float64 { + span := math.Inf(1) + for i := 1; i < len(times); i++ { + span = math.Min(span, times[i]-times[i-1]) + } + if math.IsInf(span, 1) { + return 1 + } + return math.Max(span, sheetSampleSpanSeconds) +} diff --git a/internal/mediasample/sheet_repair_test.go b/internal/mediasample/sheet_repair_test.go new file mode 100644 index 0000000000..d380ff9fc2 --- /dev/null +++ b/internal/mediasample/sheet_repair_test.go @@ -0,0 +1,259 @@ +package mediasample + +import ( + "context" + "errors" + "io" + "math" + "slices" + "strings" + "testing" +) + +// at is a frame tagged for sample, logged at pts in the list's timeline. +func at(luma byte, sample float64, pts string) fakeSheetFrame { + return fakeSheetFrame{luma: luma, sample: &sample, pts: pts} +} + +// runSequence answers the probe and then each list run with the next fake, +// returning err after the run whose index fails names. +func runSequence(runs []*fakeSheets, fail map[int]error) (ExecFunc, *[][]string) { + var calls [][]string + next := 0 + return func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + calls = append(calls, args) + if !slices.Contains(args, "concat") { + return runs[0].exec(ctx, name, args, stdin, stdout, stderr) + } + run := next + next++ + if err := runs[run].exec(ctx, name, args, stdin, stdout, stderr); err != nil { + return err + } + return fail[run] + }, &calls +} + +// TestRunSheetsRepairsMissingSamples decodes two of four samples through the +// list, as a container that flags no keyframes leaves a seek with none +// before the sample. The repair lists the other two again, each entry +// lasting until the next sample, and keeps the keyframe it finds after each +// sample's time. +func TestRunSheetsRepairsMissingSamples(t *testing.T) { + list := &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{tagged(10, 5), tagged(30, 25)}} + repair := &fakeSheets{frames: []fakeSheetFrame{at(20, 15, "0.4"), at(40, 35, "10.2")}} + exec, calls := runSequence([]*fakeSheets{list, repair}, nil) + result, err := Runner{Exec: exec}.Run(t.Context(), sheetsRequest(secondsFrom(5, 10, 4), 2, 2)) + if err != nil { + t.Fatal(err) + } + if got := sheetLumas(t, result, 2, 2); !near(got[0], []int{10, 20, 30, 40}) { + t.Fatalf("cells %v, want every sample", got) + } + if result.SheetFrames != (SheetFrames{Decoded: 4, Refined: 2}) { + t.Fatalf("frames %+v, want 4 decoded, 2 of them repaired", result.SheetFrames) + } + if len(*calls) != 3 || !strings.Contains(strings.Join((*calls)[2], " "), "-skip_frame:v nokey") { + t.Fatalf("runs %q, want a probe, the list, and a keyframes-only repair", *calls) + } + want := "ffconcat version 1.0\n" + + "file 'file:/media/a.mkv'\nfile_packet_meta sample 15\ninpoint 15\noutpoint 25\n" + + "file 'file:/media/a.mkv'\nfile_packet_meta sample 35\ninpoint 35\noutpoint 45\n" + if len(repair.lists) != 1 || repair.lists[0] != want { + t.Fatalf("repair list %q, want %q", repair.lists, want) + } +} + +// TestRunSheetsRepairsRepeatedKeyframes gives the first two samples the same +// keyframe, at 2 s, as a GOP longer than the interval does. The repair lists +// the second sample again decoding reference frames, and the cell takes the +// last frame at or before its time rather than the one after it. +func TestRunSheetsRepairsRepeatedKeyframes(t *testing.T) { + // A frame for sample k of a list whose entries last 0.5 s has the input + // time pts - 0.5k + sample. + list := &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{at(10, 5, "-3"), at(11, 15, "-12.5"), at(30, 25, "-4")}} + repair := &fakeSheets{frames: []fakeSheetFrame{at(11, 15, "-13"), at(20, 15, "-1"), at(21, 15, "0.2")}} + exec, calls := runSequence([]*fakeSheets{list, repair}, nil) + result, err := Runner{Exec: exec}.Run(t.Context(), sheetsRequest(secondsFrom(5, 10, 3), 3, 1)) + if err != nil { + t.Fatal(err) + } + if got := sheetLumas(t, result, 3, 1); !near(got[0], []int{10, 20, 30}) { + t.Fatalf("cells %v, want the second sample's own moment", got) + } + if result.SheetFrames != (SheetFrames{Decoded: 3, Refined: 1}) { + t.Fatalf("frames %+v, want 3 decoded, 1 of them repaired", result.SheetFrames) + } + if len(*calls) != 3 || !strings.Contains(strings.Join((*calls)[2], " "), "-skip_frame:v noref") || strings.Count(repair.lists[0], "file ") != 1 { + t.Fatalf("runs %q, lists %q; want one reference-frame repair of the second sample", *calls, repair.lists) + } +} + +// TestRunSheetsRepairFindsTheInputStart reads each list input's start time +// from its header: ffmpeg shifts every frame by it, and without it the +// repair would take frames just before the sample's time for frames after it. +func TestRunSheetsRepairFindsTheInputStart(t *testing.T) { + // The input time of a frame for sample k is pts - 0.3 - 0.5k + sample. + list := &fakeSheets{format: "matroska,webm", listStart: -0.3, frames: []fakeSheetFrame{at(10, 5, "-2.7"), at(11, 15, "-12.2"), at(30, 25, "-3.7")}} + repair := &fakeSheets{listStart: -0.3, frames: []fakeSheetFrame{at(20, 15, "0.25"), at(21, 15, "0.29"), at(22, 15, "0.5")}} + exec, _ := runSequence([]*fakeSheets{list, repair}, nil) + result, err := Runner{Exec: exec}.Run(t.Context(), sheetsRequest(secondsFrom(5, 10, 3), 3, 1)) + if err != nil { + t.Fatal(err) + } + if got := sheetLumas(t, result, 3, 1); !near(got[0], []int{10, 21, 30}) { + t.Fatalf("cells %v, want the last frame before 15 s", got) + } +} + +// TestRunSheetsKeepsWhatAnInterruptedRepairPlaced ends the repair with an +// error after its first frame, as its time box would. The frame it placed +// stands, the other sample shows its neighbor, and the run succeeds. +func TestRunSheetsKeepsWhatAnInterruptedRepairPlaced(t *testing.T) { + list := &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{tagged(10, 5), tagged(30, 25), tagged(50, 45), tagged(60, 55), tagged(70, 65), tagged(80, 75), tagged(90, 85), tagged(100, 95)}} + repair := &fakeSheets{frames: []fakeSheetFrame{at(20, 15, "0.4")}} + exec, _ := runSequence([]*fakeSheets{list, repair}, map[int]error{1: context.DeadlineExceeded}) + result, err := Runner{Exec: exec}.Run(t.Context(), sheetsRequest(secondsFrom(5, 10, 10), 5, 2)) + if err != nil { + t.Fatal(err) + } + if got := sheetLumas(t, result, 5, 2); !near(got[0], []int{10, 20, 30, 30, 50, 60, 70, 80, 90, 100}) { + t.Fatalf("cells %v", got) + } + if result.SheetFrames != (SheetFrames{Decoded: 9, Filled: 1, Refined: 1}) { + t.Fatalf("frames %+v, want 9 decoded with 1 repaired and 1 filled", result.SheetFrames) + } +} + +// TestRunSheetsIgnoresABrokenRepair changes nothing when the repair's frames +// do not line up with its log. +func TestRunSheetsIgnoresABrokenRepair(t *testing.T) { + seconds := secondsFrom(5, 10, 11) + var frames []fakeSheetFrame + for i, at := range seconds { + if i != 1 { + frames = append(frames, tagged(byte(10*(i+1)), at)) + } + } + list := &fakeSheets{format: "matroska,webm", frames: frames} + repair := &fakeSheets{frames: []fakeSheetFrame{at(200, 15, "0.4")}, extraStdout: rawFrame(1)} + exec, _ := runSequence([]*fakeSheets{list, repair}, nil) + result, err := Runner{Exec: exec}.Run(t.Context(), sheetsRequest(seconds, 6, 2)) + if err != nil { + t.Fatal(err) + } + if got := sheetLumas(t, result, 6, 2); got[0][1] > 13 { + t.Fatalf("cells %v, want the missing sample filled from its neighbor", got) + } + if result.SheetFrames.Refined != 0 || result.SheetFrames.Filled != 1 { + t.Fatalf("frames %+v, want the broken repair ignored", result.SheetFrames) + } +} + +// TestRunSheetsCanceledDuringRepairFails ends the run as canceled when the +// caller cancels while a repair runs. +func TestRunSheetsCanceledDuringRepairFails(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + list := &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{tagged(10, 5)}} + inner, _ := runSequence([]*fakeSheets{list, {}}, nil) + runs := 0 + exec := func(ctx context.Context, name string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { + if slices.Contains(args, "concat") { + runs++ + if runs == 2 { + cancel() + return ctx.Err() + } + } + return inner(ctx, name, args, stdin, stdout, stderr) + } + _, err := Runner{Exec: exec}.Run(ctx, sheetsRequest([]float64{5, 15}, 2, 1)) + if failure, ok := errors.AsType[*Error](err); !ok || failure.Reason != ReasonCanceled { + t.Fatalf("error %v, want canceled", err) + } +} + +// TestRunSheetsAcceptsAGraphReinit restarts the filter's frame counter at +// zero halfway, as a hardware decoder that renews its surfaces makes ffmpeg +// rebuild the graph. The frames still line up with the log. +func TestRunSheetsAcceptsAGraphReinit(t *testing.T) { + fake := &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{tagged(10, 5), tagged(20, 15), tagged(30, 25), tagged(40, 35)}, + editLog: func(log []string) []string { + log[4] = strings.Replace(log[4], "frame:2", "frame:0", 1) + log[6] = strings.Replace(log[6], "frame:3", "frame:1", 1) + return log + }} + result, err := Runner{Exec: fake.exec}.Run(t.Context(), sheetsRequest(secondsFrom(5, 10, 4), 2, 2)) + if err != nil { + t.Fatal(err) + } + if got := sheetLumas(t, result, 2, 2); !near(got[0], []int{10, 20, 30, 40}) { + t.Fatalf("cells %v", got) + } +} + +func TestMissingRepairSpan(t *testing.T) { + for _, tt := range []struct { + times []float64 + want float64 + }{ + {[]float64{5}, 1}, + {[]float64{5, 15, 25, 34}, 9}, + {[]float64{5, 5.2}, sheetSampleSpanSeconds}, + } { + if got := missingRepairSpan(tt.times); got != tt.want { + t.Errorf("missingRepairSpan(%v) = %g, want %g", tt.times, got, tt.want) + } + } +} + +// TestRunSheetsRepairsAListThatDecodedNothing repairs a list whose short +// entries all missed a keyframe, as one over a container that flags none can. +func TestRunSheetsRepairsAListThatDecodedNothing(t *testing.T) { + list := &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{{luma: 9, pts: "0"}}} + repair := &fakeSheets{frames: []fakeSheetFrame{at(10, 5, "0.2"), at(20, 15, "10.2"), at(30, 25, "20.2"), at(40, 35, "30.2")}} + exec, _ := runSequence([]*fakeSheets{list, repair}, nil) + result, err := Runner{Exec: exec}.Run(t.Context(), sheetsRequest(secondsFrom(5, 10, 4), 2, 2)) + if err != nil { + t.Fatal(err) + } + if got := sheetLumas(t, result, 2, 2); !near(got[0], []int{10, 20, 30, 40}) || result.SheetFrames != (SheetFrames{Decoded: 4, Refined: 4}) { + t.Fatalf("cells %v, frames %+v; want every sample from the repair", got, result.SheetFrames) + } +} + +// TestRunSheetsRepairsARecoveredRepeat recovers the second sample's missing +// cell with the keyframe the first cell shows, then decodes it at its own +// time. The cell counts as repaired once. +func TestRunSheetsRepairsARecoveredRepeat(t *testing.T) { + list := &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{at(10, 5, "-3"), at(30, 25, "-4")}} + missing := &fakeSheets{frames: []fakeSheetFrame{at(11, 15, "-13")}} + exact := &fakeSheets{frames: []fakeSheetFrame{at(11, 15, "-13"), at(20, 15, "-1")}} + exec, calls := runSequence([]*fakeSheets{list, missing, exact}, nil) + result, err := Runner{Exec: exec}.Run(t.Context(), sheetsRequest(secondsFrom(5, 10, 3), 3, 1)) + if err != nil { + t.Fatal(err) + } + if got := sheetLumas(t, result, 3, 1); !near(got[0], []int{10, 20, 30}) || result.SheetFrames != (SheetFrames{Decoded: 3, Refined: 1}) { + t.Fatalf("cells %v, frames %+v; want the second sample's own moment, repaired once", got, result.SheetFrames) + } + if len(*calls) != 4 || !strings.Contains(strings.Join((*calls)[3], " "), "-skip_frame:v noref") { + t.Fatalf("runs %q, want a probe, the list, a keyframe repair, and a reference-frame repair", *calls) + } +} + +// TestSourceSecondsFollowsAClampedInpoint places a frame back in the input +// when the container starts before zero and an early sample's inpoint is +// clamped there: the same keyframe gives both samples the same time. +func TestSourceSecondsFollowsAClampedInpoint(t *testing.T) { + a := newSheetAssembler(SheetsOutput{TileWidth: 16, TileHeight: 16, Columns: 1, Rows: 1, Quality: 80}, []float64{0, 0.1}, true, 0, 0.5) + a.inputStart = -0.042 + // The keyframe at container time 0 (input time 0.042) opens both entries: + // the first at its clamped inpoint 0, the second 0.058 before its inpoint. + first := a.sourceSeconds(sheetFrameMeta{seconds: 0, hasTime: true}, 0) + second := a.sourceSeconds(sheetFrameMeta{seconds: 0.5 - 0.058, hasTime: true}, 1) + if math.Abs(first-0.042) > 1e-9 || math.Abs(second-0.042) > 1e-9 { + t.Fatalf("source times %g and %g, want both 0.042", first, second) + } +} diff --git a/internal/mediasample/sheets.go b/internal/mediasample/sheets.go index 5764caa8a0..ca30fe1aaa 100644 --- a/internal/mediasample/sheets.go +++ b/internal/mediasample/sheets.go @@ -6,6 +6,8 @@ import ( "fmt" "math" "strings" + + "github.com/Silo-Server/silo-server/internal/tonemap" ) // SheetsOutput asks for the sampled frames tiled into JPEG sprite sheets, @@ -54,6 +56,10 @@ type SheetFrames struct { Decoded int `json:"decoded"` // Filled cells had no frame of their own and show a neighbor's. Filled int `json:"filled"` + // Refined cells show a frame a repair found (see sheet_repair.go): one + // the first read missed, or one nearer the sample's time than the + // keyframe the previous cell already shows. They count as decoded too. + Refined int `json:"refined,omitzero"` } // Bounds of a SheetsOutput. A sheet of the widest tiles in the largest grid @@ -65,6 +71,10 @@ const ( maxSheetPixels = 16384 maxFilledShare = 0.1 sheetMetadataID = "sheets" + // maxSheetsRunPixels bounds the tile pixels of one run, which holds + // every tile it decodes, about 1.5 bytes a pixel, until its repairs are + // done. Callers chunk longer files into several runs. + maxSheetsRunPixels = 64 << 20 ) func (o SheetsOutput) validate() error { @@ -82,6 +92,19 @@ func (o SheetsOutput) validate() error { if o.Quality < 1 || o.Quality > 100 { return fmt.Errorf("sheet quality %d is outside 1..100", o.Quality) } + if o.ToneMap != nil { + return o.ToneMap.validate() + } + return nil +} + +// validateRun checks that the sheets of samples fit maxSheetsRunPixels. +func (o SheetsOutput) validateRun(samples int) error { + perSheet := o.Columns * o.Rows + sheets := (samples + perSheet - 1) / perSheet + if pixels := int64(sheets) * int64(perSheet) * int64(o.TileWidth) * int64(o.TileHeight); pixels > maxSheetsRunPixels { + return fmt.Errorf("%d sheets of %dx%d tiles of %dx%d exceed %d pixels in one run", sheets, o.Columns, o.Rows, o.TileWidth, o.TileHeight, maxSheetsRunPixels) + } return nil } @@ -98,45 +121,79 @@ const sheetFullRange = "out_range=full:out_color_matrix=bt601" // sheetPixelFormat is the format of the raw frames a Sheets run writes. const sheetPixelFormat = "yuv420p" -// buildSheetsGraph returns the video filter chain of a Sheets output for -// frames decoded on accel (empty for software). softwareToneMap is the -// software tone-map chain, needed when the attempt tone maps in software. +// gpuScaleFactor is how many times a tile's size a GPU scales frames to +// before they are downloaded and scaled the rest of the way in software. The +// GPU scalers sample a few source pixels per output pixel, so a direct 4K to +// 300-pixel scale aliases (SSIM 0.94 against an area scale of the same +// frames); scaling to four tiles' size on the GPU and by area after keeps it +// at 0.999 while downloading a fraction of the frame. +const gpuScaleFactor = 4 + +// sheetsToneMap is how a sheet attempt converts an HDR source. kind is the +// source's base signal when the request named one (ToneMap.Source). software +// is the software chain for scaled frames: kind's chain, or the best-effort +// chain when the request named no kind. +type sheetsToneMap struct { + kind tonemap.SourceKind + software string +} + +// buildSheetsGraph returns the video chain of a Sheets output for frames +// decoded on accel (empty for software), converting HDR as toneMap says when +// the output asks for tone mapping. // // Software and VideoToolbox frames are scaled first, tone mapped if asked, // and converted once: // // scale=W:H:flags=area:out_range=full:out_color_matrix=bt601,format=yuv420p -// scale=W:H:flags=area,,scale=out_range=full:out_color_matrix=bt601,format=yuv420p +// scale=W:H:flags=area,,scale=out_range=full:out_color_matrix=bt601,format=yuv420p // -// VAAPI surfaces, which QSV decodes into too, are tone mapped on the GPU, -// which needs the source's HDR metadata and so comes first, scaled there, and -// downloaded: +// GPU surfaces (VAAPI, which QSV decodes into too, and CUDA) are scaled on the +// GPU to gpuScaleFactor tiles, downloaded, and then take the same chain. An +// HDR source whose base signal the request named downloads as 10-bit P010, +// so it is tone mapped in software, on thumbnail-sized frames, exactly as on +// a software attempt: the GPU tone mappers disagree with it and with one +// another (Intel's VAAPI tone mapper crushes PQ midtones and garbles HLG, +// AMD's refuses HDR, neither applies a Dolby Vision RPU), and the frames are +// too small for the cost to matter. A VAAPI attempt without a named signal +// keeps the best-effort GPU tone map images use; CUDA has none. // -// [,]scale_vaapi=w=W:h=H:format=nv12,hwdownload,format=nv12,scale=…,format=yuv420p +// scale_vaapi=w=min(iw\,4W):h=min(ih\,4H):format=nv12,hwdownload,format=nv12,scale=W:H:flags=area:out_range=full:… +// scale_cuda=w=min(iw\,4W):h=min(ih\,4H):interp_algo=bilinear:format=p010,hwdownload,format=p010le,scale=W:H:flags=area,,scale=out_range=full:… // // Every chain ends by giving each frame a "sample" entry of -1 unless its // packet already carries one, so metadata=print logs every frame and the // log lines up with the raw frames on stdout (see sheets_join.go). -func buildSheetsGraph(out SheetsOutput, accel, softwareToneMap string) (string, error) { - toneMap := out.ToneMap != nil - scale := fmt.Sprintf("scale=%d:%d:flags=area", out.TileWidth, out.TileHeight) - convert := "format=" + sheetPixelFormat +func buildSheetsGraph(out SheetsOutput, accel string, toneMap sheetsToneMap) (string, error) { + hdr := out.ToneMap != nil + gpuToneMaps := false var filters []string - switch { - case accel != "" && !framesInSystemMemory(accel): - if toneMap { + if accel != "" && !framesInSystemMemory(accel) { + format, download := "nv12", hwDownloadFilter + switch { + case !hdr: + case toneMap.kind != "": + format, download = "p010", "hwdownload,format=p010le" + case accel == hwAccelNVENC: + return "", errors.New("CUDA sheets need the source's base signal to tone map") + default: filters = append(filters, vaapiToneMap) + gpuToneMaps = true } - filters = append(filters, - fmt.Sprintf("scale_vaapi=w=%d:h=%d:format=nv12", out.TileWidth, out.TileHeight), - hwDownloadFilter, - "scale="+sheetFullRange, convert) - case toneMap: - if softwareToneMap == "" { + filters = append(filters, gpuScaleFilter(out, accel, format), download) + } + software := "" + if hdr && !gpuToneMaps { + if toneMap.software == "" { return "", errors.New("HDR sheets require a software tone-map filter") } - filters = append(filters, scale, softwareToneMap, "scale="+sheetFullRange, convert) - default: + software = toneMap.software + } + scale := fmt.Sprintf("scale=%d:%d:flags=area", out.TileWidth, out.TileHeight) + convert := "format=" + sheetPixelFormat + if software != "" { + filters = append(filters, scale, software, "scale="+sheetFullRange, convert) + } else { filters = append(filters, scale+":"+sheetFullRange, convert) } filters = append(filters, @@ -145,18 +202,52 @@ func buildSheetsGraph(out SheetsOutput, accel, softwareToneMap string) (string, return strings.Join(filters, ","), nil } -// prepareSheets settles the video chain of a sheet attempt before ffmpeg -// starts, choosing the software tone-map chain if the attempt needs one. -func (r Runner) prepareSheets(ctx context.Context, req Request, attempt Attempt, toneMap *toneMapResolver) (string, *AttemptError) { - softwareToneMap, failure := r.softwareToneMapFilter(ctx, req, attempt, toneMap) - if failure != nil { - return "", failure +// gpuScaleFilter scales GPU surfaces of accel to gpuScaleFactor times the +// tile, or the frame's own size if that is smaller, into format, ready for +// hwdownload. The commas inside min() are escaped from the graph parser. +func gpuScaleFilter(out SheetsOutput, accel, format string) string { + w := fmt.Sprintf("min(iw\\,%d)", gpuScaleFactor*out.TileWidth) + h := fmt.Sprintf("min(ih\\,%d)", gpuScaleFactor*out.TileHeight) + if accel == hwAccelNVENC { + return "scale_cuda=w=" + w + ":h=" + h + ":interp_algo=bilinear:format=" + format } + return "scale_vaapi=w=" + w + ":h=" + h + ":format=" + format +} + +// prepareSheets settles the video chain of a sheet attempt before ffmpeg +// starts. A request naming its source's base signal takes that signal's +// software chain on every attempt (see buildSheetsGraph), which needs +// software tone mapping allowed and a filter the binary lists for the +// signal; without them the attempt fails as unsupported, so a Dolby Vision +// base makes no sheets rather than green and magenta ones. Otherwise an +// attempt takes the best-effort chain, which CUDA lacks. +func (r Runner) prepareSheets(ctx context.Context, req Request, attempt Attempt, resolver *toneMapResolver) (string, *AttemptError) { accel := "" if attempt.Hardware { accel = r.HWAccel } - graph, err := buildSheetsGraph(*req.Sheets, accel, softwareToneMap) + toneMap := sheetsToneMap{} + switch out := req.Sheets.ToneMap; { + case out == nil: + case out.Source != "": + if !out.AllowSoftware { + return "", &AttemptError{Reason: ReasonUnsupported, Err: errors.New("software HDR tone mapping is disabled")} + } + software, failure := resolver.source(ctx, r, out.Source) + if failure != nil { + return "", failure + } + toneMap = sheetsToneMap{kind: out.Source, software: software} + case accel == hwAccelNVENC: + return "", &AttemptError{Reason: ReasonUnsupported, Err: errors.New("CUDA sheets need the source's base signal to tone map")} + default: + software, failure := r.softwareToneMapFilter(ctx, req, attempt, resolver) + if failure != nil { + return "", failure + } + toneMap.software = software + } + graph, err := buildSheetsGraph(*req.Sheets, accel, toneMap) if err != nil { return "", &AttemptError{Reason: ReasonArgs, Err: err} } diff --git a/internal/mediasample/sheets_join.go b/internal/mediasample/sheets_join.go index 7522dcc36a..69e260a36a 100644 --- a/internal/mediasample/sheets_join.go +++ b/internal/mediasample/sheets_join.go @@ -30,15 +30,23 @@ import ( // waited for a log line would stall ffmpeg's exit, and a stand-in Exec that // writes all of stdout before its log would deadlock. // +// A filter graph reinit, as a hardware decoder that renews its surfaces +// causes mid-run, restarts the counter at zero. The old graph has passed on +// every frame it logged, so the frames still line up and the count goes on. +// // Where a frame goes: // // - Sampled lists: the cell of the sample its tag names. A sample that // decodes more than one frame (a keyframe within its short span, or an // all-intra source) keeps the first, which is the frame at or before the -// sample time. Untagged frames are dropped. +// sample time. Untagged frames are dropped. Each frame's time also +// places it back in the input (sourceSeconds), which a repair reads. // - Windows (containers the list cannot seek in, and ReadThrough): each // sample takes the last frame at or before its time, as // pickSampleFrames does for statistics. +// - Repairs (see sheet_repair.go): a list over some samples whose frames +// are collected as candidates, each sample keeping the frame nearest its +// time, and replace what the first run placed. // // A cell without a frame of its own shows the previous cell's frame, and // cells before the first frame show the first; the grid never shifts. A run @@ -47,9 +55,8 @@ import ( // than a sheet worth keeping, and a hardware attempt then falls back to // software. // -// Sheets are encoded as soon as no later frame can land in them: frames -// arrive in sample order, so once a frame lands two sheets further on, the -// earlier sheet is done. At most three canvases are held at a time. +// Sheets are encoded once the run and its repairs are done, since a repair +// may replace any cell; a run's sheets are bounded by its caller's chunking. // maxQueuedFrameBytes bounds the raw frames held while their log lines are // behind. The log normally leads, so this only trips when the log parser @@ -83,11 +90,27 @@ type sheetAssembler struct { sampled bool cells map[int64]int offset float64 + // A sampled list's entries each last span seconds and start at their + // sample's time plus inputStart, the input's container start time, + // clamped at zero. header reads the list input's start time, by which + // ffmpeg shifted every frame's time; with these, sourceSeconds finds a + // frame's time in the input. sources holds that time for each cell's + // frame, NaN when it is unknown. + span float64 + inputStart float64 + header inputHeaderParser + sources []float64 + // candidates, when set, collects each sample's nearest frame for a + // repair instead of tiling. + candidates []sheetCandidate logContext string current *sheetFrameMeta - logged int64 - metas []sheetFrameMeta + // counter is the frame number the filter logs next; logged counts + // every frame logged, across graph reinits. + counter int64 + logged int64 + metas []sheetFrameMeta partial []byte queued [][]byte @@ -104,20 +127,22 @@ type sheetAssembler struct { hasPacketTime bool open map[int]*sheetCanvas - furthest int finalized int carry []byte sheets []Sheet decoded int filled int + // refined counts the cells a repair replaced, each once. + refined int + refinedCells map[int]bool err error } // newSheetAssembler returns an assembler for the frames of times. A sampled -// run places frames by their sample tag; a window run by their time plus -// offset. -func newSheetAssembler(out SheetsOutput, times []float64, sampled bool, offset float64) *sheetAssembler { +// run places frames by their sample tag, its list entries lasting span +// seconds; a window run by their time plus offset. +func newSheetAssembler(out SheetsOutput, times []float64, sampled bool, offset, span float64) *sheetAssembler { a := &sheetAssembler{ out: out, times: times, @@ -125,14 +150,17 @@ func newSheetAssembler(out SheetsOutput, times []float64, sampled bool, offset f perSheet: out.Columns * out.Rows, sampled: sampled, offset: offset, + span: span, logContext: "[" + filterMetadata + "@" + sheetMetadataID + " @ ", open: map[int]*sheetCanvas{}, - furthest: -1, } if sampled { a.cells = make(map[int64]int, len(times)) + a.refinedCells = map[int]bool{} + a.sources = make([]float64, len(times)) for i, at := range times { a.cells[sampleKey(at)] = i + a.sources[i] = math.NaN() } } return a @@ -173,10 +201,16 @@ func (a *sheetAssembler) Write(p []byte) (int, error) { return n, nil } -// line takes one log line, keeping those of the sheets metadata filter. +// line takes one log line, keeping the input header and the lines of the +// sheets metadata filter. func (a *sheetAssembler) line(line string) { message, ok := filterMessage(line, a.logContext) if !ok { + if a.sampled { + a.mu.Lock() + a.header.line(line) + a.mu.Unlock() + } return } a.mu.Lock() @@ -186,13 +220,14 @@ func (a *sheetAssembler) line(line string) { } if match := sheetFramePattern.FindStringSubmatch(message); match != nil { index, err := strconv.ParseInt(match[1], 10, 64) - if err != nil || index != a.logged { - // A counter that skips or restarts, as after a filter graph - // reinit, no longer counts the frames on stdout. + // A counter that skips, or restarts anywhere but at zero, no longer + // counts the frames on stdout. + if err != nil || (index != a.counter && index != 0) { a.err = fmt.Errorf("ffmpeg logged frame %s after %d frames", match[1], a.logged) return } - meta := sheetFrameMeta{index: index} + a.counter = index + 1 + meta := sheetFrameMeta{index: a.logged} if seconds, err := strconv.ParseFloat(match[2], 64); err == nil && finite(seconds) { meta.seconds, meta.hasTime = seconds, true } @@ -285,8 +320,18 @@ func (a *sheetAssembler) drain() { func (a *sheetAssembler) place(meta sheetFrameMeta, frame []byte) { if a.sampled { cell, ok := a.cells[sampleKey(meta.sample)] - if meta.sample >= 0 && ok && a.setCell(cell, frame) { + if meta.sample < 0 || !ok { + a.recycle(frame) + return + } + source := a.sourceSeconds(meta, cell) + if a.candidates != nil { + a.offer(cell, source, frame) + return + } + if a.setCell(cell, frame) { a.decoded++ + a.sources[cell] = source } a.recycle(frame) return @@ -311,24 +356,29 @@ func (a *sheetAssembler) assignWindow(limit float64) { } } +// sourceSeconds is the input time, counted from the start of the file as +// sample times are, of a sampled list's frame for cell, NaN when the frame +// has no time. The list's entry for cell starts cell*span seconds into the +// list and at its inpoint in the container (see buildConcatListSpan), and +// ffmpeg shifted every frame by the list input's start time. +func (a *sheetAssembler) sourceSeconds(meta sheetFrameMeta, cell int) float64 { + if !meta.hasTime { + return math.NaN() + } + inpoint := math.Max(0, a.times[cell]+a.inputStart) + return meta.seconds + a.header.info.StartSeconds - float64(cell)*a.span + inpoint - a.inputStart +} + // setCell copies frame into cell unless the cell already holds one, and -// reports whether it did. Frames for a sheet already encoded are dropped. +// reports whether it did. func (a *sheetAssembler) setCell(cell int, frame []byte) bool { - sheet := cell / a.perSheet - if sheet < a.finalized { - return false - } - canvas := a.canvas(sheet) + canvas := a.canvas(cell / a.perSheet) slot := cell % a.perSheet if canvas.set[slot] { return false } canvas.write(slot, frame) canvas.set[slot] = true - if sheet > a.furthest { - a.furthest = sheet - a.finalizeThrough(sheet - 1) - } return true } @@ -387,13 +437,13 @@ func (a *sheetAssembler) finalize(sheet int) { a.sheets = append(a.sheets, Sheet{Index: sheet, Thumbnails: count, JPEG: encoded.Bytes()}) } -// finish checks the run's frames against its log, assigns what the window -// placement still holds, and encodes the remaining sheets. -func (a *sheetAssembler) finish() ([]Sheet, SheetFrames, *AttemptError) { +// close checks the run's frames against its log once ffmpeg has exited and +// assigns what the window placement still holds. +func (a *sheetAssembler) close() *AttemptError { a.mu.Lock() defer a.mu.Unlock() - fail := func(reason Reason, err error) ([]Sheet, SheetFrames, *AttemptError) { - return nil, SheetFrames{}, &AttemptError{Reason: reason, Err: err} + fail := func(reason Reason, err error) *AttemptError { + return &AttemptError{Reason: reason, Err: err} } switch { case a.err != nil: @@ -409,6 +459,16 @@ func (a *sheetAssembler) finish() ([]Sheet, SheetFrames, *AttemptError) { } a.assignWindow(a.packetEnd + sampleMatchSeconds) } + return nil +} + +// finish encodes the sheets of a closed run and its repairs. +func (a *sheetAssembler) finish() ([]Sheet, SheetFrames, *AttemptError) { + a.mu.Lock() + defer a.mu.Unlock() + fail := func(reason Reason, err error) ([]Sheet, SheetFrames, *AttemptError) { + return nil, SheetFrames{}, &AttemptError{Reason: reason, Err: err} + } if a.decoded == 0 { return fail(ReasonEmpty, errors.New("ffmpeg decoded no frame for any sample")) } @@ -420,7 +480,7 @@ func (a *sheetAssembler) finish() ([]Sheet, SheetFrames, *AttemptError) { if limit := max(1, int(maxFilledShare*float64(len(a.times)))); a.filled > limit { return fail(ReasonEmpty, fmt.Errorf("ffmpeg decoded frames for only %d of %d samples", len(a.times)-a.filled, len(a.times))) } - return a.sheets, SheetFrames{Decoded: len(a.times) - a.filled, Filled: a.filled}, nil + return a.sheets, SheetFrames{Decoded: len(a.times) - a.filled, Filled: a.filled, Refined: a.refined}, nil } func (a *sheetAssembler) frameBuffer() []byte { diff --git a/internal/mediasample/sheets_test.go b/internal/mediasample/sheets_test.go index 98f9e9a633..8bd620ff69 100644 --- a/internal/mediasample/sheets_test.go +++ b/internal/mediasample/sheets_test.go @@ -16,6 +16,8 @@ import ( "slices" "strings" "testing" + + "github.com/Silo-Server/silo-server/internal/tonemap" ) // The tile size the unit tests use: small, even, and not square. @@ -71,6 +73,51 @@ func TestSheetsRequestValidation(t *testing.T) { } } +// TestSheetsRequestBoundsARunsPixels refuses a run whose tiles would not fit +// maxSheetsRunPixels, since a run holds every tile until it finishes. +func TestSheetsRequestBoundsARunsPixels(t *testing.T) { + huge := Request{Input: "/media/a.mkv", Samples: &Samples{Seconds: secondsFrom(5, 10, 10000)}, + Sheets: &SheetsOutput{TileWidth: 1920, TileHeight: 1920, Columns: 1, Rows: 1, Quality: 80}} + if err := huge.Validate(); err == nil || !strings.Contains(err.Error(), "pixels in one run") { + t.Fatalf("error %v, want the run's pixels refused", err) + } + // Trickplay's largest chunk: 11 sheets of 10x8 tiles of 300x126. + chunk := Request{Input: "/media/a.mkv", Samples: &Samples{Seconds: secondsFrom(5, 10, 880)}, + Sheets: &SheetsOutput{TileWidth: 300, TileHeight: 126, Columns: 10, Rows: 8, Quality: 80}} + if err := chunk.Validate(); err != nil { + t.Fatalf("trickplay chunk refused: %v", err) + } + // A height the probe will replace does not count against the run: a + // rotated file's catalog height can exceed the height it decodes at. + rotated := chunk + rotated.Sheets = &SheetsOutput{TileWidth: 300, TileHeight: 1920, Columns: 10, Rows: 8, Quality: 80, UseInputAspect: true} + if err := rotated.Validate(); err != nil { + t.Fatalf("run with a probed height refused before probing: %v", err) + } + rotated.Sheets.UseInputAspect = false + if err := rotated.Validate(); err == nil { + t.Fatal("run of tall tiles with a fixed height: want it refused") + } +} + +// TestRunSheetsBoundsTheProbedRun refuses a run whose probed tile height +// makes it too large, before ffmpeg decodes anything. +func TestRunSheetsBoundsTheProbedRun(t *testing.T) { + req := Request{Input: "/media/a.mkv", Samples: &Samples{Seconds: secondsFrom(5, 10, 880)}, + Sheets: &SheetsOutput{TileWidth: 300, TileHeight: 16, Columns: 10, Rows: 8, Quality: 80, UseInputAspect: true}} + var calls [][]string + runner := Runner{Exec: func(_ context.Context, _ string, args []string, _ io.Reader, _, stderr io.Writer) error { + calls = append(calls, args) + // A portrait picture: 300-pixel tiles 534 pixels high. + _, _ = io.WriteString(stderr, "Input #0, matroska,webm, from '/media/a.mkv':\n Duration: 02:00:00.00, start: 0.000000, bitrate: 900 kb/s\n Stream #0:0: Video: hevc (Main), yuv420p(tv), 1080x1920, SAR 1:1 DAR 9:16\n") + return nil + }} + _, err := runner.Run(t.Context(), req) + if failure, ok := errors.AsType[*Error](err); !ok || failure.Reason != ReasonArgs || len(calls) != 1 { + t.Fatalf("error %v after %d runs, want the probed run refused as args after the probe", err, len(calls)) + } +} + func TestSheetsRequestRoundTripsThroughJSON(t *testing.T) { req := sheetsRequest([]float64{5, 15}, 10, 10) req.Samples.ReadThrough = true @@ -95,24 +142,35 @@ func TestBuildSheetsGraph(t *testing.T) { hdr := out hdr.ToneMap = &ToneMap{AllowSoftware: true} tail := ",metadata=mode=add:key=sample:value=-1,metadata@sheets=print" + vaapiScale := `scale_vaapi=w=min(iw\,1280):h=min(ih\,536):format=nv12,hwdownload,format=nv12,` + vaapi10 := `scale_vaapi=w=min(iw\,1280):h=min(ih\,536):format=p010,hwdownload,format=p010le,` + cudaScale := `scale_cuda=w=min(iw\,1280):h=min(ih\,536):interp_algo=bilinear:format=nv12,hwdownload,format=nv12,` + cuda10 := `scale_cuda=w=min(iw\,1280):h=min(ih\,536):interp_algo=bilinear:format=p010,hwdownload,format=p010le,` + areaFull := "scale=320:134:flags=area:out_range=full:out_color_matrix=bt601,format=yuv420p" + dv := tonemap.SoftwareFilter(tonemap.SourceDolbyVision, tonemap.SoftwareFilterBT2390) + hlg := tonemap.SoftwareFilter(tonemap.SourceHLG, tonemap.SoftwareFilterBT2390) + toneMapped := func(chain string) string { + return "scale=320:134:flags=area," + chain + ",scale=out_range=full:out_color_matrix=bt601,format=yuv420p" + } tests := []struct { - name, accel, softwareToneMap string - out SheetsOutput - want string + name, accel string + toneMap sheetsToneMap + out SheetsOutput + want string }{ - {"software", "", "", out, - "scale=320:134:flags=area:out_range=full:out_color_matrix=bt601,format=yuv420p" + tail}, - {"software HDR", "", softwareToneMapBT2390, hdr, - "scale=320:134:flags=area," + softwareToneMapBT2390 + ",scale=out_range=full:out_color_matrix=bt601,format=yuv420p" + tail}, - {"vaapi", "vaapi", "", out, - "scale_vaapi=w=320:h=134:format=nv12,hwdownload,format=nv12,scale=out_range=full:out_color_matrix=bt601,format=yuv420p" + tail}, - {"qsv HDR", "qsv", "", hdr, - vaapiToneMap + ",scale_vaapi=w=320:h=134:format=nv12,hwdownload,format=nv12,scale=out_range=full:out_color_matrix=bt601,format=yuv420p" + tail}, - {"videotoolbox HDR", "videotoolbox", softwareToneMapHable, hdr, - "scale=320:134:flags=area," + softwareToneMapHable + ",scale=out_range=full:out_color_matrix=bt601,format=yuv420p" + tail}, + {"software", "", sheetsToneMap{}, out, areaFull + tail}, + {"software HDR", "", sheetsToneMap{software: softwareToneMapBT2390}, hdr, toneMapped(softwareToneMapBT2390) + tail}, + {"software Dolby Vision", "", sheetsToneMap{kind: tonemap.SourceDolbyVision, software: dv}, hdr, toneMapped(dv) + tail}, + {"vaapi", "vaapi", sheetsToneMap{}, out, vaapiScale + areaFull + tail}, + {"qsv HDR best effort", "qsv", sheetsToneMap{}, hdr, vaapiToneMap + "," + vaapiScale + areaFull + tail}, + {"vaapi HLG", "vaapi", sheetsToneMap{kind: tonemap.SourceHLG, software: hlg}, hdr, vaapi10 + toneMapped(hlg) + tail}, + {"qsv Dolby Vision", "qsv", sheetsToneMap{kind: tonemap.SourceDolbyVision, software: dv}, hdr, vaapi10 + toneMapped(dv) + tail}, + {"cuda", "nvenc", sheetsToneMap{}, out, cudaScale + areaFull + tail}, + {"cuda Dolby Vision", "nvenc", sheetsToneMap{kind: tonemap.SourceDolbyVision, software: dv}, hdr, cuda10 + toneMapped(dv) + tail}, + {"videotoolbox HDR", "videotoolbox", sheetsToneMap{software: softwareToneMapHable}, hdr, toneMapped(softwareToneMapHable) + tail}, } for _, tt := range tests { - got, err := buildSheetsGraph(tt.out, tt.accel, tt.softwareToneMap) + got, err := buildSheetsGraph(tt.out, tt.accel, tt.toneMap) if err != nil { t.Fatalf("%s: %v", tt.name, err) } @@ -120,9 +178,15 @@ func TestBuildSheetsGraph(t *testing.T) { t.Errorf("%s:\n got %s\nwant %s", tt.name, got, tt.want) } } - if _, err := buildSheetsGraph(hdr, "", ""); err == nil { + if _, err := buildSheetsGraph(hdr, "", sheetsToneMap{}); err == nil { t.Error("software HDR without a tone-map chain: want an error") } + if _, err := buildSheetsGraph(hdr, "nvenc", sheetsToneMap{}); err == nil { + t.Error("CUDA HDR without a source signal: want an error") + } + if _, err := buildSheetsGraph(hdr, "vaapi", sheetsToneMap{kind: tonemap.SourcePQ}); err == nil { + t.Error("GPU HDR with a source signal but no software chain: want an error") + } if vaapiToneMapDownload != vaapiToneMap+",hwdownload,format=nv12" { t.Errorf("image tone-map chain changed: %s", vaapiToneMapDownload) } @@ -133,7 +197,7 @@ func TestBuildSheetsArgs(t *testing.T) { req.Threads = 2 output := []string{"-map", "0:V:0", "-an", "-sn", "-dn", "-vf", "GRAPH", "-fps_mode", "passthrough", "-pix_fmt", "yuv420p", "-f", "rawvideo", "pipe:1"} - args, list, err := buildSheetsArgs(req, Attempt{Hardware: true}, hardwareDecode{Accel: "vaapi", Device: "/dev/dri/renderD128"}, 11.4, "GRAPH", "") + args, list, err := buildSheetsArgs(req, Attempt{Hardware: true}, hardwareDecode{Accel: "vaapi", Device: "/dev/dri/renderD128"}, 11.4, "GRAPH", "", sampledSheetsList) if err != nil { t.Fatal(err) } @@ -154,7 +218,7 @@ func TestBuildSheetsArgs(t *testing.T) { window := req window.Samples = nil window.Window = &Window{StartSeconds: 0, DurationSeconds: 15.04, KeyframesOnly: true} - args, list, err = buildSheetsArgs(window, Attempt{}, hardwareDecode{}, 0, "GRAPH", "packets.framecrc") + args, list, err = buildSheetsArgs(window, Attempt{}, hardwareDecode{}, 0, "GRAPH", "packets.framecrc", sampledSheetsList) if err != nil { t.Fatal(err) } @@ -199,14 +263,25 @@ type fakeSheets struct { editLog func([]string) []string calls [][]string packetEnd float64 + // listStart is the start time a list run's input header reports, and + // lists the ffconcat lists the runs read. + listStart float64 + lists []string } -func (f *fakeSheets) exec(_ context.Context, _ string, args []string, _ io.Reader, stdout, stderr io.Writer) error { +func (f *fakeSheets) exec(_ context.Context, _ string, args []string, stdin io.Reader, stdout, stderr io.Writer) error { f.calls = append(f.calls, args) if slices.Contains(args, "copy") && !slices.Contains(args, "framecrc") { _, _ = fmt.Fprintf(stderr, "Input #0, %s, from '/media/a.mkv':\n Duration: 00:02:00.00, start: 0.000000, bitrate: 900 kb/s\n", f.format) return nil } + if slices.Contains(args, "concat") { + if stdin != nil { + list, _ := io.ReadAll(stdin) + f.lists = append(f.lists, string(list)) + } + _, _ = fmt.Fprintf(stderr, "Input #0, concat, from 'pipe:0':\n Duration: N/A, start: %f, bitrate: N/A\n", f.listStart) + } var log []string for i, frame := range f.frames { log = append(log, fmt.Sprintf("[metadata@sheets @ 0x55d0] frame:%-4d pts:%-7s pts_time:%s", i, frame.pts, frame.pts)) @@ -368,7 +443,8 @@ func TestRunSheetsFillsMissingSamples(t *testing.T) { } // TestRunSheetsRereadsSparseListAsWindow decodes one of four samples through -// the list. When the HEVC decoder logged dropping keyframes as duplicates, as +// the list, and none of the other three through the repair that lists them +// again. When the HEVC decoder logged dropping keyframes as duplicates, as // it does for open-GOP keyframes after each jump, the attempt reads the // samples again as a keyframes-only window; otherwise, including for pictures // dropped as merely undecodable, the list's failure stands. A window whose @@ -388,11 +464,11 @@ func TestRunSheetsRereadsSparseListAsWindow(t *testing.T) { runs int want Reason }{ - "duplicate keyframes": {editLog: logDuplicates, window: keyframes, runs: 3}, - "no drops logged": {window: keyframes, runs: 2, want: ReasonEmpty}, - "damaged pictures": {editLog: logUndecodable, window: keyframes, runs: 2, want: ReasonEmpty}, - "window empty too": {editLog: logDuplicates, runs: 3, want: ReasonEmpty}, - "window duplicates too": {editLog: logDuplicates, window: keyframes, windowEditLog: logDuplicates, runs: 3, want: ReasonEmpty}, + "duplicate keyframes": {editLog: logDuplicates, window: keyframes, runs: 4}, + "no drops logged": {window: keyframes, runs: 3, want: ReasonEmpty}, + "damaged pictures": {editLog: logUndecodable, window: keyframes, runs: 3, want: ReasonEmpty}, + "window empty too": {editLog: logDuplicates, runs: 4, want: ReasonEmpty}, + "window duplicates too": {editLog: logDuplicates, window: keyframes, windowEditLog: logDuplicates, runs: 4, want: ReasonEmpty}, } for name, tt := range tests { list := &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{tagged(10, 5)}, editLog: tt.editLog} @@ -406,12 +482,13 @@ func TestRunSheetsRereadsSparseListAsWindow(t *testing.T) { return window.exec(ctx, name, args, stdin, stdout, stderr) }} result, err := runner.Run(t.Context(), sheetsRequest(secondsFrom(5, 10, 4), 2, 2)) - if len(calls) != tt.runs || !slices.Contains(calls[1], "concat") || (tt.runs == 3 && !slices.Contains(calls[2], "framecrc")) { - t.Fatalf("%s: runs %q, want a probe, a list run, and %d more", name, calls, tt.runs-2) + if len(calls) != tt.runs || !slices.Contains(calls[1], "concat") || !slices.Contains(calls[2], "concat") || + (tt.runs == 4 && !slices.Contains(calls[3], "framecrc")) { + t.Fatalf("%s: runs %q, want a probe, a list run, its repair, and %d more", name, calls, tt.runs-3) } if tt.want != "" { failure, ok := errors.AsType[*Error](err) - if !ok || failure.Reason != tt.want || (tt.runs == 3 && !strings.Contains(err.Error(), "after the list run")) { + if !ok || failure.Reason != tt.want || (tt.runs == 4 && !strings.Contains(err.Error(), "after the list run")) { t.Fatalf("%s: error %v, want %s naming the list run", name, err, tt.want) } continue @@ -571,8 +648,12 @@ func TestRunSheetsRejectsBrokenOutput(t *testing.T) { "log without frame": {&fakeSheets{frames: frames(), editLog: func(log []string) []string { return append(log, "[metadata@sheets @ 0x55d0] frame:4 pts:0 pts_time:0", "[metadata@sheets @ 0x55d0] sample=5") }}, ReasonOutput}, - "counter restarts": {&fakeSheets{frames: frames(), editLog: func(log []string) []string { - log[6] = strings.Replace(log[6], "frame:3", "frame:0", 1) + "counter skips": {&fakeSheets{frames: frames(), editLog: func(log []string) []string { + log[6] = strings.Replace(log[6], "frame:3", "frame:4", 1) + return log + }}, ReasonOutput}, + "counter restarts past zero": {&fakeSheets{frames: frames(), editLog: func(log []string) []string { + log[6] = strings.Replace(log[6], "frame:3", "frame:1", 1) return log }}, ReasonOutput}, "no sample line": {&fakeSheets{frames: frames(), editLog: func(log []string) []string { diff --git a/internal/mediasample/sheets_tonemap_test.go b/internal/mediasample/sheets_tonemap_test.go new file mode 100644 index 0000000000..578b762cc2 --- /dev/null +++ b/internal/mediasample/sheets_tonemap_test.go @@ -0,0 +1,228 @@ +package mediasample + +import ( + "context" + "encoding/json" + "errors" + "io" + "slices" + "strings" + "sync/atomic" + "testing" + + "github.com/Silo-Server/silo-server/internal/tonemap" +) + +// jellyfinFilters and stockFilters are the tone-map filters a Jellyfin FFmpeg +// and a stock FFmpeg list. +var ( + jellyfinFilters = []string{"zscale", "sidedata", "tonemapx", "tonemap"} + stockFilters = []string{"zscale", "sidedata", "tonemap"} +) + +func hdrSheetsRequest(source tonemap.SourceKind, attempts ...Attempt) Request { + req := sheetsRequest(secondsFrom(5, 10, 4), 2, 2) + req.Sheets.ToneMap = &ToneMap{AllowSoftware: true, Source: source} + req.Attempts = attempts + return req +} + +func fourFrames() *fakeSheets { + return &fakeSheets{format: "matroska,webm", frames: []fakeSheetFrame{tagged(10, 5), tagged(20, 15), tagged(30, 25), tagged(40, 35)}} +} + +// graphOf returns the -vf argument of a run. +func graphOf(args []string) string { + if i := slices.Index(args, "-vf"); i >= 0 { + return args[i+1] + } + return "" +} + +func TestRunSheetsToneMapsDolbyVisionWithTheRPU(t *testing.T) { + dv := tonemap.SoftwareFilter(tonemap.SourceDolbyVision, tonemap.SoftwareFilterBT2390) + for _, accel := range []string{"", "vaapi", "qsv", "nvenc", "videotoolbox"} { + var loads atomic.Int32 + fake := fourFrames() + runner := Runner{FFmpegPath: "ffmpeg", HWAccel: accel, HWDevice: "/dev/dri/renderD128", Exec: fake.exec, + Capabilities: listingCapabilities(&loads, nil, jellyfinFilters...)} + attempts := []Attempt{{}} + if accel != "" { + attempts = []Attempt{{Hardware: true}, {}} + } + result, err := runner.Run(t.Context(), hdrSheetsRequest(tonemap.SourceDolbyVision, attempts...)) + if err != nil { + t.Fatalf("%q: %v", accel, err) + } + graph := graphOf(fake.calls[1]) + if !strings.Contains(graph, "scale=24:16:flags=area,"+dv+",") || !strings.Contains(graph, "apply_dovi=1") { + t.Errorf("%q: graph %s, want the scaled frames tone mapped with the RPU", accel, graph) + } + gpu := accel == "vaapi" || accel == "qsv" || accel == "nvenc" + if gpu != strings.Contains(graph, "hwdownload,format=p010le,") { + t.Errorf("%q: graph %s; GPU frames download as P010 and nothing else does", accel, graph) + } + if strings.Contains(graph, "tonemap_vaapi") || strings.Contains(graph, "tonemap_cuda") || strings.Contains(graph, "opencl") { + t.Errorf("%q: graph %s tone maps on the GPU", accel, graph) + } + want := "software" + if accel != "" { + want = "hardware:" + accel + } + if result.Decoder != want || loads.Load() != 1 { + t.Errorf("%q: decoder %s after %d capability loads", accel, result.Decoder, loads.Load()) + } + } +} + +func TestRunSheetsToneMapsHLGWithItsTransfer(t *testing.T) { + var loads atomic.Int32 + fake := fourFrames() + runner := Runner{HWAccel: "vaapi", HWDevice: "/dev/dri/renderD128", Exec: fake.exec, Capabilities: listingCapabilities(&loads, nil, stockFilters...)} + result, err := runner.Run(t.Context(), hdrSheetsRequest(tonemap.SourceHLG, Attempt{Hardware: true}, Attempt{})) + if err != nil { + t.Fatal(err) + } + graph := graphOf(fake.calls[1]) + if result.Decoder != "hardware:vaapi" || !strings.Contains(graph, tonemap.SoftwareFilter(tonemap.SourceHLG, tonemap.SoftwareFilterHable)) || + !strings.Contains(graph, "color_trc=arib-std-b67") || strings.Contains(graph, "procamp_vaapi") { + t.Fatalf("decoder %s, graph %s; want the HLG chain after a VAAPI decode", result.Decoder, graph) + } +} + +func TestRunSheetsDecodesOnCUDA(t *testing.T) { + var loads atomic.Int32 + fake := fourFrames() + runner := Runner{HWAccel: "nvenc", HWDevice: "1", Exec: fake.exec, Capabilities: listingCapabilities(&loads, nil, jellyfinFilters...)} + result, err := runner.Run(t.Context(), hdrSheetsRequest(tonemap.SourcePQ, Attempt{Hardware: true}, Attempt{})) + if err != nil { + t.Fatal(err) + } + args := fake.calls[1] + decode := []string{"-init_hw_device", "cuda=cu:1", "-filter_hw_device", "cu", "-hwaccel", "cuda", "-hwaccel_output_format", "cuda"} + if result.Decoder != "hardware:nvenc" || !slices.Equal(args[4:12], decode) || !strings.Contains(graphOf(args), "scale_cuda=") { + t.Fatalf("decoder %s, args %q; want CUDA decode on device 1", result.Decoder, args) + } + // Without a source signal there is no CUDA chain: software tone maps. + fake = fourFrames() + runner = Runner{HWAccel: "nvenc", Exec: fake.exec, Capabilities: listingCapabilities(&loads, nil, jellyfinFilters...)} + result, err = runner.Run(t.Context(), hdrSheetsRequest("", Attempt{Hardware: true}, Attempt{})) + if err != nil || result.Decoder != "software" || len(fake.calls) != 2 { + t.Fatalf("decoder %s, runs %d, error %v; want software only", result.Decoder, len(fake.calls), err) + } +} + +// TestRunRefusesCUDAForStatsAndImages keeps the outputs whose chains assume +// VAAPI surfaces off CUDA: their hardware attempts fail as unsupported +// without starting ffmpeg. +func TestRunRefusesCUDAForStatsAndImages(t *testing.T) { + for name, req := range map[string]Request{ + "stats": creditsTailRequest(), + "images": imageRequest(nil), + } { + var started atomic.Int32 + runner := Runner{HWAccel: "nvenc", Exec: func(context.Context, string, []string, io.Reader, io.Writer, io.Writer) error { + started.Add(1) + return nil + }} + req.Attempts = []Attempt{{Hardware: true}} + _, err := runner.Run(t.Context(), req) + if failure, ok := errors.AsType[*Error](err); !ok || failure.Reason != ReasonUnsupported || started.Load() != 0 { + t.Errorf("%s: error %v after %d processes, want unsupported before ffmpeg", name, err, started.Load()) + } + } +} + +func TestRunSheetsWithoutASourceToneMap(t *testing.T) { + tests := map[string]struct { + filters []string + loadErr error + allow bool + want Reason + }{ + "Dolby Vision without tonemapx": {filters: stockFilters, allow: true, want: ReasonUnsupported}, + "no sidedata": {filters: []string{"zscale", "tonemapx"}, allow: true, want: ReasonUnsupported}, + "software not allowed": {filters: jellyfinFilters, want: ReasonUnsupported}, + "listing failed": {loadErr: errors.New("ffmpeg -filters timed out"), allow: true, want: ReasonCapabilities}, + } + for name, tt := range tests { + var loads atomic.Int32 + fake := fourFrames() + runner := Runner{HWAccel: "vaapi", HWDevice: "/dev/dri/renderD128", Exec: fake.exec, Capabilities: listingCapabilities(&loads, tt.loadErr, tt.filters...)} + req := hdrSheetsRequest(tonemap.SourceDolbyVision, Attempt{Hardware: true}, Attempt{}) + req.Sheets.ToneMap.AllowSoftware = tt.allow + _, err := runner.Run(t.Context(), req) + wantLoads := int32(1) + if !tt.allow { + wantLoads = 0 + } + if failure, ok := errors.AsType[*Error](err); !ok || failure.Reason != tt.want || len(fake.calls) != 0 || loads.Load() != wantLoads { + t.Errorf("%s: error %v after %d processes and %d loads, want %s before ffmpeg", name, err, len(fake.calls), loads.Load(), tt.want) + } + } +} + +func TestSelectSourceToneMap(t *testing.T) { + tests := []struct { + kind tonemap.SourceKind + filters []string + want string + }{ + {tonemap.SourcePQ, jellyfinFilters, tonemap.SoftwareFilter(tonemap.SourcePQ, tonemap.SoftwareFilterBT2390)}, + {tonemap.SourcePQ, stockFilters, tonemap.SoftwareFilter(tonemap.SourcePQ, tonemap.SoftwareFilterHable)}, + {tonemap.SourceDolbyVision, jellyfinFilters, tonemap.SoftwareFilter(tonemap.SourceDolbyVision, tonemap.SoftwareFilterBT2390)}, + {tonemap.SourceDolbyVision, stockFilters, ""}, + {tonemap.SourceSDRBT2020, []string{"zscale", "sidedata"}, tonemap.SoftwareFilter(tonemap.SourceSDRBT2020, "")}, + } + for _, tt := range tests { + var loads atomic.Int32 + runner := Runner{Capabilities: listingCapabilities(&loads, nil, tt.filters...)} + got, failure := runner.selectSourceToneMap(t.Context(), tt.kind) + if got != tt.want || (tt.want == "") != (failure != nil) { + t.Errorf("%s with %v: chain %q, failure %v; want %q", tt.kind, tt.filters, got, failure, tt.want) + } + } +} + +func TestToneMapSourceValidation(t *testing.T) { + req := hdrSheetsRequest(tonemap.SourceHLG) + if err := req.Validate(); err != nil { + t.Fatalf("valid request rejected: %v", err) + } + encoded, err := json.Marshal(req) + if err != nil || !strings.Contains(string(encoded), `"source":"hlg"`) { + t.Fatalf("encoded %s, error %v", encoded, err) + } + var decoded Request + if err := json.Unmarshal(encoded, &decoded); err != nil || decoded.Sheets.ToneMap.Source != tonemap.SourceHLG { + t.Fatalf("decoded %+v, error %v", decoded.Sheets.ToneMap, err) + } + req.Sheets.ToneMap.Source = "hdr12" + if err := req.Validate(); err == nil { + t.Error("unknown source: want a validation error") + } + image := imageRequest(&ToneMap{AllowSoftware: true, Source: tonemap.SourcePQ}) + if err := image.Validate(); err == nil { + t.Error("image with a source: want a validation error") + } +} + +func TestSupportsHardwareSheets(t *testing.T) { + for accel, want := range map[string]bool{"qsv": true, "vaapi": true, "videotoolbox": true, "nvenc": true, "none": false, "": false} { + if got := SupportsHardwareSheets(accel); got != want { + t.Errorf("SupportsHardwareSheets(%q) = %t, want %t", accel, got, want) + } + } +} + +func TestHardwareDecoderRefusalIsUnsupported(t *testing.T) { + for _, log := range []string{ + "[mpeg4 @ 0x5] Failed setup for format vaapi: hwaccel initialisation returned error.", + "[h264 @ 0x5] Your platform doesn't support hardware accelerated H264 decoding.", + } { + failure := AttemptError{Reason: ReasonExit, Err: errors.New("exit status 218"), StderrTail: log} + if got := failure.Cause(); got != ReasonUnsupported { + t.Errorf("%q: cause %s, want unsupported", log, got) + } + } +} diff --git a/internal/playback/protocol_v3.go b/internal/playback/protocol_v3.go index 361ce90b26..2affdeb0a3 100644 --- a/internal/playback/protocol_v3.go +++ b/internal/playback/protocol_v3.go @@ -119,6 +119,10 @@ const ( TransportFeaturePreparedTracksV1 = "prepared_tracks_v1" // A transcode node that makes trickplay sheets (POST /trickplay/extract). TransportFeatureTrickplayExtractV1 = "trickplay_extract_v1" + // A trickplay node that converts HDR with the tone-map source signal a + // request names, decodes on CUDA, and repairs missing and repeated cells. + // API servers that send the signal only use nodes that advertise it. + TransportFeatureTrickplayExtractV2 = "trickplay_extract_v2" ) // Degradation warning codes reported by playback plans. diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index 6de9db4f47..e0d349c8a2 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -1448,7 +1448,8 @@ func (s *Server) buildCapabilitySnapshotLocked(ctx context.Context) (playback.HW } } } - info.TransportFeatures = append(info.TransportFeatures, playback.TransportFeaturePreparedTracksV1, playback.TransportFeatureTrickplayExtractV1) + info.TransportFeatures = append(info.TransportFeatures, playback.TransportFeaturePreparedTracksV1, + playback.TransportFeatureTrickplayExtractV1, playback.TransportFeatureTrickplayExtractV2) info.CapabilityHash = playback.ComputeCapabilityHash(info) return info, nil } diff --git a/internal/transcodenode/trickplay.go b/internal/transcodenode/trickplay.go index 7f0a3857fe..73f786ddc1 100644 --- a/internal/transcodenode/trickplay.go +++ b/internal/transcodenode/trickplay.go @@ -92,7 +92,7 @@ func (s *Server) handleTrickplayExtract(w http.ResponseWriter, r *http.Request) hardware.Set(accel, cfg.Playback.HWDevice) resolved, device := hardware.Backend(r.Context(), ffmpeg) runner := mediasample.Runner{FFmpegPath: ffmpeg, Workload: processmetrics.Trickplay} - if mediasample.SupportsHardwareDecode(resolved) { + if mediasample.SupportsHardwareSheets(resolved) { runner.HWAccel, runner.HWDevice = resolved, device } else { req.Attempts = softwareAttempts(req.Attempts) diff --git a/internal/trickplay/extract.go b/internal/trickplay/extract.go index e6e2a49ed8..2fadfe1d33 100644 --- a/internal/trickplay/extract.go +++ b/internal/trickplay/extract.go @@ -65,7 +65,7 @@ func (e *LocalExtractor) Extract(ctx context.Context, _ *Job, req mediasample.Re } e.hardware.Set(accel, readSetting(ctx, e.settings, hwDeviceSetting)) resolved, device := e.hardware.Backend(ctx, ffmpeg) - hardware := mediasample.SupportsHardwareDecode(resolved) + hardware := mediasample.SupportsHardwareSheets(resolved) req.Attempts = AttemptPlan(hardware, len(req.Samples.Seconds)) runner := mediasample.Runner{ FFmpegPath: ffmpeg, @@ -93,17 +93,20 @@ func (e *LocalExtractor) Extract(ctx context.Context, _ *Job, req mediasample.Re // fallback moves a failed hardware attempt on to software, logging the // first failure per accelerator loudly. An input that has no video stream -// fails the same way in software, so that ends the run. +// fails the same way in software, so that ends the run. A GPU that cannot +// decode a file's codec is no fault of the accelerator and is not its first +// failure. func (e *LocalExtractor) fallback(ctx context.Context, accel string) func(mediasample.Attempt, mediasample.AttemptError) bool { return func(attempt mediasample.Attempt, failure mediasample.AttemptError) bool { if !attempt.Hardware { return true } - if failure.Cause() == mediasample.ReasonNoStream { + cause := failure.Cause() + if cause == mediasample.ReasonNoStream { return false } level := slog.LevelDebug - if e.hardware.FirstFailure(accel) { + if cause != mediasample.ReasonUnsupported && e.hardware.FirstFailure(accel) { level = slog.LevelWarn } e.logger.Log(ctx, level, "trickplay hardware decode failed; using software", diff --git a/internal/trickplay/remote.go b/internal/trickplay/remote.go index eb8ca72f21..fda92b4e7f 100644 --- a/internal/trickplay/remote.go +++ b/internal/trickplay/remote.go @@ -34,7 +34,7 @@ const ( // remoteOverhead covers the transfer and the node's own start on top of // a request's attempt timeouts. remoteOverhead = 30 * time.Second - // Generation caps chunks at 64 million pixels, including the initial + // Generation caps chunks at 32 million pixels, including the initial // geometry sample. This also fits the base64 form of uncompressible JPEGs. maxRemoteResultBytes = 512 << 20 ) @@ -130,7 +130,9 @@ func trickplayNodeEligible(node *nodepool.Node) bool { } // makesTrickplay reports whether a node's capability report advertises the -// trickplay endpoint. +// trickplay endpoint at the version this server's requests need: one that +// reads the tone-map source signal, which an older node would ignore and +// convert with the best-effort chains. func makesTrickplay(report []byte) bool { var capabilities struct { TransportFeatures []string `json:"transport_features"` @@ -138,7 +140,7 @@ func makesTrickplay(report []byte) bool { if len(report) == 0 || json.Unmarshal(report, &capabilities) != nil { return false } - return slices.Contains(capabilities.TransportFeatures, playback.TransportFeatureTrickplayExtractV1) + return slices.Contains(capabilities.TransportFeatures, playback.TransportFeatureTrickplayExtractV2) } // remote runs req on node. The node decides whether its hardware can decode, diff --git a/internal/trickplay/remote_test.go b/internal/trickplay/remote_test.go index 540a7b35b9..2885fbee7a 100644 --- a/internal/trickplay/remote_test.go +++ b/internal/trickplay/remote_test.go @@ -20,7 +20,7 @@ func (n fixedNodes) ReserveTranscodeWorkWith(workID string, eligible func(*nodep return plannerForNodes(n...).ReserveTranscodeWorkWith(workID, eligible) } -var trickplayCapabilities = []byte(`{"transport_features":["prepared_tracks_v1","trickplay_extract_v1"]}`) +var trickplayCapabilities = []byte(`{"transport_features":["prepared_tracks_v1","trickplay_extract_v1","trickplay_extract_v2"]}`) // fakeNode answers /trickplay/extract with status and body, counting calls. func fakeNode(t *testing.T, status int, body any) (*nodepool.Node, *atomic.Int32) { @@ -119,7 +119,9 @@ func TestNodeExtractorModes(t *testing.T) { func TestMakesTrickplay(t *testing.T) { for report, want := range map[string]bool{ - `{"transport_features":["trickplay_extract_v1"]}`: true, + `{"transport_features":["trickplay_extract_v1","trickplay_extract_v2"]}`: true, + // An older node would ignore the tone-map source signal. + `{"transport_features":["trickplay_extract_v1"]}`: false, `{"transport_features":["prepared_tracks_v1"]}`: false, `{}`: false, ``: false, diff --git a/internal/trickplay/repository.go b/internal/trickplay/repository.go index dec7fe2c9b..bff3d77075 100644 --- a/internal/trickplay/repository.go +++ b/internal/trickplay/repository.go @@ -205,6 +205,9 @@ type Published struct { SheetBytes []int Decoder string Filled int + // Refined counts the cells a repair read again (see + // mediasample.SheetFrames); it is logged, not stored. + Refined int } // Publish makes the claim's uploaded revision of fileID the one served, and diff --git a/internal/trickplay/service.go b/internal/trickplay/service.go index 42ac92be0a..b3e2aadc5e 100644 --- a/internal/trickplay/service.go +++ b/internal/trickplay/service.go @@ -55,9 +55,10 @@ const ( // maxChunkSheets bounds the sheets one ffmpeg run makes, which bounds // the memory of a run and the size of a transcode node's answer. maxChunkSheets = 16 - // Bound decoded output as well as sheet count. The first sheet learns - // actual display geometry before batching subsequent sheets. - maxChunkPixels = 64 << 20 + // Bound decoded output as well as sheet count: a run holds every tile it + // decodes, about 1.5 bytes a pixel, until its repairs are done. The first + // sheet learns actual display geometry before batching subsequent sheets. + maxChunkPixels = 32 << 20 // runThreads caps each ffmpeg run's decoder and filter threads. Work // runs at idle priority, so more parallelism comes from more workers. runThreads = 2 @@ -296,7 +297,7 @@ func (s *Service) process(ctx context.Context, job *Job) { log := s.logger.With("file_id", job.FileID, "duration", s.now().Sub(started).Round(time.Millisecond)) if err == nil { log.InfoContext(ctx, "trickplay sheets published", "thumbnails", published.Count, "sheets", len(published.SheetBytes), - "decoder", published.Decoder, "filled", published.Filled) + "decoder", published.Decoder, "filled", published.Filled, "refined", published.Refined) // A reconcile run while this job held its lease skipped the row, so // sheets made to settings that changed meanwhile are requeued now // rather than at the periodic pass. @@ -396,9 +397,7 @@ func (s *Service) generate(ctx context.Context, job *Job) (Published, error) { columns, rows := recipe.Grid() perSheet := columns * rows sheets := &mediasample.SheetsOutput{TileWidth: recipe.Width, TileHeight: height, Columns: columns, Rows: rows, Quality: Quality, UseInputAspect: true} - if tonemap.NeedsToneMap(&models.MediaFile{HDR: job.HDR, VideoTracks: job.VideoTracks}) { - sheets.ToneMap = &mediasample.ToneMap{AllowSoftware: true} - } + sheets.ToneMap = sheetsToneMap(&models.MediaFile{HDR: job.HDR, VideoTracks: job.VideoTracks}) published := Published{Recipe: recipe, StoreIdentity: s.store.Identity(), Height: height, Count: len(times)} var revision int64 chunkSheets := 1 @@ -444,6 +443,7 @@ func (s *Service) generate(ctx context.Context, job *Job) (Published, error) { published.SheetBytes = append(published.SheetBytes, len(sheet.JPEG)) } published.Filled += result.SheetFrames.Filled + published.Refined += result.SheetFrames.Refined published.Decoder = result.Decoder start += len(chunk) } @@ -457,6 +457,26 @@ func (s *Service) generate(ctx context.Context, job *Job) (Published, error) { return published, nil } +// sheetsToneMap is how a file's sheets convert HDR: nil for SDR, else +// software tone mapping allowed, since it only handles thumbnail-sized +// frames, with the base signal playback classifies the file's catalog facts +// as. A candidate that playback would confirm by decoding frames first is +// taken as it is, as thumbnails always have been best effort. A file the +// facts cannot classify, such as a legacy row without Dolby Vision presence +// flags, keeps the best-effort chains, except a Profile 5 one: its base is +// not a signal any chain without its RPU can show, so it takes the Dolby +// Vision signal and an RPU-applying executor. +func sheetsToneMap(file *models.MediaFile) *mediasample.ToneMap { + if !tonemap.NeedsToneMap(file) { + return nil + } + source := tonemap.ResolveSource(tonemap.MetadataForFile(file)).Kind + if source == "" && file.PrimaryDVProfile() == 5 { + source = tonemap.SourceDolbyVision + } + return &mediasample.ToneMap{AllowSoftware: true, Source: source} +} + // leaseAware reports a lost lease rather than the error it caused. func leaseAware(ctx context.Context, err error) error { if errors.Is(context.Cause(ctx), errLeaseLost) { diff --git a/internal/trickplay/service_test.go b/internal/trickplay/service_test.go index 5a2bfd9f75..c8a47a2f92 100644 --- a/internal/trickplay/service_test.go +++ b/internal/trickplay/service_test.go @@ -13,6 +13,7 @@ import ( "github.com/Silo-Server/silo-server/internal/mediasample" "github.com/Silo-Server/silo-server/internal/models" + "github.com/Silo-Server/silo-server/internal/tonemap" ) type fakeSettings map[string]string @@ -155,12 +156,13 @@ func TestGenerateChunksUploadsAndPublishes(t *testing.T) { store := &fakeStore{} extractor := &fakeExtractor{} s := testService(q, store, extractor, fakeSettings{IntervalSetting: "10"}) - // 5 h at 10 s: one geometry sheet, then batches of at most 16 sheets. + // 5 h at 10 s: one geometry sheet, then batches of 11 sheets, the most + // that fit maxChunkPixels. job := testJob(42, 18000) job.HDR = true s.process(t.Context(), job) - if len(extractor.requests) != 3 || len(extractor.requests[0].Samples.Seconds) != 80 || len(extractor.requests[1].Samples.Seconds) != 1280 || len(extractor.requests[2].Samples.Seconds) != 440 { + if len(extractor.requests) != 3 || len(extractor.requests[0].Samples.Seconds) != 80 || len(extractor.requests[1].Samples.Seconds) != 880 || len(extractor.requests[2].Samples.Seconds) != 840 { t.Fatalf("requests of %d and %d samples", len(extractor.requests[0].Samples.Seconds), len(extractor.requests[len(extractor.requests)-1].Samples.Seconds)) } first := extractor.requests[0] @@ -544,3 +546,30 @@ func TestPublishAtOldRecipeRequestsReconcile(t *testing.T) { default: } } + +// TestSheetsToneMapNamesTheSourceSignal hands sheets the base signal playback +// classifies a file's catalog facts as, and keeps the best-effort chains for +// facts it cannot classify. +func TestSheetsToneMapNamesTheSourceSignal(t *testing.T) { + profile5 := models.VideoTrack{DVProfile: 5, DVConfigPresent: true, DVBLCompatIDPresent: true, DVBLPresent: true, DVRPUPresent: true, DolbyVision: "Dolby Vision"} + tests := map[string]struct { + file *models.MediaFile + want *mediasample.ToneMap + }{ + "SDR": {&models.MediaFile{VideoTracks: []models.VideoTrack{{ColorTransfer: "bt709"}}}, nil}, + "HLG": {&models.MediaFile{HDR: true, VideoTracks: []models.VideoTrack{{ColorTransfer: "arib-std-b67", ColorPrimaries: "bt2020", ColorSpace: "bt2020nc", ColorRange: "tv"}}}, + &mediasample.ToneMap{AllowSoftware: true, Source: tonemap.SourceHLG}}, + "Dolby Vision profile 5": {&models.MediaFile{HDR: true, VideoTracks: []models.VideoTrack{profile5}}, + &mediasample.ToneMap{AllowSoftware: true, Source: tonemap.SourceDolbyVision}}, + "legacy Dolby Vision row": {&models.MediaFile{VideoTracks: []models.VideoTrack{{DolbyVision: "Dolby Vision", DVProfile: 8}}}, + &mediasample.ToneMap{AllowSoftware: true}}, + "legacy Dolby Vision profile 5 row": {&models.MediaFile{VideoTracks: []models.VideoTrack{{DolbyVision: "Dolby Vision", DVProfile: 5}}}, + &mediasample.ToneMap{AllowSoftware: true, Source: tonemap.SourceDolbyVision}}, + } + for name, tt := range tests { + got := sheetsToneMap(tt.file) + if (got == nil) != (tt.want == nil) || (got != nil && *got != *tt.want) { + t.Errorf("%s: tone map %+v, want %+v", name, got, tt.want) + } + } +} diff --git a/migrations/sql/20261013170245_requeue_trickplay_hdr_and_repairable_sheets.sql b/migrations/sql/20261013170245_requeue_trickplay_hdr_and_repairable_sheets.sql new file mode 100644 index 0000000000..47290631c4 --- /dev/null +++ b/migrations/sql/20261013170245_requeue_trickplay_hdr_and_repairable_sheets.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- Trickplay sheets now convert HDR with the chains playback validated for the +-- source's base signal, and repair cells a list read missed or repeated (see +-- docs/architecture/trickplay.md). Regenerate the published sheets the old +-- way got wrong, while they keep serving until their replacement publishes: +-- HDR sheets the VAAPI or QSV tone mapper made, which rendered a Dolby Vision +-- base without its RPU (green and magenta) and HLG as PQ, and sheets with +-- cells filled from a neighbor. Files whose last attempts failed, such as +-- VC-1 in Matroska, which flags no keyframes, are tried again now instead of +-- after their backoff. +UPDATE public.media_file_trickplay t +SET state = 'pending', available_at = now(), failure_count = 0, last_error = '', updated_at = now() +FROM public.media_files mf +WHERE mf.id = t.media_file_id + AND t.state = 'ready' + AND ( + (t.decoder IN ('hardware:vaapi', 'hardware:qsv') + AND (mf.hdr OR btrim(COALESCE(mf.video_tracks -> 0 ->> 'dolby_vision', '')) <> '')) + OR COALESCE(t.filled, 0) > 0 + ); + +UPDATE public.media_file_trickplay +SET available_at = now(), updated_at = now() +WHERE state = 'pending' AND failure_count > 0 AND available_at > now(); + +-- +goose Down +-- Requeued rows regenerate or keep serving their sheets; there is nothing to +-- restore. +SELECT 1; diff --git a/web/src/pages/admin-settings/PlaybackSettings.test.tsx b/web/src/pages/admin-settings/PlaybackSettings.test.tsx index 03ead25f42..3ad1537a8f 100644 --- a/web/src/pages/admin-settings/PlaybackSettings.test.tsx +++ b/web/src/pages/admin-settings/PlaybackSettings.test.tsx @@ -381,10 +381,14 @@ describe("PlaybackSettings seek-preview node capability", () => { it.each([ [{ capabilities: undefined }, true], [{ capabilities: { transport_features: ["chapter_extract_v1"] } }, true], - [{ capabilities: { transport_features: ["trickplay_extract_v1"] } }, false], + [{ capabilities: { transport_features: ["trickplay_extract_v1"] } }, true], + [ + { capabilities: { transport_features: ["trickplay_extract_v1", "trickplay_extract_v2"] } }, + false, + ], [ { - capabilities: { transport_features: ["trickplay_extract_v1"] }, + capabilities: { transport_features: ["trickplay_extract_v1", "trickplay_extract_v2"] }, advertised_capabilities_hash: "", capabilities_hash: "snapshot", }, @@ -392,7 +396,7 @@ describe("PlaybackSettings seek-preview node capability", () => { ], [ { - capabilities: { transport_features: ["trickplay_extract_v1"] }, + capabilities: { transport_features: ["trickplay_extract_v1", "trickplay_extract_v2"] }, advertised_capabilities_hash: "new-snapshot", capabilities_hash: "old-snapshot", }, @@ -400,14 +404,14 @@ describe("PlaybackSettings seek-preview node capability", () => { ], [ { - capabilities: { transport_features: ["trickplay_extract_v1"] }, + capabilities: { transport_features: ["trickplay_extract_v1", "trickplay_extract_v2"] }, advertised_capabilities_hash: "snapshot", }, true, ], [ { - capabilities: { transport_features: ["trickplay_extract_v1"] }, + capabilities: { transport_features: ["trickplay_extract_v1", "trickplay_extract_v2"] }, advertised_capabilities_hash: "snapshot", capabilities_hash: "snapshot", }, @@ -429,7 +433,7 @@ describe("PlaybackSettings seek-preview node capability", () => { useAdminNodesMock.mockReturnValue({ data: [ transcodeNode({ - capabilities: { transport_features: ["trickplay_extract_v1"] }, + capabilities: { transport_features: ["trickplay_extract_v1", "trickplay_extract_v2"] }, advertised_capabilities_hash: "new-snapshot", capabilities_hash: "old-snapshot", }), diff --git a/web/src/pages/admin-settings/playbackSettings.utils.ts b/web/src/pages/admin-settings/playbackSettings.utils.ts index 155229b804..14f9aa6bb2 100644 --- a/web/src/pages/admin-settings/playbackSettings.utils.ts +++ b/web/src/pages/admin-settings/playbackSettings.utils.ts @@ -90,14 +90,19 @@ export function hasUsableTranscodeNode(nodes: StreamNode[] | undefined): boolean return (nodes ?? []).some((node) => node.type === "transcode" && node.enabled && node.healthy); } -/** True when a node can extract seek previews with its current capability snapshot. */ +/** + * True when a node can extract seek previews with its current capability + * snapshot. Mirrors the server's rule (internal/trickplay makesTrickplay): only + * a node advertising trickplay_extract_v2, which reads the tone-map source + * signal, is sent work. + */ export function hasUsableTrickplayNode(nodes: StreamNode[] | undefined): boolean { return (nodes ?? []).some( (node) => node.type === "transcode" && node.enabled && node.healthy && - node.capabilities?.transport_features?.includes("trickplay_extract_v1") && + node.capabilities?.transport_features?.includes("trickplay_extract_v2") && (node.advertised_capabilities_hash === undefined || (node.advertised_capabilities_hash !== "" && node.advertised_capabilities_hash === node.capabilities_hash)), From 864d7d139b18baa13b537532eea1ea32b56f75ae Mon Sep 17 00:00:00 2001 From: bulderi <62747998+bulderi@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:54:35 +0200 Subject: [PATCH 024/196] fix(apiv2): describe notification reason_flags as notification data (#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 #2381 Co-authored-by: Claude Opus 5.5 --- contracts/api/v2/openapi.json | 5 +- internal/apiv2/notification_inbox.go | 62 ++++++++++++++++------- internal/apiv2/notification_inbox_test.go | 40 +++++++++++++++ web/src/api/v2/schema.ts | 6 ++- 4 files changed, 93 insertions(+), 20 deletions(-) diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index ce483bef62..699c23f88b 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -35525,7 +35525,10 @@ ] }, "reason_flags": { - "description": "A JSON value whose shape is the setting's value_schema in the settings contract." + "additionalProperties": true, + "description": "Event-specific reason data. Its keys depend on the notification type; clients ignore keys they do not recognize.", + "type": "object", + "x-silo-extension-bag": "notification-reason-flags" }, "season_number": { "format": "int64", diff --git a/internal/apiv2/notification_inbox.go b/internal/apiv2/notification_inbox.go index 67d2999767..f88480b342 100644 --- a/internal/apiv2/notification_inbox.go +++ b/internal/apiv2/notification_inbox.go @@ -8,6 +8,8 @@ import ( "strconv" "time" + "github.com/danielgtaylor/huma/v2" + "github.com/Silo-Server/silo-server/internal/api/handlers" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/notifications" @@ -29,26 +31,52 @@ type NotificationInboxService interface { // NotificationItem preserves event-specific reason data while normalizing IDs // and timestamps for the native API. Realtime retains its existing event shape. type NotificationItem struct { - ID ID `json:"id"` - Type string `json:"type"` - ProfileID ID `json:"profile_id"` - LibraryID *ID `json:"library_id,omitempty"` - SeriesID *ID `json:"series_id,omitempty"` - EpisodeID *ID `json:"episode_id,omitempty"` - SeriesTitle string `json:"series_title,omitempty"` - EpisodeTitle string `json:"episode_title,omitempty"` - SeasonNumber *int `json:"season_number,omitempty"` - EpisodeNumber *int `json:"episode_number,omitempty"` - PosterPath string `json:"poster_path,omitempty"` - PosterURL string `json:"poster_url,omitempty"` - PosterThumbhash string `json:"poster_thumbhash,omitempty"` - ReasonFlags JSONValue `json:"reason_flags"` - CreatedAt Instant `json:"created_at"` - ReadAt NullableInstant `json:"read_at"` + ID ID `json:"id"` + Type string `json:"type"` + ProfileID ID `json:"profile_id"` + LibraryID *ID `json:"library_id,omitempty"` + SeriesID *ID `json:"series_id,omitempty"` + EpisodeID *ID `json:"episode_id,omitempty"` + SeriesTitle string `json:"series_title,omitempty"` + EpisodeTitle string `json:"episode_title,omitempty"` + SeasonNumber *int `json:"season_number,omitempty"` + EpisodeNumber *int `json:"episode_number,omitempty"` + PosterPath string `json:"poster_path,omitempty"` + PosterURL string `json:"poster_url,omitempty"` + PosterThumbhash string `json:"poster_thumbhash,omitempty"` + ReasonFlags NotificationReasonFlags `json:"reason_flags"` + CreatedAt Instant `json:"created_at"` + ReadAt NullableInstant `json:"read_at"` +} + +// NotificationReasonFlags is a delivery's event-specific reason data: the +// stored reason_flags object, carried as written. Its keys depend on the +// delivery type, so the contract declares only an object. +type NotificationReasonFlags []byte + +func (v NotificationReasonFlags) MarshalJSON() ([]byte, error) { + if len(v) == 0 { + return []byte("{}"), nil + } + return v, nil +} + +func (v *NotificationReasonFlags) UnmarshalJSON(b []byte) error { + *v = append((*v)[:0], b...) + return nil +} + +func (NotificationReasonFlags) Schema(huma.Registry) *huma.Schema { + return &huma.Schema{ + Type: huma.TypeObject, + AdditionalProperties: true, + Description: "Event-specific reason data. Its keys depend on the notification type; clients ignore keys they do not recognize.", + Extensions: map[string]any{extExtensionBag: "notification-reason-flags"}, + } } func notificationItemOf(row notifications.DeliveryRowPayload) NotificationItem { - out := NotificationItem{ID: ID(row.ID), Type: row.Type, ProfileID: ID(row.ProfileID), SeriesTitle: row.SeriesTitle, EpisodeTitle: row.EpisodeTitle, SeasonNumber: row.SeasonNumber, EpisodeNumber: row.EpisodeNumber, PosterPath: row.PosterPath, PosterURL: row.PosterURL, PosterThumbhash: row.PosterThumbhash, ReasonFlags: JSONValue(row.ReasonFlags), CreatedAt: NewInstant(row.CreatedAt)} + out := NotificationItem{ID: ID(row.ID), Type: row.Type, ProfileID: ID(row.ProfileID), SeriesTitle: row.SeriesTitle, EpisodeTitle: row.EpisodeTitle, SeasonNumber: row.SeasonNumber, EpisodeNumber: row.EpisodeNumber, PosterPath: row.PosterPath, PosterURL: row.PosterURL, PosterThumbhash: row.PosterThumbhash, ReasonFlags: NotificationReasonFlags(row.ReasonFlags), CreatedAt: NewInstant(row.CreatedAt)} if row.LibraryID != nil { out.LibraryID = new(ID(strconv.Itoa(*row.LibraryID))) } diff --git a/internal/apiv2/notification_inbox_test.go b/internal/apiv2/notification_inbox_test.go index b1876527de..af96b17d8d 100644 --- a/internal/apiv2/notification_inbox_test.go +++ b/internal/apiv2/notification_inbox_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/url" + "strings" "testing" "time" @@ -243,3 +244,42 @@ func TestNotificationDisplayCredentialScope(t *testing.T) { }) } } + +// reason_flags is notification data, not a settings value: the contract must +// not borrow JSONValue's settings description for it. +func TestNotificationItemReasonFlagsSchema(t *testing.T) { + NewHandler(Dependencies{testRegister: func(reg *Registry) { + item := reg.api.OpenAPI().Components.Schemas.Map()["NotificationItem"] + if item == nil { + t.Fatal("NotificationItem schema is not published") + } + flags := item.Properties["reason_flags"] + if flags == nil { + t.Fatal("NotificationItem.reason_flags is not declared") + } + if flags.Type != "object" || flags.AdditionalProperties != true { + t.Fatalf("reason_flags = type %q, additionalProperties %v; want an open object", flags.Type, flags.AdditionalProperties) + } + if flags.Extensions[extExtensionBag] != "notification-reason-flags" { + t.Fatalf("reason_flags extension bag = %v", flags.Extensions[extExtensionBag]) + } + if !strings.Contains(flags.Description, "notification type") || strings.Contains(flags.Description, "value_schema") { + t.Fatalf("reason_flags description = %q", flags.Description) + } + }}) +} + +func TestNotificationReasonFlagsMarshal(t *testing.T) { + for _, tc := range []struct { + in NotificationReasonFlags + want string + }{ + {nil, `{}`}, + {NotificationReasonFlags(`{"follower":true,"request_id":"request-1"}`), `{"follower":true,"request_id":"request-1"}`}, + } { + got, err := json.Marshal(tc.in) + if err != nil || string(got) != tc.want { + t.Fatalf("marshal %q = %s, %v; want %s", tc.in, got, err, tc.want) + } + } +} diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index b1da85b7be..7d9794fd28 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -25794,8 +25794,10 @@ export interface components { * @description RFC 3339 instant in UTC with millisecond precision, or null */ read_at: string | null; - /** @description A JSON value whose shape is the setting's value_schema in the settings contract. */ - reason_flags: unknown; + /** @description Event-specific reason data. Its keys depend on the notification type; clients ignore keys they do not recognize. */ + reason_flags: { + [key: string]: unknown; + }; /** Format: int64 */ season_number?: number; /** From fb6c444924f55ac3bf8133341b7b787b085afa01 Mon Sep 17 00:00:00 2001 From: bulderi <62747998+bulderi@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:55:49 +0200 Subject: [PATCH 025/196] fix(web): keep debouncing admin log filters after Reconnect (#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 #2389 Co-authored-by: Claude Opus 5.5 --- .../admin/useAdminLogStream.scope.test.ts | 34 +++++++++++++++++++ web/src/hooks/admin/useAdminLogStream.ts | 10 ++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/web/src/hooks/admin/useAdminLogStream.scope.test.ts b/web/src/hooks/admin/useAdminLogStream.scope.test.ts index b27afdbbb1..d5bb6abcb0 100644 --- a/web/src/hooks/admin/useAdminLogStream.scope.test.ts +++ b/web/src/hooks/admin/useAdminLogStream.scope.test.ts @@ -203,3 +203,37 @@ it("reports a filter change as connecting until the new selection's snapshot arr expect(result.current.rows).toEqual([]); expect(result.current.isConnecting).toBe(false); }); + +it("keeps debouncing filter changes after an explicit reconnect", async () => { + vi.useFakeTimers(); + vi.stubGlobal("WebSocket", FakeWebSocket); + mint.mockResolvedValue({ ticket: "test-ticket" }); + const { result, rerender } = renderHook( + ({ method }) => { + const params = useMemo(() => ({ method }), [method]); + return useAdminLogStream("audit", params, true); + }, + { initialProps: { method: "PUT" } }, + ); + await act(async () => vi.advanceTimersByTimeAsync(250)); + expect(mint).toHaveBeenCalledTimes(1); + + act(() => result.current.reconnect()); + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(mint).toHaveBeenCalledTimes(2); + + for (const method of ["PO", "POS", "POST"]) { + rerender({ method }); + await act(async () => vi.advanceTimersByTimeAsync(0)); + } + expect(mint).toHaveBeenCalledTimes(2); + + await act(async () => vi.advanceTimersByTimeAsync(249)); + expect(mint).toHaveBeenCalledTimes(2); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(mint).toHaveBeenCalledTimes(3); + + act(() => result.current.reconnect()); + await act(async () => vi.advanceTimersByTimeAsync(0)); + expect(mint).toHaveBeenCalledTimes(4); +}); diff --git a/web/src/hooks/admin/useAdminLogStream.ts b/web/src/hooks/admin/useAdminLogStream.ts index 97ff3bdee5..7d175b6123 100644 --- a/web/src/hooks/admin/useAdminLogStream.ts +++ b/web/src/hooks/admin/useAdminLogStream.ts @@ -1,4 +1,4 @@ -import { startTransition, useDeferredValue, useEffect, useMemo, useState } from "react"; +import { startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import { captureProfileRequestContext, isCapturedProfileAuthorityActive } from "@/api/client"; import { adminLogsSocketProtocols, @@ -61,6 +61,7 @@ export function useAdminLogStream( const [connectionState, setConnectionState] = useState("disconnected"); const [error, setError] = useState(); const [reconnectNonce, setReconnectNonce] = useState(0); + const attemptedNonce = useRef(reconnectNonce); const deferredParams = useDeferredValue(params); const queryString = useMemo(() => buildAdminLogsSocketQuery(deferredParams), [deferredParams]); @@ -116,11 +117,14 @@ export function useAdminLogStream( setConnectionState("connecting"); setError(undefined); - // Debounce the initial attempt; an explicit reconnect() retries immediately. + // Debounce every attempt except the one an explicit reconnect() starts, so + // filter changes after a reconnect still wait for typing to settle. // Authority is captured once per connection attempt: the credential is // minted under it, and frames arriving after it changed are discarded. let closedByEffect = false; - const connectDelay = reconnectNonce === 0 ? 250 : 0; + const reconnectRequested = reconnectNonce !== attemptedNonce.current; + attemptedNonce.current = reconnectNonce; + const connectDelay = reconnectRequested ? 0 : 250; const connectTimer = window.setTimeout(() => { const authority = captureProfileRequestContext(); if (!authority) { From 22489b17b9ced83dbcb1d8281b1ac01bcbb30064 Mon Sep 17 00:00:00 2001 From: Daniel Shafer Date: Tue, 13 Oct 2026 14:57:00 -0700 Subject: [PATCH 026/196] fix(opslog): store logged errors as their text instead of {} (#1658) * fix(opslog): store logged errors as their text instead of {} Fixes #1605. Co-Authored-By: Claude Opus 5.5 * 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 * 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 Claude-Session: https://claude.ai/code/session_01Uj5ANjmwiWfx36d1AEHjKH --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Daniel Shafer --- internal/logredact/diagnostic.go | 52 +++++++++++++++ internal/logredact/diagnostic_test.go | 16 +++++ internal/logredact/url.go | 48 +++++++++++++- internal/logredact/url_test.go | 37 +++++++++++ internal/opslog/handler.go | 27 ++++++++ internal/opslog/handler_test.go | 91 ++++++++++++++++++++++++++- 6 files changed, 269 insertions(+), 2 deletions(-) diff --git a/internal/logredact/diagnostic.go b/internal/logredact/diagnostic.go index e924976fa3..2b86658b6a 100644 --- a/internal/logredact/diagnostic.go +++ b/internal/logredact/diagnostic.go @@ -124,6 +124,58 @@ func looksLikeDiagnosticURL(value string) bool { return true } +// assignmentKey matches the key half of a key=value pair in free text. The +// value is not part of the match, so a non-secret pair such as desc=api_key=x +// cannot swallow the secret assignment that follows it. +var assignmentKey = regexp.MustCompile(`[A-Za-z0-9_.-]+=`) + +// RedactSecretAssignments masks the value of every key=value pair in free text +// whose key is secret-bearing (api_key=, access_token=, password=, ...). It is +// for error text that crosses a boundary as a plain string, such as a gRPC +// status description, where no structured URL is left to sanitize. +func RedactSecretAssignments(text string) string { + if !strings.Contains(text, "=") { + return text + } + var out strings.Builder + last := 0 + for _, m := range assignmentKey.FindAllStringIndex(text, -1) { + if m[0] < last || !diagnosticSecretKey(text[m[0]:m[1]-1]) { + continue + } + start, end := assignmentValue(text, m[1]) + if start == end { + continue + } + out.WriteString(text[last:start]) + out.WriteString(Placeholder) + last = end + } + if last == 0 { + return text + } + out.WriteString(text[last:]) + return out.String() +} + +// assignmentValue returns the bounds of the value that starts at i. A quoted +// value runs to its closing quote; a bare one ends at whitespace, a query or +// list separator, a quote or a closing bracket. +func assignmentValue(text string, i int) (start, end int) { + if i < len(text) && (text[i] == '"' || text[i] == '\'') { + quote := text[i] + if j := strings.IndexByte(text[i+1:], quote); j >= 0 { + return i + 1, i + 1 + j + } + return i + 1, len(text) + } + end = i + for end < len(text) && !strings.ContainsRune(" \t\r\n&;,\"')]}>", rune(text[end])) { + end++ + } + return i, end +} + // secretAssignmentPattern matches a key=value or key: value pair whose key // names a credential, anywhere in free text. Matching on the key alone means a // non-secret outer assignment (desc = api_key=SECRET) cannot hide a nested one. diff --git a/internal/logredact/diagnostic_test.go b/internal/logredact/diagnostic_test.go index 1661a6cbdc..fad94775ef 100644 --- a/internal/logredact/diagnostic_test.go +++ b/internal/logredact/diagnostic_test.go @@ -136,6 +136,22 @@ func TestSanitizeJSONRedactsURLFormsWithSpaces(t *testing.T) { } } +func TestRedactSecretAssignments(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"desc = api_key=s3cret rejected", "desc = api_key=[REDACTED] rejected"}, + {"desc=api_key=s3cret", "desc=api_key=[REDACTED]"}, + {`Get "https://h.example/x?Limit=5&access_token=s3cret": EOF`, `Get "https://h.example/x?Limit=5&access_token=[REDACTED]": EOF`}, + {`password="two words" and X-Emby-Token='s3cret'`, `password="[REDACTED]" and X-Emby-Token='[REDACTED]'`}, + {"Pw=s3cret;status=401", "Pw=[REDACTED];status=401"}, + {"status=401 attempts=3", "status=401 attempts=3"}, + {"api_key= empty", "api_key= empty"}, + } { + if got := RedactSecretAssignments(tc.in); got != tc.want { + t.Errorf("RedactSecretAssignments(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + func TestSanitizeTextMasksCredentials(t *testing.T) { for _, text := range []string{ "desc = api_key=SECRET", diff --git a/internal/logredact/url.go b/internal/logredact/url.go index 9cc5dfa3c8..a36bae72e8 100644 --- a/internal/logredact/url.go +++ b/internal/logredact/url.go @@ -4,6 +4,7 @@ import ( "errors" "net/url" "sort" + "strconv" "strings" ) @@ -74,7 +75,51 @@ func SanitizeURLError(err error) error { default: outer = clone.Error() } - return &sanitizedWrapperError{message: outer, cause: &clone} + return &sanitizedWrapperError{message: scrubRequestedURLs(outer, err), cause: &clone} +} + +// scrubRequestedURLs replaces the raw URL of every *url.Error in err's tree +// wherever it still appears in message. errors.As above only finds the first +// URL error, so a second one under a joined cause, or a wrapper that repeats +// the requested URL in its own text, would otherwise keep its credentials. +func scrubRequestedURLs(message string, err error) string { + var raws []string + collectRequestedURLs(err, &raws, 0) + // Longest first, so a URL that prefixes another is not rewritten inside it. + sort.SliceStable(raws, func(a, b int) bool { return len(raws[a]) > len(raws[b]) }) + for _, raw := range raws { + // Credentials can only sit in userinfo, query or fragment. Skipping + // other URLs keeps short or unusual values from rewriting plain text. + if !strings.ContainsAny(raw, "@?#") { + continue + } + clean := SanitizeURL(raw) + message = strings.ReplaceAll(message, strconv.Quote(raw), strconv.Quote(clean)) + message = strings.ReplaceAll(message, raw, clean) + } + return message +} + +func collectRequestedURLs(err error, raws *[]string, depth int) { + if err == nil || depth > 64 { + return + } + if urlErr, ok := err.(*url.Error); ok { //nolint:errorlint // walking the tree by hand + if urlErr == nil { + return + } + if urlErr.URL != "" { + *raws = append(*raws, urlErr.URL) + } + } + switch wrapped := err.(type) { //nolint:errorlint // walking the tree by hand + case interface{ Unwrap() []error }: + for _, child := range wrapped.Unwrap() { + collectRequestedURLs(child, raws, depth+1) + } + case interface{ Unwrap() error }: + collectRequestedURLs(wrapped.Unwrap(), raws, depth+1) + } } // sanitizeMultiURLError rebuilds a multi-error's message in place: each @@ -110,6 +155,7 @@ func sanitizeMultiURLError(original error, components []error) error { if !allPresent { return errors.Join(sanitized...) } + outer = scrubRequestedURLs(outer, original) if outer == original.Error() { return original } diff --git a/internal/logredact/url_test.go b/internal/logredact/url_test.go index 1803d268bd..faf149dfb0 100644 --- a/internal/logredact/url_test.go +++ b/internal/logredact/url_test.go @@ -162,6 +162,43 @@ func TestSanitizeURLErrorPreservesMultiPercentWFmtMessage(t *testing.T) { } } +func TestSanitizeURLErrorSanitizesEveryJoinedURL(t *testing.T) { + err := fmt.Errorf("fetch: %w", errors.Join( + &url.Error{Op: "Get", URL: "https://operator:first-secret@a.example/v?token=first-token", Err: errors.New("timeout")}, + &url.Error{Op: "Get", URL: "https://operator:second-secret@b.example/v?token=second-token", Err: errors.New("refused")}, + )) + + message := SanitizeURLError(err).Error() + for _, secret := range []string{"operator", "first-secret", "first-token", "second-secret", "second-token"} { + if strings.Contains(message, secret) { + t.Fatalf("sanitized joined error contains %q: %q", secret, message) + } + } + for _, want := range []string{"fetch: ", "https://a.example/v", "timeout", "https://b.example/v", "refused"} { + if !strings.Contains(message, want) { + t.Fatalf("sanitized joined error lost %q: %q", want, message) + } + } +} + +func TestSanitizeURLErrorSanitizesURLRepeatedByWrapper(t *testing.T) { + const raw = "https://cdn.example/v.mp4?token=query-secret" + cause := errors.New("timeout") + err := fmt.Errorf("GET %s failed: %w", raw, &url.Error{Op: "Get", URL: raw, Err: cause}) + + sanitized := SanitizeURLError(err) + message := sanitized.Error() + if strings.Contains(message, "query-secret") { + t.Fatalf("sanitized wrapper repeats the raw URL: %q", message) + } + if want := `GET https://cdn.example/v.mp4 failed: Get "https://cdn.example/v.mp4": timeout`; message != want { + t.Fatalf("sanitized wrapper = %q, want %q", message, want) + } + if !errors.Is(sanitized, cause) { + t.Fatalf("sanitized wrapper does not preserve its cause: %v", sanitized) + } +} + func TestSanitizeURLFailsClosedForMalformedInput(t *testing.T) { const secret = "node-password" got := SanitizeURL("https://operator:" + secret + "@node.example/\x00") diff --git a/internal/opslog/handler.go b/internal/opslog/handler.go index 8415aeb91e..1cefdf3252 100644 --- a/internal/opslog/handler.go +++ b/internal/opslog/handler.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "log/slog" + "reflect" "strings" "time" @@ -173,12 +174,38 @@ func attrValue(v slog.Value) any { case slog.KindTime: return v.Time().UTC().Format(time.RFC3339Nano) case slog.KindAny: + // Error types from errors.New and fmt.Errorf have no exported fields + // and would encode as {}, hiding the failure cause. Record the text, + // as the console handler does. + if err, ok := v.Any().(error); ok && err != nil { + return errorText(err) + } return snapshot(v.Any()) default: return v.String() } } +// errorText records an error's message for the stored entry. Requested URLs +// inside the chain are sanitized with logredact.SanitizeURLError, the same +// helper transport errors pass through elsewhere, so signed stream URLs and +// query-string tokens are not written to the database. Secret key=value pairs +// left in plain text, such as a plugin's gRPC status description, are masked +// with logredact.RedactSecretAssignments. A typed-nil pointer +// that satisfies error is recorded as a placeholder instead of calling Error +// on a nil receiver, and a panicking Error method is contained. +func errorText(err error) (text string) { + if rv := reflect.ValueOf(err); rv.Kind() == reflect.Pointer && rv.IsNil() { + return fmt.Sprintf("", err) + } + defer func() { + if r := recover(); r != nil { + text = fmt.Sprintf("<%T: Error panicked>", err) + } + }() + return logredact.RedactSecretAssignments(logredact.SanitizeURLError(err).Error()) +} + // snapshot encodes a value the caller still owns, such as a map, slice or // pointer. The consumer encodes Attrs on its own goroutine seconds later, or // longer while Postgres is down, and a caller that changed a logged map in the diff --git a/internal/opslog/handler_test.go b/internal/opslog/handler_test.go index 67edadd423..5c42920b6f 100644 --- a/internal/opslog/handler_test.go +++ b/internal/opslog/handler_test.go @@ -4,7 +4,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "log/slog" + "net/url" + "strings" "testing" ) @@ -32,7 +35,7 @@ func TestHandlerSnapshotsValuesTheCallerOwns(t *testing.T) { t.Fatal(err) } // encoding/json sorts map keys, so the encoding is stable. - want := `{"counts":{"movies":1},"error":{},"ids":[1,2],"static":{"movies":1},"status":200}` + want := `{"counts":{"movies":1},"error":"boom","ids":[1,2],"static":{"movies":1},"status":200}` if string(got) != want { t.Fatalf("attrs = %s, want %s", got, want) } @@ -57,3 +60,89 @@ func TestHandlerKeepsAValueItCannotEncode(t *testing.T) { t.Fatalf("attrs[status] = %#v, want 200", entry.Attrs["status"]) } } + +// TestHandlerRecordsErrorText checks that an error attr is stored as its +// text. Errors from errors.New and fmt.Errorf have no exported fields, so a +// JSON encode would store {} and hide why the logged operation failed. +func TestHandlerRecordsErrorText(t *testing.T) { + t.Parallel() + writer := &recordingWriter{} + logger := slog.New(NewHandler(slog.DiscardHandler, writer, slog.LevelInfo, "node-a")) + + wrapped := fmt.Errorf("loading overlay summaries: %w", errors.New("connection refused")) + logger.With("static_err", errors.New("static")).ErrorContext(context.Background(), "probe: error", "error", wrapped) + + writer.mu.Lock() + entry := writer.entries[0] + writer.mu.Unlock() + if got := entry.Attrs["error"]; got != "loading overlay summaries: connection refused" { + t.Fatalf("attrs[error] = %#v, want the error text", got) + } + if got := entry.Attrs["static_err"]; got != "static" { + t.Fatalf("attrs[static_err] = %#v, want the error text", got) + } +} + +type nilPtrErr struct{ msg string } + +func (e *nilPtrErr) Error() string { return e.msg } + +// TestHandlerRecordsTypedNilError checks that a nil pointer satisfying error +// is recorded instead of panicking when Error dereferences the receiver. +func TestHandlerRecordsTypedNilError(t *testing.T) { + t.Parallel() + writer := &recordingWriter{} + logger := slog.New(NewHandler(slog.DiscardHandler, writer, slog.LevelInfo, "node-a")) + + var err *nilPtrErr + logger.ErrorContext(context.Background(), "probe: error", "error", err) + + writer.mu.Lock() + entry := writer.entries[0] + writer.mu.Unlock() + if got := entry.Attrs["error"]; got != "" { + t.Fatalf("attrs[error] = %#v, want a nil placeholder", got) + } +} + +// TestHandlerSanitizesErrorURLs checks that a requested URL inside an error +// chain loses its query string and credentials before it is stored. +func TestHandlerSanitizesErrorURLs(t *testing.T) { + t.Parallel() + writer := &recordingWriter{} + logger := slog.New(NewHandler(slog.DiscardHandler, writer, slog.LevelInfo, "node-a")) + + urlErr := &url.Error{Op: "Get", URL: "https://user:pass@cdn.example/v.mp4?token=s3cret&sig=abc", Err: errors.New("timeout")} + logger.ErrorContext(context.Background(), "probe: error", "error", fmt.Errorf("fetch stream: %w", urlErr)) + + writer.mu.Lock() + entry := writer.entries[0] + writer.mu.Unlock() + got, _ := entry.Attrs["error"].(string) + if strings.Contains(got, "s3cret") || strings.Contains(got, "pass") || strings.Contains(got, "sig=") { + t.Fatalf("attrs[error] = %q leaks a secret", got) + } + if !strings.Contains(got, "fetch stream") || !strings.Contains(got, "timeout") { + t.Fatalf("attrs[error] = %q, want the wrapper and cause kept", got) + } +} + +// TestHandlerRedactsSecretAssignmentsInErrorText checks that a secret +// key=value pair in plain error text, as a plugin's gRPC status description +// carries it, is masked before the entry is stored. +func TestHandlerRedactsSecretAssignmentsInErrorText(t *testing.T) { + t.Parallel() + writer := &recordingWriter{} + logger := slog.New(NewHandler(slog.DiscardHandler, writer, slog.LevelInfo, "node-a")) + + err := errors.New("rpc error: code = Unauthenticated desc = api_key=FAKE_FIXTURE_SECRET rejected") + logger.ErrorContext(context.Background(), "probe: error", "error", fmt.Errorf("metadata lookup: %w", err)) + + writer.mu.Lock() + entry := writer.entries[0] + writer.mu.Unlock() + got, _ := entry.Attrs["error"].(string) + if want := "metadata lookup: rpc error: code = Unauthenticated desc = api_key=[REDACTED] rejected"; got != want { + t.Fatalf("attrs[error] = %q, want %q", got, want) + } +} From c13a6697c4c27d438f33f134e69118e15ac58e27 Mon Sep 17 00:00:00 2001 From: Brady3035 <40967324+Brady3035@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:27:08 -0600 Subject: [PATCH 027/196] feat(admin): show total egress for the selected window (#2406) * Initial Feature * Revert "Initial Feature" This reverts commit eec3f378b3f733e93b639c057ff8af830006cce2. * 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 --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- contracts/api/v2/openapi.json | 14 +++ docs/admin-api.md | 18 +++- .../api/handlers/admin_stats_timeseries.go | 49 ++++++++-- .../handlers/admin_stats_timeseries_test.go | 63 ++++++++++++- .../apiv2/admin_dashboard_capabilities.go | 5 +- .../admin_dashboard_capabilities_test.go | 2 +- internal/apiv2/admin_dashboard_insights.go | 14 +-- .../apiv2/admin_dashboard_insights_test.go | 4 +- web/src/api/types.ts | 7 ++ web/src/api/v2/schema.ts | 5 + .../components/admin/dashboard/format.test.ts | 40 ++++++++ web/src/components/admin/dashboard/format.ts | 33 +++++++ .../dashboard/widgets/EgressWidget.test.tsx | 94 +++++++++++++++++++ .../admin/dashboard/widgets/EgressWidget.tsx | 13 ++- 14 files changed, 337 insertions(+), 24 deletions(-) create mode 100644 web/src/components/admin/dashboard/format.test.ts create mode 100644 web/src/components/admin/dashboard/widgets/EgressWidget.test.tsx diff --git a/contracts/api/v2/openapi.json b/contracts/api/v2/openapi.json index 699c23f88b..5ee3539cfe 100644 --- a/contracts/api/v2/openapi.json +++ b/contracts/api/v2/openapi.json @@ -4348,6 +4348,9 @@ "downloads_stats": { "type": "boolean" }, + "egress_totals": { + "type": "boolean" + }, "health": { "type": "boolean" }, @@ -4393,6 +4396,7 @@ "log_level_list", "watch_providers", "downloads_stats", + "egress_totals", "revision", "state", "allowed" @@ -4661,6 +4665,14 @@ "AdminDashboardTimeseries": { "additionalProperties": false, "properties": { + "download_egress_bytes": { + "format": "int64", + "type": "integer" + }, + "egress_bytes": { + "format": "int64", + "type": "integer" + }, "from": { "description": "RFC 3339 instant in UTC with millisecond precision", "format": "date-time", @@ -4701,6 +4713,8 @@ "from", "to", "oldest_sample_at", + "egress_bytes", + "download_egress_bytes", "points" ], "type": "object" diff --git a/docs/admin-api.md b/docs/admin-api.md index 130e582183..e874b7989f 100644 --- a/docs/admin-api.md +++ b/docs/admin-api.md @@ -1041,6 +1041,7 @@ client tells "this deployment is older than my build" from "the request failed". | `log_level_list` | `GET /admin/logs/app` accepts a multi-level filter. | | `watch_providers` | `GET /admin/stats` carries the per-provider `watch_providers` array. | | `downloads_stats` | `GET /admin/stats/downloads` serves the offline-download aggregate, and timeseries points carry the additive `download_egress_kbps` split. | +| `egress_totals` | `GET /admin/stats/timeseries` carries the window's additive `egress_bytes` and `download_egress_bytes` totals. | ```json { @@ -1051,7 +1052,8 @@ client tells "this deployment is older than my build" from "the request failed". "health": true, "log_level_list": true, "watch_providers": true, - "downloads_stats": true + "downloads_stats": true, + "egress_totals": true } ``` @@ -1186,6 +1188,12 @@ an average: these charts are read to answer "how bad did it get", and a mean would erase exactly that. Stream counts and egress are maxed independently, so a bucket's columns may come from different minutes within it. +`egress_bytes` and `download_egress_bytes` are the data served over the whole +window, integrated from every sampled minute rather than from `points` — summing +the peak-per-bucket points would count a one-minute spike as if it lasted the +whole bucket. Unsampled minutes contribute nothing and node egress is a rolling +average, so treat the totals as a close estimate, not byte-exact accounting. + Each minute holds up to two kinds of row. The `shared` row is the cluster-wide snapshot: stream counts by play method, plus the egress reported by enabled, healthy stream nodes. Every replica tries to write it and the first one to land @@ -1228,6 +1236,8 @@ fresh install renders "collecting data" instead of an empty chart. "from": "2026-08-25T12:00:00Z", "to": "2026-08-26T12:00:00Z", "oldest_sample_at": "2026-08-24T09:31:00Z", + "egress_bytes": 412500000000, + "download_egress_bytes": 37800000000, "points": [ { "t": "2026-08-26T11:55:00Z", @@ -2211,12 +2221,12 @@ invalidates the exact compatibility status scope only for the active authority. ### Dashboard capability discovery in v2 -`GET /api/v2/admin/dashboard/capabilities` returns the eight dashboard support +`GET /api/v2/admin/dashboard/capabilities` returns the nine dashboard support flags to an acting administrator. These describe the v2 surface in the API build; they do not promise that a database or other runtime dependency is healthy. -`timeseries`, `playback_activity`, `top_activity`, `health`, `watch_providers` and -`downloads_stats` are true for the existing v2 dashboard operations. +`timeseries`, `playback_activity`, `top_activity`, `health`, `watch_providers`, +`downloads_stats` and `egress_totals` are true for the existing v2 dashboard operations. `server_layouts` remains false while the v2 layout lifecycle is unavailable. `log_level_list` is true for the accepted multi-level application-log reader. Clients must not infer v2 support from the bridge's independently retained capabilities. diff --git a/internal/api/handlers/admin_stats_timeseries.go b/internal/api/handlers/admin_stats_timeseries.go index 6c17cd0a17..955ef069b4 100644 --- a/internal/api/handlers/admin_stats_timeseries.go +++ b/internal/api/handlers/admin_stats_timeseries.go @@ -34,6 +34,10 @@ const ( // under. A dashboard widget is a few hundred CSS pixels wide, so a month of // minutes would spend megabytes drawing sub-pixel detail nobody can see. adminTimeseriesMaxPoints = 750 + + // bytesPerKbpsMinute converts one sampled minute at 1 kbps into bytes: + // 1000 bits/s × 60 s ÷ 8 bits per byte. + bytesPerKbpsMinute = 1000 * adminTimeseriesResolutionSeconds / 8 ) // timeseriesBucketSeconds picks the display bucket for a window, keeping every @@ -95,12 +99,22 @@ type AdminTimeseriesPoint struct { // // ResolutionSeconds is the bucket this window was aggregated into, not a // constant: it widens with the requested window (timeseriesBucketSeconds). +// +// EgressBytes and DownloadEgressBytes are the data transferred over the whole +// window, integrated from every sampled minute rather than from Points: the +// points are peak-per-bucket, so summing them would count a one-minute spike +// as if it lasted the whole bucket. Minutes the sampler missed contribute +// nothing, and node egress is a rolling average, so the totals are a close +// estimate rather than exact byte accounting. /api/v1 is frozen, so only v2 +// emits them (json:"-"). type AdminTimeseries struct { - ResolutionSeconds int `json:"resolution_seconds"` - From time.Time `json:"from"` - To time.Time `json:"to"` - OldestSampleAt *time.Time `json:"oldest_sample_at"` - Points []AdminTimeseriesPoint `json:"points"` + ResolutionSeconds int `json:"resolution_seconds"` + From time.Time `json:"from"` + To time.Time `json:"to"` + OldestSampleAt *time.Time `json:"oldest_sample_at"` + EgressBytes int64 `json:"-"` + DownloadEgressBytes int64 `json:"-"` + Points []AdminTimeseriesPoint `json:"points"` } // AdminTimeseriesSource returns cached or freshly queried samples for a window @@ -227,6 +241,20 @@ type timeseriesRow struct { Transcode *int64 EgressKbps int64 DownloadEgressKbps int64 + // The Sum columns add up every sampled minute's rate in the bucket, which + // the transfer totals integrate; the Kbps columns above are the peak. + EgressKbpsSum int64 + DownloadEgressKbpsSum int64 +} + +// sumTransferredBytes integrates the per-minute rates of every bucket into +// total bytes transferred over the window. +func sumTransferredBytes(rows []timeseriesRow) (egressBytes, downloadBytes int64) { + for _, row := range rows { + egressBytes += row.EgressKbpsSum * bytesPerKbpsMinute + downloadBytes += row.DownloadEgressKbpsSum * bytesPerKbpsMinute + } + return egressBytes, downloadBytes } // assembleTimeseries turns grouped rows into response points, reading a missing @@ -271,7 +299,9 @@ func queryAdminTimeseries(ctx context.Context, pool *pgxpool.Pool, hours int) (* // The outer one collapses those minutes into the display bucket by taking // the peak minute of each column. Averaging would erase the spikes these // charts exist to show; a bucket therefore answers "the worst minute in - // here", and its columns may come from different minutes. + // here", and its columns may come from different minutes. The trailing + // SUM columns are the exception: they feed the window's transfer totals, + // which need every minute's rate, not the peak. rows, err := pool.Query(ctx, ` WITH minutes AS ( SELECT bucket, @@ -291,7 +321,9 @@ func queryAdminTimeseries(ctx context.Context, pool *pgxpool.Pool, hours int) (* MAX(streams_remux), MAX(streams_transcode), MAX(egress_kbps)::bigint, - MAX(download_egress_kbps)::bigint + MAX(download_egress_kbps)::bigint, + SUM(egress_kbps)::bigint, + SUM(download_egress_kbps)::bigint FROM minutes GROUP BY display_bucket ORDER BY display_bucket @@ -312,6 +344,8 @@ func queryAdminTimeseries(ctx context.Context, pool *pgxpool.Pool, hours int) (* &row.Transcode, &row.EgressKbps, &row.DownloadEgressKbps, + &row.EgressKbpsSum, + &row.DownloadEgressKbpsSum, ); err != nil { return nil, fmt.Errorf("scanning dashboard metric sample: %w", err) } @@ -325,6 +359,7 @@ func queryAdminTimeseries(ctx context.Context, pool *pgxpool.Pool, hours int) (* ResolutionSeconds: bucketSeconds, Points: assembleTimeseries(grouped), } + series.EgressBytes, series.DownloadEgressBytes = sumTransferredBytes(grouped) // The window bounds come from the same clock the samples were bucketed // with, so a client comparing them against point timestamps never sees the diff --git a/internal/api/handlers/admin_stats_timeseries_test.go b/internal/api/handlers/admin_stats_timeseries_test.go index 38133b063e..34ffcec18c 100644 --- a/internal/api/handlers/admin_stats_timeseries_test.go +++ b/internal/api/handlers/admin_stats_timeseries_test.go @@ -114,6 +114,56 @@ func TestAssembleTimeseries(t *testing.T) { } } +func TestSumTransferredBytes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rows []timeseriesRow + wantEgress int64 + wantDownload int64 + }{ + { + name: "no samples transfer nothing", + }, + { + // One minute at 8 kbps is 8,000 bits/s for 60 s: 60,000 bytes. + name: "one minute converts kbps to bytes", + rows: []timeseriesRow{{EgressKbpsSum: 8}}, + wantEgress: 60_000, + }, + { + // The totals integrate the per-minute sums, never the bucket + // peaks: a bucket whose peak minute hit 1,000 kbps but whose + // minutes added up to 1,200 kbps moved 1,200 kbps-minutes. + name: "buckets integrate the sum, not the peak", + rows: []timeseriesRow{ + {EgressKbps: 1_000, EgressKbpsSum: 1_200, DownloadEgressKbps: 400, DownloadEgressKbpsSum: 500}, + {EgressKbps: 50, EgressKbpsSum: 50}, + }, + wantEgress: 1_250 * bytesPerKbpsMinute, + wantDownload: 500 * bytesPerKbpsMinute, + }, + { + // A month of minutes at 10 Gbps must not overflow. + name: "a busy month fits in int64", + rows: []timeseriesRow{{EgressKbpsSum: 10_000_000 * 31 * 24 * 60}}, + wantEgress: 10_000_000 * 31 * 24 * 60 * bytesPerKbpsMinute, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + egress, download := sumTransferredBytes(tt.rows) + if egress != tt.wantEgress || download != tt.wantDownload { + t.Fatalf("sumTransferredBytes = (%d, %d), want (%d, %d)", egress, download, tt.wantEgress, tt.wantDownload) + } + }) + } +} + func TestTimeseriesBucketSeconds(t *testing.T) { t.Parallel() @@ -240,8 +290,10 @@ func TestHandleGetTimeseriesFreshInstallSerializesCollectingState(t *testing.T) t.Parallel() source := &stubTimeseriesSource{series: &AdminTimeseries{ - ResolutionSeconds: adminTimeseriesResolutionSeconds, - Points: []AdminTimeseriesPoint{}, + ResolutionSeconds: adminTimeseriesResolutionSeconds, + EgressBytes: 1, + DownloadEgressBytes: 1, + Points: []AdminTimeseriesPoint{}, }} handler := &AdminHandler{TimeseriesSource: source} rec := httptest.NewRecorder() @@ -254,6 +306,8 @@ func TestHandleGetTimeseriesFreshInstallSerializesCollectingState(t *testing.T) ResolutionSeconds int `json:"resolution_seconds"` Points []json.RawMessage `json:"points"` OldestSampleAt *string `json:"oldest_sample_at"` + EgressBytes *int64 `json:"egress_bytes"` + DownloadBytes *int64 `json:"download_egress_bytes"` } if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { t.Fatalf("decode body: %v", err) @@ -270,6 +324,11 @@ func TestHandleGetTimeseriesFreshInstallSerializesCollectingState(t *testing.T) if body.ResolutionSeconds != adminTimeseriesResolutionSeconds { t.Fatalf("resolution_seconds = %d, want %d", body.ResolutionSeconds, adminTimeseriesResolutionSeconds) } + // /api/v1 is frozen: the window totals are v2-only and must not leak into + // the bridge contract. + if body.EgressBytes != nil || body.DownloadBytes != nil { + t.Fatalf("egress_bytes = %v, download_egress_bytes = %v, want both absent from v1", body.EgressBytes, body.DownloadBytes) + } } func TestAdminTimeseriesProviderInvalidateClearsEveryWindow(t *testing.T) { diff --git a/internal/apiv2/admin_dashboard_capabilities.go b/internal/apiv2/admin_dashboard_capabilities.go index 5fbd2e22dd..80d283e2bb 100644 --- a/internal/apiv2/admin_dashboard_capabilities.go +++ b/internal/apiv2/admin_dashboard_capabilities.go @@ -14,6 +14,9 @@ type AdminDashboardCapabilities struct { LogLevelList bool `json:"log_level_list"` WatchProviders bool `json:"watch_providers"` DownloadsStats bool `json:"downloads_stats"` + // EgressTotals reports that GET /admin/stats/timeseries carries the + // window's egress_bytes and download_egress_bytes totals. + EgressTotals bool `json:"egress_totals"` } type AdminDashboardCapabilitiesOutput struct { Status int @@ -26,7 +29,7 @@ func registerAdminDashboardCapabilities(reg *Registry) { op := Operation{Operation: humaOp("GET", Prefix+"/admin/dashboard/capabilities", "getAdminDashboardCapabilities", "admin-observability", "Discover dashboard features supported by v2 in this build. Support does not promise dependency readiness; layout storage is not yet available on v2."), Class: ClassActingAdmin} Register(reg, op, func(context.Context, *CapabilityInput) (*AdminDashboardCapabilitiesOutput, error) { return &AdminDashboardCapabilitiesOutput{Body: AdminDashboardCapabilities{ - Timeseries: true, PlaybackActivity: true, TopActivity: true, Health: true, WatchProviders: true, DownloadsStats: true, LogLevelList: true, + Timeseries: true, PlaybackActivity: true, TopActivity: true, Health: true, WatchProviders: true, DownloadsStats: true, LogLevelList: true, EgressTotals: true, }}, nil }) } diff --git a/internal/apiv2/admin_dashboard_capabilities_test.go b/internal/apiv2/admin_dashboard_capabilities_test.go index 9ff86a99a4..4079f3b380 100644 --- a/internal/apiv2/admin_dashboard_capabilities_test.go +++ b/internal/apiv2/admin_dashboard_capabilities_test.go @@ -19,7 +19,7 @@ func TestAdminDashboardCapabilities(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } - expected := map[string]any{"server_layouts": false, "timeseries": true, "playback_activity": true, "top_activity": true, "health": true, "log_level_list": true, "watch_providers": true, "downloads_stats": true} + expected := map[string]any{"server_layouts": false, "timeseries": true, "playback_activity": true, "top_activity": true, "health": true, "log_level_list": true, "watch_providers": true, "downloads_stats": true, "egress_totals": true} if len(got) != len(expected)+3 { t.Fatal(got) } diff --git a/internal/apiv2/admin_dashboard_insights.go b/internal/apiv2/admin_dashboard_insights.go index d652aaddb3..35037e8d4a 100644 --- a/internal/apiv2/admin_dashboard_insights.go +++ b/internal/apiv2/admin_dashboard_insights.go @@ -36,11 +36,13 @@ type AdminDashboardTimeseriesPoint struct { DownloadEgressKbps int64 `json:"download_egress_kbps"` } type AdminDashboardTimeseries struct { - ResolutionSeconds int `json:"resolution_seconds"` - From Instant `json:"from"` - To Instant `json:"to"` - OldestSampleAt NullableInstant `json:"oldest_sample_at"` - Points []AdminDashboardTimeseriesPoint `json:"points"` + ResolutionSeconds int `json:"resolution_seconds"` + From Instant `json:"from"` + To Instant `json:"to"` + OldestSampleAt NullableInstant `json:"oldest_sample_at"` + EgressBytes int64 `json:"egress_bytes"` + DownloadEgressBytes int64 `json:"download_egress_bytes"` + Points []AdminDashboardTimeseriesPoint `json:"points"` } type AdminDashboardTimeseriesOutput struct{ Body AdminDashboardTimeseries } type AdminDashboardPlaybackBucket struct { @@ -111,7 +113,7 @@ func registerAdminDashboardInsights(reg *Registry) { if err != nil || s == nil { return nil, adminDashboardInsightProblem(err) } - body := AdminDashboardTimeseries{ResolutionSeconds: s.ResolutionSeconds, From: NewInstant(s.From), To: NewInstant(s.To), Points: make([]AdminDashboardTimeseriesPoint, 0, len(s.Points))} + body := AdminDashboardTimeseries{ResolutionSeconds: s.ResolutionSeconds, From: NewInstant(s.From), To: NewInstant(s.To), EgressBytes: s.EgressBytes, DownloadEgressBytes: s.DownloadEgressBytes, Points: make([]AdminDashboardTimeseriesPoint, 0, len(s.Points))} if s.OldestSampleAt != nil { body.OldestSampleAt = NullableInstant{Valid: true, Time: NewInstant(*s.OldestSampleAt)} } diff --git a/internal/apiv2/admin_dashboard_insights_test.go b/internal/apiv2/admin_dashboard_insights_test.go index 9a6d385c44..d0452bd03d 100644 --- a/internal/apiv2/admin_dashboard_insights_test.go +++ b/internal/apiv2/admin_dashboard_insights_test.go @@ -45,7 +45,7 @@ func (s *dashboardInsightsStub) ReadAdminDownloadsStats(_ context.Context, limit } func TestAdminDashboardInsights(t *testing.T) { at := time.Date(2026, 9, 1, 1, 2, 3, 123456789, time.FixedZone("test", 3600)) - s := &dashboardInsightsStub{series: &handlers.AdminTimeseries{ResolutionSeconds: 60, From: at, To: at.Add(time.Hour), Points: []handlers.AdminTimeseriesPoint{{T: at, EgressKbps: 50, DownloadEgressKbps: 20}}}} + s := &dashboardInsightsStub{series: &handlers.AdminTimeseries{ResolutionSeconds: 60, From: at, To: at.Add(time.Hour), EgressBytes: 412_500_000_000, DownloadEgressBytes: 37_800_000_000, Points: []handlers.AdminTimeseriesPoint{{T: at, EgressKbps: 50, DownloadEgressKbps: 20}}}} deps := requestDeps(fixtureRequests()) deps.AdminDashboardInsights = s h := NewHandler(deps) @@ -56,7 +56,7 @@ func TestAdminDashboardInsights(t *testing.T) { for _, tc := range []struct { path string fragments []string - }{{"timeseries?hours=1&refresh=true", []string{`"from":"2026-09-01T00:02:03.123Z"`, `"oldest_sample_at":null`, `"download_egress_kbps":20`}}, {"playback-activity?hours=1&refresh=true", []string{`"hours":1`, `"buckets":[]`, `"profiles_active_24h":0`}}, {"top-activity?days=1&limit=2&refresh=true", []string{`"titles":[]`, `"user_id":"7"`, `"profile_id":"household-profile"`}}, {"downloads?limit=2&refresh=true", []string{`"active_downloads":3`, `"user_id":"7"`}}} { + }{{"timeseries?hours=1&refresh=true", []string{`"from":"2026-09-01T00:02:03.123Z"`, `"oldest_sample_at":null`, `"download_egress_kbps":20`, `"egress_bytes":412500000000`, `"download_egress_bytes":37800000000`}}, {"playback-activity?hours=1&refresh=true", []string{`"hours":1`, `"buckets":[]`, `"profiles_active_24h":0`}}, {"top-activity?days=1&limit=2&refresh=true", []string{`"titles":[]`, `"user_id":"7"`, `"profile_id":"household-profile"`}}, {"downloads?limit=2&refresh=true", []string{`"active_downloads":3`, `"user_id":"7"`}}} { r := do(t, h, "GET", path+tc.path, "", actingRequestAdmin) if r.Code != 200 || !s.refresh { t.Fatal(tc.path, r.Code, r.Body.String(), s) diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3e4cfbd5f8..05b794130a 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -5007,6 +5007,13 @@ export interface AdminTimeseries { from: string; to: string; oldest_sample_at: string | null; + // Data transferred over the whole window, integrated server-side from every + // sampled minute (not from `points`, which are per-bucket peaks). An estimate: + // node egress is a rolling average and unsampled minutes count as nothing. + // v2 only (advertised by the `egress_totals` dashboard capability); optional + // because older servers do not send it. + egress_bytes?: number; + download_egress_bytes?: number; points: AdminTimeseriesPoint[]; } diff --git a/web/src/api/v2/schema.ts b/web/src/api/v2/schema.ts index 7d9794fd28..58b3b7b36f 100644 --- a/web/src/api/v2/schema.ts +++ b/web/src/api/v2/schema.ts @@ -13708,6 +13708,7 @@ export interface components { /** @description Whether the current principal may use the capability */ allowed: boolean; downloads_stats: boolean; + egress_totals: boolean; health: boolean; log_level_list: boolean; playback_activity: boolean; @@ -13825,6 +13826,10 @@ export interface components { watch_providers: components["schemas"]["AdminDashboardWatchProviderStats"][]; }; AdminDashboardTimeseries: { + /** Format: int64 */ + download_egress_bytes: number; + /** Format: int64 */ + egress_bytes: number; /** * Format: date-time * @description RFC 3339 instant in UTC with millisecond precision diff --git a/web/src/components/admin/dashboard/format.test.ts b/web/src/components/admin/dashboard/format.test.ts new file mode 100644 index 0000000000..0bc976831f --- /dev/null +++ b/web/src/components/admin/dashboard/format.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { formatDataTotal } from "./format"; + +describe("formatDataTotal", () => { + it("reads missing or invalid totals as unknown", () => { + expect(formatDataTotal(undefined)).toBe("—"); + expect(formatDataTotal(null)).toBe("—"); + expect(formatDataTotal(Number.NaN)).toBe("—"); + expect(formatDataTotal(-1)).toBe("—"); + }); + + it("keeps whole bytes for tiny totals", () => { + expect(formatDataTotal(0)).toBe("0 B"); + expect(formatDataTotal(999)).toBe("999 B"); + }); + + it("uses decimal units with one decimal below 100", () => { + expect(formatDataTotal(1_500)).toBe("1.5 KB"); + expect(formatDataTotal(42_300_000_000)).toBe("42.3 GB"); + expect(formatDataTotal(1_400_000_000_000)).toBe("1.4 TB"); + }); + + it("drops the decimal once the number is large", () => { + expect(formatDataTotal(512_000_000_000)).toBe("512 GB"); + }); + + it("chooses unit and precision after rounding", () => { + // Rounds up across a unit boundary: never "1,000 KB" or "1,000 B". + expect(formatDataTotal(999_950)).toBe("1.0 MB"); + expect(formatDataTotal(999_999_999_999)).toBe("1.0 TB"); + // Rounds up to 100: the decimal goes, never "100.0 GB". + expect(formatDataTotal(99_960_000_000)).toBe("100 GB"); + expect(formatDataTotal(99_940_000_000)).toBe("99.9 GB"); + }); + + it("stays in petabytes rather than running off the unit list", () => { + expect(formatDataTotal(5_000_000_000_000_000_000)).toBe("5,000 PB"); + }); +}); diff --git a/web/src/components/admin/dashboard/format.ts b/web/src/components/admin/dashboard/format.ts index d42318c37e..73e390e83b 100644 --- a/web/src/components/admin/dashboard/format.ts +++ b/web/src/components/admin/dashboard/format.ts @@ -45,6 +45,39 @@ export function formatMbps(mbps: number) { return `${formatMbpsValue(mbps)} Mbps`; } +const DATA_TOTAL_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const; + +/** + * Data transferred over a window. Decimal (1000-based) units, matching the + * Mbps rates beside it, with one decimal below 100 so a quiet window does not + * round to a misleading whole number. + * + * Unit and precision are chosen on the value as it will be displayed, so + * rounding never produces "1,000 KB" (it reads "1.0 MB") or "100.0 GB" (it + * reads "100 GB"). + */ +export function formatDataTotal(bytes: number | null | undefined) { + if (bytes == null || !Number.isFinite(bytes) || bytes < 0) { + return "—"; + } + let value = bytes; + let unit = 0; + for (;;) { + const decimals = unit > 0 && value < 100 ? 1 : 0; + const rounded = Number(value.toFixed(decimals)); + if (rounded >= 1000 && unit < DATA_TOTAL_UNITS.length - 1) { + value /= 1000; + unit += 1; + continue; + } + const shown = unit > 0 && rounded < 100 ? 1 : 0; + return `${rounded.toLocaleString(undefined, { + minimumFractionDigits: shown, + maximumFractionDigits: shown, + })} ${DATA_TOTAL_UNITS[unit]}`; + } +} + export function formatDashboardLibraryScanProgress(scan: ScanRun, activeScanCount: number) { const status = scan.status === "running" ? "Scanning" : "Queued"; const progress = formatActiveScanProgress(scan); diff --git a/web/src/components/admin/dashboard/widgets/EgressWidget.test.tsx b/web/src/components/admin/dashboard/widgets/EgressWidget.test.tsx new file mode 100644 index 0000000000..fc52e716a8 --- /dev/null +++ b/web/src/components/admin/dashboard/widgets/EgressWidget.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AdminTimeseries } from "@/api/types"; + +const mocks = vi.hoisted(() => ({ + useAdminTimeseries: vi.fn(), +})); + +vi.mock("@/hooks/queries/admin/dashboardInsights", () => ({ + useAdminTimeseries: mocks.useAdminTimeseries, +})); + +import { EgressWidget } from "./EgressWidget"; + +const HOUR_MS = 3_600_000; + +function series(overrides: Partial = {}): AdminTimeseries { + const to = Date.now(); + const from = to - 24 * HOUR_MS; + return { + resolution_seconds: 300, + from: new Date(from).toISOString(), + to: new Date(to).toISOString(), + oldest_sample_at: new Date(from - 7 * 24 * HOUR_MS).toISOString(), + points: [ + { + t: new Date(to - HOUR_MS).toISOString(), + streams: 1, + direct: 1, + remux: 0, + transcode: 0, + egress_kbps: 56_000, + }, + ], + ...overrides, + }; +} + +describe("EgressWidget", () => { + beforeEach(() => { + mocks.useAdminTimeseries.mockReset(); + }); + + it("shows the server's transfer total beside the peak", () => { + mocks.useAdminTimeseries.mockReturnValue({ data: series({ egress_bytes: 1_400_000_000_000 }) }); + + render(); + + expect(screen.getByText("Total 1.4 TB")).toBeTruthy(); + expect(screen.getByText("Peak 56 Mbps")).toBeTruthy(); + }); + + // jsdom does not evaluate container queries, so this pins the contract + // instead: hidden by default, shown only once the card header is wide + // enough, and never truncated or carrying hover-only details. + it("drops the total, rather than truncating it, in a narrow header", () => { + mocks.useAdminTimeseries.mockReturnValue({ data: series({ egress_bytes: 1_400_000_000_000 }) }); + + render(); + + const total = screen.getByText("Total 1.4 TB"); + expect(total.classList).toContain("hidden"); + expect(total.classList).toContain("@md/card-header:inline"); + expect(total.classList).toContain("whitespace-nowrap"); + expect(total.classList).not.toContain("truncate"); + expect(total.getAttribute("title")).toBeNull(); + expect(total.closest('[data-slot="card-header"]')?.className).toContain( + "@container/card-header", + ); + // With the total gone, Peak must still be free to wrap; holding it on one + // line pushes the range picker out of a minimum-span card. + const peak = screen.getByText("Peak 56 Mbps"); + expect(peak.classList).not.toContain("whitespace-nowrap"); + expect(peak.classList).not.toContain("shrink-0"); + }); + + it("shows a zero total, which is a real answer", () => { + mocks.useAdminTimeseries.mockReturnValue({ data: series({ egress_bytes: 0 }) }); + + render(); + + expect(screen.getByText("Total 0 B")).toBeTruthy(); + }); + + it("hides the total when the server does not send one", () => { + mocks.useAdminTimeseries.mockReturnValue({ data: series() }); + + render(); + + expect(screen.queryByText(/^Total /)).toBeNull(); + expect(screen.getByText("Peak 56 Mbps")).toBeTruthy(); + }); +}); diff --git a/web/src/components/admin/dashboard/widgets/EgressWidget.tsx b/web/src/components/admin/dashboard/widgets/EgressWidget.tsx index f1cfcf8040..9b63ca2a25 100644 --- a/web/src/components/admin/dashboard/widgets/EgressWidget.tsx +++ b/web/src/components/admin/dashboard/widgets/EgressWidget.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useAdminTimeseries } from "@/hooks/queries/admin/dashboardInsights"; -import { formatMbps, formatMbpsValue } from "../format"; +import { formatDataTotal, formatMbps, formatMbpsValue } from "../format"; import { formatRangeTimestamp, rangeEdgeLabels, @@ -51,12 +51,23 @@ export function EgressWidget() { ); const peak = points.reduce((max, point) => Math.max(max, point.value ?? 0), 0); + // Integrated server-side from every sampled minute; summing `points` here + // would be wrong, since wide windows carry each bucket's peak minute. + const totalBytes = query.data?.egress_bytes; return ( {rangeTitle("Egress", range)}
+ {totalBytes != null ? ( + // Secondary to the peak: when the header is too narrow for both + // beside the range picker, the total is dropped rather than + // truncated. CardHeader is the `card-header` container. + + Total {formatDataTotal(totalBytes)} + + ) : null} Peak {formatMbps(peak)} From 88ea8d250374cc1161d1d7a1af3e78632c87b90e Mon Sep 17 00:00:00 2001 From: Daniel Shafer Date: Tue, 13 Oct 2026 15:28:28 -0700 Subject: [PATCH 028/196] fix(web): block saving users while a limit field is invalid on a hidden tab (#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 * 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 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 #1867 describes, and check that the field is reported. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Daniel Shafer Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com> --- web/src/components/UserPolicyFields.tsx | 41 ++++++++++++-- web/src/pages/AdminUsers.test.tsx | 72 +++++++++++++++++++++++++ web/src/pages/AdminUsers.tsx | 31 ++++++++++- 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/web/src/components/UserPolicyFields.tsx b/web/src/components/UserPolicyFields.tsx index 5238271e5c..84a34af1cb 100644 --- a/web/src/components/UserPolicyFields.tsx +++ b/web/src/components/UserPolicyFields.tsx @@ -203,6 +203,19 @@ interface PolicyContext { disabled?: boolean; } +// Raw text of limit fields whose box holds no valid number. A cleared or +// half-typed box is not in UserPolicyState, which keeps the last valid value, +// so the form keeps these drafts itself and refuses to save while any remain, +// even when the Limits tab is not mounted and native validation cannot see the +// box. Keeping the text, not just a flag, lets a remounted field show it again. +export type LimitPolicyKey = "maxStreams" | "maxTranscodes"; + +export interface LimitValidity { + invalidLimitDrafts: ReadonlyMap; + // `undefined` clears the entry once the box holds a valid number again. + onInvalidLimitDraftChange: (key: LimitPolicyKey, draft: string | undefined) => void; +} + function defaultHint(source: PolicyDefaultSource, effectiveText: string | undefined): string { const text = DEFAULT_SOURCE_TEXT[source]; return effectiveText === undefined ? text.unknown : `${text.prefix}: ${effectiveText}`; @@ -313,21 +326,28 @@ function LimitPolicyField({ onValueChange, source, effectiveValue, + invalidDraft, + onInvalidDraftChange, }: { label: string; value: number | null; onValueChange: (value: number | null) => void; source: PolicyDefaultSource; effectiveValue?: number; + // The invalid text left behind when the field last unmounted, or undefined. + // The field reopens overridden with that text so it shows the pending edit + // instead of the stale saved value. + invalidDraft: string | undefined; + onInvalidDraftChange: (draft: string | undefined) => void; }) { const id = useId(); // Override is tracked locally because "overriding, but nothing typed yet" has // no representation in UserPolicyState: while the box is empty the field // keeps inheriting rather than pinning 0, which would mean unlimited. - const [overridden, setOverridden] = useState(value !== null); + const [overridden, setOverridden] = useState(value !== null || invalidDraft !== undefined); // The raw string stays local so a cleared or half-typed box is an unsaved // edit instead of collapsing to 0 or NaN. - const [draft, setDraft] = useState(() => (value === null ? "" : String(value))); + const [draft, setDraft] = useState(() => invalidDraft ?? (value === null ? "" : String(value))); const draftValue = limitDraftValue(draft); function handleOverrideChange(checked: boolean) { @@ -335,6 +355,7 @@ function LimitPolicyField({ if (!checked) { setDraft(""); onValueChange(null); + onInvalidDraftChange(undefined); return; } // Seed the value the field already resolves to. With no hint available the @@ -342,11 +363,13 @@ function LimitPolicyField({ // value, so an unknown limit is never silently saved as unlimited. setDraft(effectiveValue === undefined ? "" : String(effectiveValue)); onValueChange(effectiveValue ?? null); + onInvalidDraftChange(effectiveValue === undefined ? "" : undefined); } function handleDraftChange(raw: string) { setDraft(raw); const parsed = limitDraftValue(raw); + onInvalidDraftChange(parsed === null ? raw : undefined); if (parsed === null) return; onValueChange(parsed); } @@ -495,7 +518,15 @@ export function PolicyAccessFields({ } // Limits-tab policy fields: stream/transcode ceilings and the quality gate. -export function PolicyLimitFields({ state, onChange, source, effective, disabled }: PolicyContext) { +export function PolicyLimitFields({ + state, + onChange, + source, + effective, + disabled, + invalidLimitDrafts, + onInvalidLimitDraftChange, +}: PolicyContext & LimitValidity) { const qualityId = useId(); const qualityValue: PlaybackQualityPreset | typeof INHERIT = state.maxPlaybackQuality === null @@ -510,6 +541,8 @@ export function PolicyLimitFields({ state, onChange, source, effective, disabled onValueChange={(maxStreams) => onChange({ ...state, maxStreams })} source={source} effectiveValue={effective?.max_streams} + invalidDraft={invalidLimitDrafts.get("maxStreams")} + onInvalidDraftChange={(draft) => onInvalidLimitDraftChange("maxStreams", draft)} /> onChange({ ...state, maxTranscodes })} source={source} effectiveValue={effective?.max_transcodes} + invalidDraft={invalidLimitDrafts.get("maxTranscodes")} + onInvalidDraftChange={(draft) => onInvalidLimitDraftChange("maxTranscodes", draft)} /> { expect(within(dialog).getByRole("combobox", { name: "Group" })).toBeDisabled(); expect(within(dialog).getByText("Admin accounts can't join groups.")).toBeInTheDocument(); }); + // Overrides Max Streams to 3 in a fresh Create User dialog and fills the + // account fields, so only the limit can stop the save. The 3 is the last + // valid value a later edit could leave behind. + async function openCreateWithStreamsOverride(user: ReturnType) { + mocks.create.mockReset().mockResolvedValue({ id: 11 }); + renderPage(); + const dialog = await openLimits(user, /Add User/); + // Max Streams is the first field on the Limits tab. + await user.click(within(dialog).getAllByRole("switch")[0]!); + await user.type(within(dialog).getByLabelText("Max Streams"), "3"); + await user.click(within(dialog).getByRole("tab", { name: "Account" })); + await user.type(within(dialog).getByLabelText("Username"), "newbie"); + await user.type(within(dialog).getByLabelText("Email"), "newbie@example.test"); + await user.type(within(dialog).getByLabelText(/^Password/), "a-long-password"); + await user.click(within(dialog).getByRole("tab", { name: "Limits" })); + return dialog; + } + + it("refuses to save a cleared limit from another tab and shows the Limits tab", async () => { + const user = userEvent.setup(); + const dialog = await openCreateWithStreamsOverride(user); + await user.clear(within(dialog).getByLabelText("Max Streams")); + + await user.click(within(dialog).getByRole("tab", { name: "Account" })); + // The browser reports a field by firing `invalid` at it. + const reported: EventTarget[] = []; + dialog + .querySelector("form")! + .addEventListener("invalid", (event) => reported.push(event.target!), true); + await user.click(within(dialog).getByRole("button", { name: /save/i })); + + expect(mocks.create).not.toHaveBeenCalled(); + expect(within(dialog).getByRole("tab", { name: "Limits" })).toHaveAttribute( + "aria-selected", + "true", + ); + // The field still shows the edit, not the last valid value, and the + // browser points at it. + const field = within(dialog).getByLabelText("Max Streams"); + expect(field).toHaveValue(null); + expect(reported).toContain(field); + }); + + it("keeps a non-whole limit across a tab switch and still refuses to save it", async () => { + const user = userEvent.setup(); + const dialog = await openCreateWithStreamsOverride(user); + await user.clear(within(dialog).getByLabelText("Max Streams")); + await user.type(within(dialog).getByLabelText("Max Streams"), "1.5"); + + await user.click(within(dialog).getByRole("tab", { name: "Account" })); + expect(within(dialog).queryByLabelText("Max Streams")).not.toBeInTheDocument(); + await user.click(within(dialog).getByRole("tab", { name: "Limits" })); + expect(within(dialog).getByLabelText("Max Streams")).toHaveValue(1.5); + + await user.click(within(dialog).getByRole("tab", { name: "Account" })); + await user.click(within(dialog).getByRole("button", { name: /save/i })); + expect(mocks.create).not.toHaveBeenCalled(); + expect(within(dialog).getByLabelText("Max Streams")).toHaveValue(1.5); + }); + + it("saves the retyped value after a cleared limit", async () => { + const user = userEvent.setup(); + const dialog = await openCreateWithStreamsOverride(user); + await user.clear(within(dialog).getByLabelText("Max Streams")); + await user.click(within(dialog).getByRole("tab", { name: "Account" })); + await user.click(within(dialog).getByRole("tab", { name: "Limits" })); + await user.type(within(dialog).getByLabelText("Max Streams"), "2"); + await user.click(within(dialog).getByRole("tab", { name: "Account" })); + await user.click(within(dialog).getByRole("button", { name: /save/i })); + await waitFor(() => expect(mocks.create).toHaveBeenCalledTimes(1)); + expect(mocks.create.mock.calls[0]![0].body.max_streams).toBe(2); + }); }); describe("AdminUsers access group column and filter", () => { diff --git a/web/src/pages/AdminUsers.tsx b/web/src/pages/AdminUsers.tsx index c248d7aac8..b7c686837d 100644 --- a/web/src/pages/AdminUsers.tsx +++ b/web/src/pages/AdminUsers.tsx @@ -1,4 +1,5 @@ import { useState, useId, useMemo, useRef } from "react"; +import { flushSync } from "react-dom"; import type { FormEvent, ReactNode } from "react"; import { Link, useSearchParams } from "react-router"; import type { AdminUser, CreateUserRequest } from "@/api/types"; @@ -21,6 +22,7 @@ import { policyDefaultSource, policyInheritHints, policyStateFromUser, + type LimitPolicyKey, } from "@/components/UserPolicyFields"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { AdminUserImpersonationDialog } from "@/components/AdminUserImpersonationDialog"; @@ -729,6 +731,20 @@ function CreateUserForm({ const [permissions, setPermissions] = useState([PERMISSION_MARKER_EDIT]); // Policy fields inherit from the access group unless explicitly overridden. const [policy, setPolicy] = useState(() => policyStateFromUser(null)); + const [invalidLimitDrafts, setInvalidLimitDrafts] = useState>( + new Map(), + ); + const [dialogTab, setDialogTab] = useState("account"); + const formRef = useRef(null); + function handleInvalidLimitDraftChange(key: LimitPolicyKey, draft: string | undefined) { + setInvalidLimitDrafts((current) => { + if (current.get(key) === draft) return current; + const next = new Map(current); + if (draft === undefined) next.delete(key); + else next.set(key, draft); + return next; + }); + } const [maxProfiles, setMaxProfiles] = useState(5); const usernameId = useId(); const emailId = useId(); @@ -785,6 +801,15 @@ function CreateUserForm({ setError(INVALID_EMAIL_MESSAGE); return; } + // A cleared or half-typed limit is not in the policy state, which still + // holds the last valid value; saving now would send that instead. + if (invalidLimitDrafts.size > 0) { + // The Limits fields mount only once the tab has rendered, so render it + // now and then let the browser point at the invalid field. + flushSync(() => setDialogTab("limits")); + formRef.current?.reportValidity(); + return; + } busy.current = true; onBusy(true); setError(""); @@ -813,7 +838,7 @@ function CreateUserForm({ } return ( -
+ {error &&

{error}

}