Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f39667b469 | ||
|
|
936f1990af | ||
|
|
f1a909e7ad | ||
|
|
2dd4069009 | ||
|
|
3d12f467dd | ||
|
|
5400717c3e | ||
|
|
10d4624222 | ||
|
|
6f445c1cda | ||
|
|
d4edf0068e | ||
|
|
9c9e10e373 | ||
|
|
c39737e456 | ||
|
|
371d959cc8 | ||
|
|
b4542bb53c | ||
|
|
fa1d7ba2b4 | ||
|
|
40a9de7f26 | ||
|
|
31a26b2554 | ||
|
|
8bde6f1156 | ||
|
|
567cdd1a15 | ||
|
|
3bdfc58512 | ||
|
|
4cd51d8f6c | ||
|
|
b0418a4430 | ||
|
|
c1cac4ece9 |
@@ -212,6 +212,10 @@ If you prefer running Silo without Docker:
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
Client implementers can use the [Canonical Settings API guide](docs/settings-api.md)
|
||||
for contract discovery, contextual headers, remote scopes, effective reads, and
|
||||
the admin projection.
|
||||
|
||||
If you are reporting a bug, install problem, or performance issue, start with the admin workflow and reproduction steps, not Claude/Codex analysis.
|
||||
|
||||
Please include:
|
||||
|
||||
@@ -157,6 +157,7 @@ func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
|
||||
}
|
||||
out.WriteString(" */\n\n")
|
||||
|
||||
fmt.Fprintf(&out, "export const SETTINGS_API_VERSION = %d;\n", contract.APIVersion)
|
||||
fmt.Fprintf(&out, "export const SETTINGS_REVISION = %d;\n\n", contract.Revision)
|
||||
|
||||
out.WriteString("export interface SettingSuggestedOption {\n")
|
||||
|
||||
+104
-5
@@ -19,6 +19,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -893,8 +894,10 @@ func main() {
|
||||
)
|
||||
}
|
||||
var watchProviderService *watchsync.Service
|
||||
var watchProviderRegistry *watchsync.Registry
|
||||
var watchProviderRepo *watchsync.PostgresRepository
|
||||
if deps.DB != nil {
|
||||
watchProviderRegistry := watchsync.NewRegistry()
|
||||
watchProviderRegistry = watchsync.NewRegistry()
|
||||
if err := watchProviderRegistry.Register(trakt.NewProvider(nil, "")); err != nil {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
@@ -904,10 +907,8 @@ func main() {
|
||||
if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
watchProviderService = watchsync.NewService(
|
||||
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
|
||||
watchProviderRegistry,
|
||||
)
|
||||
watchProviderRepo = watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher)
|
||||
watchProviderService = watchsync.NewService(watchProviderRepo, watchProviderRegistry)
|
||||
deps.WatchProviderService = watchProviderService
|
||||
}
|
||||
|
||||
@@ -1119,6 +1120,15 @@ func main() {
|
||||
installer,
|
||||
plugins.NewHostAdapter(pluginHost),
|
||||
)
|
||||
if watchProviderRegistry != nil {
|
||||
reloadWatchProviders := func(ctx context.Context) {
|
||||
if err := reloadWatchSyncPluginProviders(ctx, watchProviderRegistry, installationStore, pluginService, watchProviderRepo); err != nil {
|
||||
slog.WarnContext(ctx, "failed to reload watch sync plugin providers", "component", "app", "error", err)
|
||||
}
|
||||
}
|
||||
pluginService.AddLifecycleHook(reloadWatchProviders)
|
||||
reloadWatchProviders(appCtx)
|
||||
}
|
||||
if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil {
|
||||
markerPluginResolver := markers.NewPluginResolverAdapter(pluginService)
|
||||
pluginService.AddLifecycleHook(func(ctx context.Context) {
|
||||
@@ -3088,11 +3098,100 @@ func metadataInt(value any) int {
|
||||
}
|
||||
}
|
||||
|
||||
var watchSyncPluginReloadMu sync.Mutex
|
||||
|
||||
type markerPluginCapabilityStore interface {
|
||||
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
|
||||
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
|
||||
}
|
||||
|
||||
func reloadWatchSyncPluginProviders(
|
||||
ctx context.Context,
|
||||
registry *watchsync.Registry,
|
||||
store markerPluginCapabilityStore,
|
||||
service *plugins.Service,
|
||||
repository watchsync.PluginCredentialRepository,
|
||||
) error {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
watchSyncPluginReloadMu.Lock()
|
||||
defer watchSyncPluginReloadMu.Unlock()
|
||||
|
||||
var providers []watchsync.Provider
|
||||
if store == nil || service == nil {
|
||||
return registry.ReplacePluginProviders(providers)
|
||||
}
|
||||
installations, err := store.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list enabled watch sync plugin installations: %w", err)
|
||||
}
|
||||
sort.Slice(installations, func(i, j int) bool {
|
||||
if installations[i] == nil {
|
||||
return false
|
||||
}
|
||||
if installations[j] == nil {
|
||||
return true
|
||||
}
|
||||
return installations[i].ID < installations[j].ID
|
||||
})
|
||||
for _, installation := range installations {
|
||||
if installation == nil || installation.IsBuiltin() {
|
||||
continue
|
||||
}
|
||||
capabilities, err := store.ListCapabilities(ctx, installation.ID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip watch sync plugin with unreadable capabilities",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, capability := range capabilities {
|
||||
if capability == nil || capability.Type != sdkcapability.WatchSyncProvider {
|
||||
continue
|
||||
}
|
||||
descriptor, err := plugins.DecodeCapability(capability)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip invalid watch sync plugin capability",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", capability.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
|
||||
InstallationID: installation.ID,
|
||||
ProviderKey: fmt.Sprintf("plugin:%d:%s", installation.ID, capability.ID),
|
||||
CapabilityID: capability.ID,
|
||||
DisplayName: descriptor.GetDisplayName(),
|
||||
Descriptor: descriptor.GetWatchSyncProvider(),
|
||||
ConnectionConfigSchema: descriptor.GetConfigSchema(),
|
||||
ResolveClient: func(callCtx context.Context, installationID int, capabilityID string) (watchsync.WatchSyncPluginClient, error) {
|
||||
return service.WatchSyncProviderClient(callCtx, installationID, capabilityID)
|
||||
},
|
||||
ResolveConfig: func(callCtx context.Context, installationID int) (*pluginv1.WatchSyncProviderConfig, error) {
|
||||
return service.WatchSyncProviderConfig(callCtx, installationID)
|
||||
},
|
||||
Repository: repository,
|
||||
})
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip unsupported watch sync plugin capability",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", capability.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
}
|
||||
return registry.ReplacePluginProviders(providers)
|
||||
}
|
||||
|
||||
type markerPluginRuntimeConfigStore interface {
|
||||
ListGlobalConfigs(ctx context.Context, installationID int) ([]*plugins.RuntimeConfig, error)
|
||||
PutGlobalConfig(ctx context.Context, installationID int, key string, value map[string]any) error
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -8,9 +10,12 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"github.com/Silo-Server/silo-server/internal/api"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||||
)
|
||||
|
||||
func TestConfigureS3Clients_SetsCORSOnPublicAssetsBucket(t *testing.T) {
|
||||
@@ -182,3 +187,104 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type failingWatchSyncCapabilityStore struct{}
|
||||
|
||||
func (failingWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
|
||||
return []*plugins.Installation{{ID: 2, Enabled: true, Kind: plugins.KindPlugin}}, nil
|
||||
}
|
||||
|
||||
func (failingWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
|
||||
type staticWatchSyncCapabilityStore struct {
|
||||
capabilities []*plugins.Capability
|
||||
}
|
||||
|
||||
func (s staticWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
|
||||
return []*plugins.Installation{{ID: 4, Enabled: true, Kind: plugins.KindPlugin}}, nil
|
||||
}
|
||||
|
||||
func (s staticWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
|
||||
return s.capabilities, nil
|
||||
}
|
||||
|
||||
func TestReloadWatchSyncPluginProvidersPreservesConnectionForm(t *testing.T) {
|
||||
manifest := &pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{
|
||||
Type: "watch_sync_provider.v1", Id: "floppy", DisplayName: "Floppy",
|
||||
ConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "floppy", Title: "Your Floppy server", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"base_url":{"type":"string"}},"required":["base_url"]}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "base_url", Label: "Server URL", Required: true,
|
||||
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT,
|
||||
}}},
|
||||
}},
|
||||
WatchSyncProvider: &pluginv1.WatchSyncProviderDescriptor{
|
||||
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
|
||||
},
|
||||
}}}
|
||||
records, err := plugins.CapabilityRecordsFromManifest(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capabilities := make([]*plugins.Capability, 0, len(records))
|
||||
for i := range records {
|
||||
record := records[i]
|
||||
capabilities = append(capabilities, &record)
|
||||
}
|
||||
|
||||
registry := watchsync.NewRegistry()
|
||||
if err := reloadWatchSyncPluginProviders(
|
||||
context.Background(), registry, staticWatchSyncCapabilityStore{capabilities: capabilities}, &plugins.Service{}, nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider, ok := registry.Get("plugin:4:floppy")
|
||||
if !ok {
|
||||
t.Fatal("Floppy provider was not registered")
|
||||
}
|
||||
configurable, ok := provider.(interface {
|
||||
ConnectionConfigSchema() []plugins.ConfigSchemaView
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("Floppy provider does not expose connection configuration")
|
||||
}
|
||||
schemas := configurable.ConnectionConfigSchema()
|
||||
if len(schemas) != 1 || schemas[0].AdminForm == nil || len(schemas[0].AdminForm.Fields) != 1 ||
|
||||
schemas[0].AdminForm.Fields[0].Control != "TEXT" {
|
||||
t.Fatalf("connection config schema = %#v", schemas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadWatchSyncPluginProvidersDropsStaleProvidersOnCapabilityReadFailure(t *testing.T) {
|
||||
registry := watchsync.NewRegistry()
|
||||
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
|
||||
InstallationID: 1,
|
||||
ProviderKey: "plugin:1:tracker",
|
||||
CapabilityID: "tracker",
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{
|
||||
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
|
||||
ExportWatched: true,
|
||||
},
|
||||
ResolveClient: func(context.Context, int, string) (watchsync.WatchSyncPluginClient, error) {
|
||||
return nil, errors.New("not used")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Register(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := reloadWatchSyncPluginProviders(
|
||||
context.Background(), registry, failingWatchSyncCapabilityStore{}, &plugins.Service{}, nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := registry.Get(provider.Key()); ok {
|
||||
t.Fatalf("stale provider %q remained registered", provider.Key())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,82 @@
|
||||
{
|
||||
"fixture_version": 1,
|
||||
"manifest_revision": 4,
|
||||
"manifest_revision": 5,
|
||||
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "profile_client_sits_between_device_and_profile",
|
||||
"description": "Presentation values roam across like clients, while an exact-device override remains more specific and a profile value remains the cross-family fallback.",
|
||||
"keys": ["ui.card_presentation"],
|
||||
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "living-room" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "poster_size": "standard", "caption": "title" }
|
||||
},
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"scope": "profile_client",
|
||||
"profile_id": "p1",
|
||||
"client_family": "tv",
|
||||
"value": { "poster_size": "large", "caption": "artwork" }
|
||||
},
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"scope": "profile_device",
|
||||
"profile_id": "p1",
|
||||
"device_id": "living-room",
|
||||
"value": { "poster_size": "compact", "caption": "title_metadata" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"value": { "poster_size": "compact", "caption": "title_metadata" },
|
||||
"source": "profile_device"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "profile_client_roams_only_within_its_family",
|
||||
"description": "A television menu applies to another television identity but not to mobile; the family is explicit resolution context rather than inferred from device metadata.",
|
||||
"keys": ["nav.primary_menu"],
|
||||
"context": { "profile_id": "p1", "client_family": "tv", "device_id": "bedroom-tv" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "nav.primary_menu",
|
||||
"scope": "profile_client",
|
||||
"profile_id": "p1",
|
||||
"client_family": "mobile",
|
||||
"value": { "items": [{ "type": "builtin", "destination": "home" }] }
|
||||
},
|
||||
{
|
||||
"key": "nav.primary_menu",
|
||||
"scope": "profile_client",
|
||||
"profile_id": "p1",
|
||||
"client_family": "tv",
|
||||
"value": {
|
||||
"items": [
|
||||
{ "type": "builtin", "destination": "home" },
|
||||
{ "type": "builtin", "destination": "movies" }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "nav.primary_menu",
|
||||
"value": {
|
||||
"items": [
|
||||
{ "type": "builtin", "destination": "home" },
|
||||
{ "type": "builtin", "destination": "movies" }
|
||||
]
|
||||
},
|
||||
"source": "profile_client"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resolution_order_series_wins",
|
||||
"description": "The full ladder: with values stored at every scope a content key allows, profile_series beats profile_library, profile_device, and profile.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version": 1,
|
||||
"revision": 4,
|
||||
"revision": 5,
|
||||
"option_sets": {
|
||||
"playback_audio_languages": {
|
||||
"type": "language_tag",
|
||||
@@ -827,6 +827,59 @@
|
||||
"recommended_control": "select",
|
||||
"notes": "Registered from the legacy unprefixed key next_up_mode. The server reads it directly when assembling home sections, so it cannot be client-local; that read moves to the canonical resolver at cutover. The legacy value was untyped and absent meant combined, which is why combined is the default rather than a third \"unset\" member."
|
||||
},
|
||||
{
|
||||
"key": "nav.primary_menu",
|
||||
"introduced_in": 5,
|
||||
"persistence": "remote",
|
||||
"allowed_scopes": ["profile_client", "profile_device"],
|
||||
"resolution_order": ["profile_device", "profile_client", "default"],
|
||||
"value_schema": {
|
||||
"type": "object",
|
||||
"schema_ref": "primary-menu.json",
|
||||
"nullable": true
|
||||
},
|
||||
"default_value": null,
|
||||
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
|
||||
"category": "navigation",
|
||||
"label": "Primary menu",
|
||||
"description": "The ordered visible destinations shared by like clients.",
|
||||
"recommended_control": "panel",
|
||||
"notes": "Search and profile remain fixed client utilities. Home is required by primary-menu.json; omitting any other supported built-in hides it. Semantic destination identities are unique even when labels differ. A null default lets each family keep its native baseline until the user customizes it. Family scope synchronizes like clients while profile_device remains an explicit escape hatch for one screen; clients ignore built-in destinations they do not support."
|
||||
},
|
||||
{
|
||||
"key": "nav.shortcuts",
|
||||
"introduced_in": 5,
|
||||
"persistence": "remote",
|
||||
"allowed_scopes": ["profile"],
|
||||
"resolution_order": ["profile", "default"],
|
||||
"value_schema": {
|
||||
"type": "object",
|
||||
"schema_ref": "navigation-shortcuts.json"
|
||||
},
|
||||
"default_value": { "items": [] },
|
||||
"category": "navigation",
|
||||
"label": "Navigation shortcuts",
|
||||
"description": "Libraries, sections, and collections pinned for use across navigation surfaces.",
|
||||
"notes": "Profile-wide catalog; individual client families decide which shortcuts to place in their primary menu. Semantic destination identities are unique even when labels differ. The profile_client migration keeps ui.sidebar_pins unchanged and seeds this key from convertible legacy web pins only when this key has no authored row."
|
||||
},
|
||||
{
|
||||
"key": "ui.card_presentation",
|
||||
"introduced_in": 5,
|
||||
"persistence": "remote",
|
||||
"allowed_scopes": ["profile", "profile_client", "profile_device"],
|
||||
"resolution_order": ["profile_device", "profile_client", "profile", "default"],
|
||||
"value_schema": {
|
||||
"type": "object",
|
||||
"schema_ref": "card-presentation.json"
|
||||
},
|
||||
"default_value": { "poster_size": "standard", "caption": "title_metadata" },
|
||||
"platforms": ["web", "ios", "tvos", "macos", "android", "android_tv"],
|
||||
"category": "appearance",
|
||||
"label": "Media cards",
|
||||
"description": "Poster size and caption detail used by media cards.",
|
||||
"recommended_control": "panel",
|
||||
"notes": "Semantic presets roam between like devices without forcing identical pixel dimensions across platforms. A profile fallback can opt into one presentation everywhere; family and exact-device values remain more specific."
|
||||
},
|
||||
{
|
||||
"key": "ui.sidebar_pins",
|
||||
"introduced_in": 1,
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"enum": [
|
||||
"account",
|
||||
"profile",
|
||||
"profile_client",
|
||||
"profile_device",
|
||||
"profile_library",
|
||||
"profile_series",
|
||||
@@ -291,6 +292,7 @@
|
||||
"enum": [
|
||||
"account",
|
||||
"profile",
|
||||
"profile_client",
|
||||
"profile_device",
|
||||
"profile_library",
|
||||
"profile_series",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/card-presentation.json",
|
||||
"title": "Media card presentation",
|
||||
"description": "Semantic density and caption presets that each client family maps to platform-appropriate card dimensions.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["poster_size", "caption"],
|
||||
"properties": {
|
||||
"poster_size": {
|
||||
"type": "string",
|
||||
"enum": ["compact", "standard", "large"]
|
||||
},
|
||||
"caption": {
|
||||
"type": "string",
|
||||
"enum": ["title_metadata", "title", "artwork"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/navigation-shortcuts.json",
|
||||
"title": "Navigation shortcuts",
|
||||
"description": "Profile-wide catalog of pinned libraries, sections, and collections that client-family menus may surface.",
|
||||
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["items"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"description": "Pinned targets. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
|
||||
"type": "array",
|
||||
"maxItems": 256,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/library" },
|
||||
{ "$ref": "#/$defs/section" },
|
||||
{ "$ref": "#/$defs/collection" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
|
||||
"libraryId": { "type": "integer", "minimum": 1 },
|
||||
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
|
||||
"library": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "library_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "library" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "library_id", "section_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "section" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"section_id": { "$ref": "#/$defs/targetId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "collection_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "collection" },
|
||||
"collection_id": { "$ref": "#/$defs/targetId" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/primary-menu.json",
|
||||
"title": "Primary navigation menu",
|
||||
"description": "Ordered visible destinations for one client family. Search and profile controls are fixed client utilities and are intentionally absent.",
|
||||
"$comment": "Contract validation also rejects repeated semantic destination identities even when labels differ: builtin(destination), library(library_id), section(library_id, section_id), collection(optional library_id, collection_id).",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["items"],
|
||||
"properties": {
|
||||
"items": {
|
||||
"description": "Ordered destinations. Each semantic destination identity may appear at most once; changing its label does not create a distinct destination.",
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 64,
|
||||
"uniqueItems": true,
|
||||
"contains": {
|
||||
"type": "object",
|
||||
"required": ["type", "destination"],
|
||||
"properties": {
|
||||
"type": { "const": "builtin" },
|
||||
"destination": { "const": "home" }
|
||||
}
|
||||
},
|
||||
"minContains": 1,
|
||||
"maxContains": 1,
|
||||
"items": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/builtin" },
|
||||
{ "$ref": "#/$defs/library" },
|
||||
{ "$ref": "#/$defs/section" },
|
||||
{ "$ref": "#/$defs/collection" }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"label": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "\\S" },
|
||||
"libraryId": { "type": "integer", "minimum": 1 },
|
||||
"targetId": { "type": "string", "minLength": 1, "maxLength": 128, "pattern": "\\S" },
|
||||
"builtin": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "destination"],
|
||||
"properties": {
|
||||
"type": { "const": "builtin" },
|
||||
"destination": {
|
||||
"type": "string",
|
||||
"enum": ["home", "movies", "series", "music", "audiobooks", "for_you", "calendar"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"library": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "library_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "library" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
},
|
||||
"section": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "library_id", "section_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "section" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"section_id": { "$ref": "#/$defs/targetId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "collection_id", "label"],
|
||||
"properties": {
|
||||
"type": { "const": "collection" },
|
||||
"collection_id": { "$ref": "#/$defs/targetId" },
|
||||
"library_id": { "$ref": "#/$defs/libraryId" },
|
||||
"label": { "$ref": "#/$defs/label" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# Canonical Settings API
|
||||
|
||||
The canonical settings API stores typed user preferences from the shared
|
||||
settings contract. Client implementations should discover the server contract
|
||||
before rendering controls or writing a value; do not keep a separate list of
|
||||
keys, scopes, enum members, or defaults.
|
||||
|
||||
All paths below are relative to `/api/v1`.
|
||||
|
||||
## Contract discovery
|
||||
|
||||
| Method and path | Purpose |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /settings/manifest` | Public client projection of the current manifest. Supports `If-None-Match`. |
|
||||
| `GET /settings/capability` | Contract API version, revision, remote scopes, supported client families, and batch/write capabilities. |
|
||||
|
||||
`/settings/contract` and `/settings/contract/capabilities` are equivalent
|
||||
aliases. A client whose vendored contract is newer than the advertised server
|
||||
revision must hide definitions and features introduced after that revision.
|
||||
Navigation shortcut mutation controls additionally require
|
||||
`supports_atomic_shortcuts: true`. Revision-5 customization also requires
|
||||
`supports_batched_effective: true` and `supports_idempotent_writes: true` so
|
||||
batched resolution and replayed writes have the semantics the clients depend
|
||||
on. Any missing flag fails closed.
|
||||
|
||||
## Request identity headers
|
||||
|
||||
Authenticated settings routes use the active account and profile from the
|
||||
normal Silo session. The contextual headers below identify which client is
|
||||
resolving or writing an override.
|
||||
|
||||
| Header | When required | Meaning |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `X-Profile-Id` | All `/settings/values` routes | Active household profile. |
|
||||
| `X-Silo-Device-Id` | A `profile_device` explicit request, or an effective request containing a key that permits `profile_device` | Stable exact-device identity. |
|
||||
| `X-Silo-Client-Family` | Required for a `profile_client` explicit request; optional on effective reads | Closed like-client identity: `tv`, `mobile`, `tablet`, `desktop`, or `web`. A valid value includes the like-client layer; absence skips it. |
|
||||
| `X-Silo-Mutation-Id` | Optional on `PUT` | Idempotency key for safe retries. Reusing it for a different identity or value returns a conflict. |
|
||||
|
||||
Client family is intentionally independent of the free-form
|
||||
`X-Silo-Device-Platform` display metadata. The server never guesses one from
|
||||
the other. Send the exact lower-case family:
|
||||
|
||||
| Client | Family |
|
||||
| ----------------------- | --------- |
|
||||
| tvOS or Android TV | `tv` |
|
||||
| iPhone or Android phone | `mobile` |
|
||||
| iPad or Android tablet | `tablet` |
|
||||
| macOS | `desktop` |
|
||||
| Browser | `web` |
|
||||
|
||||
## Remote scopes
|
||||
|
||||
Every stored value has exactly one identity. Context fields not named by the
|
||||
selected scope must be absent.
|
||||
|
||||
| Scope | Identity after account |
|
||||
| ----------------- | ----------------------------- |
|
||||
| `account` | none |
|
||||
| `profile` | `profile_id` |
|
||||
| `profile_client` | `profile_id`, `client_family` |
|
||||
| `profile_device` | `profile_id`, `device_id` |
|
||||
| `profile_library` | `profile_id`, `library_id` |
|
||||
| `profile_series` | `profile_id`, `series_id` |
|
||||
|
||||
The manifest's `allowed_scopes` decides where each key may be written, and its
|
||||
`resolution_order` decides precedence. `profile_client` values roam only among
|
||||
like clients. For example, a TV value applies to tvOS and Android TV but not to
|
||||
a phone or browser.
|
||||
|
||||
## Explicit values
|
||||
|
||||
Use explicit endpoints to edit or clear one scope, without resolving inherited
|
||||
values:
|
||||
|
||||
- `GET /settings/values?keys=<csv>&scope=<scope>` returns every requested key
|
||||
with `is_set`; unset rows remain in the response.
|
||||
- `GET /settings/values/{key}?scope=<scope>` returns one stored value or `404`.
|
||||
- `PUT /settings/values/{key}?scope=<scope>` accepts `{"value": <typed JSON>}`.
|
||||
- `DELETE /settings/values/{key}?scope=<scope>` removes the row so inheritance
|
||||
applies again.
|
||||
|
||||
`library_id` and `series_id` are query parameters for their matching scopes.
|
||||
An explicitly managed device may be named with `device_id`, subject to the
|
||||
profile/device authorization checks. The self-service `profile_client` scope
|
||||
takes its family only from `X-Silo-Client-Family`.
|
||||
|
||||
Example: share a TV menu between tvOS and Android TV clients.
|
||||
|
||||
```http
|
||||
PUT /api/v1/settings/values/nav.primary_menu?scope=profile_client
|
||||
Authorization: Bearer <token>
|
||||
X-Profile-Id: <profile-id>
|
||||
X-Silo-Client-Family: tv
|
||||
Content-Type: application/json
|
||||
|
||||
{"value":{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":"Movies"}]}}
|
||||
```
|
||||
|
||||
Stored-value responses include `client_family` when the source or explicit row
|
||||
is at `profile_client`.
|
||||
|
||||
### Atomic navigation shortcuts
|
||||
|
||||
`nav.shortcuts` is a profile-wide catalog shared by TV, mobile, desktop, and
|
||||
web clients. Self-service clients must mutate one destination at a time instead
|
||||
of replacing that shared document:
|
||||
|
||||
```http
|
||||
PUT /api/v1/settings/values/nav.shortcuts/item
|
||||
Authorization: Bearer <token>
|
||||
X-Profile-Id: <profile-id>
|
||||
X-Silo-Mutation-Id: <stable-uuid-for-this-intent>
|
||||
Content-Type: application/json
|
||||
|
||||
{"item":{"type":"section","library_id":7,"section_id":"recent","label":"Recently Added"},"present":true}
|
||||
```
|
||||
|
||||
The item is one member of `navigation-shortcuts.json`: a library, section, or
|
||||
collection (whose `collection_id` is a string and whose `library_id` is
|
||||
optional). Identity is exactly the schema's semantic identity:
|
||||
|
||||
- library: `type + library_id`
|
||||
- section: `type + library_id + section_id`
|
||||
- collection: `type + optional library_id + collection_id`
|
||||
|
||||
`present: true` appends an absent item or refreshes the label of an existing
|
||||
identity in place without reordering it. `present: false` removes that identity;
|
||||
the supplied label is validated but ignored for matching. The server atomically
|
||||
rebases on concurrent edits, enforces the full schema and 256-item cap, and
|
||||
returns the normal stored-value object containing the complete resulting
|
||||
`value`, row `revision`, and `updated_at`. An already-satisfied operation is a
|
||||
successful no-op and does not increment the revision. Removing from a catalog
|
||||
that has never been stored returns `{"items":[]}` at revision `0` with no
|
||||
`updated_at`.
|
||||
|
||||
Retry with the same `X-Silo-Mutation-Id`. A recorded retry returns the original
|
||||
response with `X-Silo-Idempotent-Replay: true`; reusing the id for a different
|
||||
semantic operation returns `409 mutation_id_conflict`. The mutation ID is
|
||||
serialized before the setting write, and the setting plus its replay receipt
|
||||
commit in one database transaction, so concurrent reuse cannot apply twice and
|
||||
a crash cannot persist only one half. A rare exhausted
|
||||
contention loop returns retryable `409 setting_update_conflict`. Malformed
|
||||
envelopes or unknown envelope fields return `400 bad_request`, while item
|
||||
schema failures (including unknown item fields) and cap failures return
|
||||
`400 invalid_value`.
|
||||
|
||||
Ordinary session `PUT` and `DELETE` at
|
||||
`/settings/values/nav.shortcuts?scope=profile` are rejected with
|
||||
`400 atomic_update_required` so a whole-document mutation cannot erase
|
||||
concurrent item edits or reset the row revision. The admin endpoint retains
|
||||
whole-document `PUT` for explicit repair work, but rejects physical `DELETE`
|
||||
for the same revision-history reason. An admin clears the catalog with
|
||||
`{"value":{"items":[]}}`, which advances the row revision, or replaces it with
|
||||
another validated document.
|
||||
|
||||
## Effective values
|
||||
|
||||
- `GET /settings/values/effective?keys=<csv>` resolves several keys for the
|
||||
request profile, device, and client family. Omitting `keys` resolves all
|
||||
remote definitions.
|
||||
- `POST /settings/values/effective` resolves a bounded list of content contexts
|
||||
in one store read. Use it for grids or lists rather than issuing one request
|
||||
per item.
|
||||
|
||||
An effective request must include the device header when any requested
|
||||
definition permits an exact-device override. The family header is optional for
|
||||
backward compatibility: absence skips the `profile_client` layer, while a
|
||||
non-empty invalid family is rejected. First-party clients should send their
|
||||
family so like-device preferences participate in resolution. Each response
|
||||
includes its source scope and source context; `client_family` is included for a
|
||||
family-scoped winner.
|
||||
|
||||
## Admin projection
|
||||
|
||||
Admin routes are mounted behind the normal acting-admin authorization:
|
||||
|
||||
- `GET /admin/users/{id}/settings/values` lists every stored row across all
|
||||
scopes.
|
||||
- `PUT /admin/users/{id}/settings/values/{key}?scope=<scope>` writes through the
|
||||
same contract validation and normalization as the self-service endpoint.
|
||||
- `DELETE /admin/users/{id}/settings/values/{key}?scope=<scope>` clears the
|
||||
selected row.
|
||||
|
||||
Admin requests name profile/context identity with query parameters because the
|
||||
target user is not the admin's active session. In particular,
|
||||
`scope=profile_client` requires both `profile_id` and `client_family` query
|
||||
parameters; it does not use `X-Silo-Client-Family`.
|
||||
|
||||
```http
|
||||
PUT /api/v1/admin/users/42/settings/values/ui.card_presentation?scope=profile_client&profile_id=main&client_family=tv
|
||||
Authorization: Bearer <admin-token>
|
||||
Content-Type: application/json
|
||||
|
||||
{"value":{"poster_size":"large","caption":"artwork"}}
|
||||
```
|
||||
|
||||
The five accepted `client_family` values are also returned by the capability
|
||||
endpoint so admin tooling does not need to invent them.
|
||||
@@ -416,6 +416,7 @@ The remote scopes are:
|
||||
|---|---|---|
|
||||
| `account` | `(user_id)` | Same for every profile and signed-in client on the account. |
|
||||
| `profile` | `(user_id, profile_id)` | Roams with one profile. |
|
||||
| `profile_client` | `(user_id, profile_id, client_family)` | Roams between like clients: `tv`, `mobile`, `tablet`, `desktop`, or `web`. |
|
||||
| `profile_device` | `(user_id, profile_id, device_id)` | Override for one profile on one device identity. |
|
||||
| `profile_library` | `(user_id, profile_id, library_id)` | Content preference for one library. |
|
||||
| `profile_series` | `(user_id, profile_id, series_id)` | Content preference for one series. |
|
||||
@@ -423,6 +424,10 @@ The remote scopes are:
|
||||
`client_local` definitions use a single logical `client_local` scope and are never addressed by the
|
||||
server values API.
|
||||
|
||||
Clients send the family explicitly as `X-Silo-Client-Family`; the server never infers it from the
|
||||
free-form `X-Silo-Device-Platform` registry label. tvOS and Android TV use `tv`, phones use
|
||||
`mobile`, tablets use `tablet`, native desktop clients use `desktop`, and browsers use `web`.
|
||||
|
||||
All remote mutations carry their complete identity explicitly. The server authorizes that the
|
||||
profile, library, series, and device belong to the authenticated user. A queued operation must not
|
||||
derive its profile or server from whichever account happens to be active when the retry runs.
|
||||
@@ -448,6 +453,8 @@ Examples:
|
||||
|---|---|
|
||||
| Audio/subtitle selection | series → library → device → profile → default |
|
||||
| Playback behavior with device override | device → profile → default |
|
||||
| Family-synchronized presentation | device → client family → profile → default |
|
||||
| Family-synchronized navigation | device → client family → default |
|
||||
| Device playback capability | device → default |
|
||||
| Account UI preference | account → default |
|
||||
| Client-local OS behavior | local value → default |
|
||||
@@ -625,8 +632,10 @@ to check compatibility without transferring the manifest body.
|
||||
|
||||
- Returns the explicit value and revision at exactly one requested scope; it does not resolve
|
||||
fallbacks.
|
||||
- Context parameters are required by scope: `profile_id`, `device_id`, `library_id`, or `series_id`
|
||||
as defined by the identity table above.
|
||||
- Context parameters are required by scope: `profile_id`, `client_family`, `device_id`,
|
||||
`library_id`, or `series_id` as defined by the identity table above. Self-service
|
||||
`profile_client` requests take client family from `X-Silo-Client-Family`; admin explicit-value
|
||||
routes name it with the `client_family` query parameter.
|
||||
- An unset value is represented as `is_set: false` with no `value` member, never as an empty string
|
||||
or JSON `null`.
|
||||
- Settings screens use this endpoint to show profile defaults and device overrides independently.
|
||||
@@ -637,7 +646,8 @@ to check compatibility without transferring the manifest body.
|
||||
`GET /api/v1/settings/values/effective?keys=<comma-separated-keys>`
|
||||
|
||||
- Requires the active profile and device identity headers for definitions that can resolve those
|
||||
scopes.
|
||||
scopes. `X-Silo-Client-Family` is optional for effective reads so older callers remain compatible:
|
||||
absence skips `profile_client`, a valid value includes it, and a non-empty invalid value is rejected.
|
||||
- Rejects unknown keys rather than fabricating defaults.
|
||||
- Returns native typed values, resolution source, source context, definition revision,
|
||||
`updated_at`, and any policy constraint.
|
||||
@@ -789,6 +799,7 @@ CREATE TABLE user_setting_values (
|
||||
key text NOT NULL,
|
||||
scope text NOT NULL,
|
||||
profile_id text,
|
||||
client_family text,
|
||||
device_id text,
|
||||
library_id integer,
|
||||
series_id text,
|
||||
@@ -796,13 +807,15 @@ CREATE TABLE user_setting_values (
|
||||
revision bigint NOT NULL DEFAULT 1,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
|
||||
CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series')),
|
||||
CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web')),
|
||||
CHECK (
|
||||
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
|
||||
(scope = 'account' AND profile_id IS NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_device' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_library' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_series' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
```
|
||||
@@ -819,6 +832,8 @@ CREATE UNIQUE INDEX user_setting_values_account_uq
|
||||
ON user_setting_values (user_id, key) WHERE scope = 'account';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_uq
|
||||
ON user_setting_values (user_id, profile_id, key) WHERE scope = 'profile';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_client_uq
|
||||
ON user_setting_values (user_id, profile_id, client_family, key) WHERE scope = 'profile_client';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_device_uq
|
||||
ON user_setting_values (user_id, profile_id, device_id, key) WHERE scope = 'profile_device';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_library_uq
|
||||
@@ -949,8 +964,19 @@ client. The following decisions resolve today's duplicate semantics:
|
||||
| Date/time format | remote: **profile** | Existing account rows are copied to every profile. |
|
||||
| Search media scope | remote: profile | Preserve strict enums. |
|
||||
| `ui.library_page_state` | remote: profile_device | Keep navigation state tied to one profile/device. |
|
||||
| `nav.primary_menu` | remote: profile_client, profile_device | Family menu ordering/visibility with an optional exact-device escape hatch; null means use the family-native baseline. |
|
||||
| `nav.shortcuts` | remote: profile | Profile-wide catalog of library, section, and collection pins; seed convertible legacy `ui.sidebar_pins` without deleting the old row. |
|
||||
| `ui.card_presentation` | remote: profile, profile_client, profile_device | Semantic size/caption presets; device → family → profile → default. |
|
||||
| OS caption mirroring, platform decoder diagnostics, temporary sleep timers | client_local or private `local.*` | Production caption-mirroring UI is contract-known local; diagnostics/timers remain private local. |
|
||||
|
||||
Navigation destinations are unique by semantic identity, not by the complete JSON object: built-ins
|
||||
use `destination`, libraries use `library_id`, sections use `(library_id, section_id)`, and collections
|
||||
use `(optional library_id, collection_id)`. Changing a label therefore cannot create a duplicate menu
|
||||
entry. Labels and string target ids must contain a non-whitespace character; labels are bounded to
|
||||
256 characters and target ids to 128. The JSON schemas document these rules and the canonical
|
||||
validator enforces the cross-item identity check that JSON Schema `uniqueItems` cannot express by
|
||||
itself.
|
||||
|
||||
### Appearance belongs to the profile, not the account
|
||||
|
||||
Theme, text scale, contrast, custom CSS, and date/time format are stored today in `user_settings`
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# Plugin-backed watch sync providers
|
||||
|
||||
## Status
|
||||
|
||||
Implemented design for the expanded `silo-plugin-sdk` v0.13
|
||||
`watch_sync_provider.v1` contract. The server uses the immutable SDK commit
|
||||
associated with the v0.13 pull request until that version is released. This
|
||||
specification does not introduce a second watch synchronization pipeline.
|
||||
Plugin-backed providers adapt the public plugin RPC contract to Silo's existing
|
||||
`watchsync` service, connection repository, history exports, scrobble sessions,
|
||||
rate-limit handling, list reconciliation, and scheduled reconciliation.
|
||||
|
||||
## Goals
|
||||
|
||||
- Allow an enabled plugin capability to appear as a profile-scoped watch
|
||||
provider.
|
||||
- Reuse Silo's encrypted watch-provider connections for personal credentials.
|
||||
- Support API-key and device-code connection flows.
|
||||
- Import and export watched state, progress, favorites, and watchlists according
|
||||
to each plugin's advertised capabilities.
|
||||
- Deliver explicit watched/unwatched and list operations plus live playback
|
||||
start, pause, and stop events.
|
||||
- Preserve the existing durable history-export reconciliation path.
|
||||
- Preserve complete provider credential shapes, including token type, scopes,
|
||||
expiry, and opaque secret attributes.
|
||||
- Supply declared installation configuration to provider calls without mixing
|
||||
it with profile credentials.
|
||||
- Allow API-key providers to declare setup fields that are supplied per profile
|
||||
connection rather than forcing one installation-wide endpoint.
|
||||
- Keep provider plugins stateless and prevent credentials from entering generic
|
||||
plugin configuration.
|
||||
- Avoid behavior changes for built-in Trakt, Simkl, and MDBList providers.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Authorization-code OAuth UI and callback routes.
|
||||
- A second outbox or worker implementation.
|
||||
- Allowing plugins to persist or own personal provider credentials.
|
||||
- Removing built-in Trakt, Simkl, or MDBList providers in the same change. They
|
||||
remain available while equivalent plugins are built and connections are
|
||||
migrated deliberately.
|
||||
|
||||
Plugins may implement any subset of the state families and operations. A
|
||||
provider such as Floppy can advertise watched import/export, progress import,
|
||||
and live scrobbling without claiming favorites, watchlists, or unwatch support.
|
||||
Silo exposes only executable capabilities from the descriptor.
|
||||
|
||||
## SDK boundary
|
||||
|
||||
Plugins advertise `watch_sync_provider.v1` with a typed descriptor containing:
|
||||
|
||||
- supported authentication methods;
|
||||
- import/export capabilities;
|
||||
- supported media types and external-ID namespaces;
|
||||
- maximum apply batch size.
|
||||
|
||||
The host uses `WatchSyncProvider` for the stable provider operations and a
|
||||
separate `WatchSyncDeviceAuthorizationService` for device-code start and poll.
|
||||
The split keeps direct Go implementations of the released v0.12 provider
|
||||
interface source-compatible. Together the services support:
|
||||
|
||||
- device authorization `Start` and `Poll`;
|
||||
- `ExchangeAPIKey`;
|
||||
- `RefreshCredentials`;
|
||||
- `GetAccount`;
|
||||
- `ApplyEvents` for watched, unwatched, favorite, watchlist, and live scrobble
|
||||
operations;
|
||||
- `ListRemoteState` for watched, progress, favorite, and watchlist state.
|
||||
|
||||
Authorization-code descriptors remain valid SDK contracts, but the host does
|
||||
not register a plugin that offers only authorization-code authentication until
|
||||
the public watch-provider callback flow exists.
|
||||
|
||||
Every provider RPC receives the complete current credentials and relevant
|
||||
installation configuration only for that call. Plugins must not persist or log
|
||||
credentials. Generic `Runtime.Configure` data is not a personal credential
|
||||
store. Returned credentials are an authoritative replacement, and the host
|
||||
persists them before interpreting results, pages, or faults from the same RPC.
|
||||
|
||||
## Discovery and lifecycle
|
||||
|
||||
At startup and after plugin lifecycle changes, Silo:
|
||||
|
||||
1. lists enabled, non-builtin plugin installations;
|
||||
2. selects `watch_sync_provider.v1` capabilities;
|
||||
3. decodes and validates their typed descriptors;
|
||||
4. constructs thin `watchsync.Provider` adapters;
|
||||
5. atomically replaces the registry's previous plugin-backed providers while
|
||||
preserving built-ins.
|
||||
|
||||
Provider keys bind persisted credentials to the installation and capability
|
||||
(`plugin:<installation-id>:<capability-id>`), while RPC routing continues to use
|
||||
the manifest capability ID. This prevents a subsequently installed plugin from
|
||||
inheriting credentials merely by reusing another plugin's capability ID.
|
||||
Provider keys must not shadow built-ins or other plugin providers. A conflicting
|
||||
lifecycle reload fails without partially replacing the registry. Invalid,
|
||||
unreadable, or currently unsupported descriptors are logged and omitted, and
|
||||
the successfully discovered set still replaces the previous plugin providers.
|
||||
This fail-closed behavior prevents a disabled or removed plugin from remaining
|
||||
registered because another installation could not be decoded.
|
||||
|
||||
## Existing watchsync interfaces
|
||||
|
||||
The adapter implements the existing interfaces corresponding to the
|
||||
descriptor's advertised capabilities:
|
||||
|
||||
- `Provider`;
|
||||
- `AuthProvider`, `APIKeyAuthProvider`, and device authorization;
|
||||
- watched import/export and unwatch export;
|
||||
- progress import;
|
||||
- favorite and watchlist import/export/removal, including ordered watchlists;
|
||||
- `Scrobbler` and `OrderedScrobbler` for start, pause, and stop delivery.
|
||||
|
||||
`LocalPlay` and `ScrobbleEvent` values become rich desired-state plugin events.
|
||||
The host supplies movie and series external IDs, season and episode numbers,
|
||||
local history identity, playback identity, timestamps, and completion values.
|
||||
The plugin never needs a Silo API key or a callback into catalog HTTP routes.
|
||||
|
||||
## Authentication and credentials
|
||||
|
||||
The existing watch-provider API-key connection route passes the entered token
|
||||
to `ExchangeAPIKey`. A plugin capability may also declare `config_schema`
|
||||
entries; the host renders those fields beside the API key, validates submitted
|
||||
objects against the declared JSON schemas, classifies secret fields, and
|
||||
overlays the flattened values onto `provider_config` for that exchange only.
|
||||
The plugin validates or exchanges the credential and returns normalized
|
||||
credentials plus provider account identity. Provider-specific connection
|
||||
context needed by later calls is returned in encrypted opaque credential
|
||||
attributes. Device-code providers use the existing start and poll routes.
|
||||
|
||||
Credential refresh remains host-initiated and serialized by the existing
|
||||
watchsync service. The host encrypts an authoritative credential bundle
|
||||
containing access token, refresh token, expiry, token type, scopes, and opaque
|
||||
secret attributes. Legacy access/refresh columns remain readable during the
|
||||
migration. Device codes are also encrypted at rest. A pending device poll may
|
||||
rotate its opaque provider state, interval, or expiry; the host validates and
|
||||
persists the replacement before another poll. Invalid-credential faults are
|
||||
persisted using only the plugin's safe message.
|
||||
|
||||
Authorization-code OAuth can be added separately to the existing watch-
|
||||
provider service after its client-secret storage and callback-state design are
|
||||
reviewed.
|
||||
|
||||
## Watched export flow
|
||||
|
||||
### Explicit mark watched
|
||||
|
||||
The existing manual watch path records leaf history entries and invokes
|
||||
`HandleLocalWatchEvent`. The existing history-export table is populated before
|
||||
provider I/O. The plugin adapter sends each pending `LocalPlay` as an absolute
|
||||
`MARK_WATCHED` desired-state event. The interactive path sends one bounded
|
||||
plugin batch; additional queued events are delivered by scheduled
|
||||
reconciliation.
|
||||
|
||||
### Playback completion
|
||||
|
||||
The existing playback path invokes `ScrobbleStop` with rich identity. For a
|
||||
completed event and a provider that exports watched state, Silo first upserts a
|
||||
pending history-export record, then dispatches the immediate stop operation.
|
||||
|
||||
On successful immediate delivery, the history export is marked
|
||||
`satisfied_by_scrobble`. If immediate delivery fails or the process exits, the
|
||||
existing scheduled history reconciliation retries the same local history. A
|
||||
compatibility playback path without a local history ID retains its own durable
|
||||
terminal event until the confirmed stop succeeds. The plugin operation is
|
||||
convergent, so an uncertain duplicate cannot lower progress or increment a play
|
||||
counter.
|
||||
|
||||
Start, pause, and incomplete stop calls are no-ops for providers that only
|
||||
advertise watched export. Providers advertising live scrobbling receive all
|
||||
three operations with playback position and duration.
|
||||
|
||||
If the remote stop succeeds but the local history-export transition fails, the
|
||||
stop is not dispatched again. The scrobble session records a durable pending
|
||||
history-reconciliation marker, and the sweeper retries only the local database
|
||||
transition.
|
||||
|
||||
## Remote state import and lists
|
||||
|
||||
The scheduled sync path requests one state family at a time. Each request
|
||||
contains the stable cursor for that family and an opaque page token. The host:
|
||||
|
||||
1. persists any credentials returned by the page;
|
||||
2. discards the page and retains its cursor on a top-level fault;
|
||||
3. applies valid items idempotently through existing watch-state and list
|
||||
services;
|
||||
4. advances the page token until the plugin reports no next page;
|
||||
5. commits the family cursor only after the traversal succeeds.
|
||||
|
||||
Incremental list pages do not imply deletion of absent items; they use explicit
|
||||
provider-key tombstones for removals. Complete snapshots may reconcile removals.
|
||||
Ordered watchlists preserve the plugin's list position and require complete
|
||||
snapshots because an incremental subset cannot define positions relative to
|
||||
omitted items. Traversals are bounded by both page and item count before their
|
||||
durable cursor advances.
|
||||
Progress items without a valid provider timestamp are rejected rather than
|
||||
being stamped with host time.
|
||||
|
||||
Imported history is tagged with the stable plugin provider key, not a generic
|
||||
import source. Echo suppression therefore filters only export back to the
|
||||
originating plugin; the same local event remains eligible for other connected
|
||||
providers.
|
||||
|
||||
## Provider configuration
|
||||
|
||||
Manifest-declared global configuration is resolved per installation before each
|
||||
provider call. Public fields are sent in `values`; secret and undeclared fields
|
||||
are sent in `secret_values`. Nested manifest fields use the stable
|
||||
`<config-key>.<field>` key form, such as `floppy.base_url`. Profile credentials
|
||||
remain separate and encrypted in watch-provider connection storage.
|
||||
|
||||
Capability-level `config_schema` entries are connection setup fields, not
|
||||
installation defaults. For API-key authorization, the host validates and
|
||||
flattens them using the same `<config-key>.<field>` convention and lets the
|
||||
connection values override a same-named global value. This supports migrations
|
||||
from a legacy installation-wide endpoint while ensuring every new connection
|
||||
chooses its own endpoint. The submitted objects are not retained as generic
|
||||
plugin configuration; the plugin returns any context required after exchange
|
||||
inside its opaque encrypted credentials.
|
||||
|
||||
## Apply result handling
|
||||
|
||||
Each event has a stable ID. The adapter requires a matching result and maps it
|
||||
as follows:
|
||||
|
||||
- `APPLIED` and `NO_CHANGE`: export sent;
|
||||
- `REJECTED`: permanent not-found/unmappable export;
|
||||
- `RETRY` with a temporary fault: failed export eligible for existing retry handling;
|
||||
- top-level or per-event rate limit: existing `RateLimitedError` and account
|
||||
deferral, after committing any successfully applied events from the same RPC;
|
||||
- missing or unknown result: failed export eligible for retry, unless the same
|
||||
RPC reported a rate limit, in which case unmentioned events remain pending;
|
||||
- plugin transport, process availability, and top-level temporary faults:
|
||||
remain pending without consuming a per-event attempt.
|
||||
|
||||
A top-level invalid-credential fault records a typed safe connection error.
|
||||
A dedicated reconnect-required connection state is deferred as described
|
||||
above. Plugin messages persisted by Silo must be explicitly safe for operator
|
||||
display and must not contain response bodies or secrets. The host redacts the
|
||||
connection credentials, normalizes control characters and whitespace, and
|
||||
bounds the displayed message length; transport details are replaced with
|
||||
host-owned text.
|
||||
|
||||
## Ordering and batching
|
||||
|
||||
Immediate scrobbles are ordered by connection and stable series identity using
|
||||
the existing ordered-scrobble queue. History reconciliation is ordered by local
|
||||
watch timestamp.
|
||||
|
||||
Each synchronization run sends at most one plugin RPC, bounded by the
|
||||
descriptor's maximum batch size. The existing service commits every per-event
|
||||
result before a later run requests the next pending batch. Failures increment
|
||||
attempts only for events included in that RPC. This avoids partial-success loss,
|
||||
retry exhaustion within one run, and unbounded cumulative RPC time.
|
||||
|
||||
## Reliability boundaries
|
||||
|
||||
This design deliberately reuses the reliability guarantees currently accepted
|
||||
for built-in providers:
|
||||
|
||||
- encrypted profile-scoped connections;
|
||||
- durable local watch history;
|
||||
- durable history-export rows;
|
||||
- immediate scrobble sessions;
|
||||
- scheduled reconciliation;
|
||||
- bounded retry attempts and safe recorded errors;
|
||||
- provider/account rate-limit deferral.
|
||||
|
||||
It does not claim multi-node leased delivery or infinite retries. Those should
|
||||
be improved in the shared watchsync pipeline for built-in and plugin providers
|
||||
together rather than introduced only for plugins.
|
||||
|
||||
## Security requirements
|
||||
|
||||
- Personal tokens never enter plugin runtime config or user settings.
|
||||
- Tokens are decrypted only at the provider invocation boundary.
|
||||
- RPC request values containing credentials are never logged.
|
||||
- Plugin-returned errors are sanitized before persistence and API display;
|
||||
transport errors are replaced by a generic message.
|
||||
- Account/profile scope remains enforced by the existing connection lookup.
|
||||
- Credential AAD and provider lookup include installation identity, preventing
|
||||
sequential capability-ID reuse from exposing an old token to a new install.
|
||||
- Disabled plugins disappear from provider discovery; existing connections are
|
||||
unavailable until that same installation and capability return.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The change is additive to `/api/v1`. Existing provider summaries and connection
|
||||
routes continue to work. A plugin provider appears through the same provider
|
||||
summary model and uses the existing API-key or device-code connection routes.
|
||||
No client changes are required because no existing fields or status codes
|
||||
change. SDK v0.12 plugins remain wire-compatible; descriptors only expose the
|
||||
capabilities the older contract can execute.
|
||||
|
||||
## Validation plan
|
||||
|
||||
- capability enforcement in the plugin host client;
|
||||
- atomic registry replacement and built-in collision tests;
|
||||
- API-key and device-code authentication, full credential replacement, and
|
||||
account identity conversion;
|
||||
- provider configuration routing and secret/public field classification;
|
||||
- rich movie and episode identity conversion;
|
||||
- watched, progress, favorite, and watchlist import pagination and cursor
|
||||
behavior;
|
||||
- unwatch, favorite, watchlist, and ordered-list event conversion;
|
||||
- provider-specific import source keys and cross-provider export behavior;
|
||||
- completed playback persistence before plugin I/O;
|
||||
- `satisfied_by_scrobble` transition after success;
|
||||
- durable retry of a failed local post-stop history transition without another
|
||||
remote stop;
|
||||
- immediate failure followed by scheduled history reconciliation;
|
||||
- typed rate-limit and invalid-credential handling;
|
||||
- complete credential encryption/decryption and legacy fallback;
|
||||
- plugin disable/re-enable lifecycle reload;
|
||||
- no regression in existing watchsync, pluginhost, and plugin service tests;
|
||||
- `go test`, targeted race tests, `go vet`, lint, formatting checks, and local
|
||||
path verification before proposing a merge request.
|
||||
@@ -109,7 +109,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||
|
||||
@@ -4,8 +4,10 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0 h1:7MnaSIJBVdRE8FhfLXWx7Pe+Q2apJiaPMotfhb1VHoE=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.1 h1:3vMaV+aPT/vu47CPk/f3nODsjXCp1OseI0QIoLnOvOM=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.1/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2 h1:w7U0mmljVPauKfzRLNKusuiYFpuoEhuCSX4Hp3s9eRw=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
|
||||
@@ -462,7 +462,7 @@ type changeAudioResponse struct {
|
||||
PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"`
|
||||
}
|
||||
|
||||
func (resp *changeAudioResponse) setCopyTimeline(position, origin float64) {
|
||||
func (resp *changeAudioResponse) setWindowedTimeline(position, origin float64) {
|
||||
playerStart := max(0, position-origin)
|
||||
canSeekAnywhere := false
|
||||
resp.PlayerStartSeconds = &playerStart
|
||||
@@ -533,10 +533,20 @@ func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool {
|
||||
if file == nil || file.Duration <= 0 {
|
||||
return false
|
||||
}
|
||||
// Copy-video HLS sessions use FFmpeg's real manifest so the player only
|
||||
// seeks within the currently exposed window. Out-of-window seeks should
|
||||
// restart explicitly instead of relying on segment 404s to move FFmpeg.
|
||||
return !strings.EqualFold(req.TargetCodecVideo, "copy")
|
||||
return !usesRealTranscodeManifest(req, file)
|
||||
}
|
||||
|
||||
func usesRealTranscodeManifest(req transcodeStartRequest, file *models.MediaFile) bool {
|
||||
durationSeconds := 0.0
|
||||
if file != nil {
|
||||
durationSeconds = float64(file.Duration)
|
||||
}
|
||||
// Copy-video, unknown-duration, and oversized HLS sessions use FFmpeg's
|
||||
// real manifest so the player only seeks within the currently exposed
|
||||
// window. Out-of-window seeks should restart explicitly instead of relying
|
||||
// on segment 404s to move FFmpeg.
|
||||
return strings.EqualFold(req.TargetCodecVideo, "copy") ||
|
||||
!playback.CanGenerateSyntheticManifest(durationSeconds, req.SegmentDuration)
|
||||
}
|
||||
|
||||
func buildTranscodeStartResponse(
|
||||
@@ -2122,17 +2132,26 @@ func buildSubtitleURLs(
|
||||
Label: dl.ReleaseName + " (" + dl.Provider + ")",
|
||||
Source: "downloaded",
|
||||
HearingImpaired: dl.HearingImpaired,
|
||||
URL: subtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID),
|
||||
URL: downloadedSubtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID, dl.ID),
|
||||
})
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
const downloadedSubtitleIDParam = "downloaded_subtitle_id"
|
||||
|
||||
func subtitleStreamURL(sessionID string, trackIndex int, codec string, fileID int) string {
|
||||
return fmt.Sprintf("/stream/%s/subtitles/%d%s?file_id=%d", sessionID, trackIndex, subtitleURLExt(codec), fileID)
|
||||
}
|
||||
|
||||
// downloadedSubtitleStreamURL binds a downloaded subtitle URL to its stable
|
||||
// database identity while preserving the combined track index for clients.
|
||||
func downloadedSubtitleStreamURL(sessionID string, trackIndex int, codec string, fileID, downloadedID int) string {
|
||||
return subtitleStreamURL(sessionID, trackIndex, codec, fileID) +
|
||||
"&" + downloadedSubtitleIDParam + "=" + strconv.Itoa(downloadedID)
|
||||
}
|
||||
|
||||
func subtitleFontBundleURL(sessionID string, trackIndex int, codec string, fileID int) string {
|
||||
if !playback.IsASS(codec) {
|
||||
return ""
|
||||
@@ -2369,8 +2388,16 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
restartStartSegment := computeStartSegment(restartSeekSeconds, restartSegmentDuration)
|
||||
restartStreamOriginSeconds := 0.0
|
||||
restartCopyAnchorResolved := false
|
||||
legacyCopyRestart := session.PlayMethod == playback.PlayTranscode &&
|
||||
strings.EqualFold(targetVideoCodec, "copy") && isLegacyTransportSession(session)
|
||||
restartManifestRequest := transcodeStartRequest{
|
||||
TargetCodecVideo: targetVideoCodec,
|
||||
SegmentDuration: restartSegmentDuration,
|
||||
}
|
||||
legacyWindowedRestart := session.PlayMethod == playback.PlayTranscode &&
|
||||
isLegacyTransportSession(session) && usesRealTranscodeManifest(restartManifestRequest, file)
|
||||
if legacyWindowedRestart {
|
||||
restartStreamOriginSeconds = restartSeekSeconds
|
||||
}
|
||||
legacyCopyRestart := legacyWindowedRestart && strings.EqualFold(targetVideoCodec, "copy")
|
||||
if legacyCopyRestart {
|
||||
restartCopyAnchorResolved = true
|
||||
if req.Position > 0 {
|
||||
@@ -2779,8 +2806,8 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
}
|
||||
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
|
||||
}
|
||||
if legacyCopyRestart {
|
||||
resp.setCopyTimeline(req.Position, restartStreamOriginSeconds)
|
||||
if legacyWindowedRestart {
|
||||
resp.setWindowedTimeline(req.Position, restartStreamOriginSeconds)
|
||||
}
|
||||
|
||||
h.syncSessionsNow(r.Context(), "audio_change")
|
||||
@@ -3231,6 +3258,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
transportSeekSeconds := alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo)
|
||||
startSegmentNumber := computeStartSegment(transportSeekSeconds, req.SegmentDuration)
|
||||
streamOriginSeconds := 0.0
|
||||
if usesRealTranscodeManifest(req, file) {
|
||||
streamOriginSeconds = transportSeekSeconds
|
||||
}
|
||||
if videoCopy {
|
||||
streamOriginSeconds = req.SeekSeconds
|
||||
if req.SeekSeconds > 0 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
func TestSubtitleURLExt(t *testing.T) {
|
||||
@@ -26,6 +27,17 @@ func TestSubtitleURLExt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLsBindsDownloadedSubtitleIdentity(t *testing.T) {
|
||||
file := &models.MediaFile{ID: 42}
|
||||
urls := buildSubtitleURLs("sess-downloaded", file, []subtitles.DownloadedSubtitle{{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT}}, true)
|
||||
if len(urls) != 1 {
|
||||
t.Fatalf("downloaded subtitle URLs = %#v", urls)
|
||||
}
|
||||
if want := "/stream/sess-downloaded/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71"; urls[0].URL != want {
|
||||
t.Fatalf("downloaded subtitle URL = %q, want %q", urls[0].URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_IncludesAllBitmapTracksForBurnInClients(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
|
||||
@@ -616,6 +616,31 @@ func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
|
||||
if encodedResp.TimelineOffsetSeconds != 0 {
|
||||
t.Fatalf("encoded TimelineOffsetSeconds = %v, want 0", encodedResp.TimelineOffsetSeconds)
|
||||
}
|
||||
|
||||
longEncodedResp := buildTranscodeStartResponse(
|
||||
transcodeStartRequest{
|
||||
SessionID: "session-long-encoded",
|
||||
SeekSeconds: 18.261,
|
||||
TargetCodecVideo: "h264",
|
||||
SegmentDuration: 2,
|
||||
},
|
||||
&models.MediaFile{Duration: 1_000_000},
|
||||
nil,
|
||||
"/playback/transcode/session-long-encoded/master.m3u8",
|
||||
18,
|
||||
)
|
||||
if longEncodedResp.CanSeekAnywhere {
|
||||
t.Fatal("long encoded response should require explicit restart seeks")
|
||||
}
|
||||
if math.Abs(longEncodedResp.PlayerStartSeconds-0.261) > 0.0001 {
|
||||
t.Fatalf("long encoded PlayerStartSeconds = %v, want 0.261", longEncodedResp.PlayerStartSeconds)
|
||||
}
|
||||
if longEncodedResp.StreamOriginSeconds != 18 {
|
||||
t.Fatalf("long encoded StreamOriginSeconds = %v, want 18", longEncodedResp.StreamOriginSeconds)
|
||||
}
|
||||
if longEncodedResp.TimelineOffsetSeconds != 18 {
|
||||
t.Fatalf("long encoded TimelineOffsetSeconds = %v, want 18", longEncodedResp.TimelineOffsetSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartPlayback_PersistsSeriesPlaybackPreferenceForEpisodes(t *testing.T) {
|
||||
@@ -1614,7 +1639,7 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
|
||||
CodecAudio: "aac",
|
||||
Container: "mkv",
|
||||
Bitrate: 8000,
|
||||
Duration: 3600,
|
||||
Duration: 1_000_000,
|
||||
AudioTracks: []models.AudioTrack{
|
||||
{Codec: "aac", Default: true},
|
||||
{Codec: "ac3"},
|
||||
@@ -1703,6 +1728,9 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
|
||||
if remoteStartReq.SeekSeconds != 120 {
|
||||
t.Fatalf("remote SeekSeconds = %v, want aligned 120", remoteStartReq.SeekSeconds)
|
||||
}
|
||||
if remoteStartReq.StreamOriginSeconds != 120 {
|
||||
t.Fatalf("remote StreamOriginSeconds = %v, want aligned 120", remoteStartReq.StreamOriginSeconds)
|
||||
}
|
||||
if remoteStartReq.TargetResolution != "720p" || remoteStartReq.TargetCodecVideo != "h264" {
|
||||
t.Fatalf("remote target recipe = %q/%q, want 720p/h264", remoteStartReq.TargetResolution, remoteStartReq.TargetCodecVideo)
|
||||
}
|
||||
@@ -1739,6 +1767,15 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
|
||||
if claims.TargetBitrateKbps != 2000 {
|
||||
t.Fatalf("token TargetBitrateKbps = %d, want 2000 (recipe-complete)", claims.TargetBitrateKbps)
|
||||
}
|
||||
if claims.StreamOriginSeconds != 120 {
|
||||
t.Fatalf("token StreamOriginSeconds = %v, want aligned 120", claims.StreamOriginSeconds)
|
||||
}
|
||||
if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 ||
|
||||
resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 ||
|
||||
resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 ||
|
||||
resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere {
|
||||
t.Fatalf("remote long encoded response timeline = %+v", resp)
|
||||
}
|
||||
if claims.SeekSeconds != 120 {
|
||||
t.Fatalf("token SeekSeconds = %v, want aligned 120", claims.SeekSeconds)
|
||||
}
|
||||
@@ -2142,6 +2179,98 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChangeAudioTrack_LocalLongEncodedPreservesWindowedTimeline(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "movie-local-long-encoded.mkv"),
|
||||
Resolution: "1080p",
|
||||
CodecVideo: "hevc",
|
||||
CodecAudio: "ac3",
|
||||
Container: "mkv",
|
||||
Bitrate: 8000,
|
||||
Duration: 1_000_000,
|
||||
AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}, {Codec: "dts"}},
|
||||
}
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.JWTSecret = "test-secret"
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
|
||||
startReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/playback/transcode/start",
|
||||
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1}`),
|
||||
).WithContext(newAuthorizedPlaybackContext())
|
||||
startRR := httptest.NewRecorder()
|
||||
handler.HandleStartTranscode(startRR, startReq)
|
||||
if startRR.Code != http.StatusAccepted {
|
||||
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
|
||||
}
|
||||
|
||||
predecessor := handler.tm.GetTranscodeSession(session.ID)
|
||||
if predecessor == nil {
|
||||
t.Fatal("expected local long encoded session")
|
||||
}
|
||||
t.Cleanup(func() { handler.tm.CloseTranscodeSession(session.ID, "") })
|
||||
|
||||
changeReq := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/playback/"+session.ID+"/audio",
|
||||
strings.NewReader(`{"audio_track_index":1,"position":121.5}`),
|
||||
).WithContext(newAuthorizedPlaybackContext())
|
||||
changeReq = withPlaybackRouteParam(changeReq, "session_id", session.ID)
|
||||
changeRR := httptest.NewRecorder()
|
||||
handler.HandleChangeAudioTrack(changeRR, changeReq)
|
||||
if changeRR.Code != http.StatusOK {
|
||||
t.Fatalf("change status = %d, body = %s", changeRR.Code, changeRR.Body.String())
|
||||
}
|
||||
|
||||
successor := handler.tm.GetTranscodeSession(session.ID)
|
||||
if successor == nil || successor == predecessor {
|
||||
t.Fatal("audio switch did not publish a prepared successor")
|
||||
}
|
||||
if predecessor.IsRunning() {
|
||||
t.Fatal("audio switch predecessor is still running after commit")
|
||||
}
|
||||
opts := successor.Opts()
|
||||
if opts.TargetCodecVideo != "h264" || opts.SeekSeconds != 120 ||
|
||||
opts.StreamOriginSeconds != 120 || opts.CopySeekAnchorResolved ||
|
||||
opts.StartSegmentNumber != 60 || opts.AudioTrackIndex != 1 {
|
||||
t.Fatalf("local long encoded restart opts = %+v", opts)
|
||||
}
|
||||
|
||||
var resp changeAudioResponse
|
||||
if err := json.NewDecoder(changeRR.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode change response: %v", err)
|
||||
}
|
||||
manifestURL, err := url.Parse(resp.StreamURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse stream URL: %v", err)
|
||||
}
|
||||
claims, err := streamtoken.Verify(manifestURL.Query().Get(streamTokenParam), handler.JWTSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("verify stream token: %v", err)
|
||||
}
|
||||
if claims.TargetCodec != "h264" || claims.SeekSeconds != 120 ||
|
||||
claims.StreamOriginSeconds != 120 || claims.CopySeekAnchorResolved ||
|
||||
claims.StartSegmentNumber != 60 || claims.AudioTrackIndex != 1 {
|
||||
t.Fatalf("local long encoded reconstruction claims = %+v", claims)
|
||||
}
|
||||
if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 ||
|
||||
resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 ||
|
||||
resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 ||
|
||||
resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere {
|
||||
t.Fatalf("local long encoded response timeline = %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChangeAudioTrack_LocalCopyRestartUsesFreshSeekAnchor(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
file := &models.MediaFile{
|
||||
@@ -2395,6 +2524,61 @@ func TestHandleStartTranscode_SeekedCopyRemainsCopyVideo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartTranscode_LongEncodedUsesAlignedRealManifestOrigin(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "movie-long-encoded.mkv"),
|
||||
Resolution: "1080p",
|
||||
CodecVideo: "hevc",
|
||||
CodecAudio: "dts",
|
||||
Container: "mkv",
|
||||
Bitrate: 25000,
|
||||
Duration: 1_000_000,
|
||||
AudioTracks: []models.AudioTrack{{Codec: "dts", Default: true}},
|
||||
}
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
|
||||
transcodeReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/playback/transcode/start",
|
||||
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
|
||||
).WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
transcodeRR := httptest.NewRecorder()
|
||||
handler.HandleStartTranscode(transcodeRR, transcodeReq)
|
||||
if transcodeRR.Code != http.StatusAccepted {
|
||||
t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String())
|
||||
}
|
||||
|
||||
var response transcodeStartResponse
|
||||
if err := json.NewDecoder(transcodeRR.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode transcode response: %v", err)
|
||||
}
|
||||
if math.Abs(response.PlayerStartSeconds-0.261) > 0.0001 || response.StreamOriginSeconds != 18 ||
|
||||
response.TimelineOffsetSeconds != 18 || response.CanSeekAnywhere {
|
||||
t.Fatalf("long encoded response timeline = %+v", response)
|
||||
}
|
||||
|
||||
transcodeSession := handler.tm.GetTranscodeSession(session.ID)
|
||||
if transcodeSession == nil {
|
||||
t.Fatal("expected local transcode session")
|
||||
}
|
||||
t.Cleanup(func() { _ = transcodeSession.Close() })
|
||||
opts := transcodeSession.Opts()
|
||||
if opts.SeekSeconds != 18 || opts.StreamOriginSeconds != 18 || opts.CopySeekAnchorResolved || opts.StartSegmentNumber != 9 {
|
||||
t.Fatalf("long encoded seek recipe = seek %v origin %v copy anchor %v segment %d", opts.SeekSeconds, opts.StreamOriginSeconds, opts.CopySeekAnchorResolved, opts.StartSegmentNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartTranscode_CopyAnchorFailureKeepsActiveTransport(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
file := &models.MediaFile{
|
||||
|
||||
@@ -35,6 +35,11 @@ const (
|
||||
maxPlaybackV3EventBodyBytes = 32 << 10
|
||||
replanLeaseDurationV3 = 15 * time.Second
|
||||
v3NodeCapabilityTTL = time.Minute
|
||||
playbackNodeIntegratedV3 = "integrated"
|
||||
subtitleFormatVTTV3 = "vtt"
|
||||
subtitleMIMEVTTV3 = "text/vtt"
|
||||
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
|
||||
seekRestorationPlayerV3 = "player_position"
|
||||
// Failed capability fetches are memoized briefly so an unreachable node
|
||||
// costs one timeout per window instead of one per planning request.
|
||||
v3NodeCapabilityErrorTTL = 15 * time.Second
|
||||
@@ -44,6 +49,8 @@ const (
|
||||
v3NodeCapabilityPlanTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
var errSubtitleStoreUnavailableV3 = errors.New("subtitle store unavailable")
|
||||
|
||||
type v3NodeCapabilityCache struct {
|
||||
transformations []playback.TransformationV3
|
||||
err error
|
||||
@@ -67,6 +74,19 @@ type transportErrorV3 struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func subtitleArtifactErrorV3(message string, cause error) *transportErrorV3 {
|
||||
return &transportErrorV3{
|
||||
reason: subtitleUnavailableReasonV3,
|
||||
message: message,
|
||||
retryable: errors.Is(cause, errSubtitleStoreUnavailableV3),
|
||||
cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
func wrapSubtitleStoreErrorV3(err error) error {
|
||||
return fmt.Errorf("%w: %w", errSubtitleStoreUnavailableV3, err)
|
||||
}
|
||||
|
||||
type v3ReplanLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
@@ -525,19 +545,24 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
|
||||
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to load the initialized playback session.", cause: err}
|
||||
}
|
||||
result.Plan.SessionID = session.ID
|
||||
frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result)
|
||||
if frozenErr != nil {
|
||||
abort()
|
||||
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
|
||||
}
|
||||
transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result)
|
||||
if transportErr != nil {
|
||||
abort()
|
||||
return playback.DecisionResponseV3{}, transportErr
|
||||
}
|
||||
result.Plan.Stream.URL = transport.url
|
||||
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil {
|
||||
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &frozenRecipe); err != nil {
|
||||
transport.rollback()
|
||||
abort()
|
||||
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err}
|
||||
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
|
||||
}
|
||||
response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: playback.ServerFeaturesV3(), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan}
|
||||
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
|
||||
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
|
||||
if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil {
|
||||
transport.rollback()
|
||||
abort()
|
||||
@@ -708,9 +733,10 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
|
||||
if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 {
|
||||
videoCodec = "copy"
|
||||
}
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration))
|
||||
sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result)
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration)
|
||||
unlock := h.tm.LockSessionLifecycle(session.ID)
|
||||
ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), FastStart: true, NodeType: "integrated", ExecutionMode: "integrated", FFmpegLogSink: h.FFmpegLogSink})
|
||||
ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink})
|
||||
if err != nil {
|
||||
unlock()
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
|
||||
@@ -765,8 +791,9 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
|
||||
if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 {
|
||||
videoCodec = "copy"
|
||||
}
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration))
|
||||
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), RequireReady: true}
|
||||
sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result)
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration)
|
||||
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, RequireReady: true}
|
||||
nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req)
|
||||
if err != nil {
|
||||
// A timeout can fire after the node actually started the job; the
|
||||
@@ -812,6 +839,16 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func sourceExecutionMetadataV3(file *models.MediaFile, result playback.PlannerResultV3) (string, float64) {
|
||||
if result.FrozenSourceMetadata != nil {
|
||||
return result.FrozenSourceMetadata.VideoCodec, result.FrozenSourceMetadata.DurationSeconds
|
||||
}
|
||||
if file == nil {
|
||||
return "", 0
|
||||
}
|
||||
return file.CodecVideo, float64(file.Duration)
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) v3SessionStreamState(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, transport preparedTransportV3) playback.SessionStreamState {
|
||||
state := playback.SessionStreamState{PlayMethod: result.PlayMethod, BasePlayMethod: result.PlayMethod, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), TranscodeAudio: result.TranscodeAudio, RemuxDVMode: remuxDVModeForPlanV3(result.Plan), TranscodeNodeURL: transport.nodeURL, TranscodeTransportID: transport.transportID, TranscodeRouteSet: true, ClientIP: clientip.FromContext(ctx), ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientUserAgent: session.ClientUserAgent, StreamBitrateKbps: result.TargetBitrateKbps, TargetVideoCodec: result.TargetVideoCodec, TargetAudioCodec: result.TargetAudioCodec, TargetResolution: result.TargetResolution, SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn}
|
||||
if result.Plan != nil && (result.Plan.Delivery == playback.DeliveryTranscodeHLSV3 || result.Plan.Delivery == playback.DeliveryRemuxHLSV3) {
|
||||
@@ -842,16 +879,38 @@ func transportGenerationV3(sessionID, planID string) string {
|
||||
return sessionID + "-" + planSuffix + "-" + uuid.NewString()[:8]
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int) error {
|
||||
func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int, recipe *playback.ExecutableRecipeV3) error {
|
||||
if plan == nil || file == nil || selectedIndex < 0 || (plan.Subtitle.Mode != playback.SubtitleRenderV3 && plan.Subtitle.Mode != playback.SubtitleConvertV3) {
|
||||
return nil
|
||||
}
|
||||
if recipe != nil && recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 {
|
||||
if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 {
|
||||
return errors.New("the frozen downloaded subtitle is unavailable")
|
||||
}
|
||||
downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID)
|
||||
if err != nil {
|
||||
return wrapSubtitleStoreErrorV3(err)
|
||||
}
|
||||
if downloaded == nil || downloaded.MediaFileID != file.ID {
|
||||
return errors.New("the frozen downloaded subtitle is unavailable for the selected media file")
|
||||
}
|
||||
url := downloadedSubtitleStreamURL(sessionID, selectedIndex, string(downloaded.Format), file.ID, downloaded.ID)
|
||||
format := strings.ToLower(string(downloaded.Format))
|
||||
mime := subtitleMIMEV3(format)
|
||||
if plan.Subtitle.Mode == playback.SubtitleConvertV3 {
|
||||
format = subtitleFormatVTTV3
|
||||
mime = subtitleMIMEVTTV3
|
||||
url = forceSubtitleExtensionV3(url, ".vtt")
|
||||
}
|
||||
plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds}
|
||||
return nil
|
||||
}
|
||||
var downloaded []subtitles.DownloadedSubtitle
|
||||
if h.SubtitleRepo != nil {
|
||||
var err error
|
||||
downloaded, err = h.SubtitleRepo.ListDownloadedSubtitles(ctx, file.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
return wrapSubtitleStoreErrorV3(err)
|
||||
}
|
||||
}
|
||||
for _, value := range buildSubtitleURLs(sessionID, file, downloaded, true) {
|
||||
@@ -862,8 +921,8 @@ func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionI
|
||||
mime := subtitleMIMEV3(format)
|
||||
url := value.URL
|
||||
if plan.Subtitle.Mode == playback.SubtitleConvertV3 {
|
||||
format = "vtt"
|
||||
mime = "text/vtt"
|
||||
format = subtitleFormatVTTV3
|
||||
mime = subtitleMIMEVTTV3
|
||||
url = forceSubtitleExtensionV3(value.URL, ".vtt")
|
||||
}
|
||||
plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds}
|
||||
@@ -883,7 +942,7 @@ func (h *PlaybackHandler) downloadedSubtitleInventoryV3(ctx context.Context, fil
|
||||
base := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
|
||||
result := make([]playback.SubtitleInventoryEntryV3, 0, len(downloaded))
|
||||
for index, value := range downloaded {
|
||||
result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded"})
|
||||
result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded", DownloadedSubtitleID: value.ID})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1182,7 +1241,11 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
cause: err,
|
||||
}
|
||||
}
|
||||
if seekScopedRecovery && effectiveFile.Duration > 0 && req.PositionSeconds > float64(effectiveFile.Duration) {
|
||||
seekDuration := float64(effectiveFile.Duration)
|
||||
if seekReanchor && record.FrozenRecipe.ValidFor(record.CurrentPlan) {
|
||||
seekDuration = record.FrozenRecipe.SourceDurationSeconds
|
||||
}
|
||||
if seekScopedRecovery && seekDuration > 0 && req.PositionSeconds > seekDuration {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
|
||||
reason: "invalid_seek_position",
|
||||
message: "The requested seek position is beyond the end of the selected media source.",
|
||||
@@ -1191,9 +1254,12 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
if _, err := start.NormalizeAndValidate(); err != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "invalid_replan", message: err.Error()}
|
||||
}
|
||||
audioIndex, err := resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
|
||||
if err != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
|
||||
audioIndex := 0
|
||||
if !seekReanchor {
|
||||
audioIndex, err = resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
|
||||
if err != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
|
||||
}
|
||||
}
|
||||
attemptedKeys := []string(nil)
|
||||
if !intentChange && !seekReanchor {
|
||||
@@ -1212,7 +1278,23 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
attemptedKeys = append(attemptedKeys, currentKey)
|
||||
}
|
||||
}
|
||||
result := playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
|
||||
var result playback.PlannerResultV3
|
||||
if seekReanchor {
|
||||
if err := h.validateFrozenSubtitleIdentityV3(r.Context(), effectiveFile, record.FrozenRecipe); err != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("The selected subtitle is no longer available at its frozen route.", err)
|
||||
}
|
||||
var frozenErr error
|
||||
result, frozenErr = frozenSeekReanchorResultV3(record, req.PositionSeconds, time.Now())
|
||||
if frozenErr != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
|
||||
reason: "seek_reanchor_recipe_unavailable",
|
||||
message: "The active playback recipe cannot be reopened; start a new playback attempt.",
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
|
||||
}
|
||||
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && replanAllowsAlternateFileV3(operation, start.QualityPreference) {
|
||||
if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil {
|
||||
alternate = h.ensurePlaybackProbe(r.Context(), alternate)
|
||||
@@ -1252,17 +1334,31 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
}
|
||||
}
|
||||
result.Plan.SessionID = session.ID
|
||||
artifactRecipe := record.FrozenRecipe
|
||||
if !seekReanchor {
|
||||
frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result)
|
||||
if frozenErr != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
|
||||
}
|
||||
artifactRecipe = frozenRecipe
|
||||
}
|
||||
transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result)
|
||||
if transportErr != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, transportErr
|
||||
}
|
||||
result.Plan.Stream.URL = transport.url
|
||||
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil {
|
||||
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &artifactRecipe); err != nil {
|
||||
transport.rollback()
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err}
|
||||
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
|
||||
}
|
||||
if seekReanchor {
|
||||
if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil {
|
||||
changedFields := seekReanchorIdentityChangesV3(record, result.Plan)
|
||||
slog.ErrorContext(r.Context(), "protocol v3 seek reanchor changed route identity",
|
||||
"session", record.SessionID,
|
||||
"playback_attempt_id", record.PlaybackAttemptID,
|
||||
"changed_fields", changedFields,
|
||||
)
|
||||
transport.rollback()
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
|
||||
reason: "seek_reanchor_route_changed",
|
||||
@@ -1274,6 +1370,15 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
updated := *record
|
||||
updated.CurrentPlanID = result.Plan.PlanID
|
||||
updated.CurrentPlan = *result.Plan
|
||||
// A seek reanchor replays the durable recipe verbatim (updated already
|
||||
// carries it); re-freezing from live inventory could only re-introduce
|
||||
// the drift this path exists to exclude. Every other replan just accepted
|
||||
// a freshly planned route and must freeze its recipe — loudly, because a
|
||||
// recipe with a silently missing subtitle identity would disable drift
|
||||
// detection for every later seek on this attempt.
|
||||
if !seekReanchor {
|
||||
updated.FrozenRecipe = artifactRecipe
|
||||
}
|
||||
updated.NormalizedRequest = start
|
||||
updated.EffectiveMediaFileID = effectiveFile.ID
|
||||
updated.ExpiresAt = time.Now().Add(playback.MaxTokenTTL)
|
||||
@@ -1314,6 +1419,133 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
return response, updated, &transport, nil
|
||||
}
|
||||
|
||||
func frozenSeekReanchorResultV3(record *playback.AttemptRecordV3, position float64, now time.Time) (playback.PlannerResultV3, error) {
|
||||
if record == nil || !record.FrozenRecipe.ValidFor(record.CurrentPlan) {
|
||||
return playback.PlannerResultV3{}, errors.New("the active playback recipe is unavailable")
|
||||
}
|
||||
plan := record.CurrentPlan
|
||||
plan.ExpiresAt = playback.NewPlanExpiryV3(now)
|
||||
plan.Timeline = playback.TimelineV3{
|
||||
SourceStartSeconds: position,
|
||||
PlayerStartSeconds: position,
|
||||
CanSeekAnywhere: true,
|
||||
SeekRestoration: seekRestorationPlayerV3,
|
||||
}
|
||||
return record.FrozenRecipe.PlannerResult(&plan), nil
|
||||
}
|
||||
|
||||
type subtitleIndexLocationV3 struct {
|
||||
source string
|
||||
offset int
|
||||
}
|
||||
|
||||
// classifySubtitleIndexV3 maps the combined subtitle index used by
|
||||
// buildSubtitleURLs to its inventory segment and segment-local offset.
|
||||
func classifySubtitleIndexV3(file *models.MediaFile, index int) (subtitleIndexLocationV3, bool) {
|
||||
if file == nil || index < 0 {
|
||||
return subtitleIndexLocationV3{}, false
|
||||
}
|
||||
externalCount := len(file.ExternalSubtitles)
|
||||
if index < externalCount {
|
||||
return subtitleIndexLocationV3{source: playback.SubtitleSourceExternalV3, offset: index}, true
|
||||
}
|
||||
embeddedOffset := index - externalCount
|
||||
if embeddedOffset < len(file.SubtitleTracks) {
|
||||
return subtitleIndexLocationV3{source: playback.SubtitleSourceEmbeddedV3, offset: embeddedOffset}, true
|
||||
}
|
||||
return subtitleIndexLocationV3{
|
||||
source: playback.SubtitleSourceDownloadedV3,
|
||||
offset: embeddedOffset - len(file.SubtitleTracks),
|
||||
}, true
|
||||
}
|
||||
|
||||
// freezeExecutableRecipeV3 extends the pure planner freeze with the identity
|
||||
// of the selected sidecar subtitle. The combined subtitle index space
|
||||
// (externals, then embedded, then downloaded — see buildSubtitleURLs) is not
|
||||
// stable across inventory changes, so the index alone cannot anchor a durable
|
||||
// selection. A downloaded selection whose identity cannot be established is
|
||||
// an error: silently omitting it would disable drift detection for exactly
|
||||
// the seeks this recipe exists to protect.
|
||||
func (h *PlaybackHandler) freezeExecutableRecipeV3(_ context.Context, file *models.MediaFile, result playback.PlannerResultV3) (playback.ExecutableRecipeV3, error) {
|
||||
recipe := playback.FreezeExecutableRecipeV3(result)
|
||||
if file != nil {
|
||||
recipe.SourceVideoCodec = file.CodecVideo
|
||||
recipe.SourceDurationSeconds = float64(file.Duration)
|
||||
}
|
||||
if file == nil || result.SubtitleTrackIndex < 0 {
|
||||
return recipe, nil
|
||||
}
|
||||
// A downloaded row ID was selected from the planner's inventory snapshot.
|
||||
// Treat it as authoritative before consulting the mutable combined-index
|
||||
// segments: an external or embedded subtitle added after planning must not
|
||||
// make this downloaded selection look like a different source.
|
||||
if recipe.DownloadedSubtitleID > 0 {
|
||||
recipe.SubtitleSource = playback.SubtitleSourceDownloadedV3
|
||||
return recipe, nil
|
||||
}
|
||||
location, ok := classifySubtitleIndexV3(file, result.SubtitleTrackIndex)
|
||||
if !ok {
|
||||
return recipe, nil
|
||||
}
|
||||
switch location.source {
|
||||
case playback.SubtitleSourceExternalV3:
|
||||
recipe.SubtitleSource = playback.SubtitleSourceExternalV3
|
||||
recipe.ExternalSubtitlePath = file.ExternalSubtitles[location.offset].Path
|
||||
case playback.SubtitleSourceEmbeddedV3:
|
||||
recipe.SubtitleSource = playback.SubtitleSourceEmbeddedV3
|
||||
recipe.EmbeddedStreamIndex = file.SubtitleTracks[location.offset].Index
|
||||
case playback.SubtitleSourceDownloadedV3:
|
||||
if recipe.DownloadedSubtitleID <= 0 {
|
||||
return playback.ExecutableRecipeV3{}, errors.New("the selected downloaded subtitle has no stable identity")
|
||||
}
|
||||
}
|
||||
return recipe, nil
|
||||
}
|
||||
|
||||
// validateFrozenSubtitleIdentityV3 confirms the frozen combined subtitle
|
||||
// index still resolves to the identical inventory entry it was frozen
|
||||
// against. It mirrors the segment layout of buildSubtitleURLs so a change in
|
||||
// any earlier segment's size — which shifts every later index — is detected
|
||||
// as an identity mismatch rather than silently re-resolved.
|
||||
func (h *PlaybackHandler) validateFrozenSubtitleIdentityV3(ctx context.Context, file *models.MediaFile, recipe playback.ExecutableRecipeV3) error {
|
||||
if recipe.SubtitleSource == "" {
|
||||
return nil
|
||||
}
|
||||
if file == nil || recipe.SubtitleTrackIndex < 0 {
|
||||
return errors.New("the frozen subtitle selection is unavailable")
|
||||
}
|
||||
if recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 {
|
||||
if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 {
|
||||
return errors.New("the downloaded subtitle inventory is unavailable")
|
||||
}
|
||||
downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID)
|
||||
if err != nil {
|
||||
return wrapSubtitleStoreErrorV3(err)
|
||||
}
|
||||
if downloaded == nil || downloaded.MediaFileID != file.ID {
|
||||
return errors.New("the frozen downloaded subtitle identity changed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
location, ok := classifySubtitleIndexV3(file, recipe.SubtitleTrackIndex)
|
||||
if !ok || location.source != recipe.SubtitleSource {
|
||||
return errors.New("the frozen subtitle inventory segment changed")
|
||||
}
|
||||
switch recipe.SubtitleSource {
|
||||
case playback.SubtitleSourceExternalV3:
|
||||
if file.ExternalSubtitles[location.offset].Path != recipe.ExternalSubtitlePath {
|
||||
return errors.New("the frozen external subtitle identity changed")
|
||||
}
|
||||
case playback.SubtitleSourceEmbeddedV3:
|
||||
if file.SubtitleTracks[location.offset].Index != recipe.EmbeddedStreamIndex {
|
||||
return errors.New("the frozen embedded subtitle identity changed")
|
||||
}
|
||||
default:
|
||||
return errors.New("the frozen subtitle identity is unrecognized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playback.ReplanRequestV3) error {
|
||||
if record == nil {
|
||||
return errors.New("the current playback attempt is unavailable")
|
||||
@@ -1332,41 +1564,66 @@ func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playbac
|
||||
return nil
|
||||
}
|
||||
|
||||
// seekReanchorIdentityChangesV3 returns only bounded, non-secret field names.
|
||||
// It is safe for structured logs: values, URLs, headers, tokens, and subtitle
|
||||
// artifact locations are deliberately excluded.
|
||||
func seekReanchorIdentityChangesV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) []string {
|
||||
if record == nil || candidate == nil {
|
||||
return []string{"route"}
|
||||
}
|
||||
current := record.CurrentPlan
|
||||
changed := make([]string, 0, 16)
|
||||
add := func(name string, differs bool) {
|
||||
if differs {
|
||||
changed = append(changed, name)
|
||||
}
|
||||
}
|
||||
add("plan_id", candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID)
|
||||
add("requested_file_id", candidate.RequestedMediaFileID != record.RequestedMediaFileID)
|
||||
add("effective_file_id", candidate.EffectiveMediaFileID != record.EffectiveMediaFileID)
|
||||
add("delivery", candidate.Delivery != current.Delivery)
|
||||
add("engine", candidate.Engine != current.Engine)
|
||||
add("protocol", candidate.Stream.Protocol != current.Stream.Protocol)
|
||||
add("container", candidate.Stream.Container != current.Stream.Container)
|
||||
add("mime_type", candidate.Stream.MIMEType != current.Stream.MIMEType)
|
||||
add("header_refresh", candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh)
|
||||
add("video_codec", candidate.EffectiveRecipe.VideoCodec != current.EffectiveRecipe.VideoCodec)
|
||||
add("audio_codec", candidate.EffectiveRecipe.AudioCodec != current.EffectiveRecipe.AudioCodec)
|
||||
add("resolution", !optionalIntEqualV3(candidate.EffectiveRecipe.Width, current.EffectiveRecipe.Width) || !optionalIntEqualV3(candidate.EffectiveRecipe.Height, current.EffectiveRecipe.Height))
|
||||
add("frame_rate", !optionalFloatEqualV3(candidate.EffectiveRecipe.FrameRate, current.EffectiveRecipe.FrameRate))
|
||||
add("bitrate", !optionalIntEqualV3(candidate.EffectiveRecipe.BitrateKbps, current.EffectiveRecipe.BitrateKbps))
|
||||
add("dynamic_range", candidate.EffectiveRecipe.DynamicRange != current.EffectiveRecipe.DynamicRange)
|
||||
add("audio_channels", !optionalIntEqualV3(candidate.EffectiveRecipe.AudioChannels, current.EffectiveRecipe.AudioChannels) || candidate.EffectiveRecipe.AudioLayout != current.EffectiveRecipe.AudioLayout)
|
||||
add("selected_audio", !sameTrackIdentityV3(candidate.SelectedTracks.Audio, current.SelectedTracks.Audio))
|
||||
add("selected_subtitle", !sameTrackIdentityV3(candidate.SelectedTracks.Subtitle, current.SelectedTracks.Subtitle))
|
||||
add("subtitle_mode", candidate.Subtitle.Mode != current.Subtitle.Mode || candidate.Subtitle.TrackID != current.Subtitle.TrackID)
|
||||
add("subtitle_artifact_route", !sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact))
|
||||
add("subtitle_fidelity", candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy)
|
||||
add("transformations", !sameTransformationsV3(candidate.Transformations, current.Transformations))
|
||||
add("quirks", !sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks))
|
||||
add("runtime_corrections", !sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections))
|
||||
add("claims", candidate.Claims != current.Claims)
|
||||
return changed
|
||||
}
|
||||
|
||||
func validateSeekReanchorPlanV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) error {
|
||||
if record == nil || candidate == nil {
|
||||
return errors.New("seek reanchor produced no playback route")
|
||||
}
|
||||
current := record.CurrentPlan
|
||||
if candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID {
|
||||
changedFields := seekReanchorIdentityChangesV3(record, candidate)
|
||||
if len(changedFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
if containsStringExactV3(changedFields, "plan_id") {
|
||||
return errors.New("seek reanchor changed the playback plan identity")
|
||||
}
|
||||
if candidate.RequestedMediaFileID != record.RequestedMediaFileID || candidate.EffectiveMediaFileID != record.EffectiveMediaFileID {
|
||||
if containsStringExactV3(changedFields, "requested_file_id") || containsStringExactV3(changedFields, "effective_file_id") {
|
||||
return errors.New("seek reanchor changed the selected media version")
|
||||
}
|
||||
if !sameSelectedTracksV3(candidate.SelectedTracks, current.SelectedTracks) {
|
||||
if containsStringExactV3(changedFields, "selected_audio") || containsStringExactV3(changedFields, "selected_subtitle") {
|
||||
return errors.New("seek reanchor changed selected tracks")
|
||||
}
|
||||
if candidate.Engine != current.Engine ||
|
||||
candidate.Stream.MIMEType != current.Stream.MIMEType ||
|
||||
candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh ||
|
||||
!sameEffectiveRecipeV3(candidate.EffectiveRecipe, current.EffectiveRecipe) ||
|
||||
candidate.Claims != current.Claims ||
|
||||
candidate.Subtitle.Mode != current.Subtitle.Mode ||
|
||||
candidate.Subtitle.TrackID != current.Subtitle.TrackID ||
|
||||
!sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact) ||
|
||||
candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy ||
|
||||
!sameTransformationsV3(candidate.Transformations, current.Transformations) ||
|
||||
!sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks) ||
|
||||
!sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections) {
|
||||
return errors.New("seek reanchor changed the playback route semantics")
|
||||
}
|
||||
generation := record.NormalizedRequest.OutputRouteGeneration
|
||||
currentKey := playback.PlanAttemptKeyV3(current, generation, nil)
|
||||
candidateKey := playback.PlanAttemptKeyV3(*candidate, generation, nil)
|
||||
if candidateKey != currentKey {
|
||||
return errors.New("seek reanchor changed the playback route recipe")
|
||||
}
|
||||
return nil
|
||||
return errors.New("seek reanchor changed the playback route semantics")
|
||||
}
|
||||
|
||||
func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool {
|
||||
@@ -1378,18 +1635,6 @@ func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool
|
||||
return left.MIMEType == right.MIMEType && left.Format == right.Format
|
||||
}
|
||||
|
||||
func sameEffectiveRecipeV3(left, right playback.EffectiveRecipeV3) bool {
|
||||
return left.VideoCodec == right.VideoCodec &&
|
||||
left.AudioCodec == right.AudioCodec &&
|
||||
optionalIntEqualV3(left.Width, right.Width) &&
|
||||
optionalIntEqualV3(left.Height, right.Height) &&
|
||||
optionalFloatEqualV3(left.FrameRate, right.FrameRate) &&
|
||||
optionalIntEqualV3(left.BitrateKbps, right.BitrateKbps) &&
|
||||
left.DynamicRange == right.DynamicRange &&
|
||||
optionalIntEqualV3(left.AudioChannels, right.AudioChannels) &&
|
||||
left.AudioLayout == right.AudioLayout
|
||||
}
|
||||
|
||||
func sameTransformationsV3(left, right []playback.TransformationV3) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
@@ -1934,7 +2179,7 @@ func subtitleMIMEV3(format string) string {
|
||||
case "pgs", "hdmv_pgs_subtitle":
|
||||
return "application/octet-stream"
|
||||
default:
|
||||
return "text/vtt"
|
||||
return subtitleMIMEVTTV3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2021,13 +2266,18 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
|
||||
seek := alignedSeekSeconds(requested, segmentDuration, videoCodec)
|
||||
startSegment := computeStartSegment(seek, segmentDuration)
|
||||
plan.Timeline.SourceStartSeconds = requested
|
||||
if strings.EqualFold(videoCodec, "copy") {
|
||||
plan.Timeline.PlayerStartSeconds = 0
|
||||
usesGrowingManifest := strings.EqualFold(videoCodec, "copy") ||
|
||||
!playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
|
||||
if usesGrowingManifest {
|
||||
// Encoded streams seek to the preceding segment boundary. Preserve the
|
||||
// requested sub-segment offset so playback still begins at the exact
|
||||
// requested source position. Copy seeks are already exact, making this 0.
|
||||
plan.Timeline.PlayerStartSeconds = max(0, requested-seek)
|
||||
plan.Timeline.StreamOriginSeconds = seek
|
||||
plan.Timeline.TimelineOffsetSeconds = seek
|
||||
windowStart := seek
|
||||
plan.Timeline.SeekWindowStartSeconds = &windowStart
|
||||
// A copy remux is served from FFmpeg's live, still-growing playlist
|
||||
// This transport is served from FFmpeg's live, still-growing playlist
|
||||
// (see BuildPlaybackManifest), so the seekable extent is whatever has
|
||||
// been produced so far — a value this plan cannot know and could not
|
||||
// keep current if it did. Publishing the media runtime here instead
|
||||
@@ -2049,7 +2299,7 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
|
||||
plan.Timeline.SeekWindowStartSeconds = nil
|
||||
plan.Timeline.SeekWindowEndSeconds = nil
|
||||
plan.Timeline.CanSeekAnywhere = durationSeconds > 0
|
||||
plan.Timeline.SeekRestoration = "player_position"
|
||||
plan.Timeline.SeekRestoration = seekRestorationPlayerV3
|
||||
}
|
||||
return seek, startSegment
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||||
)
|
||||
|
||||
@@ -605,6 +608,274 @@ func TestHandleReplanPlaybackV3SeekReanchorKeepsCurrentRecipeEligible(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplanPlaybackV3SeekReanchorIgnoresRefreshedProbeMetadata(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo"})
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
selectedAudio := 1
|
||||
startRequest.AudioTrackIndex = &selectedAudio
|
||||
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
|
||||
startRR := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRR, startReq)
|
||||
if startRR.Code != http.StatusCreated {
|
||||
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
|
||||
}
|
||||
var started playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if started.PlaybackPlan == nil || started.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 {
|
||||
t.Fatalf("start plan = %#v", started.PlaybackPlan)
|
||||
}
|
||||
|
||||
// Simulate a refreshed probe changing a live planner input after the route
|
||||
// was durably accepted. A seek is not authority to consume that drift.
|
||||
file.Container = "mkv"
|
||||
file.AudioTracks = file.AudioTracks[:1]
|
||||
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
|
||||
reanchor := playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: "seek-reanchor-probe-drift-0001", FailedPlanID: started.PlaybackPlan.PlanID,
|
||||
PlanAttemptID: "plan-attempt-seek-probe-0001", PlanAttemptKey: currentKey,
|
||||
AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 321,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: started.PlaybackPlan.SelectedTracks,
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
}
|
||||
body, err := json.Marshal(reanchor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
|
||||
req = withPlaybackRouteParam(req, "session_id", started.SessionID)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReplanPlaybackV3(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("reanchor status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var response playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.PlaybackPlan == nil || response.PlaybackPlan.PlanID != started.PlaybackPlan.PlanID ||
|
||||
response.PlaybackPlan.Delivery != started.PlaybackPlan.Delivery ||
|
||||
response.PlaybackPlan.Timeline.SourceStartSeconds != 321 {
|
||||
t.Fatalf("reanchored plan = %#v, terminal = %#v", response.PlaybackPlan, response.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPlaybackV3FreezesDownloadedSubtitleFromPlanningSnapshot(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
first := subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}
|
||||
reordered := subtitles.DownloadedSubtitle{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}
|
||||
repo.listResults = [][]subtitles.DownloadedSubtitle{{first}, {reordered, first}}
|
||||
repo.subtitles[first.ID] = &first
|
||||
repo.subtitles[reordered.ID] = &reordered
|
||||
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.SubtitleRepo = repo
|
||||
request := v3HandlerStartRequest()
|
||||
downloadedIndex := 0
|
||||
request.SubtitleTrackIndex = &downloadedIndex
|
||||
request.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", downloadedIndex)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, request))).WithContext(newAuthorizedPlaybackContext())
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var response playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.PlaybackPlan == nil || response.PlaybackPlan.Subtitle.Artifact == nil ||
|
||||
!strings.Contains(response.PlaybackPlan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") {
|
||||
t.Fatalf("playback plan = %#v, want downloaded subtitle 71", response.PlaybackPlan)
|
||||
}
|
||||
if repo.listCalls != 1 {
|
||||
t.Fatalf("downloaded inventory listed %d times, want one planning snapshot", repo.listCalls)
|
||||
}
|
||||
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.FrozenRecipe.DownloadedSubtitleID != 71 {
|
||||
t.Fatalf("frozen downloaded subtitle = %d, want 71", record.FrozenRecipe.DownloadedSubtitleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplanPlaybackV3SeekReanchorPreservesFallbackRecipe(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
|
||||
{Name: "audio_to_aac", RecipeVersion: "1", Available: true},
|
||||
{Name: "video_to_h264", RecipeVersion: "1", Available: true},
|
||||
{Name: "server_dv7_to_hdr10", RecipeVersion: "1", Available: true},
|
||||
}))
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true}
|
||||
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3HLSV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true}
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
|
||||
startRR := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRR, startReq)
|
||||
if startRR.Code != http.StatusCreated {
|
||||
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
|
||||
}
|
||||
var active playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(startRR.Body.Bytes(), &active); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active.PlaybackPlan == nil || active.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 {
|
||||
t.Fatalf("initial plan = %#v", active.PlaybackPlan)
|
||||
}
|
||||
|
||||
attempted := []string{}
|
||||
wantFallbacks := []playback.DeliveryV3{playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3}
|
||||
for index, classification := range []string{"playback_error", "decoder_error"} {
|
||||
currentKey := playback.PlanAttemptKeyV3(*active.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
|
||||
attempted = append(attempted, currentKey)
|
||||
response := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: fmt.Sprintf("fallback-replan-%04d", index+1),
|
||||
FailedPlanID: active.PlaybackPlan.PlanID,
|
||||
PlanAttemptID: fmt.Sprintf("fallback-plan-attempt-%04d", index+1),
|
||||
PlanAttemptKey: currentKey, AttemptedPlanKeys: append([]string(nil), attempted...),
|
||||
AttemptCount: index + 1, QualityPreference: startRequest.QualityPreference,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: active.PlaybackPlan.SelectedTracks,
|
||||
Failure: playback.FailureV3{Classification: classification},
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
})
|
||||
if response.PlaybackPlan == nil {
|
||||
t.Fatalf("fallback %d = %#v", index, response)
|
||||
}
|
||||
if response.PlaybackPlan.Delivery != wantFallbacks[index] {
|
||||
t.Fatalf("fallback %d delivery = %q, want %q; response=%#v", index, response.PlaybackPlan.Delivery, wantFallbacks[index], response)
|
||||
}
|
||||
active = response
|
||||
}
|
||||
if active.PlaybackPlan.Delivery != playback.DeliveryRemuxHLSV3 {
|
||||
t.Fatalf("fallback route = %#v", active.PlaybackPlan)
|
||||
}
|
||||
frozen := *active.PlaybackPlan
|
||||
currentKey := playback.PlanAttemptKeyV3(frozen, startRequest.OutputRouteGeneration, nil)
|
||||
reanchored := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: "fallback-seek-reanchor-0001", FailedPlanID: frozen.PlanID,
|
||||
PlanAttemptID: "fallback-seek-plan-0001", PlanAttemptKey: currentKey,
|
||||
AttemptCount: 3, QualityPreference: startRequest.QualityPreference, PositionSeconds: 819.185,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: frozen.SelectedTracks,
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
})
|
||||
if reanchored.PlaybackPlan == nil || reanchored.PlaybackPlan.PlanID != frozen.PlanID ||
|
||||
reanchored.PlaybackPlan.Delivery != frozen.Delivery || reanchored.PlaybackPlan.Engine != frozen.Engine ||
|
||||
reanchored.PlaybackPlan.Timeline.SourceStartSeconds != 819.185 {
|
||||
t.Fatalf("reanchored fallback = %#v, terminal = %#v", reanchored.PlaybackPlan, reanchored.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-migration attempt row carries frozen_recipe = '{}', which decodes to
|
||||
// an invalid zero recipe. A seek reanchor against it must fail with the
|
||||
// dedicated retryable reason — telling the client to mint a fresh playback
|
||||
// attempt — while leaving the durable attempt fully usable for subsequent
|
||||
// failure replans.
|
||||
func TestHandleReplanPlaybackV3SeekReanchorWithoutFrozenRecipeFailsRetryably(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
|
||||
startRR := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRR, startReq)
|
||||
if startRR.Code != http.StatusCreated {
|
||||
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
|
||||
}
|
||||
var started playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if started.PlaybackPlan == nil {
|
||||
t.Fatalf("start response = %s", startRR.Body.String())
|
||||
}
|
||||
|
||||
// Simulate the row predating the frozen_recipe migration: the JSONB
|
||||
// default '{}' unmarshals to the zero recipe.
|
||||
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.FrozenRecipe = playback.ExecutableRecipeV3{}
|
||||
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
|
||||
|
||||
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
|
||||
seekResponse := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: "legacy-seek-reanchor-0001", FailedPlanID: started.PlaybackPlan.PlanID,
|
||||
PlanAttemptID: "legacy-seek-plan-0001", PlanAttemptKey: currentKey,
|
||||
AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: started.PlaybackPlan.SelectedTracks,
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
})
|
||||
if seekResponse.Terminal == nil || seekResponse.Terminal.Reason != "seek_reanchor_recipe_unavailable" || !seekResponse.Terminal.Retryable {
|
||||
t.Fatalf("legacy seek reanchor = %#v, terminal = %#v", seekResponse.PlaybackPlan, seekResponse.Terminal)
|
||||
}
|
||||
|
||||
// The failed seek must not have consumed the durable attempt: the current
|
||||
// plan is unchanged and an ordinary failure replan still succeeds.
|
||||
after, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.CurrentPlanID != started.PlaybackPlan.PlanID {
|
||||
t.Fatalf("failed legacy seek moved the current plan: %q -> %q", started.PlaybackPlan.PlanID, after.CurrentPlanID)
|
||||
}
|
||||
recovery := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: "legacy-recovery-0001", FailedPlanID: started.PlaybackPlan.PlanID,
|
||||
PlanAttemptID: "legacy-recovery-plan-0001", PlanAttemptKey: currentKey,
|
||||
AttemptedPlanKeys: []string{currentKey},
|
||||
AttemptCount: 2, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: started.PlaybackPlan.SelectedTracks,
|
||||
Failure: playback.FailureV3{Classification: "playback_error"},
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
})
|
||||
if recovery.PlaybackPlan == nil {
|
||||
t.Fatalf("failure replan after legacy seek = %#v, terminal = %#v", recovery.PlaybackPlan, recovery.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *testing.T) {
|
||||
// This test has never passed. It fails at 854d07cf, the commit that
|
||||
// introduced it, so it describes behavior that was specified and not
|
||||
@@ -828,6 +1099,7 @@ func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGon
|
||||
record.CurrentPlan,
|
||||
)
|
||||
record.CurrentPlanID = record.CurrentPlan.PlanID
|
||||
record.FrozenRecipe.PlanID = record.CurrentPlan.PlanID
|
||||
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
|
||||
|
||||
currentKey := playback.PlanAttemptKeyV3(record.CurrentPlan, record.NormalizedRequest.OutputRouteGeneration, nil)
|
||||
@@ -1061,6 +1333,283 @@ func TestValidateSeekReanchorPlanV3RejectsRouteDrift(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeekReanchorIdentityChangesV3ReportsOnlyChangedFieldNames(t *testing.T) {
|
||||
current := playback.PlanV3{
|
||||
PlanID: "plan:current", Delivery: playback.DeliveryOriginalHTTPV3,
|
||||
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4"},
|
||||
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac"},
|
||||
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
|
||||
}
|
||||
record := &playback.AttemptRecordV3{
|
||||
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
|
||||
CurrentPlanID: current.PlanID, CurrentPlan: current,
|
||||
}
|
||||
candidate := current
|
||||
candidate.PlanID = "plan:candidate"
|
||||
candidate.Delivery = playback.DeliveryRemuxHLSV3
|
||||
candidate.Stream.Container = "hls"
|
||||
candidate.EffectiveRecipe.AudioCodec = "ac3"
|
||||
got := strings.Join(seekReanchorIdentityChangesV3(record, &candidate), ",")
|
||||
if want := "plan_id,delivery,container,audio_codec"; got != want {
|
||||
t.Fatalf("changed fields = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrozenSeekReanchorResultV3PreservesRouteMatrix(t *testing.T) {
|
||||
audioIndex := 0
|
||||
basePlan := func(name string) playback.PlanV3 {
|
||||
return playback.PlanV3{
|
||||
ProtocolVersion: playback.ProtocolV3, PlanID: "plan:" + name,
|
||||
Delivery: playback.DeliveryOriginalHTTPV3, Engine: playback.EngineMedia3DirectV3,
|
||||
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", HeaderRefresh: playback.HeaderRefreshSessionV3},
|
||||
SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", audioIndex), Index: &audioIndex}},
|
||||
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac", DynamicRange: "sdr"},
|
||||
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleOffV3},
|
||||
Transformations: []playback.TransformationV3{}, AppliedQuirks: []playback.AppliedQuirkV3{}, RuntimeCorrections: []string{},
|
||||
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*playback.PlanV3, *playback.PlannerResultV3)
|
||||
}{
|
||||
{name: "direct"},
|
||||
{name: "progressive remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
plan.Delivery = playback.DeliveryRemuxProgressiveV3
|
||||
plan.Engine = playback.EngineMedia3ProgressiveRemuxV3
|
||||
result.PlayMethod = playback.PlayRemux
|
||||
}},
|
||||
{name: "HLS remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
plan.Delivery = playback.DeliveryRemuxHLSV3
|
||||
plan.Engine = playback.EngineMedia3HLSV3
|
||||
plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3}
|
||||
result.PlayMethod = playback.PlayRemux
|
||||
result.TargetVideoCodec = "copy"
|
||||
result.TargetAudioCodec = "copy"
|
||||
}},
|
||||
{name: "audio converting remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
plan.Delivery = playback.DeliveryRemuxProgressiveV3
|
||||
plan.Engine = playback.EngineMedia3ProgressiveRemuxV3
|
||||
plan.Transformations = []playback.TransformationV3{{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}}
|
||||
result.PlayMethod = playback.PlayRemux
|
||||
result.TranscodeAudio = true
|
||||
result.TargetAudioCodec = "aac"
|
||||
result.TargetAudioChannels = 2
|
||||
}},
|
||||
{name: "downloaded subtitle", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
subtitleIndex := 7
|
||||
plan.SelectedTracks.Subtitle = &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "subtitle", subtitleIndex), Index: &subtitleIndex}
|
||||
plan.Subtitle = playback.SubtitleDecisionV3{Mode: playback.SubtitleConvertV3, TrackID: plan.SelectedTracks.Subtitle.ID, Artifact: &playback.SubtitleArtifactV3{MIMEType: "text/vtt", Format: "vtt"}}
|
||||
result.SubtitleTrackIndex = subtitleIndex
|
||||
result.SubtitleTransportTrackIndex = subtitleIndex
|
||||
result.SubtitleCodec = "srt"
|
||||
result.DownloadedSubtitleID = 71
|
||||
}},
|
||||
{name: "Dolby Vision transformation", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) {
|
||||
plan.EffectiveRecipe.DynamicRange = "hdr10"
|
||||
plan.Transformations = []playback.TransformationV3{{Name: "server_dv7_to_hdr10", Executor: "server", RecipeVersion: "1"}}
|
||||
}},
|
||||
{name: "pooled node only transformation", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
plan.Delivery = playback.DeliveryTranscodeHLSV3
|
||||
plan.Engine = playback.EngineMedia3HLSV3
|
||||
plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3}
|
||||
plan.Transformations = []playback.TransformationV3{{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"}}
|
||||
result.PlayMethod = playback.PlayTranscode
|
||||
result.TargetVideoCodec = "h264"
|
||||
result.TargetAudioCodec = "aac"
|
||||
result.TargetResolution = "1080p"
|
||||
}},
|
||||
{name: "device quirks and runtime corrections", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) {
|
||||
plan.AppliedQuirks = []playback.AppliedQuirkV3{{ID: "apple-hls-audio", RegistryRevision: "3", Action: "force-aac"}}
|
||||
plan.RuntimeCorrections = []string{"pcm_fallback"}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
plan := basePlan(strings.ReplaceAll(test.name, " ", "-"))
|
||||
operational := playback.PlannerResultV3{Plan: &plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
|
||||
if test.mutate != nil {
|
||||
test.mutate(&plan, &operational)
|
||||
}
|
||||
operational.Plan = &plan
|
||||
record := &playback.AttemptRecordV3{
|
||||
PlaybackAttemptID: "attempt-matrix-0001", RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
|
||||
CurrentPlanID: plan.PlanID, CurrentPlan: plan, FrozenRecipe: playback.FreezeExecutableRecipeV3(operational),
|
||||
}
|
||||
result, err := frozenSeekReanchorResultV3(record, 321.25, time.Unix(1_786_000_000, 0))
|
||||
if err != nil || result.Plan == nil {
|
||||
t.Fatalf("frozen reanchor: result=%#v err=%v", result, err)
|
||||
}
|
||||
if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil {
|
||||
t.Fatalf("route semantics changed: %v, plan=%#v", err, result.Plan)
|
||||
}
|
||||
if result.Plan.Timeline.SourceStartSeconds != 321.25 || result.PlayMethod != operational.PlayMethod ||
|
||||
result.TranscodeAudio != operational.TranscodeAudio || result.TargetVideoCodec != operational.TargetVideoCodec ||
|
||||
result.TargetAudioCodec != operational.TargetAudioCodec || result.TargetAudioChannels != operational.TargetAudioChannels ||
|
||||
result.TargetResolution != operational.TargetResolution || result.SubtitleTrackIndex != operational.SubtitleTrackIndex ||
|
||||
result.SubtitleTransportTrackIndex != operational.SubtitleTransportTrackIndex || result.SubtitleBurnIn != operational.SubtitleBurnIn ||
|
||||
result.SubtitleCodec != operational.SubtitleCodec || result.DownloadedSubtitleID != operational.DownloadedSubtitleID {
|
||||
t.Fatalf("operational recipe changed: got=%#v want=%#v", result, operational)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrozenDownloadedSubtitleV3AcceptsInventoryReordering(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.ExternalSubtitles = []models.ExternalSubtitle{{Format: "srt"}}
|
||||
file.SubtitleTracks = []models.SubtitleTrack{{Index: 0, Codec: "ass"}}
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.SubtitleRepo = repo
|
||||
downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
|
||||
plan := &playback.PlanV3{PlanID: "plan:downloaded", SelectedTracks: playback.SelectedTracksV3{Subtitle: &playback.TrackIdentityV3{ID: playback.TrackIDV3(file.ID, "subtitle", downloadedIndex), Index: &downloadedIndex}}}
|
||||
result := playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex, DownloadedSubtitleID: 71}
|
||||
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result)
|
||||
if err != nil {
|
||||
t.Fatalf("freeze: %v", err)
|
||||
}
|
||||
if recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 {
|
||||
t.Fatalf("frozen subtitle identity = %q/%d, want downloaded/71", recipe.SubtitleSource, recipe.DownloadedSubtitleID)
|
||||
}
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil {
|
||||
t.Fatalf("stable inventory rejected: %v", err)
|
||||
}
|
||||
repo.list = []subtitles.DownloadedSubtitle{{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}, {ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}}
|
||||
repo.listErr = errors.New("mutable inventory must not be consulted")
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil {
|
||||
t.Fatalf("reordered downloaded subtitle inventory rejected stable identity: %v", err)
|
||||
}
|
||||
repo.getErr = errors.New("database unavailable")
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); !errors.Is(err, errSubtitleStoreUnavailableV3) {
|
||||
t.Fatalf("validation error = %v, want wrapped subtitle-store failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachSubtitleArtifactV3UsesFrozenDownloadedIdentityWithoutOrdinalLookup(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.ExternalSubtitles = nil
|
||||
file.SubtitleTracks = nil
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[71] = &subtitles.DownloadedSubtitle{
|
||||
ID: 71, MediaFileID: file.ID, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt",
|
||||
}
|
||||
// A second ordinal lookup would either fail or select a different row.
|
||||
// Artifact attachment must use GetDownloadedSubtitle(71) exclusively.
|
||||
repo.listErr = errors.New("mutable inventory must not be consulted")
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.SubtitleRepo = repo
|
||||
selectedIndex := 0
|
||||
plan := &playback.PlanV3{
|
||||
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3},
|
||||
Timeline: playback.TimelineV3{StreamOriginSeconds: 321},
|
||||
}
|
||||
recipe := playback.ExecutableRecipeV3{
|
||||
SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71,
|
||||
SubtitleTrackIndex: selectedIndex, SubtitleCodec: "vtt",
|
||||
}
|
||||
if err := handler.attachSubtitleArtifactV3(context.Background(), "session-frozen-subtitle", file, plan, selectedIndex, &recipe); err != nil {
|
||||
t.Fatalf("attach frozen downloaded subtitle: %v", err)
|
||||
}
|
||||
if plan.Subtitle.Artifact == nil || !strings.Contains(plan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") {
|
||||
t.Fatalf("artifact = %#v, want frozen downloaded subtitle 71", plan.Subtitle.Artifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleArtifactStoreFailuresAreRetryable(t *testing.T) {
|
||||
storeErr := errors.New("database unavailable")
|
||||
wantRetryable := subtitleArtifactErrorV3("subtitle lookup failed", wrapSubtitleStoreErrorV3(storeErr))
|
||||
if !wantRetryable.retryable || !errors.Is(wantRetryable.cause, errSubtitleStoreUnavailableV3) {
|
||||
t.Fatalf("store failure = %#v, want retryable subtitle error", wantRetryable)
|
||||
}
|
||||
wantPermanent := subtitleArtifactErrorV3("subtitle identity changed", errors.New("identity changed"))
|
||||
if wantPermanent.retryable {
|
||||
t.Fatalf("identity failure = %#v, want non-retryable subtitle error", wantPermanent)
|
||||
}
|
||||
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.ExternalSubtitles = nil
|
||||
file.SubtitleTracks = nil
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.getErr = storeErr
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.SubtitleRepo = repo
|
||||
plan := &playback.PlanV3{
|
||||
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3},
|
||||
}
|
||||
recipe := playback.ExecutableRecipeV3{
|
||||
SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71,
|
||||
SubtitleTrackIndex: 0, SubtitleCodec: "vtt",
|
||||
}
|
||||
err := handler.attachSubtitleArtifactV3(context.Background(), "session-store-error", file, plan, 0, &recipe)
|
||||
if !errors.Is(err, errSubtitleStoreUnavailableV3) {
|
||||
t.Fatalf("attach error = %v, want wrapped subtitle-store failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreezeExecutableRecipeV3FailsLoudlyWhenDownloadedIdentityUnavailable(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
|
||||
result := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:downloaded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex}
|
||||
if _, err := handler.freezeExecutableRecipeV3(context.Background(), file, result); err == nil {
|
||||
t.Fatal("downloaded subtitle without a planning-snapshot identity was frozen")
|
||||
}
|
||||
result.DownloadedSubtitleID = 71
|
||||
file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/added-after-planning.srt", Format: "srt"}}
|
||||
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result)
|
||||
if err != nil || recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 {
|
||||
t.Fatalf("freeze planning snapshot identity: recipe=%#v err=%v", recipe, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrozenSubtitleIdentityV3RejectsExternalAndEmbeddedInventoryDrift(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/en.srt", Format: "srt"}, {Path: "/subs/de.srt", Format: "srt"}}
|
||||
file.SubtitleTracks = []models.SubtitleTrack{{Index: 3, Codec: "ass"}}
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
|
||||
externalIndex := 1 // /subs/de.srt
|
||||
externalResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:external"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: externalIndex, SubtitleTransportTrackIndex: externalIndex}
|
||||
externalRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, externalResult)
|
||||
if err != nil {
|
||||
t.Fatalf("freeze external: %v", err)
|
||||
}
|
||||
if externalRecipe.SubtitleSource != playback.SubtitleSourceExternalV3 || externalRecipe.ExternalSubtitlePath != "/subs/de.srt" {
|
||||
t.Fatalf("frozen external identity = %q/%q", externalRecipe.SubtitleSource, externalRecipe.ExternalSubtitlePath)
|
||||
}
|
||||
|
||||
embeddedIndex := 2 // combined index of SubtitleTracks[0]
|
||||
embeddedResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:embedded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: embeddedIndex, SubtitleTransportTrackIndex: 3}
|
||||
embeddedRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, embeddedResult)
|
||||
if err != nil {
|
||||
t.Fatalf("freeze embedded: %v", err)
|
||||
}
|
||||
if embeddedRecipe.SubtitleSource != playback.SubtitleSourceEmbeddedV3 || embeddedRecipe.EmbeddedStreamIndex != 3 {
|
||||
t.Fatalf("frozen embedded identity = %q/%d", embeddedRecipe.SubtitleSource, embeddedRecipe.EmbeddedStreamIndex)
|
||||
}
|
||||
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err != nil {
|
||||
t.Fatalf("stable external inventory rejected: %v", err)
|
||||
}
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err != nil {
|
||||
t.Fatalf("stable embedded inventory rejected: %v", err)
|
||||
}
|
||||
|
||||
// Deleting the first external subtitle shifts every later combined index.
|
||||
// The frozen external selection now points past the external segment and
|
||||
// the frozen embedded selection resolves one entry early; both must be
|
||||
// rejected rather than silently re-resolved to a different artifact.
|
||||
file.ExternalSubtitles = file.ExternalSubtitles[1:]
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err == nil {
|
||||
t.Fatal("external subtitle deletion was accepted for the frozen external selection")
|
||||
}
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err == nil {
|
||||
t.Fatal("external subtitle deletion was accepted for the frozen embedded selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.JWTSecret = "test-secret"
|
||||
@@ -1174,6 +1723,54 @@ func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTransportV3UsesFrozenSourceMetadataAfterProbeDrift(t *testing.T) {
|
||||
var startRequest transcodenode.TranscodeStartRequest
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities":
|
||||
writeJSON(w, http.StatusOK, playback.HWAccelInfo{})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/transcode/start":
|
||||
if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil {
|
||||
t.Errorf("decode remote start: %v", err)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"})
|
||||
case r.Method == http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.CodecVideo = "hevc"
|
||||
file.Duration = 7_201
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.JWTSecret = "test-secret"
|
||||
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}}
|
||||
plan := &playback.PlanV3{PlanID: "plan:frozen-source", Delivery: playback.DeliveryRemuxHLSV3}
|
||||
initial := playback.PlannerResultV3{
|
||||
Plan: plan, PlayMethod: playback.PlayRemux, TargetVideoCodec: "copy", TargetAudioCodec: "copy",
|
||||
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
|
||||
}
|
||||
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, initial)
|
||||
if err != nil {
|
||||
t.Fatalf("freeze executable recipe: %v", err)
|
||||
}
|
||||
file.CodecVideo = "mpeg2video"
|
||||
file.Duration = 99
|
||||
result := recipe.PlannerResult(plan)
|
||||
request := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
transport, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-frozen-source", UserID: 7, ProfileID: "profile-1"}, file, result)
|
||||
if transportErr != nil {
|
||||
t.Fatalf("prepare remote transport: %v", transportErr)
|
||||
}
|
||||
defer transport.rollback()
|
||||
if startRequest.SourceVideoCodec != "hevc" || startRequest.TotalDuration != 7_201 {
|
||||
t.Fatalf("remote start consumed refreshed probe metadata: %#v", startRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartPlaybackUnknownProtocolUsesLegacyBranch(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
@@ -1232,10 +1829,23 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
|
||||
encodePlan.Timeline.SeekRestoration != "player_position" {
|
||||
t.Fatalf("encode timeline=%#v seek=%v segment=%d", encodePlan.Timeline, encodeSeek, encodeSegment)
|
||||
}
|
||||
|
||||
longEncodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
|
||||
longEncodeSeek, longEncodeSegment := configureHLSTimelineV3(longEncodePlan, "h264", 2, 1_000_000)
|
||||
if longEncodeSeek != 16 || longEncodeSegment != 8 || longEncodePlan.Timeline.StreamOriginSeconds != 16 || longEncodePlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(longEncodePlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || longEncodePlan.Timeline.CanSeekAnywhere ||
|
||||
longEncodePlan.Timeline.SeekWindowStartSeconds == nil || *longEncodePlan.Timeline.SeekWindowStartSeconds != 16 ||
|
||||
longEncodePlan.Timeline.SeekWindowEndSeconds != nil ||
|
||||
longEncodePlan.Timeline.SeekRestoration != "source_position" {
|
||||
t.Fatalf("long encode timeline=%#v seek=%v segment=%d", longEncodePlan.Timeline, longEncodeSeek, longEncodeSegment)
|
||||
}
|
||||
|
||||
unknownDurationPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
|
||||
configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
|
||||
if unknownDurationPlan.Timeline.CanSeekAnywhere {
|
||||
t.Fatalf("unknown-duration timeline = %#v", unknownDurationPlan.Timeline)
|
||||
unknownDurationSeek, unknownDurationSegment := configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
|
||||
if unknownDurationSeek != 16 || unknownDurationSegment != 8 || unknownDurationPlan.Timeline.StreamOriginSeconds != 16 || unknownDurationPlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(unknownDurationPlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || unknownDurationPlan.Timeline.CanSeekAnywhere ||
|
||||
unknownDurationPlan.Timeline.SeekWindowStartSeconds == nil || *unknownDurationPlan.Timeline.SeekWindowStartSeconds != 16 ||
|
||||
unknownDurationPlan.Timeline.SeekWindowEndSeconds != nil ||
|
||||
unknownDurationPlan.Timeline.SeekRestoration != "source_position" {
|
||||
t.Fatalf("unknown-duration timeline=%#v seek=%v segment=%d", unknownDurationPlan.Timeline, unknownDurationSeek, unknownDurationSegment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1360,3 +1970,23 @@ func marshalV3StartRequest(t *testing.T, request playback.StartRequestV3) string
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func postPlaybackReplanV3(t *testing.T, handler *PlaybackHandler, sessionID string, request playback.ReplanRequestV3) playback.DecisionResponseV3 {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+sessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
|
||||
req = withPlaybackRouteParam(req, "session_id", sessionID)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReplanPlaybackV3(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("replan status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var response playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -201,69 +201,10 @@ type pluginPresentationJSON struct {
|
||||
LicenseSPDX string `json:"license_spdx"`
|
||||
}
|
||||
|
||||
type pluginConfigSchemaJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
JSONSchema string `json:"json_schema"`
|
||||
Required bool `json:"required"`
|
||||
AdminForm *pluginAdminFormJSON `json:"admin_form,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormJSON struct {
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormFieldJSON struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormOptionJSON struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormConditionJSON struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type pluginAdminFormValidationJSON struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormSectionJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
}
|
||||
type pluginConfigSchemaJSON = plugins.ConfigSchemaView
|
||||
type pluginAdminFormJSON = plugins.AdminFormView
|
||||
type pluginAdminFormFieldJSON = plugins.AdminFormFieldView
|
||||
type pluginAdminFormSectionJSON = plugins.AdminFormSectionView
|
||||
|
||||
type pluginCapabilityJSON struct {
|
||||
Type string `json:"type"`
|
||||
@@ -1567,117 +1508,11 @@ func toUserPluginSettingsSummary(
|
||||
}
|
||||
|
||||
func configSchemasToJSON(schemas []*pluginv1.ConfigSchema) []pluginConfigSchemaJSON {
|
||||
response := make([]pluginConfigSchemaJSON, 0, len(schemas))
|
||||
for _, schema := range schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
response = append(response, pluginConfigSchemaJSON{
|
||||
Key: schema.GetKey(),
|
||||
Title: schema.GetTitle(),
|
||||
Description: schema.GetDescription(),
|
||||
JSONSchema: schema.GetJsonSchema(),
|
||||
Required: schema.GetRequired(),
|
||||
AdminForm: adminFormToJSON(schema.GetAdminForm()),
|
||||
})
|
||||
}
|
||||
return response
|
||||
return plugins.ConfigSchemaViews(schemas)
|
||||
}
|
||||
|
||||
func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]pluginAdminFormFieldJSON, 0, len(form.GetFields()))
|
||||
for _, field := range form.GetFields() {
|
||||
if field == nil {
|
||||
continue
|
||||
}
|
||||
options := make([]pluginAdminFormOptionJSON, 0, len(field.GetOptions()))
|
||||
for _, option := range field.GetOptions() {
|
||||
if option == nil {
|
||||
continue
|
||||
}
|
||||
options = append(options, pluginAdminFormOptionJSON{
|
||||
Value: option.GetValue(),
|
||||
Label: option.GetLabel(),
|
||||
Description: option.GetDescription(),
|
||||
})
|
||||
}
|
||||
var defaultValue any
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *pluginAdminFormValidationJSON
|
||||
if v := field.GetValidation(); v != nil {
|
||||
validation = &pluginAdminFormValidationJSON{
|
||||
HasMin: v.GetHasMin(),
|
||||
Min: v.GetMin(),
|
||||
HasMax: v.GetHasMax(),
|
||||
Max: v.GetMax(),
|
||||
Pattern: v.GetPattern(),
|
||||
MinLength: v.GetMinLength(),
|
||||
MaxLength: v.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
fields = append(fields, pluginAdminFormFieldJSON{
|
||||
Key: field.GetKey(),
|
||||
Label: field.GetLabel(),
|
||||
Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(),
|
||||
Required: field.GetRequired(),
|
||||
Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(),
|
||||
DefaultValue: defaultValue,
|
||||
Options: options,
|
||||
Rows: field.GetRows(),
|
||||
DynamicOptions: field.GetDynamicOptions(),
|
||||
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
|
||||
Validation: validation,
|
||||
ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, pluginAdminFormSectionJSON{
|
||||
Key: section.GetKey(),
|
||||
Title: section.GetTitle(),
|
||||
Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(),
|
||||
CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
|
||||
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
|
||||
})
|
||||
}
|
||||
return &pluginAdminFormJSON{
|
||||
Fields: fields,
|
||||
SubmitLabel: form.GetSubmitLabel(),
|
||||
Sections: sections,
|
||||
}
|
||||
}
|
||||
|
||||
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
|
||||
if len(conditions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, pluginAdminFormConditionJSON{
|
||||
Field: condition.GetField(),
|
||||
Equals: append([]string(nil), condition.GetEquals()...),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
return plugins.AdminFormViewFromProto(form)
|
||||
}
|
||||
|
||||
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
|
||||
|
||||
@@ -46,6 +46,7 @@ const (
|
||||
deviceIDHeader = "X-Silo-Device-Id"
|
||||
deviceNameHeader = "X-Silo-Device-Name"
|
||||
devicePlatformHeader = "X-Silo-Device-Platform"
|
||||
clientFamilyHeader = "X-Silo-Client-Family"
|
||||
)
|
||||
|
||||
// ServerSettingReader reads individual keys from the server_settings table.
|
||||
|
||||
@@ -23,7 +23,10 @@ import (
|
||||
// activitylog is an HTTP request-log middleware whose schema has no profile or
|
||||
// body, so it cannot express "actor P changed key K for profile Q". Persisting
|
||||
// these needs its own table and migration.
|
||||
const settingsAuditMsg = "settings changed for another profile"
|
||||
const (
|
||||
settingsAuditMsg = "settings changed for another profile"
|
||||
settingsAuditActionSet = "set"
|
||||
)
|
||||
|
||||
// logComponentKey is the structured-log attribute every handler in this package
|
||||
// tags itself with.
|
||||
@@ -37,6 +40,7 @@ type settingsAuditRecord struct {
|
||||
// actor's own account only on the admin routes, where profile ids alone
|
||||
// would not say whose settings moved.
|
||||
TargetUserID int
|
||||
ClientFamily string
|
||||
DeviceID string
|
||||
Key string
|
||||
Scope string
|
||||
@@ -67,6 +71,9 @@ func auditSettingsForOther(ctx context.Context, record settingsAuditRecord) {
|
||||
if record.Scope != "" {
|
||||
attrs = append(attrs, "scope", record.Scope)
|
||||
}
|
||||
if record.ClientFamily != "" {
|
||||
attrs = append(attrs, "client_family", record.ClientFamily)
|
||||
}
|
||||
if record.DeviceID != "" {
|
||||
attrs = append(attrs, "device_id", record.DeviceID)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -118,35 +119,99 @@ const fieldValues = "values"
|
||||
// the request boundary keeps a crafted batch from failing resolution outright.
|
||||
const maxEffectiveContentIDs = 200
|
||||
|
||||
// maxShortcutMutationRetries bounds contention retries while still making a
|
||||
// normal burst of edits effectively wait-free for clients. Each failed CAS
|
||||
// means another writer made progress; exhausting this limit is therefore a
|
||||
// retryable conflict, never permission to overwrite the newer document.
|
||||
const maxShortcutMutationRetries = 32
|
||||
|
||||
const jsonNullLiteral = "null"
|
||||
|
||||
const navigationShortcutAtomicUpdateMessage = "Use PUT /settings/values/nav.shortcuts/item to change navigation shortcuts"
|
||||
|
||||
var (
|
||||
errMutationIDConflict = errors.New("setting mutation id conflict")
|
||||
errMutationReplayRollback = errors.New("setting mutation replay requires rollback")
|
||||
errMutationTransactionRequired = errors.New("settings store does not support atomic idempotent mutations")
|
||||
errShortcutMutationContention = errors.New("navigation shortcuts changed too quickly")
|
||||
errShortcutMutationInvalidValue = errors.New("invalid navigation shortcut value")
|
||||
)
|
||||
|
||||
type idempotentSettingMutationOutcome struct {
|
||||
result json.RawMessage
|
||||
stored *userstore.SettingValue
|
||||
replay bool
|
||||
changed bool
|
||||
}
|
||||
|
||||
type shortcutMutationStore interface {
|
||||
GetSettingValue(context.Context, userstore.SettingIdentity) (*userstore.SettingValue, error)
|
||||
CompareAndSetSettingValue(
|
||||
context.Context,
|
||||
userstore.SettingIdentity,
|
||||
json.RawMessage,
|
||||
int64,
|
||||
) (*userstore.SettingValue, error)
|
||||
}
|
||||
|
||||
// settingValueResponse is one explicit stored value.
|
||||
type settingValueResponse struct {
|
||||
Key string `json:"key"`
|
||||
Scope string `json:"scope"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Scope string `json:"scope"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
ClientFamily string `json:"client_family,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Revision int64 `json:"revision"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type navigationShortcutMutationRequest struct {
|
||||
Item json.RawMessage `json:"item"`
|
||||
Present *bool `json:"present"`
|
||||
}
|
||||
|
||||
type navigationShortcutDocument struct {
|
||||
Items []navigationShortcutItem `json:"items"`
|
||||
}
|
||||
|
||||
// navigationShortcutItem mirrors navigation-shortcuts.json. LibraryID is a
|
||||
// pointer because its presence is part of collection identity: a global
|
||||
// collection and a library-specific collection with the same collection_id
|
||||
// are different destinations.
|
||||
type navigationShortcutItem struct {
|
||||
Type string `json:"type"`
|
||||
LibraryID *int `json:"library_id,omitempty"`
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
CollectionID string `json:"collection_id,omitempty"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type navigationShortcutIdentity struct {
|
||||
Type string
|
||||
LibraryID int
|
||||
HasLibraryID bool
|
||||
SectionID string
|
||||
CollectionID string
|
||||
}
|
||||
|
||||
// explicitSettingValueResponse is one entry from the collection GET. Unset is
|
||||
// represented explicitly rather than as a 404 so a settings screen can fetch
|
||||
// several profile defaults or device overrides in one request.
|
||||
type explicitSettingValueResponse struct {
|
||||
Key string `json:"key"`
|
||||
Scope string `json:"scope"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
IsSet bool `json:"is_set"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
Key string `json:"key"`
|
||||
Scope string `json:"scope"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
ClientFamily string `json:"client_family,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
IsSet bool `json:"is_set"`
|
||||
Value json.RawMessage `json:"value,omitempty"`
|
||||
Revision int64 `json:"revision,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// effectiveSettingValueResponse is one resolved value plus where it came from.
|
||||
@@ -176,18 +241,20 @@ type effectiveSettingValueResponse struct {
|
||||
|
||||
// Scope locates the row the value came from, so a client can offer a reset
|
||||
// against exactly that scope. Empty for a contract default.
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
ClientFamily string `json:"client_family,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
}
|
||||
|
||||
type effectiveSourceContextResponse struct {
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
ClientFamily string `json:"client_family,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
LibraryID int `json:"library_id,omitempty"`
|
||||
SeriesID string `json:"series_id,omitempty"`
|
||||
}
|
||||
|
||||
// HandleGetContract serves the public manifest.
|
||||
@@ -235,12 +302,21 @@ func (h *SettingValuesHandler) HandleGetCapabilities(w http.ResponseWriter, r *h
|
||||
"scopes": []string{
|
||||
string(settingscontract.ScopeAccount),
|
||||
string(settingscontract.ScopeProfile),
|
||||
string(settingscontract.ScopeProfileClient),
|
||||
string(settingscontract.ScopeProfileDevice),
|
||||
string(settingscontract.ScopeProfileLibrary),
|
||||
string(settingscontract.ScopeProfileSeries),
|
||||
},
|
||||
"client_families": []string{
|
||||
string(settingscontract.ClientFamilyTV),
|
||||
string(settingscontract.ClientFamilyMobile),
|
||||
string(settingscontract.ClientFamilyTablet),
|
||||
string(settingscontract.ClientFamilyDesktop),
|
||||
string(settingscontract.ClientFamilyWeb),
|
||||
},
|
||||
"supports_batched_effective": true,
|
||||
"supports_idempotent_writes": true,
|
||||
"supports_atomic_shortcuts": true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -306,6 +382,9 @@ func (h *SettingValuesHandler) HandleGetValues(w http.ResponseWriter, r *http.Re
|
||||
switch identity.Scope {
|
||||
case settingscontract.ScopeProfile:
|
||||
query.ProfileIDs = []string{identity.ProfileID}
|
||||
case settingscontract.ScopeProfileClient:
|
||||
query.ProfileIDs = []string{identity.ProfileID}
|
||||
query.ClientFamily = identity.ClientFamily
|
||||
case settingscontract.ScopeProfileDevice:
|
||||
query.ProfileIDs = []string{identity.ProfileID}
|
||||
query.DeviceID = identity.DeviceID
|
||||
@@ -332,7 +411,8 @@ func (h *SettingValuesHandler) HandleGetValues(w http.ResponseWriter, r *http.Re
|
||||
for _, key := range keys {
|
||||
entry := explicitSettingValueResponse{
|
||||
Key: key, Scope: string(identity.Scope), ProfileID: identity.ProfileID,
|
||||
DeviceID: identity.DeviceID, LibraryID: identity.LibraryID, SeriesID: identity.SeriesID,
|
||||
ClientFamily: string(identity.ClientFamily), DeviceID: identity.DeviceID,
|
||||
LibraryID: identity.LibraryID, SeriesID: identity.SeriesID,
|
||||
}
|
||||
if value, exists := byKey[key]; exists {
|
||||
entry.IsSet = true
|
||||
@@ -363,9 +443,333 @@ func (h *SettingValuesHandler) HandleSetValue(w http.ResponseWriter, r *http.Req
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if identity.Key == settingskeys.NavShortcuts {
|
||||
writeError(w, http.StatusBadRequest, "atomic_update_required",
|
||||
navigationShortcutAtomicUpdateMessage)
|
||||
return
|
||||
}
|
||||
h.setValueAt(w, r, store, apimw.GetUserID(r.Context()), identity)
|
||||
}
|
||||
|
||||
// HandleSetNavigationShortcut applies one desired-state edit to the shared
|
||||
// profile shortcut catalog. Unlike the generic whole-document PUT, two clients
|
||||
// adding different destinations cannot overwrite one another: the handler
|
||||
// rebases after an internal compare-and-set conflict until its semantic edit
|
||||
// lands on the newest document.
|
||||
func (h *SettingValuesHandler) HandleSetNavigationShortcut(w http.ResponseWriter, r *http.Request) {
|
||||
store, ok := h.storeFor(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
shortcutStore, ok := store.(shortcutMutationStore)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error",
|
||||
"This settings store does not support atomic shortcut updates")
|
||||
return
|
||||
}
|
||||
|
||||
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
|
||||
identity := userstore.SettingIdentity{
|
||||
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: profileID,
|
||||
}
|
||||
if err := identity.Validate(); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "X-Profile-Id header is required")
|
||||
return
|
||||
}
|
||||
def, ok := h.definitionFor(w, identity.Key)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body navigationShortcutMutationRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"Body must be {\"item\": {…}, \"present\": true|false}")
|
||||
return
|
||||
}
|
||||
if _, err := decoder.Token(); !errors.Is(err, io.EOF) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Body must be a single JSON document")
|
||||
return
|
||||
}
|
||||
if body.Present == nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "present is required")
|
||||
return
|
||||
}
|
||||
|
||||
item, err := normalizeNavigationShortcutItem(def, body.Item)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_value", err.Error())
|
||||
return
|
||||
}
|
||||
requestHash := hashNavigationShortcutMutation(identity, item, *body.Present)
|
||||
mutationID := strings.TrimSpace(r.Header.Get(mutationIDHeader))
|
||||
|
||||
var stored *userstore.SettingValue
|
||||
var idempotentResult json.RawMessage
|
||||
var changed bool
|
||||
if mutationID == "" {
|
||||
stored, changed, err = mutateNavigationShortcut(
|
||||
r.Context(), shortcutStore, def, identity, item, *body.Present)
|
||||
} else {
|
||||
var outcome idempotentSettingMutationOutcome
|
||||
outcome, err = runIdempotentSettingMutation(
|
||||
r.Context(), store, mutationID, requestHash,
|
||||
func(writer userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error) {
|
||||
return mutateNavigationShortcut(r.Context(), writer, def, identity, item, *body.Present)
|
||||
},
|
||||
)
|
||||
if err == nil && outcome.replay {
|
||||
w.Header().Set("X-Silo-Idempotent-Replay", "true")
|
||||
writeRawJSON(w, http.StatusOK, outcome.result)
|
||||
return
|
||||
}
|
||||
stored, changed = outcome.stored, outcome.changed
|
||||
idempotentResult = outcome.result
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, errMutationIDConflict):
|
||||
writeError(w, http.StatusConflict, "mutation_id_conflict",
|
||||
"This mutation id was used for a different write")
|
||||
case errors.Is(err, errShortcutMutationContention):
|
||||
writeError(w, http.StatusConflict, "setting_update_conflict",
|
||||
"Navigation shortcuts changed too quickly; retry this mutation")
|
||||
case errors.Is(err, errShortcutMutationInvalidValue):
|
||||
writeError(w, http.StatusBadRequest, "invalid_value", err.Error())
|
||||
case errors.Is(err, userstore.ErrInvalidSettingIdentity),
|
||||
errors.Is(err, userstore.ErrInvalidSettingValue):
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
default:
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to store navigation shortcuts")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
response := settingValueToResponse(*stored)
|
||||
if changed {
|
||||
publishUserSettingsEvent(r.Context(), h.EventsHub,
|
||||
apimw.GetUserID(r.Context()), identity.ProfileID, identity.Key, string(identity.Scope))
|
||||
auditSettingsForOther(r.Context(), settingsAuditRecord{
|
||||
Action: settingsAuditActionSet,
|
||||
ActorProfileID: actingProfileID(r.Context()),
|
||||
TargetProfileID: identity.ProfileID,
|
||||
TargetUserID: apimw.GetUserID(r.Context()),
|
||||
Key: identity.Key,
|
||||
Scope: string(identity.Scope),
|
||||
})
|
||||
}
|
||||
if idempotentResult != nil {
|
||||
writeRawJSON(w, http.StatusOK, idempotentResult)
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeNavigationShortcutItem(
|
||||
def *settingscontract.Definition,
|
||||
item json.RawMessage,
|
||||
) (navigationShortcutItem, error) {
|
||||
if len(item) == 0 {
|
||||
return navigationShortcutItem{}, errors.New("item is required")
|
||||
}
|
||||
raw, err := json.Marshal(struct {
|
||||
Items []json.RawMessage `json:"items"`
|
||||
}{Items: []json.RawMessage{item}})
|
||||
if err != nil {
|
||||
return navigationShortcutItem{}, fmt.Errorf("encoding shortcut: %w", err)
|
||||
}
|
||||
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
|
||||
if err != nil {
|
||||
return navigationShortcutItem{}, err
|
||||
}
|
||||
var document navigationShortcutDocument
|
||||
if err := json.Unmarshal(normalized, &document); err != nil || len(document.Items) != 1 {
|
||||
return navigationShortcutItem{}, errors.New("shortcut did not normalize to one item")
|
||||
}
|
||||
return document.Items[0], nil
|
||||
}
|
||||
|
||||
func applyNavigationShortcutMutation(
|
||||
def *settingscontract.Definition,
|
||||
current *userstore.SettingValue,
|
||||
item navigationShortcutItem,
|
||||
present bool,
|
||||
) (json.RawMessage, bool, error) {
|
||||
document := navigationShortcutDocument{Items: []navigationShortcutItem{}}
|
||||
if current != nil {
|
||||
if err := json.Unmarshal(current.Value, &document); err != nil {
|
||||
return nil, false, fmt.Errorf("decoding stored navigation shortcuts: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
target := item.identity()
|
||||
match := -1
|
||||
for index, candidate := range document.Items {
|
||||
if candidate.identity() == target {
|
||||
match = index
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if present {
|
||||
if match >= 0 {
|
||||
if document.Items[match].equal(item) {
|
||||
return current.Value, false, nil
|
||||
}
|
||||
document.Items[match] = item
|
||||
} else {
|
||||
document.Items = append(document.Items, item)
|
||||
}
|
||||
} else {
|
||||
if match < 0 {
|
||||
if current == nil {
|
||||
return json.RawMessage(`{"items":[]}`), false, nil
|
||||
}
|
||||
return current.Value, false, nil
|
||||
}
|
||||
document.Items = append(document.Items[:match], document.Items[match+1:]...)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("encoding navigation shortcuts: %w", err)
|
||||
}
|
||||
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("%w: %w", errShortcutMutationInvalidValue, err)
|
||||
}
|
||||
return normalized, true, nil
|
||||
}
|
||||
|
||||
func (item navigationShortcutItem) identity() navigationShortcutIdentity {
|
||||
identity := navigationShortcutIdentity{
|
||||
Type: item.Type, SectionID: item.SectionID, CollectionID: item.CollectionID,
|
||||
}
|
||||
if item.LibraryID != nil {
|
||||
identity.LibraryID = *item.LibraryID
|
||||
identity.HasLibraryID = true
|
||||
}
|
||||
return identity
|
||||
}
|
||||
|
||||
func (item navigationShortcutItem) equal(other navigationShortcutItem) bool {
|
||||
return item.identity() == other.identity() && item.Label == other.Label
|
||||
}
|
||||
|
||||
func mutateNavigationShortcut(
|
||||
ctx context.Context,
|
||||
store shortcutMutationStore,
|
||||
def *settingscontract.Definition,
|
||||
identity userstore.SettingIdentity,
|
||||
item navigationShortcutItem,
|
||||
present bool,
|
||||
) (*userstore.SettingValue, bool, error) {
|
||||
for attempt := 0; attempt < maxShortcutMutationRetries; attempt++ {
|
||||
current, err := store.GetSettingValue(ctx, identity)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("reading navigation shortcuts: %w", err)
|
||||
}
|
||||
|
||||
next, changed, err := applyNavigationShortcutMutation(def, current, item, present)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !changed {
|
||||
if current != nil {
|
||||
return current, false, nil
|
||||
}
|
||||
return &userstore.SettingValue{
|
||||
SettingIdentity: identity,
|
||||
Value: json.RawMessage(`{"items":[]}`),
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
expectedRevision := int64(0)
|
||||
if current != nil {
|
||||
expectedRevision = current.Revision
|
||||
}
|
||||
stored, err := store.CompareAndSetSettingValue(ctx, identity, next, expectedRevision)
|
||||
if errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return stored, true, nil
|
||||
}
|
||||
return nil, false, errShortcutMutationContention
|
||||
}
|
||||
|
||||
func runIdempotentSettingMutation(
|
||||
ctx context.Context,
|
||||
store userstore.UserStore,
|
||||
mutationID string,
|
||||
requestHash string,
|
||||
mutate func(userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error),
|
||||
) (idempotentSettingMutationOutcome, error) {
|
||||
transactioner, ok := store.(userstore.SettingMutationTransactioner)
|
||||
if !ok {
|
||||
return idempotentSettingMutationOutcome{}, errMutationTransactionRequired
|
||||
}
|
||||
|
||||
var outcome idempotentSettingMutationOutcome
|
||||
err := transactioner.WithSettingMutationTransaction(ctx, mutationID,
|
||||
func(writer userstore.SettingMutationWriter) error {
|
||||
prior, err := writer.GetSettingMutation(ctx, mutationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking setting mutation: %w", err)
|
||||
}
|
||||
if prior != nil {
|
||||
if prior.RequestHash != requestHash {
|
||||
return errMutationIDConflict
|
||||
}
|
||||
outcome.result = slices.Clone(prior.Result)
|
||||
outcome.replay = true
|
||||
return nil
|
||||
}
|
||||
|
||||
stored, changed, err := mutate(writer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response := settingValueToResponse(*stored)
|
||||
result, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding setting mutation receipt: %w", err)
|
||||
}
|
||||
record, inserted, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
|
||||
MutationID: mutationID,
|
||||
RequestHash: requestHash,
|
||||
Result: result,
|
||||
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording setting mutation: %w", err)
|
||||
}
|
||||
if !inserted {
|
||||
if record.RequestHash != requestHash {
|
||||
return errMutationIDConflict
|
||||
}
|
||||
outcome.result = slices.Clone(record.Result)
|
||||
outcome.replay = true
|
||||
// A legacy writer could have inserted between the initial read and
|
||||
// this insert. Roll back our setting write before serving its receipt.
|
||||
return errMutationReplayRollback
|
||||
}
|
||||
|
||||
outcome.result = slices.Clone(record.Result)
|
||||
outcome.stored = stored
|
||||
outcome.changed = changed
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, errMutationReplayRollback) {
|
||||
return outcome, nil
|
||||
}
|
||||
return outcome, err
|
||||
}
|
||||
|
||||
// setValueAt is the write path shared by the session route and the admin
|
||||
// route: validation, normalization, idempotency and the change event are one
|
||||
// implementation regardless of who addresses the store. eventUserID names the
|
||||
@@ -413,27 +817,34 @@ func (h *SettingValuesHandler) setValueAt(
|
||||
// not double-apply it, and must be able to tell "already done" from "that
|
||||
// id means something else".
|
||||
mutationID := strings.TrimSpace(r.Header.Get(mutationIDHeader))
|
||||
var requestHash string
|
||||
if mutationID != "" {
|
||||
requestHash = hashMutationRequest(identity, normalized)
|
||||
prior, err := store.GetSettingMutation(r.Context(), mutationID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to check the mutation id")
|
||||
var stored *userstore.SettingValue
|
||||
var idempotentResult json.RawMessage
|
||||
if mutationID == "" {
|
||||
stored, err = store.UpsertSettingValue(r.Context(), identity, normalized)
|
||||
} else {
|
||||
outcome, mutationErr := runIdempotentSettingMutation(
|
||||
r.Context(), store, mutationID, hashMutationRequest(identity, normalized),
|
||||
func(writer userstore.SettingMutationWriter) (*userstore.SettingValue, bool, error) {
|
||||
value, err := writer.UpsertSettingValue(r.Context(), identity, normalized)
|
||||
return value, true, err
|
||||
},
|
||||
)
|
||||
if errors.Is(mutationErr, errMutationIDConflict) {
|
||||
writeError(w, http.StatusConflict, "mutation_id_conflict",
|
||||
"This mutation id was used for a different write")
|
||||
return
|
||||
}
|
||||
if prior != nil {
|
||||
if prior.RequestHash != requestHash {
|
||||
writeError(w, http.StatusConflict, "mutation_id_conflict",
|
||||
"This mutation id was used for a different write")
|
||||
return
|
||||
}
|
||||
if mutationErr != nil {
|
||||
err = mutationErr
|
||||
} else if outcome.replay {
|
||||
w.Header().Set("X-Silo-Idempotent-Replay", "true")
|
||||
writeRawJSON(w, http.StatusOK, prior.Result)
|
||||
writeRawJSON(w, http.StatusOK, outcome.result)
|
||||
return
|
||||
} else {
|
||||
stored = outcome.stored
|
||||
idempotentResult = outcome.result
|
||||
}
|
||||
}
|
||||
|
||||
stored, err := store.UpsertSettingValue(r.Context(), identity, normalized)
|
||||
if err != nil {
|
||||
if errors.Is(err, userstore.ErrInvalidSettingIdentity) ||
|
||||
errors.Is(err, userstore.ErrInvalidSettingValue) {
|
||||
@@ -445,14 +856,6 @@ func (h *SettingValuesHandler) setValueAt(
|
||||
}
|
||||
|
||||
response := settingValueToResponse(*stored)
|
||||
if mutationID != "" {
|
||||
// Recorded only now, after the write landed: a receipt written for a
|
||||
// failed upsert would turn the client's retry of a 500 into a silent
|
||||
// 200 replay of a write that never happened. Storing the actual
|
||||
// response also makes the replay byte-identical — revision and
|
||||
// updated_at included — rather than a reconstruction of the input.
|
||||
h.recordMutation(r, store, mutationID, requestHash, response)
|
||||
}
|
||||
acting := actingProfileID(r.Context())
|
||||
if identity.Scope == settingscontract.ScopeProfileDevice {
|
||||
// A device that only ever writes canonically must still appear in
|
||||
@@ -472,15 +875,20 @@ func (h *SettingValuesHandler) setValueAt(
|
||||
publishUserSettingsEvent(r.Context(), h.EventsHub,
|
||||
eventUserID, identity.ProfileID, identity.Key, string(identity.Scope))
|
||||
auditSettingsForOther(r.Context(), settingsAuditRecord{
|
||||
Action: "set",
|
||||
Action: settingsAuditActionSet,
|
||||
ActorProfileID: acting,
|
||||
TargetProfileID: identity.ProfileID,
|
||||
TargetUserID: eventUserID,
|
||||
ClientFamily: string(identity.ClientFamily),
|
||||
DeviceID: identity.DeviceID,
|
||||
Key: identity.Key,
|
||||
Scope: string(identity.Scope),
|
||||
})
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
if idempotentResult != nil {
|
||||
writeRawJSON(w, http.StatusOK, idempotentResult)
|
||||
} else {
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
}
|
||||
}
|
||||
|
||||
// registerWritingDevice refreshes the device registry from the request's
|
||||
@@ -531,6 +939,11 @@ func (h *SettingValuesHandler) HandleDeleteValue(w http.ResponseWriter, r *http.
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if identity.Key == settingskeys.NavShortcuts {
|
||||
writeError(w, http.StatusBadRequest, "atomic_update_required",
|
||||
navigationShortcutAtomicUpdateMessage)
|
||||
return
|
||||
}
|
||||
h.deleteValueAt(w, r, store, apimw.GetUserID(r.Context()), identity)
|
||||
}
|
||||
|
||||
@@ -557,6 +970,7 @@ func (h *SettingValuesHandler) deleteValueAt(
|
||||
ActorProfileID: actingProfileID(r.Context()),
|
||||
TargetProfileID: identity.ProfileID,
|
||||
TargetUserID: eventUserID,
|
||||
ClientFamily: string(identity.ClientFamily),
|
||||
DeviceID: identity.DeviceID,
|
||||
Key: identity.Key,
|
||||
Scope: string(identity.Scope),
|
||||
@@ -608,6 +1022,11 @@ func (h *SettingValuesHandler) HandleGetEffective(w http.ResponseWriter, r *http
|
||||
LibraryIDs: parseIntCSV(r.URL.Query().Get("library_ids")),
|
||||
SeriesIDs: splitCSV(r.URL.Query().Get("series_ids")),
|
||||
}
|
||||
if family, needed, ok := h.clientFamilyForKeys(w, r, keys); !ok {
|
||||
return
|
||||
} else if needed {
|
||||
rc.ClientFamily = family
|
||||
}
|
||||
|
||||
// A device-settings screen resolves what some *other* device sees, so this
|
||||
// read accepts the same explicit identity the write path does, under the
|
||||
@@ -714,6 +1133,10 @@ func (h *SettingValuesHandler) HandlePostEffective(w http.ResponseWriter, r *htt
|
||||
|
||||
profileID := strings.TrimSpace(apimw.GetProfileID(r.Context()))
|
||||
deviceID := deviceMetadataFromRequest(r).DeviceID
|
||||
clientFamily, familyNeeded, ok := h.clientFamilyForKeys(w, r, keys)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if deviceID == "" {
|
||||
for _, key := range keys {
|
||||
if def, exists := h.contract.Lookup(key); exists && def.AllowsScope(settingscontract.ScopeProfileDevice) {
|
||||
@@ -749,6 +1172,9 @@ func (h *SettingValuesHandler) HandlePostEffective(w http.ResponseWriter, r *htt
|
||||
return
|
||||
}
|
||||
rc := settingsresolve.Context{ProfileID: profileID, DeviceID: deviceID}
|
||||
if familyNeeded {
|
||||
rc.ClientFamily = clientFamily
|
||||
}
|
||||
if libraryID > 0 {
|
||||
rc.LibraryIDs = []int{libraryID}
|
||||
contentIDs++
|
||||
@@ -862,28 +1288,6 @@ func (h *SettingValuesHandler) constraintsFor(r *http.Request) settingsresolve.C
|
||||
return settingsresolve.Constraints{policyInputMaxPlaybackQuality: limit}
|
||||
}
|
||||
|
||||
// recordMutation stores the idempotency receipt after a successful write. The
|
||||
// receipt is the response the original request returned, so a replay serves
|
||||
// exactly what the client would have received.
|
||||
func (h *SettingValuesHandler) recordMutation(
|
||||
r *http.Request,
|
||||
store userstore.UserStore,
|
||||
mutationID, requestHash string,
|
||||
response settingValueResponse,
|
||||
) {
|
||||
receipt, _ := json.Marshal(response)
|
||||
// Best effort: the write already succeeded, and failing the request now
|
||||
// would tell the client the opposite of the truth. A missing receipt costs
|
||||
// at most a duplicate write on retry, which upsert makes harmless.
|
||||
_, _, _ = store.PutSettingMutation(r.Context(), userstore.SettingMutationRecord{
|
||||
MutationID: mutationID,
|
||||
RequestHash: requestHash,
|
||||
Result: receipt,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SettingValuesHandler) storeFor(w http.ResponseWriter, r *http.Request) (userstore.UserStore, bool) {
|
||||
store, err := h.storeProvider.ForUser(r.Context(), apimw.GetUserID(r.Context()))
|
||||
if err != nil {
|
||||
@@ -908,6 +1312,37 @@ func (h *SettingValuesHandler) definitionFor(w http.ResponseWriter, key string)
|
||||
return def, true
|
||||
}
|
||||
|
||||
// clientFamilyForKeys validates an optional family header for effective reads.
|
||||
// An absent header deliberately drops the profile_client layer so pre-revision
|
||||
// 5 callers keep resolving broader fallbacks; explicit profile_client reads and
|
||||
// writes still require the header in identityForSessionKey. The server never
|
||||
// guesses this identity from X-Silo-Device-Platform: that header is free-form
|
||||
// display metadata, while client_family is a closed storage key shared by like
|
||||
// clients.
|
||||
func (h *SettingValuesHandler) clientFamilyForKeys(
|
||||
w http.ResponseWriter, r *http.Request, keys []string,
|
||||
) (settingscontract.ClientFamily, bool, bool) {
|
||||
eligible := false
|
||||
for _, key := range keys {
|
||||
if def, ok := h.contract.Lookup(key); ok && def.AllowsScope(settingscontract.ScopeProfileClient) {
|
||||
eligible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
value := strings.TrimSpace(r.Header.Get(clientFamilyHeader))
|
||||
if value == "" {
|
||||
return "", false, true
|
||||
}
|
||||
family := settingscontract.ClientFamily(value)
|
||||
if !family.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"X-Silo-Client-Family header must be one of tv, mobile, tablet, desktop or web")
|
||||
return "", false, false
|
||||
}
|
||||
return family, eligible, true
|
||||
}
|
||||
|
||||
// identityFromRequest builds and validates the scope identity a request names.
|
||||
//
|
||||
// Scope comes from the query string rather than the path so one route serves
|
||||
@@ -947,6 +1382,15 @@ func (h *SettingValuesHandler) identityForSessionKey(
|
||||
identity.ProfileID = named
|
||||
}
|
||||
}
|
||||
if scope == settingscontract.ScopeProfileClient {
|
||||
family := settingscontract.ClientFamily(strings.TrimSpace(r.Header.Get(clientFamilyHeader)))
|
||||
if !family.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"X-Silo-Client-Family header must be one of tv, mobile, tablet, desktop or web")
|
||||
return userstore.SettingIdentity{}, false
|
||||
}
|
||||
identity.ClientFamily = family
|
||||
}
|
||||
if scope == settingscontract.ScopeProfileDevice {
|
||||
// A device may be named explicitly so one device can manage another's
|
||||
// settings — the screen that lists your devices edits them in place.
|
||||
@@ -1064,7 +1508,7 @@ func (h *SettingValuesHandler) keyedScope(
|
||||
scope := settingscontract.Scope(strings.TrimSpace(query.Get("scope")))
|
||||
if scope == "" {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"A scope is required: account, profile, profile_device, profile_library or profile_series")
|
||||
"A scope is required: account, profile, profile_client, profile_device, profile_library or profile_series")
|
||||
return "", "", false
|
||||
}
|
||||
return key, scope, true
|
||||
@@ -1133,20 +1577,22 @@ func (h *SettingValuesHandler) libraryContextExists(
|
||||
|
||||
func sameSettingContext(a, b userstore.SettingIdentity) bool {
|
||||
return a.Scope == b.Scope && a.ProfileID == b.ProfileID &&
|
||||
a.DeviceID == b.DeviceID && a.LibraryID == b.LibraryID && a.SeriesID == b.SeriesID
|
||||
a.ClientFamily == b.ClientFamily && a.DeviceID == b.DeviceID &&
|
||||
a.LibraryID == b.LibraryID && a.SeriesID == b.SeriesID
|
||||
}
|
||||
|
||||
func settingValueToResponse(value userstore.SettingValue) settingValueResponse {
|
||||
return settingValueResponse{
|
||||
Key: value.Key,
|
||||
Scope: string(value.Scope),
|
||||
ProfileID: value.ProfileID,
|
||||
DeviceID: value.DeviceID,
|
||||
LibraryID: value.LibraryID,
|
||||
SeriesID: value.SeriesID,
|
||||
Value: value.Value,
|
||||
Revision: value.Revision,
|
||||
UpdatedAt: value.UpdatedAt,
|
||||
Key: value.Key,
|
||||
Scope: string(value.Scope),
|
||||
ProfileID: value.ProfileID,
|
||||
ClientFamily: string(value.ClientFamily),
|
||||
DeviceID: value.DeviceID,
|
||||
LibraryID: value.LibraryID,
|
||||
SeriesID: value.SeriesID,
|
||||
Value: value.Value,
|
||||
Revision: value.Revision,
|
||||
UpdatedAt: value.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1169,14 +1615,16 @@ func effectiveToResponse(eff settingsresolve.Effective) effectiveSettingValueRes
|
||||
if eff.Identity != nil {
|
||||
out.Scope = string(eff.Identity.Scope)
|
||||
out.ProfileID = eff.Identity.ProfileID
|
||||
out.ClientFamily = string(eff.Identity.ClientFamily)
|
||||
out.DeviceID = eff.Identity.DeviceID
|
||||
out.LibraryID = eff.Identity.LibraryID
|
||||
out.SeriesID = eff.Identity.SeriesID
|
||||
out.SourceContext = &effectiveSourceContextResponse{
|
||||
ProfileID: eff.Identity.ProfileID,
|
||||
DeviceID: eff.Identity.DeviceID,
|
||||
LibraryID: eff.Identity.LibraryID,
|
||||
SeriesID: eff.Identity.SeriesID,
|
||||
ProfileID: eff.Identity.ProfileID,
|
||||
ClientFamily: string(eff.Identity.ClientFamily),
|
||||
DeviceID: eff.Identity.DeviceID,
|
||||
LibraryID: eff.Identity.LibraryID,
|
||||
SeriesID: eff.Identity.SeriesID,
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -1307,9 +1755,36 @@ func hashMutationRequest(identity userstore.SettingIdentity, value json.RawMessa
|
||||
sum.Write([]byte(identity.SeriesID))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(value)
|
||||
// Preserve the established fingerprint for the five pre-existing scopes so
|
||||
// an in-flight retry made across this server upgrade still replays. Only the
|
||||
// new profile_client identity appends a family discriminator.
|
||||
if identity.ClientFamily != "" {
|
||||
sum.Write([]byte{0})
|
||||
sum.Write([]byte(identity.ClientFamily))
|
||||
}
|
||||
return hex.EncodeToString(sum.Sum(nil))
|
||||
}
|
||||
|
||||
func hashNavigationShortcutMutation(
|
||||
identity userstore.SettingIdentity,
|
||||
item navigationShortcutItem,
|
||||
present bool,
|
||||
) string {
|
||||
// Removing a destination ignores presentation fields, so the fingerprint
|
||||
// does too. Reusing one mutation id for the same remove with a refreshed
|
||||
// label is the same operation; adding includes the label because it can
|
||||
// update that field in place.
|
||||
if !present {
|
||||
item.Label = ""
|
||||
}
|
||||
canonical, _ := json.Marshal(struct {
|
||||
Operation string `json:"operation"`
|
||||
Item navigationShortcutItem `json:"item"`
|
||||
Present bool `json:"present"`
|
||||
}{Operation: "set_navigation_shortcut_presence", Item: item, Present: present})
|
||||
return hashMutationRequest(identity, canonical)
|
||||
}
|
||||
|
||||
func writeRawJSON(w http.ResponseWriter, status int, body []byte) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
const adminNavigationShortcutRepairMessage = "Use whole-document PUT on this admin resource to clear or replace navigation shortcuts"
|
||||
|
||||
// Admin projections of the canonical settings API. These replace the string
|
||||
// registry's /admin/users/{id}/settings* and device-settings* routes with the
|
||||
// same typed surface clients use on /settings/values: the same validation, the
|
||||
@@ -81,6 +84,11 @@ func (h *SettingValuesHandler) HandleAdminDeleteUserSettingValue(w http.Response
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if identity.Key == settingskeys.NavShortcuts {
|
||||
writeError(w, http.StatusBadRequest, "atomic_update_required",
|
||||
adminNavigationShortcutRepairMessage)
|
||||
return
|
||||
}
|
||||
h.deleteValueAt(w, r, store, userID, identity)
|
||||
}
|
||||
|
||||
@@ -128,6 +136,14 @@ func (h *SettingValuesHandler) adminIdentityFromRequest(
|
||||
return userstore.SettingIdentity{}, false
|
||||
}
|
||||
}
|
||||
if scope == settingscontract.ScopeProfileClient {
|
||||
identity.ClientFamily = settingscontract.ClientFamily(strings.TrimSpace(query.Get("client_family")))
|
||||
if !identity.ClientFamily.Valid() {
|
||||
writeError(w, http.StatusBadRequest, "bad_request",
|
||||
"client_family must be one of tv, mobile, tablet, desktop or web")
|
||||
return userstore.SettingIdentity{}, false
|
||||
}
|
||||
}
|
||||
if scope == settingscontract.ScopeProfileDevice {
|
||||
identity.DeviceID = strings.TrimSpace(query.Get("device_id"))
|
||||
if identity.DeviceID == "" {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -129,6 +130,10 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
|
||||
settingscontract.ScopeProfile: {
|
||||
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
},
|
||||
settingscontract.ScopeProfileClient: {
|
||||
Key: "nav.primary_menu", Scope: settingscontract.ScopeProfileClient,
|
||||
ProfileID: "profile-1", ClientFamily: settingscontract.ClientFamilyTV,
|
||||
},
|
||||
settingscontract.ScopeProfileDevice: {
|
||||
Key: "playback.subtitle_language", Scope: settingscontract.ScopeProfileDevice,
|
||||
ProfileID: "profile-1", DeviceID: "tv-1",
|
||||
@@ -145,6 +150,7 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
|
||||
values := map[settingscontract.Scope]string{
|
||||
settingscontract.ScopeAccount: `"de"`,
|
||||
settingscontract.ScopeProfile: `"always"`,
|
||||
settingscontract.ScopeProfileClient: `{"items":[{"type":"builtin","destination":"home"}]}`,
|
||||
settingscontract.ScopeProfileDevice: `"en"`,
|
||||
settingscontract.ScopeProfileLibrary: `"fr"`,
|
||||
settingscontract.ScopeProfileSeries: `"ja"`,
|
||||
@@ -186,6 +192,7 @@ func TestAdminListShowsAnotherUsersValuesAcrossScopes(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
if got.Key != want.Key || got.ProfileID != want.ProfileID ||
|
||||
got.ClientFamily != string(want.ClientFamily) ||
|
||||
got.DeviceID != want.DeviceID || got.LibraryID != want.LibraryID ||
|
||||
got.SeriesID != want.SeriesID {
|
||||
t.Errorf("listed identity at %s = %+v, want %+v", got.Scope, got, want)
|
||||
@@ -243,6 +250,83 @@ func TestAdminSetAndDeleteAtExplicitScopeRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminNavigationShortcutRepairPreservesRevisionHistory(t *testing.T) {
|
||||
env := newAdminValuesEnv(t)
|
||||
ctx := context.Background()
|
||||
identity := userstore.SettingIdentity{
|
||||
Key: "nav.shortcuts", Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
}
|
||||
seeded, err := env.targetStore.UpsertSettingValue(ctx, identity,
|
||||
json.RawMessage(`{"items":[{"type":"library","library_id":42,"label":"Movies"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("seed shortcuts: %v", err)
|
||||
}
|
||||
if seeded.Revision != 1 {
|
||||
t.Fatalf("seed revision = %d, want 1", seeded.Revision)
|
||||
}
|
||||
|
||||
target := "/admin/users/7/settings/values/nav.shortcuts?scope=profile&profile_id=profile-1"
|
||||
if rec := env.do(t, "admin", http.MethodDelete, target, nil); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("admin repair DELETE = %d, want 400: %s", rec.Code, rec.Body.String())
|
||||
} else if !bytes.Contains(rec.Body.Bytes(), []byte(`"error":"atomic_update_required"`)) {
|
||||
t.Fatalf("admin repair DELETE body = %s, want atomic_update_required", rec.Body.String())
|
||||
}
|
||||
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil || got.Revision != 1 {
|
||||
t.Fatalf("shortcut after rejected DELETE = %+v err=%v, want revision 1 intact", got, err)
|
||||
}
|
||||
|
||||
rec := env.do(t, "admin", http.MethodPut, target, []byte(`{"value":{"items":[]}}`))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("admin repair PUT empty = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var repaired settingValueResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &repaired); err != nil {
|
||||
t.Fatalf("decode repair response: %v", err)
|
||||
}
|
||||
if repaired.Revision != 2 || string(repaired.Value) != `{"items":[]}` {
|
||||
t.Fatalf("repair response = revision %d value %s, want empty revision 2",
|
||||
repaired.Revision, repaired.Value)
|
||||
}
|
||||
|
||||
cas, ok := env.targetStore.(userstore.SettingValueCompareAndSetter)
|
||||
if !ok {
|
||||
t.Fatal("target store does not support compare-and-set")
|
||||
}
|
||||
_, err = cas.CompareAndSetSettingValue(ctx, identity,
|
||||
json.RawMessage(`{"items":[{"type":"library","library_id":99,"label":"Shows"}]}`),
|
||||
seeded.Revision)
|
||||
if !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
|
||||
t.Fatalf("stale CAS after admin repair = %v, want revision conflict", err)
|
||||
}
|
||||
if got, err := env.targetStore.GetSettingValue(ctx, identity); err != nil || got == nil ||
|
||||
got.Revision != 2 || string(got.Value) != `{"items":[]}` {
|
||||
t.Fatalf("shortcut after stale CAS = %+v err=%v, want empty revision 2", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminProfileClientUsesExplicitQueryIdentity(t *testing.T) {
|
||||
env := newAdminValuesEnv(t)
|
||||
target := "/admin/users/7/settings/values/nav.primary_menu" +
|
||||
"?scope=profile_client&profile_id=profile-1&client_family=tv"
|
||||
body := []byte(`{"value":{"items":[{"type":"builtin","destination":"home"}]}}`)
|
||||
rec := env.do(t, "admin", http.MethodPut, target, body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var stored settingValueResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
|
||||
t.Fatalf("decoding: %v", err)
|
||||
}
|
||||
if stored.Scope != "profile_client" || stored.ProfileID != "profile-1" || stored.ClientFamily != "tv" {
|
||||
t.Fatalf("stored identity = %+v, want profile-1/tv", stored)
|
||||
}
|
||||
|
||||
missing := "/admin/users/7/settings/values/nav.primary_menu?scope=profile_client&profile_id=profile-1"
|
||||
if rec := env.do(t, "admin", http.MethodPut, missing, body); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("PUT without family = %d, want 400: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminSetRejectsInvalidValueLikeTheSessionRoute pins that the admin write
|
||||
// is the same validation path as /settings/values, not a second validator: an
|
||||
// invalid value fails with the identical status, code and message.
|
||||
|
||||
@@ -3,14 +3,19 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -20,6 +25,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/auth"
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/notifications"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||||
@@ -36,6 +42,7 @@ func newValuesTestHandler(t *testing.T) (*SettingValuesHandler, userstore.UserSt
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := userdb.InitSchema(db); err != nil {
|
||||
t.Fatalf("init schema: %v", err)
|
||||
@@ -65,6 +72,7 @@ func valuesRequest(method, target string, body []byte) *http.Request {
|
||||
req = httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
}
|
||||
req.Header.Set(deviceIDHeader, "device-1")
|
||||
req.Header.Set(clientFamilyHeader, "web")
|
||||
ctx := apimw.SetClaims(req.Context(), &auth.Claims{UserID: 1})
|
||||
return req.WithContext(apimw.SetProfileID(ctx, "profile-1"))
|
||||
}
|
||||
@@ -94,6 +102,499 @@ func routeValues(t *testing.T, h *SettingValuesHandler, method, key, query strin
|
||||
return rec
|
||||
}
|
||||
|
||||
func routeNavigationShortcutMutation(
|
||||
t *testing.T,
|
||||
h *SettingValuesHandler,
|
||||
body string,
|
||||
mutationID string,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := valuesRequest(http.MethodPut, "/settings/values/nav.shortcuts/item", []byte(body))
|
||||
if mutationID != "" {
|
||||
req.Header.Set(mutationIDHeader, mutationID)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleSetNavigationShortcut(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func decodeShortcutResponse(t *testing.T, rec *httptest.ResponseRecorder) (settingValueResponse, navigationShortcutDocument) {
|
||||
t.Helper()
|
||||
var response settingValueResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode shortcut response: %v; body=%s", err, rec.Body.String())
|
||||
}
|
||||
var document navigationShortcutDocument
|
||||
if err := json.Unmarshal(response.Value, &document); err != nil {
|
||||
t.Fatalf("decode shortcut document: %v; value=%s", err, response.Value)
|
||||
}
|
||||
return response, document
|
||||
}
|
||||
|
||||
func TestNavigationShortcutMutationLifecycle(t *testing.T) {
|
||||
handler, _ := newValuesTestHandler(t)
|
||||
|
||||
addLibrary := `{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`
|
||||
rec := routeNavigationShortcutMutation(t, handler, addLibrary, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("add library = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
response, document := decodeShortcutResponse(t, rec)
|
||||
if response.Key != settingskeys.NavShortcuts || response.Scope != "profile" || response.Revision != 1 {
|
||||
t.Fatalf("add response = %+v, want profile nav.shortcuts revision 1", response)
|
||||
}
|
||||
if len(document.Items) != 1 || document.Items[0].Label != "Movies" {
|
||||
t.Fatalf("add document = %+v, want Movies", document)
|
||||
}
|
||||
|
||||
addSection := `{"item":{"type":"section","library_id":42,"section_id":"recent","label":"Recent"},"present":true}`
|
||||
rec = routeNavigationShortcutMutation(t, handler, addSection, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("add section = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
response, document = decodeShortcutResponse(t, rec)
|
||||
if response.Revision != 2 || len(document.Items) != 2 || document.Items[1].SectionID != "recent" {
|
||||
t.Fatalf("add section = revision %d document %+v, want both items at revision 2", response.Revision, document)
|
||||
}
|
||||
|
||||
refreshLibrary := `{"item":{"type":"library","library_id":42,"label":"Films"},"present":true}`
|
||||
rec = routeNavigationShortcutMutation(t, handler, refreshLibrary, "")
|
||||
response, document = decodeShortcutResponse(t, rec)
|
||||
if response.Revision != 3 || len(document.Items) != 2 || document.Items[0].Label != "Films" || document.Items[1].SectionID != "recent" {
|
||||
t.Fatalf("label refresh reordered or dropped items: revision %d document %+v", response.Revision, document)
|
||||
}
|
||||
|
||||
// Exact desired state is a no-op: no revision churn and no duplicate.
|
||||
rec = routeNavigationShortcutMutation(t, handler, refreshLibrary, "")
|
||||
response, document = decodeShortcutResponse(t, rec)
|
||||
if response.Revision != 3 || len(document.Items) != 2 {
|
||||
t.Fatalf("repeat add = revision %d document %+v, want unchanged revision 3", response.Revision, document)
|
||||
}
|
||||
|
||||
// Removal matches semantic identity only; a stale label cannot stop it.
|
||||
removeLibrary := `{"item":{"type":"library","library_id":42,"label":"Old label"},"present":false}`
|
||||
rec = routeNavigationShortcutMutation(t, handler, removeLibrary, "")
|
||||
response, document = decodeShortcutResponse(t, rec)
|
||||
if response.Revision != 4 || len(document.Items) != 1 || document.Items[0].SectionID != "recent" {
|
||||
t.Fatalf("remove library = revision %d document %+v, want section preserved", response.Revision, document)
|
||||
}
|
||||
|
||||
rec = routeNavigationShortcutMutation(t, handler, removeLibrary, "")
|
||||
response, _ = decodeShortcutResponse(t, rec)
|
||||
if response.Revision != 4 {
|
||||
t.Fatalf("repeat remove revision = %d, want unchanged 4", response.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutCollectionIdentityIncludesOptionalLibrary(t *testing.T) {
|
||||
handler, _ := newValuesTestHandler(t)
|
||||
global := `{"item":{"type":"collection","collection_id":"favorites","label":"All Favorites"},"present":true}`
|
||||
withinLibrary := `{"item":{"type":"collection","library_id":42,"collection_id":"favorites","label":"Movie Favorites"},"present":true}`
|
||||
for _, body := range []string{global, withinLibrary} {
|
||||
if rec := routeNavigationShortcutMutation(t, handler, body, ""); rec.Code != http.StatusOK {
|
||||
t.Fatalf("add collection = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
removeGlobal := `{"item":{"type":"collection","collection_id":"favorites","label":"Ignored"},"present":false}`
|
||||
rec := routeNavigationShortcutMutation(t, handler, removeGlobal, "")
|
||||
response, document := decodeShortcutResponse(t, rec)
|
||||
if response.Revision != 3 || len(document.Items) != 1 || document.Items[0].LibraryID == nil || *document.Items[0].LibraryID != 42 {
|
||||
t.Fatalf("global removal = revision %d document %+v, want scoped collection preserved", response.Revision, document)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutCollectionLibraryIDValidationCannotMutateExistingTargets(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
global := `{"item":{"type":"collection","collection_id":"favorites","label":"All Favorites"},"present":true}`
|
||||
withinLibrary := `{"item":{"type":"collection","library_id":42,"collection_id":"favorites","label":"Movie Favorites"},"present":true}`
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"omitted library id is global", global},
|
||||
{"positive library id", withinLibrary},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rec := routeNavigationShortcutMutation(t, handler, tc.body, "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("valid collection = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalidLibraryIDs := map[string]string{
|
||||
"explicit null": `null`,
|
||||
"string": `"42"`,
|
||||
"zero": `0`,
|
||||
}
|
||||
for name, libraryID := range invalidLibraryIDs {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
body := fmt.Sprintf(
|
||||
`{"item":{"type":"collection","library_id":%s,"collection_id":"favorites","label":"Ignored"},"present":false}`,
|
||||
libraryID,
|
||||
)
|
||||
rec := routeNavigationShortcutMutation(t, handler, body, "")
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_value") {
|
||||
t.Fatalf("invalid collection library_id = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
stored, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
|
||||
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
})
|
||||
if err != nil || stored == nil {
|
||||
t.Fatalf("read catalog after rejected remove: value=%+v err=%v", stored, err)
|
||||
}
|
||||
var document navigationShortcutDocument
|
||||
if err := json.Unmarshal(stored.Value, &document); err != nil {
|
||||
t.Fatalf("decode catalog after rejected remove: %v", err)
|
||||
}
|
||||
if stored.Revision != 2 || len(document.Items) != 2 {
|
||||
t.Fatalf("rejected remove mutated catalog: revision %d items %+v", stored.Revision, document.Items)
|
||||
}
|
||||
if document.Items[0].LibraryID != nil {
|
||||
t.Fatalf("global collection gained library identity: %+v", document.Items[0])
|
||||
}
|
||||
if document.Items[1].LibraryID == nil || *document.Items[1].LibraryID != 42 {
|
||||
t.Fatalf("library-specific collection changed identity: %+v", document.Items[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutMutationIdempotency(t *testing.T) {
|
||||
handler, _ := newValuesTestHandler(t)
|
||||
const mutationID = "d615e91d-988f-4928-bb32-8956a26c7608"
|
||||
body := `{"item":{"type":"collection","collection_id":"watchlist","label":"Watchlist"},"present":true}`
|
||||
first := routeNavigationShortcutMutation(t, handler, body, mutationID)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first mutation = %d: %s", first.Code, first.Body.String())
|
||||
}
|
||||
replay := routeNavigationShortcutMutation(t, handler, body, mutationID)
|
||||
if replay.Code != http.StatusOK || replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
|
||||
t.Fatalf("replay = %d header %q: %s", replay.Code, replay.Header().Get("X-Silo-Idempotent-Replay"), replay.Body.String())
|
||||
}
|
||||
if strings.TrimSpace(replay.Body.String()) != strings.TrimSpace(first.Body.String()) {
|
||||
t.Fatalf("replay body = %s, want exact %s", replay.Body.String(), first.Body.String())
|
||||
}
|
||||
|
||||
conflict := routeNavigationShortcutMutation(t, handler,
|
||||
`{"item":{"type":"collection","collection_id":"watchlist","label":"Renamed"},"present":true}`,
|
||||
mutationID)
|
||||
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "mutation_id_conflict") {
|
||||
t.Fatalf("mutation id reuse = %d: %s", conflict.Code, conflict.Body.String())
|
||||
}
|
||||
|
||||
// A remove's label is not part of its semantics or request fingerprint.
|
||||
const removeID = "f2f65f5e-918a-46bf-bee0-65a84491d298"
|
||||
removed := routeNavigationShortcutMutation(t, handler,
|
||||
`{"item":{"type":"collection","collection_id":"watchlist","label":"First"},"present":false}`,
|
||||
removeID)
|
||||
removeReplay := routeNavigationShortcutMutation(t, handler,
|
||||
`{"item":{"type":"collection","collection_id":"watchlist","label":"Second"},"present":false}`,
|
||||
removeID)
|
||||
if removed.Code != http.StatusOK || removeReplay.Code != http.StatusOK || removeReplay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
|
||||
t.Fatalf("semantic remove replay = %d/%d header %q", removed.Code, removeReplay.Code, removeReplay.Header().Get("X-Silo-Idempotent-Replay"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutMutationThroughNotificationWrappedProvider(t *testing.T) {
|
||||
baseHandler, store := newValuesTestHandler(t)
|
||||
provider := notifications.WrapUserStoreProvider(
|
||||
testUserStoreProvider{store: store},
|
||||
¬ifications.System{},
|
||||
)
|
||||
handler := NewSettingValuesHandler(provider, baseHandler.contract)
|
||||
const mutationID = "wrapped-provider-mutation"
|
||||
body := `{"item":{"type":"collection","collection_id":"watchlist","label":"Watchlist"},"present":true}`
|
||||
|
||||
first := routeNavigationShortcutMutation(t, handler, body, mutationID)
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("wrapped first mutation = %d: %s", first.Code, first.Body.String())
|
||||
}
|
||||
replay := routeNavigationShortcutMutation(t, handler, body, mutationID)
|
||||
if replay.Code != http.StatusOK || replay.Header().Get("X-Silo-Idempotent-Replay") != "true" {
|
||||
t.Fatalf("wrapped replay = %d header %q: %s",
|
||||
replay.Code, replay.Header().Get("X-Silo-Idempotent-Replay"), replay.Body.String())
|
||||
}
|
||||
if !bytes.Equal(bytes.TrimSpace(first.Body.Bytes()), bytes.TrimSpace(replay.Body.Bytes())) {
|
||||
t.Fatalf("wrapped replay body = %s, want %s", replay.Body.String(), first.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutConcurrentSameMutationID(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
bodies []string
|
||||
wantConflicts int
|
||||
}{
|
||||
{
|
||||
name: "same hash replays one receipt",
|
||||
bodies: []string{
|
||||
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
|
||||
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "different hash conflicts before CAS",
|
||||
bodies: []string{
|
||||
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
|
||||
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`,
|
||||
},
|
||||
wantConflicts: 1,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
const mutationID = "concurrent-same-id"
|
||||
start := make(chan struct{})
|
||||
responses := make(chan *httptest.ResponseRecorder, len(tc.bodies))
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(len(tc.bodies))
|
||||
for _, body := range tc.bodies {
|
||||
body := body
|
||||
go func() {
|
||||
ready.Done()
|
||||
<-start
|
||||
responses <- routeNavigationShortcutMutation(t, handler, body, mutationID)
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
|
||||
conflicts := 0
|
||||
replays := 0
|
||||
var successes [][]byte
|
||||
for range tc.bodies {
|
||||
rec := <-responses
|
||||
switch rec.Code {
|
||||
case http.StatusOK:
|
||||
successes = append(successes, bytes.TrimSpace(rec.Body.Bytes()))
|
||||
if rec.Header().Get("X-Silo-Idempotent-Replay") == "true" {
|
||||
replays++
|
||||
}
|
||||
case http.StatusConflict:
|
||||
if !strings.Contains(rec.Body.String(), "mutation_id_conflict") {
|
||||
t.Fatalf("conflict = %s, want mutation_id_conflict", rec.Body.String())
|
||||
}
|
||||
conflicts++
|
||||
default:
|
||||
t.Fatalf("concurrent mutation = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
if conflicts != tc.wantConflicts {
|
||||
t.Fatalf("conflicts = %d, want %d", conflicts, tc.wantConflicts)
|
||||
}
|
||||
if tc.wantConflicts == 0 {
|
||||
if len(successes) != 2 || replays != 1 || !bytes.Equal(successes[0], successes[1]) {
|
||||
t.Fatalf("same-hash outcomes: successes=%d replays=%d bodies=%q", len(successes), replays, successes)
|
||||
}
|
||||
} else if len(successes) != 1 || replays != 0 {
|
||||
t.Fatalf("different-hash outcomes: successes=%d replays=%d", len(successes), replays)
|
||||
}
|
||||
|
||||
stored, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
|
||||
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
})
|
||||
if err != nil || stored == nil || stored.Revision != 1 {
|
||||
t.Fatalf("stored shortcut = %+v (%v), want exactly one CAS revision", stored, err)
|
||||
}
|
||||
receipt, err := store.GetSettingMutation(context.Background(), mutationID)
|
||||
if err != nil || receipt == nil {
|
||||
t.Fatalf("stored receipt = %+v (%v)", receipt, err)
|
||||
}
|
||||
if len(successes) > 0 && !bytes.Equal(successes[0], bytes.TrimSpace(receipt.Result)) {
|
||||
t.Fatalf("success = %s, receipt = %s", successes[0], receipt.Result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutMutationValidationAndWholeDocumentGuard(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
|
||||
wholeDocument := routeValues(t, handler, http.MethodPut, settingskeys.NavShortcuts,
|
||||
"scope=profile", []byte(`{"value":{"items":[]}}`))
|
||||
if wholeDocument.Code != http.StatusBadRequest || !strings.Contains(wholeDocument.Body.String(), "atomic_update_required") {
|
||||
t.Fatalf("whole-document PUT = %d: %s", wholeDocument.Code, wholeDocument.Body.String())
|
||||
}
|
||||
if value, err := store.GetSettingValue(context.Background(), userstore.SettingIdentity{
|
||||
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
}); err != nil || value != nil {
|
||||
t.Fatalf("guarded PUT stored %+v, err=%v", value, err)
|
||||
}
|
||||
|
||||
invalidBodies := []string{
|
||||
`{"item":{"type":"builtin","destination":"home","label":"Home"},"present":true}`,
|
||||
`{"item":{"type":"library","library_id":42,"label":"Movies","extra":true},"present":true}`,
|
||||
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true,"extra":true}`,
|
||||
`{"item":{"type":"section","library_id":42,"section_id":"","label":"Recent"},"present":true}`,
|
||||
`{"item":{"type":"collection","collection_id":"favorites","label":" "},"present":true}`,
|
||||
`{"item":{"type":"library","library_id":0,"label":"Movies"},"present":true}`,
|
||||
`{"item":{"type":"library","library_id":42,"label":"Movies"}}`,
|
||||
}
|
||||
for _, body := range invalidBodies {
|
||||
rec := routeNavigationShortcutMutation(t, handler, body, "")
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("invalid body %s = %d: %s", body, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutWholeDocumentDeleteCannotCreateRevisionABA(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
first := routeNavigationShortcutMutation(t, handler,
|
||||
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`, "")
|
||||
firstResponse, _ := decodeShortcutResponse(t, first)
|
||||
if first.Code != http.StatusOK || firstResponse.Revision != 1 {
|
||||
t.Fatalf("seed shortcut = %d revision %d: %s", first.Code, firstResponse.Revision, first.Body.String())
|
||||
}
|
||||
|
||||
identity := userstore.SettingIdentity{
|
||||
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
}
|
||||
beforeDelete, err := store.GetSettingValue(context.Background(), identity)
|
||||
if err != nil || beforeDelete == nil {
|
||||
t.Fatalf("read shortcut before guarded delete: value=%+v err=%v", beforeDelete, err)
|
||||
}
|
||||
deleteRec := routeValues(t, handler, http.MethodDelete, settingskeys.NavShortcuts,
|
||||
"scope=profile", nil)
|
||||
if deleteRec.Code != http.StatusBadRequest || !strings.Contains(deleteRec.Body.String(), "atomic_update_required") {
|
||||
t.Fatalf("whole-document DELETE = %d: %s", deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
|
||||
second := routeNavigationShortcutMutation(t, handler,
|
||||
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`, "")
|
||||
secondResponse, secondDocument := decodeShortcutResponse(t, second)
|
||||
if second.Code != http.StatusOK || secondResponse.Revision != 2 || len(secondDocument.Items) != 2 {
|
||||
t.Fatalf("second shortcut = %d revision %d document %+v", second.Code, secondResponse.Revision, secondDocument)
|
||||
}
|
||||
|
||||
// If session DELETE had removed and recreated the row, its revision would
|
||||
// return to one and this stale writer could pass an ABA-shaped precondition.
|
||||
// Keeping the row alive makes the stale revision conflict instead.
|
||||
cas := store.(userstore.SettingValueCompareAndSetter)
|
||||
if _, err := cas.CompareAndSetSettingValue(context.Background(), identity,
|
||||
beforeDelete.Value, beforeDelete.Revision); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
|
||||
t.Fatalf("stale CAS after guarded DELETE error = %v, want revision conflict", err)
|
||||
}
|
||||
final, err := store.GetSettingValue(context.Background(), identity)
|
||||
if err != nil || final == nil || final.Revision != 2 {
|
||||
t.Fatalf("final shortcut row = %+v err=%v, want revision 2", final, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutMutationEnforcesDocumentCap(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
items := make([]navigationShortcutItem, 0, 256)
|
||||
for id := 1; id <= 256; id++ {
|
||||
libraryID := id
|
||||
items = append(items, navigationShortcutItem{Type: "library", LibraryID: &libraryID, Label: fmt.Sprintf("Library %d", id)})
|
||||
}
|
||||
raw, err := json.Marshal(navigationShortcutDocument{Items: items})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
def, _ := handler.contract.Lookup(settingskeys.NavShortcuts)
|
||||
normalized, err := def.ValueSchema.NormalizeValue(raw, settingscontract.ObjectSchemas())
|
||||
if err != nil {
|
||||
t.Fatalf("normalize seed: %v", err)
|
||||
}
|
||||
identity := userstore.SettingIdentity{
|
||||
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
}
|
||||
seed, err := store.UpsertSettingValue(context.Background(), identity, normalized)
|
||||
if err != nil {
|
||||
t.Fatalf("seed full catalog: %v", err)
|
||||
}
|
||||
|
||||
rec := routeNavigationShortcutMutation(t, handler,
|
||||
`{"item":{"type":"library","library_id":257,"label":"Too Many"},"present":true}`, "")
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "invalid_value") {
|
||||
t.Fatalf("257th shortcut = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got, err := store.GetSettingValue(context.Background(), identity)
|
||||
if err != nil || got == nil || got.Revision != seed.Revision {
|
||||
t.Fatalf("catalog after rejected add = %+v, err=%v; want unchanged revision %d", got, err, seed.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
type synchronizedShortcutStore struct {
|
||||
userstore.UserStore
|
||||
cas userstore.SettingValueCompareAndSetter
|
||||
reads atomic.Int32
|
||||
ready chan struct{}
|
||||
once sync.Once
|
||||
casMu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *synchronizedShortcutStore) GetSettingValue(ctx context.Context, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
|
||||
value, err := s.UserStore.GetSettingValue(ctx, id)
|
||||
if err == nil && id.Key == settingskeys.NavShortcuts {
|
||||
read := s.reads.Add(1)
|
||||
if read <= 2 {
|
||||
if read == 2 {
|
||||
s.once.Do(func() { close(s.ready) })
|
||||
}
|
||||
<-s.ready
|
||||
}
|
||||
}
|
||||
return value, err
|
||||
}
|
||||
|
||||
func (s *synchronizedShortcutStore) CompareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*userstore.SettingValue, error) {
|
||||
s.casMu.Lock()
|
||||
defer s.casMu.Unlock()
|
||||
return s.cas.CompareAndSetSettingValue(ctx, id, value, expectedRevision)
|
||||
}
|
||||
|
||||
func TestNavigationShortcutConcurrentAddsMerge(t *testing.T) {
|
||||
baseHandler, baseStore := newValuesTestHandler(t)
|
||||
wrapped := &synchronizedShortcutStore{
|
||||
UserStore: baseStore,
|
||||
cas: baseStore.(userstore.SettingValueCompareAndSetter),
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
handler := NewSettingValuesHandler(testUserStoreProvider{store: wrapped}, baseHandler.contract)
|
||||
bodies := []string{
|
||||
`{"item":{"type":"library","library_id":42,"label":"Movies"},"present":true}`,
|
||||
`{"item":{"type":"library","library_id":99,"label":"Shows"},"present":true}`,
|
||||
}
|
||||
|
||||
responses := make(chan *httptest.ResponseRecorder, len(bodies))
|
||||
for _, body := range bodies {
|
||||
body := body
|
||||
go func() {
|
||||
responses <- routeNavigationShortcutMutation(t, handler, body, "")
|
||||
}()
|
||||
}
|
||||
for range bodies {
|
||||
rec := <-responses
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("concurrent add = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
got, err := baseStore.GetSettingValue(context.Background(), userstore.SettingIdentity{
|
||||
Key: settingskeys.NavShortcuts, Scope: settingscontract.ScopeProfile, ProfileID: "profile-1",
|
||||
})
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("read merged catalog: value=%+v err=%v", got, err)
|
||||
}
|
||||
var document navigationShortcutDocument
|
||||
if err := json.Unmarshal(got.Value, &document); err != nil {
|
||||
t.Fatalf("decode merged catalog: %v", err)
|
||||
}
|
||||
if got.Revision != 2 || len(document.Items) != 2 {
|
||||
t.Fatalf("merged catalog = revision %d items %+v, want both adds at revision 2", got.Revision, document.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingValuesRoundTrip(t *testing.T) {
|
||||
handler, _ := newValuesTestHandler(t)
|
||||
|
||||
@@ -131,6 +632,161 @@ func TestSettingValuesRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileClientValueUsesExplicitFamilyHeader(t *testing.T) {
|
||||
handler, _ := newValuesTestHandler(t)
|
||||
key := settingskeys.NavPrimaryMenu
|
||||
body := []byte(`{"value":{"items":[{"type":"builtin","destination":"home"},` +
|
||||
`{"type":"library","library_id":42,"label":"Movies"}]}}`)
|
||||
|
||||
rec := routeValues(t, handler, http.MethodPut, key, "scope=profile_client", body)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("PUT profile_client = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var stored settingValueResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &stored); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if stored.Scope != "profile_client" || stored.ClientFamily != "web" {
|
||||
t.Fatalf("stored scope/family = %q/%q, want profile_client/web", stored.Scope, stored.ClientFamily)
|
||||
}
|
||||
var menu navigationShortcutDocument
|
||||
if err := json.Unmarshal(stored.Value, &menu); err != nil {
|
||||
t.Fatalf("decoding stored primary menu: %v", err)
|
||||
}
|
||||
if len(menu.Items) != 2 || menu.Items[1].Label != "Movies" {
|
||||
t.Fatalf("stored non-builtin menu item = %+v, want labeled Movies library", menu.Items)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
header string
|
||||
}{
|
||||
{"missing", ""},
|
||||
{"not canonical", "TV"},
|
||||
{"unknown", "car"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := valuesRequest(http.MethodPut,
|
||||
"/settings/values/"+key+"?scope=profile_client", body)
|
||||
if tc.header == "" {
|
||||
req.Header.Del(clientFamilyHeader)
|
||||
} else {
|
||||
req.Header.Set(clientFamilyHeader, tc.header)
|
||||
}
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("key", key)
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleSetValue(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("PUT with family %q = %d, want 400: %s", tc.header, rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationHashPreservesExistingScopesAndSeparatesClientFamilies(t *testing.T) {
|
||||
identity := userstore.SettingIdentity{
|
||||
Key: "playback.subtitle_mode", Scope: settingscontract.ScopeProfileDevice,
|
||||
ProfileID: "profile-1", DeviceID: "device-1",
|
||||
}
|
||||
value := json.RawMessage(`"always"`)
|
||||
legacyPayload := append([]byte(
|
||||
"playback.subtitle_mode\x00profile_device\x00profile-1\x00device-1\x000\x00\x00",
|
||||
), value...)
|
||||
want := sha256.Sum256(legacyPayload)
|
||||
if got := hashMutationRequest(identity, value); got != hex.EncodeToString(want[:]) {
|
||||
t.Fatalf("existing-scope mutation hash changed across the profile_client rollout: %s", got)
|
||||
}
|
||||
|
||||
client := userstore.SettingIdentity{
|
||||
Key: settingskeys.NavPrimaryMenu, Scope: settingscontract.ScopeProfileClient,
|
||||
ProfileID: "profile-1", ClientFamily: settingscontract.ClientFamilyTV,
|
||||
}
|
||||
tv := hashMutationRequest(client, json.RawMessage(`null`))
|
||||
client.ClientFamily = settingscontract.ClientFamilyMobile
|
||||
if mobile := hashMutationRequest(client, json.RawMessage(`null`)); mobile == tv {
|
||||
t.Fatal("profile_client mutation hashes do not distinguish client families")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveClientFamilyIsOptionalButValidated(t *testing.T) {
|
||||
handler, _ := newValuesTestHandler(t)
|
||||
key := settingskeys.UiCardPresentation
|
||||
if rec := routeValues(t, handler, http.MethodPut, key, "scope=profile",
|
||||
[]byte(`{"value":{"poster_size":"compact","caption":"title"}}`)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("seed profile fallback = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec := routeValues(t, handler, http.MethodPut, key, "scope=profile_client",
|
||||
[]byte(`{"value":{"poster_size":"large","caption":"artwork"}}`)); rec.Code != http.StatusOK {
|
||||
t.Fatalf("seed family value = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
effective := func(t *testing.T, family *string) (int, effectiveSettingValueResponse, string) {
|
||||
t.Helper()
|
||||
req := valuesRequest(http.MethodGet, "/settings/values/effective?keys="+key, nil)
|
||||
if family == nil {
|
||||
req.Header.Del(clientFamilyHeader)
|
||||
} else {
|
||||
req.Header.Set(clientFamilyHeader, *family)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleGetEffective(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
return rec.Code, effectiveSettingValueResponse{}, rec.Body.String()
|
||||
}
|
||||
var body struct {
|
||||
Settings []effectiveSettingValueResponse `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode effective response: %v", err)
|
||||
}
|
||||
if len(body.Settings) != 1 {
|
||||
t.Fatalf("settings = %d, want 1", len(body.Settings))
|
||||
}
|
||||
return rec.Code, body.Settings[0], rec.Body.String()
|
||||
}
|
||||
|
||||
t.Run("absent skips profile client layer", func(t *testing.T) {
|
||||
code, got, body := effective(t, nil)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("effective without client family = %d: %s", code, body)
|
||||
}
|
||||
if got.Source != string(settingscontract.ScopeProfile) || string(got.Value) != `{"poster_size":"compact","caption":"title"}` {
|
||||
t.Fatalf("effective without family = %s from %s, want profile fallback", got.Value, got.Source)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("absent remains compatible with resolve all", func(t *testing.T) {
|
||||
req := valuesRequest(http.MethodGet, "/settings/values/effective", nil)
|
||||
req.Header.Del(clientFamilyHeader)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleGetEffective(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("resolve all without client family = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid selects profile client layer", func(t *testing.T) {
|
||||
family := "web"
|
||||
code, got, body := effective(t, &family)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("effective with client family = %d: %s", code, body)
|
||||
}
|
||||
if got.Source != string(settingscontract.ScopeProfileClient) || got.ClientFamily != family {
|
||||
t.Fatalf("effective with family = %s/%s, want profile_client/web", got.Source, got.ClientFamily)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nonempty invalid is rejected", func(t *testing.T) {
|
||||
family := "TV"
|
||||
code, _, _ := effective(t, &family)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("effective with invalid client family = %d, want 400", code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSettingValuesReportsSetAndUnsetAtOneScope(t *testing.T) {
|
||||
handler, store := newValuesTestHandler(t)
|
||||
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
|
||||
@@ -697,12 +1353,34 @@ type failingUpsertStore struct {
|
||||
userstore.UserStore
|
||||
}
|
||||
|
||||
type failingUpsertMutationWriter struct {
|
||||
userstore.SettingMutationWriter
|
||||
}
|
||||
|
||||
func (failingUpsertStore) UpsertSettingValue(
|
||||
context.Context, userstore.SettingIdentity, json.RawMessage,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return nil, errors.New("simulated write failure")
|
||||
}
|
||||
|
||||
func (s failingUpsertStore) WithSettingMutationTransaction(
|
||||
ctx context.Context,
|
||||
mutationID string,
|
||||
fn func(userstore.SettingMutationWriter) error,
|
||||
) error {
|
||||
transactioner := s.UserStore.(userstore.SettingMutationTransactioner)
|
||||
return transactioner.WithSettingMutationTransaction(ctx, mutationID,
|
||||
func(writer userstore.SettingMutationWriter) error {
|
||||
return fn(failingUpsertMutationWriter{SettingMutationWriter: writer})
|
||||
})
|
||||
}
|
||||
|
||||
func (failingUpsertMutationWriter) UpsertSettingValue(
|
||||
context.Context, userstore.SettingIdentity, json.RawMessage,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return nil, errors.New("simulated write failure")
|
||||
}
|
||||
|
||||
// TestFailedWritesLeaveNoReceipt: a receipt for a write that never landed
|
||||
// would turn the client's retry of a 500 into a silent success replay.
|
||||
func TestFailedWritesLeaveNoReceipt(t *testing.T) {
|
||||
@@ -815,9 +1493,10 @@ func TestCapabilitiesReportTheContractRevision(t *testing.T) {
|
||||
}
|
||||
|
||||
var body struct {
|
||||
APIVersion int `json:"api_version"`
|
||||
Revision int `json:"revision"`
|
||||
Scopes []string `json:"scopes"`
|
||||
APIVersion int `json:"api_version"`
|
||||
Revision int `json:"revision"`
|
||||
Scopes []string `json:"scopes"`
|
||||
ClientFamilies []string `json:"client_families"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decoding: %v", err)
|
||||
@@ -827,8 +1506,15 @@ func TestCapabilitiesReportTheContractRevision(t *testing.T) {
|
||||
t.Errorf("reported %d/%d, want %d/%d",
|
||||
body.APIVersion, body.Revision, contract.APIVersion, contract.Revision)
|
||||
}
|
||||
if len(body.Scopes) != 5 {
|
||||
t.Errorf("reported %d scopes, want 5", len(body.Scopes))
|
||||
wantScopes := []string{
|
||||
"account", "profile", "profile_client", "profile_device", "profile_library", "profile_series",
|
||||
}
|
||||
if !slices.Equal(body.Scopes, wantScopes) {
|
||||
t.Errorf("reported scopes %v, want %v", body.Scopes, wantScopes)
|
||||
}
|
||||
wantFamilies := []string{"tv", "mobile", "tablet", "desktop", "web"}
|
||||
if !slices.Equal(body.ClientFamilies, wantFamilies) {
|
||||
t.Errorf("reported client families %v, want %v", body.ClientFamilies, wantFamilies)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,37 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// New subtitle artifact URLs bind downloaded subtitles by their stable row
|
||||
// identity. The path ordinal remains for compatibility and display, but it
|
||||
// must not be re-resolved against a mutable inventory after a seek reanchor.
|
||||
if rawID := strings.TrimSpace(r.URL.Query().Get(downloadedSubtitleIDParam)); rawID != "" {
|
||||
downloadedID, parseErr := strconv.Atoi(rawID)
|
||||
if parseErr != nil || downloadedID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid downloaded subtitle identity")
|
||||
return
|
||||
}
|
||||
if h.SubtitleRepo == nil || h.S3Client == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
|
||||
return
|
||||
}
|
||||
downloaded, lookupErr := h.SubtitleRepo.GetDownloadedSubtitle(r.Context(), downloadedID)
|
||||
if lookupErr != nil {
|
||||
slog.ErrorContext(r.Context(), "get downloaded subtitle failed", "component", "api",
|
||||
"file_id", file.ID,
|
||||
"downloaded_subtitle_id", downloadedID,
|
||||
"error", lookupErr,
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load downloaded subtitle")
|
||||
return
|
||||
}
|
||||
if downloaded == nil || downloaded.MediaFileID != file.ID {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
|
||||
return
|
||||
}
|
||||
h.serveDownloadedSubtitle(w, r, *downloaded, requestedFormat)
|
||||
return
|
||||
}
|
||||
|
||||
externalCount := len(file.ExternalSubtitles)
|
||||
if trackIndex < externalCount {
|
||||
sub := file.ExternalSubtitles[trackIndex]
|
||||
@@ -287,32 +318,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
downloadedIndex := embeddedIndex - len(file.SubtitleTracks)
|
||||
if downloadedIndex >= 0 && downloadedIndex < len(downloaded) {
|
||||
dl := downloaded[downloadedIndex]
|
||||
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, dl.S3Key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
|
||||
return
|
||||
}
|
||||
|
||||
// Serve ASS/SSA downloaded subtitles as raw data.
|
||||
if playback.IsASS(string(dl.Format)) && requestedFormat != "vtt" {
|
||||
playback.ServeSubtitle(w, data, "ass")
|
||||
return
|
||||
}
|
||||
|
||||
// If the subtitle is already VTT, serve directly.
|
||||
if dl.Format == subtitles.FormatVTT {
|
||||
playback.ServeSubtitle(w, data, "vtt")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to VTT using the playback conversion pipeline.
|
||||
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(dl.Format), h.ffmpegPath())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
|
||||
return
|
||||
}
|
||||
playback.ServeSubtitle(w, vttData, "vtt")
|
||||
h.serveDownloadedSubtitle(w, r, downloaded[downloadedIndex], requestedFormat)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -320,6 +326,34 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
|
||||
}
|
||||
|
||||
func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.Request, subtitle subtitles.DownloadedSubtitle, requestedFormat string) {
|
||||
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, subtitle.S3Key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
|
||||
return
|
||||
}
|
||||
|
||||
// Serve ASS/SSA downloaded subtitles as raw data.
|
||||
if playback.IsASS(string(subtitle.Format)) && requestedFormat != "vtt" {
|
||||
playback.ServeSubtitle(w, data, "ass")
|
||||
return
|
||||
}
|
||||
|
||||
// If the subtitle is already VTT, serve directly.
|
||||
if subtitle.Format == subtitles.FormatVTT {
|
||||
playback.ServeSubtitle(w, data, "vtt")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert other text formats to VTT using the playback conversion pipeline.
|
||||
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(subtitle.Format), h.ffmpegPath())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
|
||||
return
|
||||
}
|
||||
playback.ServeSubtitle(w, vttData, "vtt")
|
||||
}
|
||||
|
||||
// subtitleSourceFileID pins a subtitle URL to the file whose track list was
|
||||
// used to create it. A quality/seek restart may change session.MediaFileID to
|
||||
// an alternate version; interpreting the old combined track index against the
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
type hookedSessionManager struct {
|
||||
@@ -246,6 +248,53 @@ func TestHandleSubtitle_ListDownloadedSubtitlesErrorReturns500(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubtitleUsesBoundDownloadedIdentityAfterInventoryReorder(t *testing.T) {
|
||||
file := &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: "/tmp/movie.mkv", Duration: 3600}
|
||||
baseMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"}
|
||||
// The mutable ordinal now points at a different subtitle. An ID-bound URL
|
||||
// must still fetch 71 without consulting this reordered list.
|
||||
repo.list = []subtitles.DownloadedSubtitle{
|
||||
{ID: 72, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "other-72.vtt"},
|
||||
{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"},
|
||||
}
|
||||
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
|
||||
handler.SubtitleRepo = repo
|
||||
handler.S3Client = subtitleContentS3Client{objects: map[string][]byte{
|
||||
"selected-71.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nselected-71\n"),
|
||||
"other-72.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nother-72\n"),
|
||||
}}
|
||||
handler.S3Bucket = "test-bucket"
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71", nil)
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("session_id", session.ID)
|
||||
routeCtx.URLParams.Add("track", "0.vtt")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSubtitle(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "selected-71") || strings.Contains(rr.Body.String(), "other-72") {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type subtitleContentS3Client struct {
|
||||
objects map[string][]byte
|
||||
}
|
||||
|
||||
func (subtitleContentS3Client) PutObject(context.Context, string, string, []byte) error { return nil }
|
||||
func (c subtitleContentS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
|
||||
return append([]byte(nil), c.objects[key]...), nil
|
||||
}
|
||||
func (subtitleContentS3Client) DeleteObject(context.Context, string, string) error { return nil }
|
||||
|
||||
func TestHandleSubtitle_NilMediaFileReturns404(t *testing.T) {
|
||||
baseMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
|
||||
|
||||
@@ -239,9 +239,15 @@ type handlerMockSubtitleRepo struct {
|
||||
subtitles map[int]*subtitles.DownloadedSubtitle
|
||||
nextID int
|
||||
byKey map[string]*subtitles.DownloadedSubtitle
|
||||
// getErr, when set, is returned by GetDownloadedSubtitle to simulate a
|
||||
// backing-store failure.
|
||||
getErr error
|
||||
// listErr, when set, is returned by ListDownloadedSubtitles to simulate a
|
||||
// backing-store failure.
|
||||
listErr error
|
||||
listErr error
|
||||
list []subtitles.DownloadedSubtitle
|
||||
listResults [][]subtitles.DownloadedSubtitle
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
|
||||
@@ -260,6 +266,9 @@ func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, su
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
if sub, ok := m.subtitles[id]; ok {
|
||||
copy := *sub
|
||||
return ©, nil
|
||||
@@ -268,10 +277,15 @@ func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id in
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
|
||||
m.listCalls++
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
return nil, nil
|
||||
if len(m.listResults) > 0 {
|
||||
index := min(m.listCalls-1, len(m.listResults)-1)
|
||||
return append([]subtitles.DownloadedSubtitle(nil), m.listResults[index]...), nil
|
||||
}
|
||||
return append([]subtitles.DownloadedSubtitle(nil), m.list...), nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/buildinfo"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
@@ -28,24 +29,85 @@ func NewSystemHandler(transcodePool *nodepool.TranscodePool, jwtSecret string, f
|
||||
}
|
||||
}
|
||||
|
||||
// NodeHWAccel reports one transcode node's GPU inventory.
|
||||
type NodeHWAccel struct {
|
||||
NodeURL string `json:"node_url"`
|
||||
NodeName string `json:"node_name,omitempty"`
|
||||
Resolved string `json:"resolved,omitempty"`
|
||||
RenderDevices []string `json:"render_devices,omitempty"`
|
||||
RenderDeviceDetails []playback.RenderDeviceInfo `json:"render_device_details,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// HWAccelInventory is the admin hw-accel response: the primary probe (flat,
|
||||
// backward compatible with the previous single-probe response) plus every
|
||||
// healthy node's inventory. playback.hw_device is one cluster-wide value, so
|
||||
// the UI needs all node inventories to warn when they diverge (devices must
|
||||
// exist at identical paths on every node for the setting to be safe).
|
||||
type HWAccelInventory struct {
|
||||
playback.HWAccelInfo
|
||||
Nodes []NodeHWAccel `json:"nodes,omitempty"`
|
||||
}
|
||||
|
||||
// HandleHWAccel handles GET /admin/system/hw-accel.
|
||||
// When transcode nodes are registered it delegates to the first healthy node.
|
||||
// Otherwise it probes the local host.
|
||||
// With transcode nodes registered it probes every healthy node (the primary
|
||||
// flat fields come from the first that responds, preserving the historical
|
||||
// shape); with none it probes the local host.
|
||||
func (h *SystemHandler) HandleHWAccel(w http.ResponseWriter, r *http.Request) {
|
||||
var healthy []*nodepool.Node
|
||||
if h.transcodePool != nil {
|
||||
if node := h.transcodePool.Acquire(); node != nil {
|
||||
info, err := h.fetchRemoteHWAccel(r.Context(), node)
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
return
|
||||
for _, node := range h.transcodePool.Nodes() {
|
||||
if node != nil && node.Enabled && node.Healthy {
|
||||
healthy = append(healthy, node)
|
||||
}
|
||||
slog.WarnContext(r.Context(), "hw-accel: remote node probe failed, falling back to local", "component", "api",
|
||||
"node", node.URL, "error", err)
|
||||
}
|
||||
}
|
||||
if len(healthy) == 0 {
|
||||
writeJSON(w, http.StatusOK, HWAccelInventory{HWAccelInfo: playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)})
|
||||
return
|
||||
}
|
||||
|
||||
info := playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
inventory := HWAccelInventory{Nodes: make([]NodeHWAccel, len(healthy))}
|
||||
infos := make([]playback.HWAccelInfo, len(healthy))
|
||||
errs := make([]error, len(healthy))
|
||||
var wg sync.WaitGroup
|
||||
for i, node := range healthy {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for i, node := range healthy {
|
||||
entry := NodeHWAccel{NodeURL: node.URL, NodeName: node.Name}
|
||||
if errs[i] != nil {
|
||||
slog.WarnContext(r.Context(), "hw-accel: node probe failed", "component", "api",
|
||||
"node", node.URL, "error", errs[i])
|
||||
entry.Error = errs[i].Error()
|
||||
} else {
|
||||
entry.Resolved = infos[i].Resolved
|
||||
entry.RenderDevices = infos[i].RenderDevices
|
||||
entry.RenderDeviceDetails = infos[i].RenderDeviceDetails
|
||||
}
|
||||
inventory.Nodes[i] = entry
|
||||
}
|
||||
|
||||
// Primary flat fields: the first node that answered; if none did, fall
|
||||
// back to a local probe (matching the historical fallback behavior).
|
||||
primaried := false
|
||||
for i := range healthy {
|
||||
if errs[i] == nil {
|
||||
inventory.HWAccelInfo = infos[i]
|
||||
primaried = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !primaried {
|
||||
inventory.HWAccelInfo = playback.DetectHWAccelWithFFmpeg(h.ffmpegPath)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, inventory)
|
||||
}
|
||||
|
||||
// HandleBuildInfo handles GET /admin/system/build.
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/buildinfo"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
func TestSystemBuildInfoResponse(t *testing.T) {
|
||||
@@ -86,3 +88,111 @@ func TestSystemBuildInfoUnavailableResponseShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHWAccelAggregatesAllHealthyNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/hw-capabilities" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(playback.HWAccelInfo{
|
||||
Resolved: "qsv",
|
||||
RenderDevices: []string{"/dev/dri/renderD128", "/dev/dri/renderD129"},
|
||||
RenderDeviceDetails: []playback.RenderDeviceInfo{
|
||||
{Path: "/dev/dri/renderD128", Description: "Intel GPU"},
|
||||
{Path: "/dev/dri/renderD129", Description: "Intel GPU"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer nodeA.Close()
|
||||
nodeB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(playback.HWAccelInfo{
|
||||
Resolved: "vaapi",
|
||||
RenderDevices: []string{"/dev/dri/renderD128"},
|
||||
RenderDeviceDetails: []playback.RenderDeviceInfo{
|
||||
{Path: "/dev/dri/renderD128", Description: "AMD GPU"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer nodeB.Close()
|
||||
nodeDown := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer nodeDown.Close()
|
||||
|
||||
pool := nodepool.NewTranscodePool()
|
||||
pool.SetNodes([]*nodepool.Node{
|
||||
{ID: 1, Name: "node-a", URL: nodeA.URL, Enabled: true, Healthy: true},
|
||||
{ID: 2, Name: "node-b", URL: nodeB.URL, Enabled: true, Healthy: true},
|
||||
{ID: 3, Name: "node-down", URL: nodeDown.URL, Enabled: true, Healthy: true},
|
||||
{ID: 4, Name: "node-unhealthy", URL: "http://unreachable.invalid", Enabled: true, Healthy: false},
|
||||
})
|
||||
handler := &SystemHandler{transcodePool: pool, jwtSecret: "secret"}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/system/hw-accel", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleHWAccel(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got HWAccelInventory
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
|
||||
// Flat primary fields keep the historical single-probe shape, sourced
|
||||
// from the first healthy node that answered.
|
||||
if got.Resolved != "qsv" || got.Source != "transcode_node" || got.NodeURL != nodeA.URL {
|
||||
t.Fatalf("primary = resolved %q source %q node %q, want first node's probe", got.Resolved, got.Source, got.NodeURL)
|
||||
}
|
||||
|
||||
// Every healthy node appears, in pool order; the unhealthy one does not.
|
||||
if len(got.Nodes) != 3 {
|
||||
t.Fatalf("nodes len = %d, want 3: %+v", len(got.Nodes), got.Nodes)
|
||||
}
|
||||
if got.Nodes[0].NodeName != "node-a" || got.Nodes[0].Resolved != "qsv" || len(got.Nodes[0].RenderDeviceDetails) != 2 {
|
||||
t.Fatalf("node-a entry = %+v", got.Nodes[0])
|
||||
}
|
||||
if got.Nodes[1].NodeName != "node-b" || got.Nodes[1].Resolved != "vaapi" || len(got.Nodes[1].RenderDevices) != 1 {
|
||||
t.Fatalf("node-b entry = %+v", got.Nodes[1])
|
||||
}
|
||||
if got.Nodes[2].NodeName != "node-down" || got.Nodes[2].Error == "" {
|
||||
t.Fatalf("failed node entry = %+v, want populated error", got.Nodes[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHWAccelAllNodeProbesFailFallsBackLocal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
nodeDown := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer nodeDown.Close()
|
||||
|
||||
pool := nodepool.NewTranscodePool()
|
||||
pool.SetNodes([]*nodepool.Node{
|
||||
{ID: 1, Name: "node-down", URL: nodeDown.URL, Enabled: true, Healthy: true},
|
||||
})
|
||||
handler := &SystemHandler{transcodePool: pool, jwtSecret: "secret"}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/system/hw-accel", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleHWAccel(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got HWAccelInventory
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decoding response: %v", err)
|
||||
}
|
||||
if got.Source != "local" {
|
||||
t.Fatalf("source = %q, want local fallback when every node probe fails", got.Source)
|
||||
}
|
||||
if len(got.Nodes) != 1 || got.Nodes[0].Error == "" {
|
||||
t.Fatalf("nodes = %+v, want the failing node listed with its error", got.Nodes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||||
@@ -15,7 +16,7 @@ type WatchProviderService interface {
|
||||
ListProviders() []watchsync.ProviderSummary
|
||||
StartDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string) (watchsync.DeviceAuthSession, error)
|
||||
PollDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string, sessionID string) (watchsync.Connection, error)
|
||||
ConnectAPIKey(ctx context.Context, userID int, profileID string, providerKey string, apiKey string) (watchsync.Connection, error)
|
||||
ConnectAPIKeyWithConfig(ctx context.Context, userID int, profileID string, providerKey string, apiKey string, connectionConfig watchsync.ConnectionConfigValues) (watchsync.Connection, error)
|
||||
GetConnectionStatus(ctx context.Context, userID int, profileID string, provider string) (watchsync.ConnectionStatus, error)
|
||||
UpdateConnection(ctx context.Context, userID int, profileID string, provider string, update watchsync.ConnectionUpdate) (watchsync.ConnectionStatus, error)
|
||||
DeleteConnection(ctx context.Context, userID int, profileID string, provider string) error
|
||||
@@ -27,6 +28,15 @@ type WatchProviderHandler struct {
|
||||
service WatchProviderService
|
||||
}
|
||||
|
||||
type watchProviderDeviceAuthResponse struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURL string `json:"verification_url"`
|
||||
IntervalSeconds int `json:"interval_seconds"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func NewWatchProviderHandler(service WatchProviderService) *WatchProviderHandler {
|
||||
return &WatchProviderHandler{service: service}
|
||||
}
|
||||
@@ -97,7 +107,14 @@ func (h *WatchProviderHandler) HandleStartDeviceAuth(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
writeJSON(w, http.StatusOK, watchProviderDeviceAuthResponse{
|
||||
ID: session.ID,
|
||||
Provider: session.Provider,
|
||||
UserCode: session.UserCode,
|
||||
VerificationURL: session.VerificationURL,
|
||||
IntervalSeconds: session.IntervalSeconds,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WatchProviderHandler) HandlePollDeviceAuth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -130,13 +147,14 @@ func (h *WatchProviderHandler) HandleConnectAPIKey(w http.ResponseWriter, r *htt
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
APIKey string `json:"api_key"`
|
||||
APIKey string `json:"api_key"`
|
||||
ConnectionConfig watchsync.ConnectionConfigValues `json:"connection_config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if _, err := h.service.ConnectAPIKey(r.Context(), userID, profileID, provider, req.APIKey); err != nil {
|
||||
if _, err := h.service.ConnectAPIKeyWithConfig(r.Context(), userID, profileID, provider, req.APIKey, req.ConnectionConfig); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +25,25 @@ type stubWatchProviderService struct {
|
||||
runs []watchsync.SyncRun
|
||||
}
|
||||
|
||||
type capturingWatchProviderService struct {
|
||||
stubWatchProviderService
|
||||
apiKey string
|
||||
config watchsync.ConnectionConfigValues
|
||||
}
|
||||
|
||||
func (s *capturingWatchProviderService) ConnectAPIKeyWithConfig(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
_ string,
|
||||
apiKey string,
|
||||
config watchsync.ConnectionConfigValues,
|
||||
) (watchsync.Connection, error) {
|
||||
s.apiKey = apiKey
|
||||
s.config = config
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
|
||||
func (s stubWatchProviderService) ListProviders() []watchsync.ProviderSummary {
|
||||
return s.providers
|
||||
}
|
||||
@@ -32,7 +53,7 @@ func (s stubWatchProviderService) StartDeviceAuth(context.Context, int, string,
|
||||
func (s stubWatchProviderService) PollDeviceAuth(context.Context, int, string, string, string) (watchsync.Connection, error) {
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
func (s stubWatchProviderService) ConnectAPIKey(context.Context, int, string, string, string) (watchsync.Connection, error) {
|
||||
func (s stubWatchProviderService) ConnectAPIKeyWithConfig(context.Context, int, string, string, string, watchsync.ConnectionConfigValues) (watchsync.Connection, error) {
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
func (s stubWatchProviderService) GetConnectionStatus(context.Context, int, string, string) (watchsync.ConnectionStatus, error) {
|
||||
@@ -89,7 +110,7 @@ func TestWatchProviderHandlerListsProviders(t *testing.T) {
|
||||
if len(resp.Providers) != 1 {
|
||||
t.Fatalf("providers length = %d, want 1", len(resp.Providers))
|
||||
}
|
||||
if resp.Providers[0] != service.providers[0] {
|
||||
if !reflect.DeepEqual(resp.Providers[0], service.providers[0]) {
|
||||
t.Fatalf("provider = %#v, want %#v", resp.Providers[0], service.providers[0])
|
||||
}
|
||||
}
|
||||
@@ -159,6 +180,11 @@ func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T)
|
||||
t.Fatalf("response contains Go-style key %q: %#v", key, resp)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_code", "user_id", "profile_id", "completed_at"} {
|
||||
if _, ok := resp[key]; ok {
|
||||
t.Fatalf("response exposes host-private field %q: %#v", key, resp)
|
||||
}
|
||||
}
|
||||
if resp["id"] != "auth-1" {
|
||||
t.Fatalf("id = %#v, want auth-1", resp["id"])
|
||||
}
|
||||
@@ -170,6 +196,31 @@ func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchProviderHandlerPassesConnectionConfigWithAPIKey(t *testing.T) {
|
||||
service := &capturingWatchProviderService{}
|
||||
handler := NewWatchProviderHandler(service)
|
||||
req := httptest.NewRequest(http.MethodPost, "/watch-providers/plugin:4:floppy/auth/api-key", strings.NewReader(`{
|
||||
"api_key":"token",
|
||||
"connection_config":{"floppy":{"base_url":"https://floppy.example.com"}}
|
||||
}`))
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("provider", "plugin:4:floppy")
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
|
||||
ctx = middleware.SetClaims(ctx, &auth.Claims{UserID: 7})
|
||||
ctx = middleware.SetProfileID(ctx, "profile-1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleConnectAPIKey(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if service.apiKey != "token" || service.config["floppy"]["base_url"] != "https://floppy.example.com" {
|
||||
t.Fatalf("api key = %q, config = %#v", service.apiKey, service.config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchProviderHandlerManualSyncReturnsRun(t *testing.T) {
|
||||
startedAt := time.Date(2026, 5, 4, 16, 0, 0, 0, time.UTC)
|
||||
handler := NewWatchProviderHandler(stubWatchProviderService{
|
||||
|
||||
@@ -2402,6 +2402,7 @@ func NewRouter(deps Dependencies) chi.Router {
|
||||
r.Get("/values", settingValuesHandler.HandleGetValues)
|
||||
r.Get("/values/effective", settingValuesHandler.HandleGetEffective)
|
||||
r.Post("/values/effective", settingValuesHandler.HandlePostEffective)
|
||||
r.Put("/values/nav.shortcuts/item", settingValuesHandler.HandleSetNavigationShortcut)
|
||||
r.Get("/values/{key}", settingValuesHandler.HandleGetValue)
|
||||
r.Put("/values/{key}", settingValuesHandler.HandleSetValue)
|
||||
r.Delete("/values/{key}", settingValuesHandler.HandleDeleteValue)
|
||||
|
||||
@@ -6,24 +6,14 @@ import (
|
||||
)
|
||||
|
||||
var deviceMatchAdjectives = []string{
|
||||
"amber", "brisk", "calm", "cedar", "clear", "cloud", "copper", "crisp",
|
||||
"delta", "ember", "fable", "fern", "frost", "gentle", "golden", "harbor",
|
||||
"hazel", "hollow", "indigo", "ivy", "jade", "juniper", "kindle", "lagoon",
|
||||
"linen", "lunar", "maple", "meadow", "misty", "navy", "nova", "oak",
|
||||
"olive", "opal", "orbit", "pepper", "pine", "plum", "prairie", "quiet",
|
||||
"raven", "river", "rose", "rustic", "sable", "sage", "scarlet", "silver",
|
||||
"smoky", "solstice", "spruce", "stone", "summer", "sunny", "timber", "topaz",
|
||||
"velvet", "violet", "willow", "winter", "woodland", "zephyr",
|
||||
"blue", "busy", "calm", "cozy", "fast", "gold",
|
||||
"kind", "soft", "tall", "tame", "tiny", "warm",
|
||||
}
|
||||
|
||||
var deviceMatchNouns = []string{
|
||||
"anchor", "apple", "birch", "brook", "canyon", "castle", "comet", "cove",
|
||||
"crest", "dawn", "ember", "field", "fjord", "forest", "glade", "grove",
|
||||
"harbor", "hawk", "hill", "island", "lake", "lantern", "meadow", "mesa",
|
||||
"moon", "oasis", "ocean", "orchard", "owl", "pine", "planet", "pond",
|
||||
"quartz", "rain", "reef", "ridge", "river", "rock", "shadow", "shore",
|
||||
"signal", "snow", "spark", "star", "stone", "stream", "summit", "sun",
|
||||
"thunder", "trail", "tree", "valley", "wave", "whisper", "wind", "wolf",
|
||||
"barn", "bell", "cart", "coop", "corn", "cow", "duck", "goat",
|
||||
"hay", "hen", "lamb", "milk", "oats", "pail", "pond", "pony",
|
||||
"rake", "shed", "silo", "wool",
|
||||
}
|
||||
|
||||
func randomMatchCode() (string, error) {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeviceMatchCodeWordsStayWithinEightLetters(t *testing.T) {
|
||||
const maxLetters = 8
|
||||
const wantCombinations = 240
|
||||
for _, adjective := range deviceMatchAdjectives {
|
||||
for _, noun := range deviceMatchNouns {
|
||||
if got := len(adjective) + len(noun); got > maxLetters {
|
||||
t.Fatalf("match code %q has %d letters, want at most %d", adjective+" "+noun, got, maxLetters)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The match phrase is a human confirmation signal, not the login secret,
|
||||
// but keep enough combinations that accidental collisions remain uncommon.
|
||||
if combinations := len(deviceMatchAdjectives) * len(deviceMatchNouns); combinations != wantCombinations {
|
||||
t.Fatalf("match-code word lists have %d combinations, want exactly %d", combinations, wantCombinations)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,10 @@ func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string
|
||||
}
|
||||
|
||||
resolvedAccel := playback.ResolveHWAccelWithFFmpeg(opts.HWAccel, ffmpegPath)
|
||||
resolvedDevice := opts.HWDevice
|
||||
// Resolve a multi-device hw_device list to one concrete GPU for this
|
||||
// extraction; the reservation spans only the hardware attempt below.
|
||||
resolvedDevice, releaseHWDevice := playback.AcquireHWDevice(opts.HWDevice, resolvedAccel)
|
||||
defer releaseHWDevice()
|
||||
if resolvedDevice == "" && (resolvedAccel == "qsv" || resolvedAccel == "vaapi") {
|
||||
resolvedDevice = playback.PickRenderDevice("")
|
||||
}
|
||||
@@ -48,6 +51,7 @@ func ExtractFrame(ctx context.Context, opts FrameExtractOptions) ([]byte, string
|
||||
attemptCtx, cancel := context.WithTimeout(ctx, extractTimeoutForAttempt(true, opts.ToneMap))
|
||||
data, err := runExtract(attemptCtx, ffmpegPath, args)
|
||||
cancel()
|
||||
releaseHWDevice()
|
||||
if err == nil {
|
||||
return data, "", nil
|
||||
}
|
||||
|
||||
@@ -89,18 +89,17 @@ type ChapterThumbnailRequest struct {
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
fileRepo FileRepository
|
||||
folderRepo FolderRepository
|
||||
probeEnsurer ProbeEnsurer
|
||||
settings SettingsReader
|
||||
store ObjectStore
|
||||
notifier ThumbnailNotifier
|
||||
ffmpegPath string
|
||||
hwAccel string
|
||||
hwDevice string
|
||||
hwResolveOnce sync.Once
|
||||
resolvedHWAccel string
|
||||
resolvedHWDevice string
|
||||
fileRepo FileRepository
|
||||
folderRepo FolderRepository
|
||||
probeEnsurer ProbeEnsurer
|
||||
settings SettingsReader
|
||||
store ObjectStore
|
||||
notifier ThumbnailNotifier
|
||||
ffmpegPath string
|
||||
hwAccel string
|
||||
hwDevice string
|
||||
hwResolveOnce sync.Once
|
||||
resolvedHWAccel string
|
||||
|
||||
notifyNormal chan struct{}
|
||||
notifyPriority chan struct{}
|
||||
@@ -653,12 +652,10 @@ func (s *Service) extractFrameLocal(ctx context.Context, inputPath string, seekS
|
||||
func (s *Service) resolveHWConfig() (string, string) {
|
||||
s.hwResolveOnce.Do(func() {
|
||||
s.resolvedHWAccel = playback.ResolveHWAccelWithFFmpeg(s.hwAccel, s.ffmpegPath)
|
||||
s.resolvedHWDevice = s.hwDevice
|
||||
if s.resolvedHWDevice == "" && (s.resolvedHWAccel == "qsv" || s.resolvedHWAccel == "vaapi") {
|
||||
s.resolvedHWDevice = playback.PickRenderDevice("")
|
||||
}
|
||||
})
|
||||
return s.resolvedHWAccel, s.resolvedHWDevice
|
||||
// The configured device value passes through raw: ExtractFrame resolves it
|
||||
// (multi-device balancing, empty-value auto-detection) per extraction.
|
||||
return s.resolvedHWAccel, s.hwDevice
|
||||
}
|
||||
|
||||
func (s *Service) chapterThumbnailExecutionMode(ctx context.Context) string {
|
||||
|
||||
@@ -674,3 +674,33 @@ func TestReserveRemoteNodeAccountsForReservations(t *testing.T) {
|
||||
t.Fatalf("reserveRemoteNode() node = %#v, want node-2", node)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFrameResolvesMultiDeviceListToOneDevice(t *testing.T) {
|
||||
service := &Service{
|
||||
hwAccel: "vaapi",
|
||||
hwDevice: "/dev/dri/renderD888,/dev/dri/renderD889",
|
||||
}
|
||||
file := &models.MediaFile{FilePath: "/media/movie.mkv"}
|
||||
|
||||
var hwArgs []string
|
||||
service.runFFmpegFrameExtractFunc = func(_ context.Context, _ string, args []string) ([]byte, error) {
|
||||
if hwArgs == nil {
|
||||
hwArgs = append([]string(nil), args...)
|
||||
}
|
||||
return []byte("frame"), nil
|
||||
}
|
||||
|
||||
if _, _, err := service.extractFrame(context.Background(), file, 5, chapterThumbnailHDRPolicyBestEffort); err != nil {
|
||||
t.Fatalf("extractFrame() error = %v", err)
|
||||
}
|
||||
|
||||
joined := strings.Join(hwArgs, " ")
|
||||
// Neither test device exists, so the balancer deterministically falls back
|
||||
// to the first entry — but never passes the raw list to ffmpeg.
|
||||
if strings.Contains(joined, "/dev/dri/renderD888,/dev/dri/renderD889") {
|
||||
t.Fatalf("ffmpeg args contain the raw device list:\n%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "/dev/dri/renderD888") {
|
||||
t.Fatalf("ffmpeg args missing a resolved device:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +157,21 @@ func (c MatcherConfig) TVSeriesRootQueueEnabled() bool {
|
||||
|
||||
// PlaybackConfig holds transcoding and playback settings.
|
||||
type PlaybackConfig struct {
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
TranscodeDir string `yaml:"transcode_dir"`
|
||||
HWAccel string `yaml:"hw_accel"`
|
||||
FFmpegPath string `yaml:"ffmpeg_path"`
|
||||
TranscodeDir string `yaml:"transcode_dir"`
|
||||
HWAccel string `yaml:"hw_accel"`
|
||||
// HWDevice is the GPU render device for hardware transcodes. A single
|
||||
// path pins every GPU workload to that device; a comma-separated list
|
||||
// (e.g. "/dev/dri/renderD128,/dev/dri/renderD129") balances workloads
|
||||
// least-loaded across the listed devices. Empty auto-detects.
|
||||
//
|
||||
// Supported topology: balancing applies to QSV/VAAPI render devices
|
||||
// only (NVENC identifies GPUs by CUDA index/UUID and always uses the
|
||||
// first entry). The value is cluster-wide — every transcode node reads
|
||||
// it — so multi-node deployments must expose the listed devices at
|
||||
// identical paths on every node; devices absent on a node fall out of
|
||||
// that node's rotation. The admin hw-accel endpoint reports each node's
|
||||
// inventory so the UI can flag divergence.
|
||||
HWDevice string `yaml:"hw_device"`
|
||||
ChapterThumbnailWorkers int `yaml:"chapter_thumbnail_workers"`
|
||||
ChapterThumbnailExecution string `yaml:"chapter_thumbnail_execution"`
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// The planner's rules are unit-tested in internal/settingsmigrate. What this
|
||||
// covers is everything only a live database can show: that the Go migration is
|
||||
// registered and actually runs, that the rows satisfy the scope CHECK, the
|
||||
// composite profile foreign key and the five partial unique indexes, and that
|
||||
// composite profile foreign key and the six partial unique indexes, and that
|
||||
// jsonb accepts the values the planner encodes.
|
||||
func TestPostgresSettingsBackfill(t *testing.T) {
|
||||
dsn := os.Getenv("SILO_TEST_DATABASE_URL")
|
||||
|
||||
@@ -388,7 +388,7 @@ func (h *PlaybackHandler) buildProxyRedirectURL(
|
||||
}
|
||||
return redirectURL, nil
|
||||
case string(playback.PlayTranscode):
|
||||
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8", nil
|
||||
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8?" + playback.SourceTimelineQueryParam + "=1", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported proxy method %q", method)
|
||||
}
|
||||
|
||||
@@ -246,7 +246,8 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to bind transcode node")
|
||||
return
|
||||
}
|
||||
if err := h.startRemoteTranscode(r.Context(), playSession.ID, playSession.UpstreamSessionID, *source, file, playSession.InitialSeekSeconds, tcNode.URL); err != nil {
|
||||
initialSeekSeconds, _ := compatInitialTranscodePosition(*source, h.compatSegmentDuration(), playSession.InitialSeekSeconds)
|
||||
if err := h.startRemoteTranscode(r.Context(), playSession.ID, playSession.UpstreamSessionID, *source, file, initialSeekSeconds, tcNode.URL); err != nil {
|
||||
failRemoteStart()
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
@@ -279,7 +280,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Ensure the transcode process is running.
|
||||
_, err = h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
manifest, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
@@ -299,7 +300,9 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
|
||||
segDuration := h.compatSegmentDuration()
|
||||
|
||||
manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
|
||||
if manifest == nil {
|
||||
manifest = generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -329,7 +332,7 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
// Ensure the transcode process is running.
|
||||
_, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
manifest, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
@@ -349,7 +352,9 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
segDuration := h.compatSegmentDuration()
|
||||
|
||||
manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
|
||||
if manifest == nil {
|
||||
manifest = generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(rewriteManifest(manifest, playSession.RouteItemID, playSession.ID, source.ID))
|
||||
@@ -1564,10 +1569,11 @@ func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSes
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// When duration is known, Jellycompat serves its own synthetic VOD manifest.
|
||||
// We only need ffmpeg running; waiting for startup segments here adds
|
||||
// unnecessary latency before the player can request the actual target segment.
|
||||
if source.Version.Duration > 0 {
|
||||
// When the duration fits the shared segment-count bound, Jellycompat serves
|
||||
// its own synthetic VOD manifest. Longer media waits for FFmpeg's bounded
|
||||
// real playlist so one request cannot allocate hundreds of thousands of
|
||||
// segment entries.
|
||||
if shouldGenerateCompatFullManifest(source, h.compatSegmentDuration()) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -1579,7 +1585,7 @@ func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSes
|
||||
for {
|
||||
manifest, err := transcodeSession.GetManifest()
|
||||
if err == nil {
|
||||
return manifest, nil
|
||||
return playback.AlignRealManifestToSourceTimeline(manifest, transcodeSession.Opts(), "")
|
||||
}
|
||||
if !errors.Is(err, playback.ErrManifestNotReady) {
|
||||
return nil, err
|
||||
@@ -1628,11 +1634,11 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
initialSeekSeconds := 0.0
|
||||
startSegmentNumber := 0
|
||||
if playSession, ok := h.playbackStore.Get(playSessionID); ok {
|
||||
initialSeekSeconds = playSession.InitialSeekSeconds
|
||||
segDuration := h.compatSegmentDuration()
|
||||
if initialSeekSeconds > 0 && segDuration > 0 {
|
||||
startSegmentNumber = int(initialSeekSeconds / float64(segDuration))
|
||||
}
|
||||
initialSeekSeconds, startSegmentNumber = compatInitialTranscodePosition(
|
||||
source,
|
||||
h.compatSegmentDuration(),
|
||||
playSession.InitialSeekSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
opts := playback.TranscodeOpts{
|
||||
@@ -1646,6 +1652,7 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
AudioTrackIndex: compatAudioTrackIndexOrDefault(source),
|
||||
TotalDuration: float64(source.Version.Duration),
|
||||
FastStart: true,
|
||||
}
|
||||
if source.TranscodeAudio {
|
||||
@@ -1689,6 +1696,26 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
return transcodeSession, nil
|
||||
}
|
||||
|
||||
func shouldGenerateCompatFullManifest(source PlaybackMediaSource, segmentDuration int) bool {
|
||||
return playback.CanGenerateSyntheticManifest(float64(source.Version.Duration), segmentDuration)
|
||||
}
|
||||
|
||||
// compatInitialTranscodePosition keeps FFmpeg close to the requested resume
|
||||
// position. Bounded synthetic manifests list the omitted source segments;
|
||||
// seeked real manifests receive an EXT-X-GAP timeline anchor before serving.
|
||||
func compatInitialTranscodePosition(source PlaybackMediaSource, segmentDuration int, requested float64) (float64, int) {
|
||||
if requested <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if duration := float64(source.Version.Duration); duration > 0 && requested > duration {
|
||||
requested = duration
|
||||
}
|
||||
if segmentDuration <= 0 {
|
||||
segmentDuration = compatSegmentDuration
|
||||
}
|
||||
return requested, int(requested / float64(segmentDuration))
|
||||
}
|
||||
|
||||
// audioSelectionChanged reports whether an incoming AudioStreamIndex differs
|
||||
// from what the play session already records for the target media source.
|
||||
// Used to short-circuit progress reports that merely echo the current
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
@@ -81,6 +83,52 @@ func TestGenerateFullManifest_HLSVersionForResumeStartTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldGenerateCompatFullManifestBoundsSegmentCount(t *testing.T) {
|
||||
short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}}
|
||||
if !shouldGenerateCompatFullManifest(short, 2) {
|
||||
t.Fatal("historical 50,000-segment compatibility manifest should remain supported")
|
||||
}
|
||||
|
||||
long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}}
|
||||
if shouldGenerateCompatFullManifest(long, 2) {
|
||||
t.Fatal("long compatibility playback should use FFmpeg's bounded real manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatInitialTranscodePositionKeepsResumeNearRequestedSegment(t *testing.T) {
|
||||
short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}}
|
||||
seek, segment := compatInitialTranscodePosition(short, 2, 17.3)
|
||||
if seek != 17.3 || segment != 8 {
|
||||
t.Fatalf("bounded manifest position = (%v, %d), want (17.3, 8)", seek, segment)
|
||||
}
|
||||
|
||||
long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}}
|
||||
seek, segment = compatInitialTranscodePosition(long, 2, 17.3)
|
||||
if seek != 17.3 || segment != 8 {
|
||||
t.Fatalf("real manifest position = (%v, %d), want (17.3, 8)", seek, segment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyRedirectURLRequestsSourceAlignedCompatManifest(t *testing.T) {
|
||||
h := &PlaybackHandler{JWTSecret: "test-secret"}
|
||||
redirectURL, err := h.buildProxyRedirectURL(
|
||||
"play-1",
|
||||
"upstream-1",
|
||||
string(playback.PlayTranscode),
|
||||
&models.MediaFile{FilePath: "/media/movie.mkv"},
|
||||
PlaybackMediaSource{},
|
||||
"http://transcode-1",
|
||||
0,
|
||||
&nodepool.Node{URL: "http://proxy-1"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildProxyRedirectURL: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(redirectURL, "/master.m3u8?"+playback.SourceTimelineQueryParam+"=1") {
|
||||
t.Fatalf("redirect URL = %q, want source-timeline opt-in", redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteManifest_PreservesPlaybackAndMediaSourceIDs(t *testing.T) {
|
||||
manifest := strings.Join([]string{
|
||||
"#EXTM3U",
|
||||
|
||||
@@ -2,6 +2,7 @@ package notifications
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -64,6 +65,11 @@ type interestTrackingStoreWithDevices struct {
|
||||
userstore.DeviceRegistry
|
||||
}
|
||||
|
||||
var _ userstore.SettingValueCompareAndSetter = (*interestTrackingStore)(nil)
|
||||
var _ userstore.SettingMutationTransactioner = (*interestTrackingStore)(nil)
|
||||
var _ userstore.SettingValueCompareAndSetter = (*interestTrackingStoreWithDevices)(nil)
|
||||
var _ userstore.SettingMutationTransactioner = (*interestTrackingStoreWithDevices)(nil)
|
||||
|
||||
// WithPreferenceSettingsTransaction preserves the optional atomic-settings
|
||||
// capability of the wrapped store. Preference writes do not affect interest
|
||||
// signals, so the transaction can pass through unchanged; keeping the method
|
||||
@@ -80,6 +86,37 @@ func (s *interestTrackingStore) WithPreferenceSettingsTransaction(
|
||||
return transactioner.WithPreferenceSettingsTransaction(ctx, fn)
|
||||
}
|
||||
|
||||
// CompareAndSetSettingValue preserves the semantic-document CAS capability of
|
||||
// the wrapped store. Settings writes do not affect notification interests, so
|
||||
// this decorator must not intercept or downgrade the backend primitive.
|
||||
func (s *interestTrackingStore) CompareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*userstore.SettingValue, error) {
|
||||
cas, ok := s.UserStore.(userstore.SettingValueCompareAndSetter)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("wrapped user store does not support atomic setting updates")
|
||||
}
|
||||
return cas.CompareAndSetSettingValue(ctx, id, value, expectedRevision)
|
||||
}
|
||||
|
||||
// WithSettingMutationTransaction preserves the durable setting+receipt
|
||||
// transaction used by mutation IDs. Passing the transaction-scoped writer
|
||||
// through unchanged keeps both operations on the concrete backend transaction.
|
||||
func (s *interestTrackingStore) WithSettingMutationTransaction(
|
||||
ctx context.Context,
|
||||
mutationID string,
|
||||
fn func(userstore.SettingMutationWriter) error,
|
||||
) error {
|
||||
transactioner, ok := s.UserStore.(userstore.SettingMutationTransactioner)
|
||||
if !ok {
|
||||
return fmt.Errorf("wrapped user store does not support atomic idempotent setting mutations")
|
||||
}
|
||||
return transactioner.WithSettingMutationTransaction(ctx, mutationID, fn)
|
||||
}
|
||||
|
||||
// progressState is the transition-relevant projection of a progress row.
|
||||
type progressState struct {
|
||||
exists bool
|
||||
|
||||
@@ -3,8 +3,11 @@ package notifications
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/userdb"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
@@ -19,7 +22,7 @@ func (p preferenceTransactionTestProvider) ForUser(context.Context, int) (userst
|
||||
|
||||
func (preferenceTransactionTestProvider) Close() error { return nil }
|
||||
|
||||
func TestInterestTrackingStorePreservesPreferenceSettingsTransactions(t *testing.T) {
|
||||
func TestInterestTrackingStorePreservesSettingCapabilities(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
@@ -53,4 +56,47 @@ func TestInterestTrackingStorePreservesPreferenceSettingsTransactions(t *testing
|
||||
if !called {
|
||||
t.Fatal("transaction callback was not invoked")
|
||||
}
|
||||
|
||||
cas, ok := wrapped.(userstore.SettingValueCompareAndSetter)
|
||||
if !ok {
|
||||
t.Fatal("interest-tracking wrapper dropped SettingValueCompareAndSetter")
|
||||
}
|
||||
identity := userstore.SettingIdentity{Key: "ui.test", Scope: settingscontract.ScopeAccount}
|
||||
first, err := cas.CompareAndSetSettingValue(
|
||||
context.Background(), identity, json.RawMessage(`"first"`), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("CompareAndSetSettingValue: %v", err)
|
||||
}
|
||||
|
||||
mutationTx, ok := wrapped.(userstore.SettingMutationTransactioner)
|
||||
if !ok {
|
||||
t.Fatal("interest-tracking wrapper dropped SettingMutationTransactioner")
|
||||
}
|
||||
called = false
|
||||
if err := mutationTx.WithSettingMutationTransaction(context.Background(), "wrapped-mutation",
|
||||
func(writer userstore.SettingMutationWriter) error {
|
||||
called = true
|
||||
if _, err := writer.CompareAndSetSettingValue(
|
||||
context.Background(), identity, json.RawMessage(`"second"`), first.Revision); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := writer.PutSettingMutation(context.Background(), userstore.SettingMutationRecord{
|
||||
MutationID: "wrapped-mutation", RequestHash: "hash",
|
||||
Result: json.RawMessage(`{"ok":true}`), ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
return err
|
||||
}); err != nil {
|
||||
t.Fatalf("WithSettingMutationTransaction: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("mutation transaction callback was not invoked")
|
||||
}
|
||||
stored, err := wrapped.GetSettingValue(context.Background(), identity)
|
||||
if err != nil || stored == nil || stored.Revision != 2 {
|
||||
t.Fatalf("wrapped setting after transaction = %+v (%v), want revision 2", stored, err)
|
||||
}
|
||||
receipt, err := wrapped.GetSettingMutation(context.Background(), "wrapped-mutation")
|
||||
if err != nil || receipt == nil || receipt.RequestHash != "hash" {
|
||||
t.Fatalf("wrapped receipt = %+v (%v)", receipt, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package playback
|
||||
|
||||
// ExecutableRecipeV3 is the frozen operational half of a protocol-v3 plan.
|
||||
// PlanV3 describes the client-visible route identity; these fields are the
|
||||
// additional inputs needed to open another transport for that same route.
|
||||
// Keeping them with the durable attempt prevents seek reanchoring from
|
||||
// reverse-engineering execution details from presentation fields or mutable
|
||||
// planner inputs.
|
||||
type ExecutableRecipeV3 struct {
|
||||
Version int `json:"version"`
|
||||
PlanID string `json:"plan_id"`
|
||||
PlayMethod PlayMethod `json:"play_method"`
|
||||
TranscodeAudio bool `json:"transcode_audio"`
|
||||
TargetVideoCodec string `json:"target_video_codec,omitempty"`
|
||||
TargetAudioCodec string `json:"target_audio_codec,omitempty"`
|
||||
TargetAudioChannels int `json:"target_audio_channels,omitempty"`
|
||||
TargetResolution string `json:"target_resolution,omitempty"`
|
||||
TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"`
|
||||
SourceVideoCodec string `json:"source_video_codec,omitempty"`
|
||||
SourceDurationSeconds float64 `json:"source_duration_seconds,omitempty"`
|
||||
SubtitleTrackIndex int `json:"subtitle_track_index"`
|
||||
SubtitleTransportTrackIndex int `json:"subtitle_transport_track_index"`
|
||||
SubtitleBurnIn bool `json:"subtitle_burn_in"`
|
||||
SubtitleCodec string `json:"subtitle_codec,omitempty"`
|
||||
// SubtitleSource pins which sidecar inventory segment SubtitleTrackIndex
|
||||
// pointed into when the plan was accepted, and the identity fields below
|
||||
// pin the exact entry. The combined index space (externals, then embedded,
|
||||
// then downloaded) shifts when any segment grows or shrinks; a seek
|
||||
// reanchor must detect that drift and fail rather than silently resolve
|
||||
// the frozen index to a different artifact.
|
||||
SubtitleSource string `json:"subtitle_source,omitempty"`
|
||||
ExternalSubtitlePath string `json:"external_subtitle_path,omitempty"`
|
||||
EmbeddedStreamIndex int `json:"embedded_stream_index,omitempty"`
|
||||
DownloadedSubtitleID int `json:"downloaded_subtitle_id,omitempty"`
|
||||
}
|
||||
|
||||
const executableRecipeVersionV3 = 1
|
||||
|
||||
const (
|
||||
SubtitleSourceExternalV3 = "external"
|
||||
SubtitleSourceEmbeddedV3 = "embedded"
|
||||
SubtitleSourceDownloadedV3 = "downloaded"
|
||||
)
|
||||
|
||||
func FreezeExecutableRecipeV3(result PlannerResultV3) ExecutableRecipeV3 {
|
||||
planID := ""
|
||||
if result.Plan != nil {
|
||||
planID = result.Plan.PlanID
|
||||
}
|
||||
sourceMetadata := SourceExecutionMetadataV3{}
|
||||
if result.FrozenSourceMetadata != nil {
|
||||
sourceMetadata = *result.FrozenSourceMetadata
|
||||
}
|
||||
return ExecutableRecipeV3{
|
||||
Version: executableRecipeVersionV3,
|
||||
PlanID: planID,
|
||||
PlayMethod: result.PlayMethod,
|
||||
TranscodeAudio: result.TranscodeAudio,
|
||||
TargetVideoCodec: result.TargetVideoCodec,
|
||||
TargetAudioCodec: result.TargetAudioCodec,
|
||||
TargetAudioChannels: result.TargetAudioChannels,
|
||||
TargetResolution: result.TargetResolution,
|
||||
TargetBitrateKbps: result.TargetBitrateKbps,
|
||||
SourceVideoCodec: sourceMetadata.VideoCodec,
|
||||
SourceDurationSeconds: sourceMetadata.DurationSeconds,
|
||||
SubtitleTrackIndex: result.SubtitleTrackIndex,
|
||||
SubtitleTransportTrackIndex: result.SubtitleTransportTrackIndex,
|
||||
SubtitleBurnIn: result.SubtitleBurnIn,
|
||||
SubtitleCodec: result.SubtitleCodec,
|
||||
DownloadedSubtitleID: result.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
|
||||
func (r ExecutableRecipeV3) Valid() bool {
|
||||
if r.Version != executableRecipeVersionV3 || r.PlanID == "" {
|
||||
return false
|
||||
}
|
||||
switch r.PlayMethod {
|
||||
case PlayDirect, PlayRemux, PlayTranscode:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (r ExecutableRecipeV3) ValidFor(plan PlanV3) bool {
|
||||
return r.Valid() && r.PlanID == plan.PlanID
|
||||
}
|
||||
|
||||
func (r ExecutableRecipeV3) PlannerResult(plan *PlanV3) PlannerResultV3 {
|
||||
return PlannerResultV3{
|
||||
Plan: plan,
|
||||
PlayMethod: r.PlayMethod,
|
||||
TranscodeAudio: r.TranscodeAudio,
|
||||
TargetVideoCodec: r.TargetVideoCodec,
|
||||
TargetAudioCodec: r.TargetAudioCodec,
|
||||
TargetAudioChannels: r.TargetAudioChannels,
|
||||
TargetResolution: r.TargetResolution,
|
||||
TargetBitrateKbps: r.TargetBitrateKbps,
|
||||
FrozenSourceMetadata: &SourceExecutionMetadataV3{
|
||||
VideoCodec: r.SourceVideoCodec,
|
||||
DurationSeconds: r.SourceDurationSeconds,
|
||||
},
|
||||
SubtitleTrackIndex: r.SubtitleTrackIndex,
|
||||
SubtitleTransportTrackIndex: r.SubtitleTransportTrackIndex,
|
||||
SubtitleBurnIn: r.SubtitleBurnIn,
|
||||
SubtitleCodec: r.SubtitleCodec,
|
||||
DownloadedSubtitleID: r.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecutableRecipeV3RoundTripPreservesOperationalFields(t *testing.T) {
|
||||
plan := &PlanV3{PlanID: "plan:frozen", Delivery: DeliveryRemuxHLSV3}
|
||||
want := PlannerResultV3{
|
||||
Plan: plan, PlayMethod: PlayRemux, TranscodeAudio: true,
|
||||
TargetVideoCodec: "copy", TargetAudioCodec: "aac", TargetAudioChannels: 6,
|
||||
TargetResolution: "1080p", TargetBitrateKbps: 18_000,
|
||||
FrozenSourceMetadata: &SourceExecutionMetadataV3{VideoCodec: "hevc", DurationSeconds: 7_201},
|
||||
SubtitleTrackIndex: 4, SubtitleTransportTrackIndex: 2,
|
||||
SubtitleBurnIn: true, SubtitleCodec: "hdmv_pgs_subtitle", DownloadedSubtitleID: 71,
|
||||
}
|
||||
recipe := FreezeExecutableRecipeV3(want)
|
||||
if !recipe.Valid() {
|
||||
t.Fatalf("frozen recipe is invalid: %#v", recipe)
|
||||
}
|
||||
if !recipe.ValidFor(*plan) {
|
||||
t.Fatalf("frozen recipe does not match its plan: %#v", recipe)
|
||||
}
|
||||
changedPlan := *plan
|
||||
changedPlan.PlanID = "plan:newer"
|
||||
if recipe.ValidFor(changedPlan) {
|
||||
t.Fatal("stale frozen recipe matched a newer plan")
|
||||
}
|
||||
got := recipe.PlannerResult(plan)
|
||||
if got.Plan != plan || got.PlayMethod != want.PlayMethod || got.TranscodeAudio != want.TranscodeAudio ||
|
||||
got.TargetVideoCodec != want.TargetVideoCodec || got.TargetAudioCodec != want.TargetAudioCodec ||
|
||||
got.TargetAudioChannels != want.TargetAudioChannels || got.TargetResolution != want.TargetResolution ||
|
||||
got.TargetBitrateKbps != want.TargetBitrateKbps || got.SubtitleTrackIndex != want.SubtitleTrackIndex ||
|
||||
got.SubtitleTransportTrackIndex != want.SubtitleTransportTrackIndex || got.SubtitleBurnIn != want.SubtitleBurnIn ||
|
||||
got.SubtitleCodec != want.SubtitleCodec || got.DownloadedSubtitleID != want.DownloadedSubtitleID || got.FrozenSourceMetadata == nil ||
|
||||
got.FrozenSourceMetadata.VideoCodec != want.FrozenSourceMetadata.VideoCodec || got.FrozenSourceMetadata.DurationSeconds != want.FrozenSourceMetadata.DurationSeconds {
|
||||
t.Fatalf("thawed result = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutableRecipeV3SurvivesJSONRoundTrip(t *testing.T) {
|
||||
plan := &PlanV3{PlanID: "plan:frozen"}
|
||||
recipe := FreezeExecutableRecipeV3(PlannerResultV3{
|
||||
Plan: plan, PlayMethod: PlayRemux,
|
||||
FrozenSourceMetadata: &SourceExecutionMetadataV3{VideoCodec: "hevc", DurationSeconds: 7_201},
|
||||
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: 0,
|
||||
})
|
||||
recipe.SubtitleSource = SubtitleSourceDownloadedV3
|
||||
recipe.DownloadedSubtitleID = 71
|
||||
|
||||
encoded, err := json.Marshal(recipe)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal recipe: %v", err)
|
||||
}
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||
t.Fatalf("unmarshal recipe fields: %v", err)
|
||||
}
|
||||
for _, field := range []string{"subtitle_track_index", "subtitle_transport_track_index"} {
|
||||
if _, ok := fields[field]; !ok {
|
||||
t.Fatalf("encoded recipe omitted meaningful zero-value field %q: %s", field, encoded)
|
||||
}
|
||||
}
|
||||
var decoded ExecutableRecipeV3
|
||||
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
||||
t.Fatalf("unmarshal recipe: %v", err)
|
||||
}
|
||||
if decoded != recipe {
|
||||
t.Fatalf("decoded recipe = %#v, want %#v", decoded, recipe)
|
||||
}
|
||||
if !decoded.ValidFor(*plan) {
|
||||
t.Fatalf("decoded recipe no longer matches its plan: %#v", decoded)
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,13 @@ var nvencProbeCache = struct {
|
||||
|
||||
// HWAccelInfo describes the detected hardware acceleration capability.
|
||||
type HWAccelInfo struct {
|
||||
Resolved string `json:"resolved"`
|
||||
RenderDevices []string `json:"render_devices"`
|
||||
IntelDetected bool `json:"intel_detected"`
|
||||
Source string `json:"source"`
|
||||
NodeURL string `json:"node_url,omitempty"`
|
||||
Transformations []TransformationV3 `json:"transformations,omitempty"`
|
||||
Resolved string `json:"resolved"`
|
||||
RenderDevices []string `json:"render_devices"`
|
||||
RenderDeviceDetails []RenderDeviceInfo `json:"render_device_details"`
|
||||
IntelDetected bool `json:"intel_detected"`
|
||||
Source string `json:"source"`
|
||||
NodeURL string `json:"node_url,omitempty"`
|
||||
Transformations []TransformationV3 `json:"transformations,omitempty"`
|
||||
}
|
||||
|
||||
// DetectHWAccel probes this host's GPU hardware and returns structured info.
|
||||
@@ -60,15 +61,18 @@ func DetectHWAccelWithFFmpeg(ffmpegPath string) HWAccelInfo {
|
||||
}
|
||||
}
|
||||
return HWAccelInfo{
|
||||
Resolved: ResolveHWAccelWithFFmpeg("auto", ffmpegPath),
|
||||
RenderDevices: devices,
|
||||
IntelDetected: intel,
|
||||
Source: "local",
|
||||
Resolved: ResolveHWAccelWithFFmpeg("auto", ffmpegPath),
|
||||
RenderDevices: devices,
|
||||
RenderDeviceDetails: renderDeviceDetails(devices),
|
||||
IntelDetected: intel,
|
||||
Source: "local",
|
||||
}
|
||||
}
|
||||
|
||||
// PickRenderDevice returns the GPU render device path to use.
|
||||
// If explicit is non-empty, it is returned as-is.
|
||||
// If explicit is non-empty, it is returned as-is — multi-device lists are
|
||||
// resolved to one device by AcquireHWDevice before args are built, so this
|
||||
// never sees a list on a live path.
|
||||
// Otherwise, it attempts to discover a render device under /dev/dri/.
|
||||
// Returns empty string if no device is found (caller should fall back to CPU).
|
||||
func PickRenderDevice(explicit string) string {
|
||||
@@ -312,3 +316,53 @@ func detectRenderDevice(driDir string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RenderDeviceInfo describes one render device for operator-facing surfaces.
|
||||
type RenderDeviceInfo struct {
|
||||
Path string `json:"path"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// describeRenderDevice builds a short human label for a render device from
|
||||
// its sysfs PCI vendor/device ids; best-effort, never fails.
|
||||
func describeRenderDevice(renderDevPath string) string {
|
||||
name := filepath.Base(renderDevPath)
|
||||
vendor := readSysfsID(filepath.Join(sysClassDRMDir, name, "device", "vendor"))
|
||||
label := ""
|
||||
switch vendor {
|
||||
case "0x8086":
|
||||
label = "Intel GPU"
|
||||
case "0x10de":
|
||||
label = "NVIDIA GPU"
|
||||
case "0x1002":
|
||||
label = "AMD GPU"
|
||||
case "":
|
||||
return "GPU"
|
||||
default:
|
||||
label = "GPU (vendor " + vendor + ")"
|
||||
}
|
||||
if device := readSysfsID(filepath.Join(sysClassDRMDir, name, "device", "device")); device != "" && vendor != "0x1002" {
|
||||
label += " (" + device + ")"
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func readSysfsID(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// renderDeviceDetails describes every listed device.
|
||||
func renderDeviceDetails(devices []string) []RenderDeviceInfo {
|
||||
details := make([]RenderDeviceInfo, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
details = append(details, RenderDeviceInfo{
|
||||
Path: device,
|
||||
Description: describeRenderDevice(device),
|
||||
})
|
||||
}
|
||||
return details
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Multi-GPU balancing: playback.hw_device accepts a comma-separated list of
|
||||
// render devices (e.g. "/dev/dri/renderD128,/dev/dri/renderD129"). Every GPU
|
||||
// workload (streaming sessions, prepared downloads, chapter thumbnails)
|
||||
// resolves the list to exactly one concrete device via AcquireHWDevice
|
||||
// immediately before launching ffmpeg, and releases it when that process has
|
||||
// exited. Balancing is supported for the render-device accelerators (QSV and
|
||||
// VAAPI) on a homogeneous device list; NVENC identifies GPUs by CUDA
|
||||
// index/UUID rather than render-node path, so a multi-entry list falls back
|
||||
// to its first entry. A single configured value keeps the historical
|
||||
// pass-through contract for every accelerator, so existing deployments are
|
||||
// unaffected.
|
||||
|
||||
// HWDeviceSet is the parsed form of the playback.hw_device setting: an
|
||||
// ordered list of device entries. Order is priority order — ties in load
|
||||
// resolve to the earlier entry.
|
||||
type HWDeviceSet struct {
|
||||
devices []string
|
||||
}
|
||||
|
||||
// ParseHWDeviceSet splits a configured hw_device value into its device set,
|
||||
// trimming whitespace and dropping empty entries.
|
||||
func ParseHWDeviceSet(configured string) HWDeviceSet {
|
||||
if strings.TrimSpace(configured) == "" {
|
||||
return HWDeviceSet{}
|
||||
}
|
||||
parts := strings.Split(configured, ",")
|
||||
devices := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
devices = append(devices, trimmed)
|
||||
}
|
||||
}
|
||||
return HWDeviceSet{devices: devices}
|
||||
}
|
||||
|
||||
// Empty reports whether no device is configured (auto-detection applies).
|
||||
func (s HWDeviceSet) Empty() bool { return len(s.devices) == 0 }
|
||||
|
||||
// Multi reports whether more than one device is configured.
|
||||
func (s HWDeviceSet) Multi() bool { return len(s.devices) > 1 }
|
||||
|
||||
// List returns the configured devices in priority order.
|
||||
func (s HWDeviceSet) List() []string { return s.devices }
|
||||
|
||||
// First returns the first configured device, or "" when empty.
|
||||
func (s HWDeviceSet) First() string {
|
||||
if len(s.devices) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.devices[0]
|
||||
}
|
||||
|
||||
// hwDeviceStat reports whether a device path exists; overridable in tests.
|
||||
var hwDeviceStat = func(path string) error {
|
||||
_, err := os.Stat(path)
|
||||
return err
|
||||
}
|
||||
|
||||
// hwDeviceLoad tracks active GPU workloads per render device.
|
||||
var hwDeviceLoad = struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]int
|
||||
}{counts: map[string]int{}}
|
||||
|
||||
// hwAccelBalancesRenderDevices reports whether the resolved acceleration mode
|
||||
// selects GPUs by render-device path, which is what the balancer hands out.
|
||||
// NVENC addresses GPUs by CUDA index/UUID and is deliberately excluded.
|
||||
func hwAccelBalancesRenderDevices(hwAccel string) bool {
|
||||
return hwAccel == "qsv" || hwAccel == "vaapi"
|
||||
}
|
||||
|
||||
// presentHWDevices filters a device list to the entries that exist, falling
|
||||
// back to the first entry when none do so the failure mode stays
|
||||
// deterministic (ffmpeg reports the missing device, matching the historical
|
||||
// wrong-path behavior of an explicit single value).
|
||||
func presentHWDevices(devices []string) []string {
|
||||
present := make([]string, 0, len(devices))
|
||||
for _, device := range devices {
|
||||
if hwDeviceStat(device) == nil {
|
||||
present = append(present, device)
|
||||
}
|
||||
}
|
||||
if len(present) == 0 {
|
||||
return devices[:1]
|
||||
}
|
||||
return present
|
||||
}
|
||||
|
||||
// leastLoadedHWDeviceLocked picks the device with the fewest active
|
||||
// workloads, preserving list order on ties. Callers must hold hwDeviceLoad.mu.
|
||||
func leastLoadedHWDeviceLocked(present []string) string {
|
||||
best := present[0]
|
||||
for _, device := range present[1:] {
|
||||
if hwDeviceLoad.counts[device] < hwDeviceLoad.counts[best] {
|
||||
best = device
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// newHWDeviceRelease returns an idempotent release for a reservation that has
|
||||
// already been added to hwDeviceLoad.
|
||||
func newHWDeviceRelease(device string) func() {
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
hwDeviceLoad.mu.Lock()
|
||||
if hwDeviceLoad.counts[device] > 0 {
|
||||
hwDeviceLoad.counts[device]--
|
||||
}
|
||||
hwDeviceLoad.mu.Unlock()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// reserveConcreteHWDevice reserves a device that was selected for an earlier
|
||||
// process in the same transcode session. Restarts keep device affinity rather
|
||||
// than running the least-loaded selection again.
|
||||
func reserveConcreteHWDevice(device string) func() {
|
||||
hwDeviceLoad.mu.Lock()
|
||||
hwDeviceLoad.counts[device]++
|
||||
count := hwDeviceLoad.counts[device]
|
||||
hwDeviceLoad.mu.Unlock()
|
||||
slog.Info("GPU workload device reserved", "device", device, "active_workloads", count)
|
||||
return newHWDeviceRelease(device)
|
||||
}
|
||||
|
||||
var nvencMultiDeviceWarnOnce sync.Once
|
||||
|
||||
// AcquireHWDevice resolves the configured hw_device value to exactly one
|
||||
// device for one GPU workload. resolvedHWAccel must already be resolved (no
|
||||
// "auto"). The returned release must be called exactly once when the ffmpeg
|
||||
// process for this workload has exited; it is idempotent and a no-op for
|
||||
// workloads that did not reserve (empty or single-device value, or an
|
||||
// accelerator the balancer does not manage).
|
||||
//
|
||||
// - Empty value: returns "" so downstream auto-detection applies.
|
||||
// - Single value: passes through unchanged for every accelerator.
|
||||
// - Multi-device value with QSV/VAAPI: reserves the present device with the
|
||||
// fewest active workloads (ties keep list order) until release.
|
||||
// - Multi-device value with any other accelerator (including NVENC, which
|
||||
// addresses GPUs by CUDA index/UUID, not render-node path): falls back to
|
||||
// the first entry without reserving.
|
||||
func AcquireHWDevice(configured, resolvedHWAccel string) (string, func()) {
|
||||
noop := func() {}
|
||||
set := ParseHWDeviceSet(configured)
|
||||
if !set.Multi() {
|
||||
return set.First(), noop
|
||||
}
|
||||
if !hwAccelBalancesRenderDevices(resolvedHWAccel) {
|
||||
if resolvedHWAccel == "nvenc" {
|
||||
nvencMultiDeviceWarnOnce.Do(func() {
|
||||
slog.Warn("multi-device hw_device is not supported with NVENC (devices are CUDA index/UUID, not render paths); using the first entry",
|
||||
"hw_device", configured, "using", set.First())
|
||||
})
|
||||
}
|
||||
return set.First(), noop
|
||||
}
|
||||
// Select and reserve in one critical section so concurrent workload starts
|
||||
// observe each other's reservations instead of piling onto one device.
|
||||
present := presentHWDevices(set.List())
|
||||
hwDeviceLoad.mu.Lock()
|
||||
device := leastLoadedHWDeviceLocked(present)
|
||||
hwDeviceLoad.counts[device]++
|
||||
count := hwDeviceLoad.counts[device]
|
||||
hwDeviceLoad.mu.Unlock()
|
||||
slog.Info("GPU workload device selected", "device", device, "active_workloads", count)
|
||||
|
||||
return device, newHWDeviceRelease(device)
|
||||
}
|
||||
|
||||
// hwDeviceActiveCount reports the active workload count for one device; test
|
||||
// helper for asserting release boundaries.
|
||||
func hwDeviceActiveCount(device string) int {
|
||||
hwDeviceLoad.mu.Lock()
|
||||
defer hwDeviceLoad.mu.Unlock()
|
||||
return hwDeviceLoad.counts[device]
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeDeviceStat installs a stat function that reports only the given paths as
|
||||
// present, restoring the real one on cleanup.
|
||||
func fakeDeviceStat(t *testing.T, present ...string) {
|
||||
t.Helper()
|
||||
set := make(map[string]bool, len(present))
|
||||
for _, p := range present {
|
||||
set[p] = true
|
||||
}
|
||||
orig := hwDeviceStat
|
||||
hwDeviceStat = func(path string) error {
|
||||
if set[path] {
|
||||
return nil
|
||||
}
|
||||
return os.ErrNotExist
|
||||
}
|
||||
t.Cleanup(func() { hwDeviceStat = orig })
|
||||
}
|
||||
|
||||
func resetDeviceLoad(t *testing.T) {
|
||||
t.Helper()
|
||||
hwDeviceLoad.mu.Lock()
|
||||
hwDeviceLoad.counts = map[string]int{}
|
||||
hwDeviceLoad.mu.Unlock()
|
||||
}
|
||||
|
||||
func TestParseHWDeviceSet(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{"/dev/dri/renderD128", []string{"/dev/dri/renderD128"}},
|
||||
{"/dev/dri/renderD128,/dev/dri/renderD129", []string{"/dev/dri/renderD128", "/dev/dri/renderD129"}},
|
||||
{" /dev/dri/renderD128 , /dev/dri/renderD129 ,", []string{"/dev/dri/renderD128", "/dev/dri/renderD129"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := ParseHWDeviceSet(tc.in)
|
||||
if got.Empty() != (len(tc.want) == 0) || got.Multi() != (len(tc.want) > 1) {
|
||||
t.Fatalf("ParseHWDeviceSet(%q) Empty/Multi mismatch for %v", tc.in, tc.want)
|
||||
}
|
||||
list := got.List()
|
||||
if len(list) != len(tc.want) {
|
||||
t.Fatalf("ParseHWDeviceSet(%q).List() = %v, want %v", tc.in, list, tc.want)
|
||||
}
|
||||
for i := range list {
|
||||
if list[i] != tc.want[i] {
|
||||
t.Fatalf("ParseHWDeviceSet(%q).List() = %v, want %v", tc.in, list, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceEmptyValueStaysEmpty(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
device, release := AcquireHWDevice("", "qsv")
|
||||
defer release()
|
||||
if device != "" {
|
||||
t.Fatalf("device = %q, want empty so auto-detection applies", device)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceSingleValuePassesThrough(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
fakeDeviceStat(t) // nothing exists; single value must still pass through
|
||||
for _, accel := range []string{"qsv", "vaapi", "nvenc", "none"} {
|
||||
device, release := AcquireHWDevice("/dev/dri/renderD128", accel)
|
||||
if device != "/dev/dri/renderD128" {
|
||||
t.Fatalf("accel %s: device = %q, want explicit single value unchanged", accel, device)
|
||||
}
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceBalancesAcrossList(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
fakeDeviceStat(t, "/dev/dri/renderD128", "/dev/dri/renderD129")
|
||||
configured := "/dev/dri/renderD128,/dev/dri/renderD129"
|
||||
|
||||
dev1, release1 := AcquireHWDevice(configured, "qsv")
|
||||
if dev1 != "/dev/dri/renderD128" {
|
||||
t.Fatalf("first workload device = %q, want first listed on tie", dev1)
|
||||
}
|
||||
dev2, release2 := AcquireHWDevice(configured, "vaapi")
|
||||
if dev2 != "/dev/dri/renderD129" {
|
||||
t.Fatalf("second workload device = %q, want least-loaded second device", dev2)
|
||||
}
|
||||
dev3, release3 := AcquireHWDevice(configured, "qsv")
|
||||
if dev3 != "/dev/dri/renderD128" {
|
||||
t.Fatalf("third workload device = %q, want round-back to first on tie", dev3)
|
||||
}
|
||||
|
||||
// Releasing the first workload makes renderD128 least-loaded again.
|
||||
release1()
|
||||
release3()
|
||||
dev4, release4 := AcquireHWDevice(configured, "qsv")
|
||||
if dev4 != "/dev/dri/renderD128" {
|
||||
t.Fatalf("post-release device = %q, want freed first device", dev4)
|
||||
}
|
||||
release2()
|
||||
release4()
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceSkipsMissingDevices(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
fakeDeviceStat(t, "/dev/dri/renderD129")
|
||||
device, release := AcquireHWDevice("/dev/dri/renderD128,/dev/dri/renderD129", "qsv")
|
||||
defer release()
|
||||
if device != "/dev/dri/renderD129" {
|
||||
t.Fatalf("device = %q, want the only present device", device)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceAllMissingFallsBackToFirst(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
fakeDeviceStat(t) // none exist
|
||||
device, release := AcquireHWDevice("/dev/dri/renderD128,/dev/dri/renderD129", "qsv")
|
||||
defer release()
|
||||
if device != "/dev/dri/renderD128" {
|
||||
t.Fatalf("device = %q, want deterministic first entry when none exist", device)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceNVENCMultiListUsesFirstWithoutReserving(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
fakeDeviceStat(t) // NVENC entries are CUDA indexes/UUIDs, never present as paths
|
||||
device, release := AcquireHWDevice("0,1", "nvenc")
|
||||
defer release()
|
||||
if device != "0" {
|
||||
t.Fatalf("device = %q, want first NVENC entry", device)
|
||||
}
|
||||
if got := hwDeviceActiveCount("0"); got != 0 {
|
||||
t.Fatalf("active count = %d, want no reservation for NVENC", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceSoftwareAccelDoesNotReserve(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
fakeDeviceStat(t, "/dev/dri/renderD128", "/dev/dri/renderD129")
|
||||
configured := "/dev/dri/renderD128,/dev/dri/renderD129"
|
||||
|
||||
_, releaseNone := AcquireHWDevice(configured, "none")
|
||||
defer releaseNone()
|
||||
|
||||
// A software workload must not shift the balance: the next GPU workload
|
||||
// still lands on the first device.
|
||||
device, release := AcquireHWDevice(configured, "qsv")
|
||||
defer release()
|
||||
if device != "/dev/dri/renderD128" {
|
||||
t.Fatalf("device = %q, want first device unaffected by software workload", device)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceReleaseIsIdempotent(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
fakeDeviceStat(t, "/dev/dri/renderD128", "/dev/dri/renderD129")
|
||||
configured := "/dev/dri/renderD128,/dev/dri/renderD129"
|
||||
|
||||
_, release1 := AcquireHWDevice(configured, "qsv")
|
||||
release1()
|
||||
release1() // double release must not underflow the count
|
||||
|
||||
dev, release2 := AcquireHWDevice(configured, "qsv")
|
||||
defer release2()
|
||||
if dev != "/dev/dri/renderD128" {
|
||||
t.Fatalf("device = %q, want first device after idempotent release", dev)
|
||||
}
|
||||
hwDeviceLoad.mu.Lock()
|
||||
defer hwDeviceLoad.mu.Unlock()
|
||||
for device, count := range hwDeviceLoad.counts {
|
||||
if count < 0 {
|
||||
t.Fatalf("device %s count = %d, want never negative", device, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickRenderDeviceExplicitValuePassesThrough(t *testing.T) {
|
||||
// PickRenderDevice is auto-detection only; list resolution happens in
|
||||
// AcquireHWDevice before args are built, so an explicit value — even a
|
||||
// stale CSV — passes through untouched.
|
||||
if got := PickRenderDevice("/dev/dri/renderD42"); got != "/dev/dri/renderD42" {
|
||||
t.Fatalf("PickRenderDevice(single) = %q, want unchanged explicit value", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectHWAccelRenderDeviceDetails(t *testing.T) {
|
||||
driDir := t.TempDir()
|
||||
sysDir := t.TempDir()
|
||||
for name, ids := range map[string][2]string{
|
||||
"renderD128": {"0x8086", "0x56a6"},
|
||||
"renderD129": {"0x10de", "0x2489"},
|
||||
} {
|
||||
if err := os.WriteFile(driDir+"/"+name, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
devDir := sysDir + "/" + name + "/device"
|
||||
if err := os.MkdirAll(devDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(devDir+"/vendor", []byte(ids[0]+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(devDir+"/device", []byte(ids[1]+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
origDRI, origSys := defaultDRIDir, sysClassDRMDir
|
||||
defaultDRIDir, sysClassDRMDir = driDir, sysDir
|
||||
t.Cleanup(func() { defaultDRIDir, sysClassDRMDir = origDRI, origSys })
|
||||
|
||||
info := DetectHWAccel()
|
||||
if len(info.RenderDeviceDetails) != 2 {
|
||||
t.Fatalf("RenderDeviceDetails len = %d, want 2: %+v", len(info.RenderDeviceDetails), info.RenderDeviceDetails)
|
||||
}
|
||||
first, second := info.RenderDeviceDetails[0], info.RenderDeviceDetails[1]
|
||||
if first.Path != driDir+"/renderD128" || first.Description != "Intel GPU (0x56a6)" {
|
||||
t.Fatalf("first device = %+v, want Intel description", first)
|
||||
}
|
||||
if second.Path != driDir+"/renderD129" || second.Description != "NVIDIA GPU (0x2489)" {
|
||||
t.Fatalf("second device = %+v, want NVIDIA description", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeRenderDeviceUnknownVendor(t *testing.T) {
|
||||
sysDir := t.TempDir()
|
||||
devDir := sysDir + "/renderD130/device"
|
||||
if err := os.MkdirAll(devDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(devDir+"/vendor", []byte("0x1002\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origSys := sysClassDRMDir
|
||||
sysClassDRMDir = sysDir
|
||||
t.Cleanup(func() { sysClassDRMDir = origSys })
|
||||
|
||||
if got := describeRenderDevice("/dev/dri/renderD130"); got != "AMD GPU" {
|
||||
t.Fatalf("describeRenderDevice() = %q, want AMD GPU without device id", got)
|
||||
}
|
||||
if got := describeRenderDevice("/dev/dri/renderD999"); got != "GPU" {
|
||||
t.Fatalf("describeRenderDevice() = %q, want bare GPU for unreadable sysfs", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcquireHWDeviceConcurrentStartsBalanceExactly(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
fakeDeviceStat(t, "/dev/dri/renderD128", "/dev/dri/renderD129")
|
||||
configured := "/dev/dri/renderD128,/dev/dri/renderD129"
|
||||
|
||||
const workloads = 8
|
||||
var wg sync.WaitGroup
|
||||
devices := make([]string, workloads)
|
||||
releases := make([]func(), workloads)
|
||||
for i := range workloads {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
devices[i], releases[i] = AcquireHWDevice(configured, "qsv")
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
defer func() {
|
||||
for _, release := range releases {
|
||||
release()
|
||||
}
|
||||
}()
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, device := range devices {
|
||||
counts[device]++
|
||||
}
|
||||
// Atomic select+reserve guarantees an exact split; a two-step selection
|
||||
// could pile concurrent starts onto one device.
|
||||
if counts["/dev/dri/renderD128"] != workloads/2 || counts["/dev/dri/renderD129"] != workloads/2 {
|
||||
t.Fatalf("concurrent workload split = %v, want exact %d/%d", counts, workloads/2, workloads/2)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,13 @@ type PlannerInputV3 struct {
|
||||
AdditionalSubtitles []SubtitleInventoryEntryV3
|
||||
}
|
||||
|
||||
// SourceExecutionMetadataV3 is the immutable source probe snapshot used to
|
||||
// reopen a frozen playback recipe without consuming later catalog drift.
|
||||
type SourceExecutionMetadataV3 struct {
|
||||
VideoCodec string
|
||||
DurationSeconds float64
|
||||
}
|
||||
|
||||
// dvRPUStrippable resolves the per-source strip verdict, defaulting to true
|
||||
// when no probe is wired in.
|
||||
func (input PlannerInputV3) dvRPUStrippable() bool {
|
||||
@@ -84,6 +91,14 @@ type PlannerResultV3 struct {
|
||||
SubtitleTransportTrackIndex int
|
||||
SubtitleBurnIn bool
|
||||
SubtitleCodec string
|
||||
// DownloadedSubtitleID comes from the same inventory snapshot used for
|
||||
// planning. Freezing must not re-list a mutable ordinal inventory after the
|
||||
// route has already been accepted.
|
||||
DownloadedSubtitleID int
|
||||
// FrozenSourceMetadata is set only when a durable executable recipe is
|
||||
// thawed for a seek reanchor. Transport construction must then use this
|
||||
// captured source snapshot instead of a freshly probed media row.
|
||||
FrozenSourceMetadata *SourceExecutionMetadataV3
|
||||
}
|
||||
|
||||
func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
@@ -254,7 +269,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
})
|
||||
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
|
||||
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
if clientHDR10Eligible {
|
||||
@@ -275,7 +290,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
})
|
||||
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
|
||||
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,7 +304,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
|
||||
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
|
||||
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +365,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
plan.Claims.Subtitles = remuxSubtitle.Claims
|
||||
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
|
||||
if engineAvailableV3(input.Request, EngineMedia3ProgressiveRemuxV3) && !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec, DownloadedSubtitleID: remuxSubtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
if engineAvailableV3(input.Request, EngineMedia3HLSV3) && hlsRemuxSubtitleOK {
|
||||
@@ -414,7 +429,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
if hlsTranscodeAudio {
|
||||
targetAudio = "aac"
|
||||
}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec, DownloadedSubtitleID: hlsSubtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,7 +505,7 @@ func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescri
|
||||
if planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return terminalPlannerResultV3("adaptation_exhausted", "All compatible playback recipes have already failed for this output route.", false)
|
||||
}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
|
||||
}
|
||||
|
||||
func canStripDolbyVisionToHDR10V3(source SourceDescriptorV3, request StartRequestV3, registry *TransformationRegistryV3) bool {
|
||||
|
||||
@@ -79,6 +79,10 @@ func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecor
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipeJSON, err := json.Marshal(record.FrozenRecipe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -98,12 +102,12 @@ func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecor
|
||||
INSERT INTO playback_v3_attempts (
|
||||
playback_attempt_id, session_id, user_id, profile_id,
|
||||
requested_media_file_id, effective_media_file_id,
|
||||
current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at
|
||||
) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
current_plan_id, current_replan_request_id, current_plan, frozen_recipe, normalized_request, request_digest, expires_at
|
||||
) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
record.PlaybackAttemptID, record.SessionID, record.UserID, record.ProfileID,
|
||||
record.RequestedMediaFileID, record.EffectiveMediaFileID,
|
||||
record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.RequestDigest, record.ExpiresAt)
|
||||
record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, recipeJSON, requestJSON, record.RequestDigest, record.ExpiresAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,16 +163,16 @@ func (s *Postgres) getAttemptIdentity(ctx context.Context, predicate string, val
|
||||
|
||||
func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any) (*playback.AttemptRecordV3, error) {
|
||||
var record playback.AttemptRecordV3
|
||||
var planJSON, requestJSON []byte
|
||||
var planJSON, recipeJSON, requestJSON []byte
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT playback_attempt_id, session_id::text, user_id, profile_id,
|
||||
requested_media_file_id, effective_media_file_id,
|
||||
current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at
|
||||
current_plan_id, current_replan_request_id, current_plan, frozen_recipe, normalized_request, request_digest, expires_at
|
||||
FROM playback_v3_attempts
|
||||
WHERE `+predicate+` AND expires_at > NOW()`, value).Scan(
|
||||
&record.PlaybackAttemptID, &record.SessionID, &record.UserID, &record.ProfileID,
|
||||
&record.RequestedMediaFileID, &record.EffectiveMediaFileID,
|
||||
&record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt,
|
||||
&record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &recipeJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, playback.ErrSessionNotFound
|
||||
@@ -179,6 +183,9 @@ func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any)
|
||||
if err := json.Unmarshal(planJSON, &record.CurrentPlan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(recipeJSON, &record.FrozenRecipe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(requestJSON, &record.NormalizedRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -273,6 +280,10 @@ func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, bas
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipeJSON, err := json.Marshal(record.FrozenRecipe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The base-revision predicate makes the commit a true compare-and-swap:
|
||||
// under the advisory session lock it never fails, but a skipped or broken
|
||||
// lock must surface as a conflict rather than silently last-writer-win
|
||||
@@ -280,9 +291,10 @@ func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, bas
|
||||
attemptResult, err := tx.Exec(ctx, `
|
||||
UPDATE playback_v3_attempts SET
|
||||
effective_media_file_id = $2, current_plan_id = $3,
|
||||
current_replan_request_id = $4, current_plan = $5, normalized_request = $6, expires_at = $7, updated_at = NOW()
|
||||
WHERE session_id = $1::uuid AND current_replan_request_id = $8`,
|
||||
sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.ExpiresAt, baseReplanRequestID)
|
||||
current_replan_request_id = $4, current_plan = $5, frozen_recipe = $6,
|
||||
normalized_request = $7, expires_at = $8, updated_at = NOW()
|
||||
WHERE session_id = $1::uuid AND current_replan_request_id = $9`,
|
||||
sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, recipeJSON, requestJSON, record.ExpiresAt, baseReplanRequestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -60,6 +60,17 @@ func newPlanstoreFixture(t *testing.T) *planstoreFixture {
|
||||
if !hasRevision {
|
||||
t.Skip("test database has not applied the playback v3 attempt revision migration")
|
||||
}
|
||||
var hasFrozenRecipe bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'playback_v3_attempts' AND column_name = 'frozen_recipe'
|
||||
)`).Scan(&hasFrozenRecipe); err != nil {
|
||||
t.Fatalf("check frozen_recipe column: %v", err)
|
||||
}
|
||||
if !hasFrozenRecipe {
|
||||
t.Skip("test database has not applied the playback v3 frozen recipe migration")
|
||||
}
|
||||
|
||||
f := &planstoreFixture{pool: pool}
|
||||
unique := fmt.Sprintf("planstore-test-%d", time.Now().UnixNano())
|
||||
@@ -116,6 +127,10 @@ func (f *planstoreFixture) attemptRecord(sessionID, attemptID, digest string) pl
|
||||
RequestedMediaFileID: f.mediaFileID,
|
||||
EffectiveMediaFileID: f.mediaFileID,
|
||||
},
|
||||
FrozenRecipe: playback.ExecutableRecipeV3{
|
||||
Version: 1, PlanID: "plan-1", PlayMethod: playback.PlayDirect,
|
||||
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
|
||||
},
|
||||
NormalizedRequest: playback.StartRequestV3{
|
||||
ProtocolVersion: 3,
|
||||
FileID: f.mediaFileID,
|
||||
@@ -265,6 +280,9 @@ func TestPostgresPlanStore(t *testing.T) {
|
||||
if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan)) {
|
||||
t.Fatalf("%s plan JSON did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan))
|
||||
}
|
||||
if !bytes.Equal(mustJSON(t, got.FrozenRecipe), mustJSON(t, record.FrozenRecipe)) {
|
||||
t.Fatalf("%s frozen recipe did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.FrozenRecipe), mustJSON(t, record.FrozenRecipe))
|
||||
}
|
||||
if !bytes.Equal(mustJSON(t, got.NormalizedRequest), mustJSON(t, record.NormalizedRequest)) {
|
||||
t.Fatalf("%s normalized request JSON did not round-trip", name)
|
||||
}
|
||||
@@ -400,6 +418,7 @@ func TestPostgresPlanStore(t *testing.T) {
|
||||
updated.CurrentPlanID = "plan-2"
|
||||
updated.CurrentReplanRequestID = "rq-1"
|
||||
updated.CurrentPlan.PlanID = "plan-2"
|
||||
updated.FrozenRecipe.PlanID = "plan-2"
|
||||
updated.CurrentPlan.EffectiveMediaFileID = f.altFileID
|
||||
updated.CurrentPlan.DecisionReason = "transcode_fallback"
|
||||
updated.ExpiresAt = time.Now().Add(2 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
@@ -425,6 +444,9 @@ func TestPostgresPlanStore(t *testing.T) {
|
||||
if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan)) {
|
||||
t.Fatalf("plan JSON mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan))
|
||||
}
|
||||
if !bytes.Equal(mustJSON(t, got.FrozenRecipe), mustJSON(t, updated.FrozenRecipe)) {
|
||||
t.Fatalf("frozen recipe mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.FrozenRecipe), mustJSON(t, updated.FrozenRecipe))
|
||||
}
|
||||
|
||||
// The migration's sync trigger must not fight the in-transaction CAS:
|
||||
// the raw column must equal the new request ID, with no extra rewrite.
|
||||
|
||||
@@ -67,6 +67,14 @@ func PrepareFile(ctx context.Context, opts TranscodeOpts, outputPath string) err
|
||||
// any stale partial first so a failed prior attempt can't be mistaken for output.
|
||||
_ = os.Remove(partPath)
|
||||
|
||||
// Resolve a multi-device hw_device list to one concrete GPU for this
|
||||
// encode. Run blocks until ffmpeg exits, so the deferred release fires at
|
||||
// exactly the process-exit boundary.
|
||||
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
|
||||
hwDevice, releaseHWDevice := AcquireHWDevice(opts.HWDevice, opts.HWAccel)
|
||||
opts.HWDevice = hwDevice
|
||||
defer releaseHWDevice()
|
||||
|
||||
args := buildPrepareFileArgs(opts, partPath)
|
||||
bin := opts.FFmpegPath
|
||||
if bin == "" {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -82,3 +85,45 @@ func TestResolvePrepareTarget(t *testing.T) {
|
||||
t.Fatalf("transcode resolution = %q, want empty (source)", rt.Resolution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareFileResolvesOneDeviceAndReleasesAfterExit(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
devA, devB := "/dev/dri/renderD888", "/dev/dri/renderD889"
|
||||
fakeDeviceStat(t, devA, devB)
|
||||
|
||||
// Fake ffmpeg: record argv, create the output (last arg) so finalize works.
|
||||
dir := t.TempDir()
|
||||
argsFile := filepath.Join(dir, "args.txt")
|
||||
script := filepath.Join(dir, "ffmpeg")
|
||||
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > "+argsFile+"\neval \"touch \\${$#}\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(dir, "artifact.mp4")
|
||||
err := PrepareFile(context.Background(), TranscodeOpts{
|
||||
InputPath: "/nonexistent/input.mkv",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
FFmpegPath: script,
|
||||
HWAccel: "vaapi",
|
||||
HWDevice: devA + "," + devB,
|
||||
}, outputPath)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareFile: %v", err)
|
||||
}
|
||||
|
||||
argv, err := os.ReadFile(argsFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(argv)
|
||||
if !strings.Contains(got, devA) {
|
||||
t.Fatalf("ffmpeg args missing resolved device %s:\n%s", devA, got)
|
||||
}
|
||||
if strings.Contains(got, devA+","+devB) {
|
||||
t.Fatalf("ffmpeg args contain the raw device list:\n%s", got)
|
||||
}
|
||||
if count := hwDeviceActiveCount(devA); count != 0 {
|
||||
t.Fatalf("active count after PrepareFile returned = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type AttemptRecordV3 struct {
|
||||
CurrentPlanID string
|
||||
CurrentReplanRequestID string
|
||||
CurrentPlan PlanV3
|
||||
FrozenRecipe ExecutableRecipeV3
|
||||
NormalizedRequest StartRequestV3
|
||||
// RequestDigest fingerprints the normalized start request so an attempt-ID
|
||||
// reused with different input is a detectable idempotency violation rather
|
||||
|
||||
@@ -645,7 +645,7 @@ func TestPlanPlaybackV3PassthroughRequiresExactLayoutEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T) {
|
||||
func TestPlanPlaybackV3DownloadedSubtitleCarriesStableIdentity(t *testing.T) {
|
||||
file := detailedFixtureFileV3()
|
||||
req := validStartRequestV3()
|
||||
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
|
||||
@@ -655,8 +655,8 @@ func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T)
|
||||
index := 0
|
||||
req.SubtitleTrackIndex = &index
|
||||
req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", index)
|
||||
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded"}}})
|
||||
if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID {
|
||||
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded", DownloadedSubtitleID: 71}}})
|
||||
if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID || result.DownloadedSubtitleID != 71 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
@@ -1016,6 +1016,54 @@ func TestPlanPlaybackV3TimelineChangePreservesRouteIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPlaybackV3DroppingFallbackHistoryReintroducesRejectedRoute(t *testing.T) {
|
||||
file := detailedFixtureFileV3()
|
||||
file.FilePath = "/media/movie.mp4"
|
||||
file.Container = "mp4"
|
||||
file.CodecVideo = "h264"
|
||||
file.Resolution = "1080p"
|
||||
file.Bitrate = 8_000
|
||||
file.VideoTracks[0] = models.VideoTrack{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}
|
||||
request := validStartRequestV3()
|
||||
request.ClientFeatures = append(request.ClientFeatures, FeatureDetailedDecodeV3)
|
||||
request.ClientPlaybackContext.Features = append(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
|
||||
request.Capabilities.CodecsVideo = []string{"h264"}
|
||||
request.Capabilities.CodecsVideoHardware = []string{"h264"}
|
||||
request.Capabilities.Containers = []string{"mp4"}
|
||||
request.Capabilities.MaxResolution = "1080p"
|
||||
request.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}
|
||||
input := PlannerInputV3{
|
||||
Request: request, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
|
||||
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3(),
|
||||
}
|
||||
direct := PlanPlaybackV3(input)
|
||||
if direct.Plan == nil || direct.Plan.Delivery != DeliveryOriginalHTTPV3 {
|
||||
t.Fatalf("direct plan = %#v", direct)
|
||||
}
|
||||
input.AttemptedKeys = []string{PlanAttemptKeyV3(*direct.Plan, request.OutputRouteGeneration, nil)}
|
||||
progressive := PlanPlaybackV3(input)
|
||||
if progressive.Plan == nil || progressive.Plan.Delivery != DeliveryRemuxProgressiveV3 {
|
||||
t.Fatalf("progressive fallback = %#v", progressive)
|
||||
}
|
||||
input.AttemptedKeys = append(input.AttemptedKeys, PlanAttemptKeyV3(*progressive.Plan, request.OutputRouteGeneration, nil))
|
||||
hls := PlanPlaybackV3(input)
|
||||
if hls.Plan == nil || hls.Plan.Delivery != DeliveryRemuxHLSV3 {
|
||||
t.Fatalf("HLS fallback = %#v", hls)
|
||||
}
|
||||
|
||||
seek := 321.25
|
||||
input.Request.StartPosition = &seek
|
||||
input.AttemptedKeys = nil // This is what the old seek-reanchor path did.
|
||||
replanned := PlanPlaybackV3(input)
|
||||
if replanned.Plan == nil || replanned.Plan.PlanID != direct.Plan.PlanID || replanned.Plan.PlanID == hls.Plan.PlanID {
|
||||
t.Fatalf("dropped fallback history did not reproduce identity drift: direct=%#v hls=%#v replanned=%#v", direct.Plan, hls.Plan, replanned.Plan)
|
||||
}
|
||||
if replanned.Plan.Delivery != DeliveryOriginalHTTPV3 || replanned.Plan.Engine != EngineMedia3DirectV3 ||
|
||||
replanned.Plan.Stream.Protocol != StreamHTTPProgressiveV3 || replanned.Plan.Stream.Container != "mp4" {
|
||||
t.Fatalf("reintroduced route = %#v", replanned.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func validStartRequestV3() StartRequestV3 {
|
||||
return StartRequestV3{
|
||||
ProtocolVersion: ProtocolV3,
|
||||
|
||||
@@ -7,20 +7,22 @@ import (
|
||||
)
|
||||
|
||||
type SubtitlePolicyResultV3 struct {
|
||||
Decision SubtitleDecisionV3
|
||||
Claims SubtitleClaimsV3
|
||||
RequiresBurn bool
|
||||
SelectedIndex int
|
||||
TransportIndex int
|
||||
Codec string
|
||||
Source string
|
||||
Terminal *TerminalV3
|
||||
Decision SubtitleDecisionV3
|
||||
Claims SubtitleClaimsV3
|
||||
RequiresBurn bool
|
||||
SelectedIndex int
|
||||
TransportIndex int
|
||||
Codec string
|
||||
Source string
|
||||
DownloadedSubtitleID int
|
||||
Terminal *TerminalV3
|
||||
}
|
||||
|
||||
type SubtitleInventoryEntryV3 struct {
|
||||
CombinedIndex int
|
||||
Codec string
|
||||
Source string
|
||||
CombinedIndex int
|
||||
Codec string
|
||||
Source string
|
||||
DownloadedSubtitleID int
|
||||
}
|
||||
|
||||
// ResolveSubtitlePolicyV3 decides how the selected subtitle is delivered when
|
||||
@@ -44,10 +46,11 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
if file == nil {
|
||||
return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle inventory is unavailable.")
|
||||
}
|
||||
codec, source, ok := subtitleCodecAtCombinedIndexV3(file, index, additional)
|
||||
entry, ok := subtitleEntryAtCombinedIndexV3(file, index, additional)
|
||||
if !ok {
|
||||
return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle track is unavailable.")
|
||||
}
|
||||
codec, source := entry.Codec, entry.Source
|
||||
trackID := TrackIDV3(file.ID, "subtitle", index)
|
||||
transportIndex := -1
|
||||
if source == "embedded" {
|
||||
@@ -74,6 +77,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID},
|
||||
Claims: SubtitleClaimsV3{ASSStylingPreserved: !ass || engineCaps.Subtitles.ASSStyling, Reason: "client_render_supported"},
|
||||
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
|
||||
DownloadedSubtitleID: entry.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
if request.SubtitleFidelityPreference == SubtitleFidelityCompatibleV3 {
|
||||
@@ -81,6 +85,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
Decision: SubtitleDecisionV3{Mode: SubtitleConvertV3, TrackID: trackID},
|
||||
Claims: SubtitleClaimsV3{Reason: "server_text_conversion"},
|
||||
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
|
||||
DownloadedSubtitleID: entry.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +100,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID},
|
||||
Claims: SubtitleClaimsV3{BitmapSidecar: true, Reason: "client_bitmap_render_supported"},
|
||||
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
|
||||
DownloadedSubtitleID: entry.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
if transcodeAllowed {
|
||||
@@ -105,25 +111,27 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
Decision: SubtitleDecisionV3{Mode: SubtitleBurnInV3, TrackID: trackID},
|
||||
Claims: SubtitleClaimsV3{BitmapOverlay: burnInBitmap, Reason: "server_burn_in_required"},
|
||||
RequiresBurn: true, SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
|
||||
DownloadedSubtitleID: entry.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
return subtitleTerminalV3("subtitle_conversion_unsupported", fmt.Sprintf("Subtitle format %s cannot meet the selected fidelity policy.", codec))
|
||||
}
|
||||
|
||||
func subtitleCodecAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (codec, source string, ok bool) {
|
||||
func subtitleEntryAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (SubtitleInventoryEntryV3, bool) {
|
||||
if index < len(file.ExternalSubtitles) {
|
||||
return normalizeCodecV3(file.ExternalSubtitles[index].Format), "external", true
|
||||
return SubtitleInventoryEntryV3{CombinedIndex: index, Codec: normalizeCodecV3(file.ExternalSubtitles[index].Format), Source: "external"}, true
|
||||
}
|
||||
embedded := index - len(file.ExternalSubtitles)
|
||||
if embedded >= 0 && embedded < len(file.SubtitleTracks) {
|
||||
return normalizeCodecV3(file.SubtitleTracks[embedded].Codec), "embedded", true
|
||||
return SubtitleInventoryEntryV3{CombinedIndex: index, Codec: normalizeCodecV3(file.SubtitleTracks[embedded].Codec), Source: "embedded"}, true
|
||||
}
|
||||
for _, entry := range additional {
|
||||
if entry.CombinedIndex == index {
|
||||
return normalizeCodecV3(entry.Codec), entry.Source, true
|
||||
entry.Codec = normalizeCodecV3(entry.Codec)
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
return SubtitleInventoryEntryV3{}, false
|
||||
}
|
||||
|
||||
func isTextSubtitleV3(codec string) bool {
|
||||
|
||||
+213
-33
@@ -101,6 +101,10 @@ type TranscodeSession struct {
|
||||
stderrLineIndex int
|
||||
stderrWriter *ffmpegStderrWriter
|
||||
restartHook func(context.Context)
|
||||
// reserveHWDeviceOnRestart is true when StartTranscode selected and reserved
|
||||
// one device from a multi-device QSV/VAAPI setting. Each replacement ffmpeg
|
||||
// process reacquires that same concrete device.
|
||||
reserveHWDeviceOnRestart bool
|
||||
}
|
||||
|
||||
// SetRestartHook registers a callback fired after every successful Restart.
|
||||
@@ -148,6 +152,11 @@ const defaultSegmentDuration = 2
|
||||
// embedded length matches what the node actually produces.
|
||||
const DefaultSegmentDuration = defaultSegmentDuration
|
||||
|
||||
// maxSyntheticManifestSegments preserves the historical worst-case playlist
|
||||
// size (100,000 seconds at two-second segments). Longer media uses FFmpeg's
|
||||
// real sliding playlist instead of allocating a complete synthetic manifest.
|
||||
const maxSyntheticManifestSegments = 50_000
|
||||
|
||||
const maxPersistedFFmpegLines = 2000
|
||||
const maxPersistedFFmpegBytes = 256 * 1024
|
||||
const maxPersistedFFmpegChars = 2000
|
||||
@@ -171,21 +180,29 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession,
|
||||
opts.SegmentDuration = defaultSegmentDuration
|
||||
}
|
||||
opts.HWAccel = resolveEffectiveTranscodeHWAccel(opts)
|
||||
configuredHWDevices := ParseHWDeviceSet(opts.HWDevice)
|
||||
reserveHWDeviceOnRestart := configuredHWDevices.Multi() && hwAccelBalancesRenderDevices(opts.HWAccel)
|
||||
// Resolve a multi-device hw_device list to one concrete GPU. Restarts reuse
|
||||
// the selected device, but each ffmpeg process owns its own reservation.
|
||||
hwDevice, releaseHWDevice := AcquireHWDevice(opts.HWDevice, opts.HWAccel)
|
||||
opts.HWDevice = hwDevice
|
||||
|
||||
// Ensure output directory exists.
|
||||
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
|
||||
releaseHWDevice()
|
||||
return nil, fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
s := &TranscodeSession{
|
||||
cancel: cancel,
|
||||
opts: opts,
|
||||
outputDir: opts.OutputDir,
|
||||
running: true,
|
||||
done: make(chan struct{}),
|
||||
stderr: newBoundedTailBuffer(stderrTailMaxBytes),
|
||||
lastRequestedSegment: opts.StartSegmentNumber,
|
||||
cancel: cancel,
|
||||
opts: opts,
|
||||
outputDir: opts.OutputDir,
|
||||
running: true,
|
||||
done: make(chan struct{}),
|
||||
stderr: newBoundedTailBuffer(stderrTailMaxBytes),
|
||||
lastRequestedSegment: opts.StartSegmentNumber,
|
||||
reserveHWDeviceOnRestart: reserveHWDeviceOnRestart,
|
||||
}
|
||||
|
||||
args := buildFFmpegArgs(opts)
|
||||
@@ -201,6 +218,7 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession,
|
||||
stdinPipe, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
releaseHWDevice()
|
||||
return nil, fmt.Errorf("create stdin pipe: %w", err)
|
||||
}
|
||||
cmd.Dir = opts.OutputDir
|
||||
@@ -209,6 +227,7 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession,
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
releaseHWDevice()
|
||||
s.logFFmpegEvent(ctx, "ffmpeg process exit error", err.Error())
|
||||
return nil, fmt.Errorf("start ffmpeg: %w", err)
|
||||
}
|
||||
@@ -216,21 +235,28 @@ func StartTranscode(ctx context.Context, opts TranscodeOpts) (*TranscodeSession,
|
||||
s.stdinPipe = stdinPipe
|
||||
s.logFFmpegEvent(ctx, "ffmpeg process started", "")
|
||||
|
||||
// Monitor ffmpeg in background.
|
||||
go func() {
|
||||
waitErr := cmd.Wait()
|
||||
s.flushStderr(ctx)
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.waitErr = waitErr
|
||||
s.mu.Unlock()
|
||||
s.logWaitResult(ctx, waitErr)
|
||||
close(s.done)
|
||||
}()
|
||||
// Monitor ffmpeg in background. The process-specific reservation is released
|
||||
// before done closes, so waiters can safely launch a replacement process.
|
||||
go s.monitorFFmpeg(ctx, cmd, s.done, releaseHWDevice)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *TranscodeSession) monitorFFmpeg(ctx context.Context, cmd *exec.Cmd, done chan struct{}, releaseHWDevice func()) {
|
||||
waitErr := cmd.Wait()
|
||||
// The child no longer owns the device once Wait returns. Release before
|
||||
// flushing/logging so slow diagnostics cannot make the allocator count a
|
||||
// process that has already exited.
|
||||
releaseHWDevice()
|
||||
s.flushStderr(ctx)
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.waitErr = waitErr
|
||||
s.mu.Unlock()
|
||||
s.logWaitResult(ctx, waitErr)
|
||||
close(done)
|
||||
}
|
||||
|
||||
// IsMPEG2VideoCodec reports whether a probed video codec name identifies
|
||||
// MPEG-2 video. It accepts common FFmpeg aliases because codec strings can
|
||||
// come from scan metadata, direct probes, or client capability lists.
|
||||
@@ -362,7 +388,10 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
|
||||
"-max_delay", "5000000",
|
||||
"-f", "hls",
|
||||
"-hls_time", fmt.Sprintf("%d", opts.SegmentDuration),
|
||||
"-hls_list_size", "0",
|
||||
// Bound real playlists as well as synthetic ones. Segment files remain on
|
||||
// disk because delete_segments is not enabled, while the manifest itself
|
||||
// cannot grow without limit during multi-day sessions.
|
||||
"-hls_list_size", strconv.Itoa(maxSyntheticManifestSegments),
|
||||
"-hls_segment_type", segmentType,
|
||||
// Write segments to temp files first so the player never fetches a
|
||||
// partially-written segment during a quality switch.
|
||||
@@ -1002,12 +1031,14 @@ func (s *TranscodeSession) WaitForManifest(timeout time.Duration) ([]byte, error
|
||||
// Copy-video sessions always expose FFmpeg's real manifest so the playlist
|
||||
// timing matches the variable-length fragments FFmpeg actually writes and the
|
||||
// seekable window reflects what FFmpeg has produced so far. Encoded transcodes
|
||||
// still use the synthetic full VOD manifest when duration is known because
|
||||
// forced keyframes make that timeline stable and seek-anywhere friendly.
|
||||
// use the synthetic full VOD manifest only while its segment count is bounded;
|
||||
// longer media uses FFmpeg's real sliding playlist.
|
||||
func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([]byte, error) {
|
||||
opts := s.Opts()
|
||||
if strings.EqualFold(opts.TargetCodecVideo, "copy") || opts.TotalDuration <= 0 {
|
||||
// Copy-video or unknown-duration sessions must use FFmpeg's real manifest.
|
||||
if strings.EqualFold(opts.TargetCodecVideo, "copy") ||
|
||||
!CanGenerateSyntheticManifest(opts.TotalDuration, opts.SegmentDuration) {
|
||||
// Copy-video, unknown-duration, or oversized sessions must use FFmpeg's
|
||||
// real manifest.
|
||||
manifest, err := s.WaitForManifest(30 * time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1018,6 +1049,154 @@ func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([]
|
||||
return s.GenerateFullManifest(segPrefix, rawQuery), nil
|
||||
}
|
||||
|
||||
// SourceTimelineQueryParam opts a real transcode manifest into source-time
|
||||
// alignment for compatibility clients that apply their resume position to the
|
||||
// HLS timeline themselves.
|
||||
const SourceTimelineQueryParam = "source_timeline"
|
||||
|
||||
// BuildSourceAlignedPlaybackManifest builds the normal playback manifest and,
|
||||
// when it is a seeked real playlist, prepends a virtual unavailable span so the
|
||||
// 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) {
|
||||
manifest, err := s.BuildPlaybackManifest(segPrefix, rawQuery)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts := s.Opts()
|
||||
usesRealManifest := strings.EqualFold(opts.TargetCodecVideo, "copy") ||
|
||||
!CanGenerateSyntheticManifest(opts.TotalDuration, opts.SegmentDuration)
|
||||
if opts.SeekSeconds <= 0 || !usesRealManifest {
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
gapURI := segPrefix + "source_timeline_gap" + hlsSegmentExtension(opts)
|
||||
if rawQuery != "" {
|
||||
gapURI += "?" + rawQuery
|
||||
}
|
||||
return AlignRealManifestToSourceTimeline(manifest, opts, gapURI)
|
||||
}
|
||||
|
||||
// AlignRealManifestToSourceTimeline prepends bounded EXT-X-GAP segments to a
|
||||
// seeked FFmpeg playlist. The gaps contribute the omitted source time while
|
||||
// allowing clients to seek to the original source position.
|
||||
func AlignRealManifestToSourceTimeline(manifest []byte, opts TranscodeOpts, gapURI string) ([]byte, error) {
|
||||
if opts.SeekSeconds <= 0 {
|
||||
return manifest, nil
|
||||
}
|
||||
timeline, err := parseManifestTimeline(manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(timeline.entries) == 0 {
|
||||
return nil, fmt.Errorf("manifest contains no media segments")
|
||||
}
|
||||
|
||||
segmentDuration := opts.SegmentDuration
|
||||
if segmentDuration <= 0 {
|
||||
segmentDuration = defaultSegmentDuration
|
||||
}
|
||||
firstSegment := timeline.entries[0].number
|
||||
advancedSegments := max(0, firstSegment-opts.StartSegmentNumber)
|
||||
gapDuration := opts.SeekSeconds + float64(advancedSegments*segmentDuration)
|
||||
if gapDuration <= 0 {
|
||||
return manifest, nil
|
||||
}
|
||||
if gapURI == "" {
|
||||
gapURI = "source_timeline_gap" + hlsSegmentExtension(opts)
|
||||
}
|
||||
|
||||
lines := bytes.Split(manifest, []byte("\n"))
|
||||
targetDuration := segmentDuration
|
||||
for _, line := range lines {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
if !bytes.HasPrefix(trimmed, []byte("#EXT-X-TARGETDURATION:")) {
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(strings.TrimPrefix(string(trimmed), "#EXT-X-TARGETDURATION:"))
|
||||
parsed, parseErr := strconv.Atoi(value)
|
||||
if parseErr != nil || parsed <= 0 {
|
||||
return nil, fmt.Errorf("parse manifest target duration %q", value)
|
||||
}
|
||||
targetDuration = parsed
|
||||
break
|
||||
}
|
||||
|
||||
// Keep the real segment's media sequence aligned with its FFmpeg segment
|
||||
// number when possible. Very large seeks remain bounded by the same limit as
|
||||
// generated VOD manifests; their gap durations grow instead of their count.
|
||||
gapCount := max(1, firstSegment)
|
||||
if gapCount > maxSyntheticManifestSegments {
|
||||
gapCount = maxSyntheticManifestSegments
|
||||
}
|
||||
gapSegmentDuration := gapDuration / float64(gapCount)
|
||||
requiredTargetDuration := int(math.Ceil(gapSegmentDuration))
|
||||
if requiredTargetDuration > targetDuration {
|
||||
targetDuration = requiredTargetDuration
|
||||
}
|
||||
mediaSequence := max(0, firstSegment-gapCount)
|
||||
|
||||
result := make([][]byte, 0, len(lines)+gapCount*3)
|
||||
insertedGap := false
|
||||
foundSequence := false
|
||||
foundVersion := false
|
||||
for _, line := range lines {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
switch {
|
||||
case bytes.HasPrefix(trimmed, []byte("#EXT-X-VERSION:")):
|
||||
foundVersion = true
|
||||
value := strings.TrimSpace(strings.TrimPrefix(string(trimmed), "#EXT-X-VERSION:"))
|
||||
version, parseErr := strconv.Atoi(value)
|
||||
if parseErr != nil {
|
||||
return nil, fmt.Errorf("parse manifest version %q: %w", value, parseErr)
|
||||
}
|
||||
if version < 8 {
|
||||
line = []byte("#EXT-X-VERSION:8")
|
||||
}
|
||||
case bytes.HasPrefix(trimmed, []byte("#EXT-X-TARGETDURATION:")):
|
||||
line = []byte(fmt.Sprintf("#EXT-X-TARGETDURATION:%d", targetDuration))
|
||||
case bytes.HasPrefix(trimmed, []byte("#EXT-X-MEDIA-SEQUENCE:")):
|
||||
foundSequence = true
|
||||
line = []byte(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d", mediaSequence))
|
||||
case bytes.HasPrefix(trimmed, []byte("#EXTINF:")) && !insertedGap:
|
||||
if !foundVersion {
|
||||
result = append(result, []byte("#EXT-X-VERSION:8"))
|
||||
foundVersion = true
|
||||
}
|
||||
if !foundSequence {
|
||||
result = append(result, []byte(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d", mediaSequence)))
|
||||
foundSequence = true
|
||||
}
|
||||
for range gapCount {
|
||||
result = append(result,
|
||||
[]byte("#EXT-X-GAP"),
|
||||
[]byte(fmt.Sprintf("#EXTINF:%.6f,", gapSegmentDuration)),
|
||||
[]byte(gapURI),
|
||||
)
|
||||
}
|
||||
insertedGap = true
|
||||
}
|
||||
result = append(result, line)
|
||||
}
|
||||
if !insertedGap {
|
||||
return nil, fmt.Errorf("manifest contains no segment duration")
|
||||
}
|
||||
return bytes.Join(result, []byte("\n")), nil
|
||||
}
|
||||
|
||||
// CanGenerateSyntheticManifest reports whether a complete VOD playlist fits
|
||||
// within the shared segment-count bound. Callers outside playback use the same
|
||||
// decision so native and compatibility manifests cannot drift.
|
||||
func CanGenerateSyntheticManifest(totalDuration float64, segmentDuration int) bool {
|
||||
if totalDuration <= 0 || math.IsNaN(totalDuration) || math.IsInf(totalDuration, 0) {
|
||||
return false
|
||||
}
|
||||
if segmentDuration <= 0 {
|
||||
segmentDuration = defaultSegmentDuration
|
||||
}
|
||||
return totalDuration <= float64(segmentDuration)*maxSyntheticManifestSegments
|
||||
}
|
||||
|
||||
func firstNonEmptyManifestLine(manifest []byte) []byte {
|
||||
for line := range bytes.SplitSeq(manifest, []byte("\n")) {
|
||||
trimmed := bytes.TrimSpace(line)
|
||||
@@ -1611,6 +1790,7 @@ func (s *TranscodeSession) restart(
|
||||
}
|
||||
s.restartCount++
|
||||
opts := s.opts
|
||||
reserveHWDevice := s.reserveHWDeviceOnRestart
|
||||
s.mu.Unlock()
|
||||
|
||||
// Copy-mode restarts must clean stale segments so ffmpeg writes fresh
|
||||
@@ -1655,8 +1835,17 @@ func (s *TranscodeSession) restart(
|
||||
cmd.Stderr = s.newStderrWriter(ctx)
|
||||
cmd.WaitDelay = 3 * time.Second
|
||||
|
||||
// The previous process released its reservation before closing done. Keep
|
||||
// this session on the same concrete GPU while accounting for the replacement
|
||||
// process as a new active workload.
|
||||
releaseHWDevice := func() {}
|
||||
if reserveHWDevice {
|
||||
releaseHWDevice = reserveConcreteHWDevice(opts.HWDevice)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
releaseHWDevice()
|
||||
s.mu.Lock()
|
||||
s.restarting = false
|
||||
s.waitErr = err
|
||||
@@ -1680,16 +1869,7 @@ func (s *TranscodeSession) restart(
|
||||
hook := s.restartHook
|
||||
s.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
waitErr := cmd.Wait()
|
||||
s.flushStderr(ctx)
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.waitErr = waitErr
|
||||
s.mu.Unlock()
|
||||
s.logWaitResult(ctx, waitErr)
|
||||
close(s.done)
|
||||
}()
|
||||
go s.monitorFFmpeg(ctx, cmd, s.done, releaseHWDevice)
|
||||
|
||||
if hook != nil {
|
||||
hook(ctx)
|
||||
|
||||
@@ -65,6 +65,24 @@ func TestBuildFFmpegArgs_CPUPreservesSuperfastFastStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsBoundsHLSManifestSize(t *testing.T) {
|
||||
args := buildFFmpegArgs(TranscodeOpts{
|
||||
InputPath: "/media/long.mkv",
|
||||
OutputDir: "/tmp/out",
|
||||
SessionID: "session-long",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
TotalDuration: 1_000_000,
|
||||
})
|
||||
|
||||
joined := strings.Join(args, " ")
|
||||
want := "-hls_list_size 50000"
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("FFmpeg args missing %q: %s", want, joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgs_CopyVideoFromStartUsesZeroBasedTimestamps(t *testing.T) {
|
||||
args := buildFFmpegArgs(TranscodeOpts{
|
||||
InputPath: "/media/movie.mkv",
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package playback
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeFFmpegScript writes a shell script that runs until killed, standing in
|
||||
// for a long-lived ffmpeg process.
|
||||
func fakeFFmpegScript(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "ffmpeg")
|
||||
script := "#!/bin/sh\nwhile :; do sleep 0.1; done\n"
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// restartableFakeFFmpegScript exits cleanly on its first invocation, then
|
||||
// stays alive on later invocations so restart reservation behavior can be
|
||||
// observed without running a real transcode.
|
||||
func restartableFakeFFmpegScript(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "ffmpeg")
|
||||
script := "#!/bin/sh\n" +
|
||||
"marker=\"$PWD/ffmpeg-started\"\n" +
|
||||
"if [ ! -e \"$marker\" ]; then touch \"$marker\"; exit 0; fi\n" +
|
||||
"while :; do sleep 0.1; done\n"
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestStartTranscodeHoldsGPUReservationUntilProcessExit(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
devA, devB := "/dev/dri/renderD888", "/dev/dri/renderD889"
|
||||
fakeDeviceStat(t, devA, devB)
|
||||
|
||||
s, err := StartTranscode(context.Background(), TranscodeOpts{
|
||||
InputPath: "/nonexistent/input.mkv",
|
||||
OutputDir: t.TempDir(),
|
||||
SessionID: "hwdevice-session",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
FFmpegPath: fakeFFmpegScript(t),
|
||||
HWAccel: "qsv",
|
||||
HWDevice: devA + "," + devB,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTranscode: %v", err)
|
||||
}
|
||||
|
||||
if got := s.Opts().HWDevice; got != devA {
|
||||
t.Fatalf("session device = %q, want first listed device %q", got, devA)
|
||||
}
|
||||
if got := hwDeviceActiveCount(devA); got != 1 {
|
||||
t.Fatalf("active count while running = %d, want 1", got)
|
||||
}
|
||||
|
||||
// CloseProcess kills ffmpeg and waits for it to be reaped; the
|
||||
// reservation must survive until that wait completes and be gone after.
|
||||
if err := s.CloseProcess(); err != nil {
|
||||
t.Fatalf("CloseProcess: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ffmpeg process did not exit")
|
||||
}
|
||||
if got := hwDeviceActiveCount(devA); got != 0 {
|
||||
t.Fatalf("active count after close = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTranscodeReleasesReservationOnSpawnFailure(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
devA, devB := "/dev/dri/renderD888", "/dev/dri/renderD889"
|
||||
fakeDeviceStat(t, devA, devB)
|
||||
|
||||
_, err := StartTranscode(context.Background(), TranscodeOpts{
|
||||
InputPath: "/nonexistent/input.mkv",
|
||||
OutputDir: t.TempDir(),
|
||||
SessionID: "hwdevice-spawn-fail",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
FFmpegPath: filepath.Join(t.TempDir(), "missing-ffmpeg"),
|
||||
HWAccel: "qsv",
|
||||
HWDevice: devA + "," + devB,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("StartTranscode succeeded with a missing ffmpeg binary")
|
||||
}
|
||||
if got := hwDeviceActiveCount(devA); got != 0 {
|
||||
t.Fatalf("active count after failed spawn = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTranscodeReleasesReservationOnNaturalExit(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
devA, devB := "/dev/dri/renderD888", "/dev/dri/renderD889"
|
||||
fakeDeviceStat(t, devA, devB)
|
||||
|
||||
s, err := StartTranscode(context.Background(), TranscodeOpts{
|
||||
InputPath: "/nonexistent/input.mkv",
|
||||
OutputDir: t.TempDir(),
|
||||
SessionID: "hwdevice-natural-exit",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
FFmpegPath: restartableFakeFFmpegScript(t),
|
||||
HWAccel: "qsv",
|
||||
HWDevice: devA + "," + devB,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTranscode: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ffmpeg process did not exit")
|
||||
}
|
||||
if got := hwDeviceActiveCount(devA); got != 0 {
|
||||
t.Fatalf("active count after natural exit = %d, want 0", got)
|
||||
}
|
||||
if err := s.CloseProcess(); err != nil {
|
||||
t.Fatalf("CloseProcess: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartReacquiresSameGPUAfterNaturalExit(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
devA, devB := "/dev/dri/renderD888", "/dev/dri/renderD889"
|
||||
configured := devA + "," + devB
|
||||
fakeDeviceStat(t, devA, devB)
|
||||
|
||||
s, err := StartTranscode(context.Background(), TranscodeOpts{
|
||||
InputPath: "/nonexistent/input.mkv",
|
||||
OutputDir: t.TempDir(),
|
||||
SessionID: "hwdevice-restart",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
FFmpegPath: restartableFakeFFmpegScript(t),
|
||||
HWAccel: "qsv",
|
||||
HWDevice: configured,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTranscode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = s.CloseProcess() })
|
||||
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("initial ffmpeg process did not exit")
|
||||
}
|
||||
|
||||
occupiedDevice, releaseOccupant := AcquireHWDevice(configured, "qsv")
|
||||
defer releaseOccupant()
|
||||
if occupiedDevice != devA {
|
||||
t.Fatalf("device selected after natural exit = %q, want released device %q", occupiedDevice, devA)
|
||||
}
|
||||
|
||||
if err := s.Restart(context.Background(), 10, 5); err != nil {
|
||||
t.Fatalf("Restart: %v", err)
|
||||
}
|
||||
if got := s.Opts().HWDevice; got != devA {
|
||||
t.Fatalf("restart device = %q, want original device %q", got, devA)
|
||||
}
|
||||
if got := hwDeviceActiveCount(devA); got != 2 {
|
||||
t.Fatalf("active count while restarted process runs = %d, want 2", got)
|
||||
}
|
||||
if got := hwDeviceActiveCount(devB); got != 0 {
|
||||
t.Fatalf("alternate device active count = %d, want 0", got)
|
||||
}
|
||||
|
||||
if err := s.CloseProcess(); err != nil {
|
||||
t.Fatalf("CloseProcess: %v", err)
|
||||
}
|
||||
if got := hwDeviceActiveCount(devA); got != 1 {
|
||||
t.Fatalf("active count after restarted process exit = %d, want occupant only (1)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartReleasesReservationOnSpawnFailure(t *testing.T) {
|
||||
resetDeviceLoad(t)
|
||||
devA, devB := "/dev/dri/renderD888", "/dev/dri/renderD889"
|
||||
fakeDeviceStat(t, devA, devB)
|
||||
bin := restartableFakeFFmpegScript(t)
|
||||
|
||||
s, err := StartTranscode(context.Background(), TranscodeOpts{
|
||||
InputPath: "/nonexistent/input.mkv",
|
||||
OutputDir: t.TempDir(),
|
||||
SessionID: "hwdevice-restart-spawn-fail",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
FFmpegPath: bin,
|
||||
HWAccel: "qsv",
|
||||
HWDevice: devA + "," + devB,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StartTranscode: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.Done():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("initial ffmpeg process did not exit")
|
||||
}
|
||||
if err := os.Remove(bin); err != nil {
|
||||
t.Fatalf("remove fake ffmpeg: %v", err)
|
||||
}
|
||||
|
||||
if err := s.Restart(context.Background(), 10, 5); err == nil {
|
||||
t.Fatal("Restart succeeded with a missing ffmpeg binary")
|
||||
}
|
||||
if got := hwDeviceActiveCount(devA); got != 0 {
|
||||
t.Fatalf("active count after failed restart spawn = %d, want 0", got)
|
||||
}
|
||||
if err := s.CloseProcess(); err != nil {
|
||||
t.Fatalf("CloseProcess: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -160,6 +160,116 @@ func TestBuildPlaybackManifest_EncodedTranscodeUsesSyntheticVODManifest(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlaybackManifest_LongEncodedTranscodeUsesRealManifest(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
manifest := strings.Join([]string{
|
||||
"#EXTM3U",
|
||||
"#EXT-X-VERSION:3",
|
||||
"#EXT-X-TARGETDURATION:2",
|
||||
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||
"#EXTINF:2.000000,",
|
||||
"seg_00000.ts",
|
||||
"#EXTINF:2.000000,",
|
||||
"seg_00001.ts",
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(filepath.Join(tempDir, "stream.m3u8"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
|
||||
session := &TranscodeSession{
|
||||
outputDir: tempDir,
|
||||
opts: TranscodeOpts{
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
TotalDuration: 1_000_000,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := session.BuildPlaybackManifest("segment/", "token=test")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildPlaybackManifest: %v", err)
|
||||
}
|
||||
|
||||
text := string(got)
|
||||
if strings.Contains(text, "#EXT-X-PLAYLIST-TYPE:VOD") ||
|
||||
strings.Contains(text, "seg_499999.ts") {
|
||||
t.Fatalf("long encoded manifest should not synthesize every segment:\n%s", text)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||
"segment/seg_00000.ts?token=test",
|
||||
"segment/seg_00001.ts?token=test",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("manifest missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSourceAlignedPlaybackManifestAnchorsSeekedRealPlaylist(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
manifest := strings.Join([]string{
|
||||
"#EXTM3U",
|
||||
"#EXT-X-VERSION:3",
|
||||
"#EXT-X-TARGETDURATION:2",
|
||||
"#EXT-X-MEDIA-SEQUENCE:8",
|
||||
"#EXTINF:2.000000,",
|
||||
"seg_00008.ts",
|
||||
"#EXTINF:2.000000,",
|
||||
"seg_00009.ts",
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(filepath.Join(tempDir, "stream.m3u8"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
|
||||
session := &TranscodeSession{
|
||||
outputDir: tempDir,
|
||||
opts: TranscodeOpts{
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
TotalDuration: 1_000_000,
|
||||
SeekSeconds: 17.3,
|
||||
StartSegmentNumber: 8,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := session.BuildSourceAlignedPlaybackManifest("segment/", "source_timeline=1")
|
||||
if err != nil {
|
||||
t.Fatalf("BuildSourceAlignedPlaybackManifest: %v", err)
|
||||
}
|
||||
text := string(got)
|
||||
for _, want := range []string{
|
||||
"#EXT-X-VERSION:8",
|
||||
"#EXT-X-TARGETDURATION:3",
|
||||
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||
"#EXT-X-GAP\n#EXTINF:2.162500,\nsegment/source_timeline_gap.ts?source_timeline=1",
|
||||
"segment/seg_00008.ts?source_timeline=1",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("source-aligned manifest missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if gap := strings.Index(text, "source_timeline_gap.ts"); gap < 0 || gap > strings.Index(text, "seg_00008.ts") {
|
||||
t.Fatalf("timeline gap must precede the first real segment:\n%s", text)
|
||||
}
|
||||
if count := strings.Count(text, "#EXT-X-GAP"); count != 8 {
|
||||
t.Fatalf("timeline gap count = %d, want 8:\n%s", count, text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanGenerateSyntheticManifestBoundsSegmentCount(t *testing.T) {
|
||||
if !CanGenerateSyntheticManifest(100_000, 2) {
|
||||
t.Fatal("historical 50,000-segment manifest should remain supported")
|
||||
}
|
||||
if CanGenerateSyntheticManifest(100_001, 2) {
|
||||
t.Fatal("manifest above 50,000 segments should use the real playlist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlaybackManifest_UnknownDurationRejectsBrokenManifest(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
manifest := strings.Join([]string{
|
||||
|
||||
@@ -79,6 +79,12 @@ type HTTPRoutesClient struct {
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type WatchSyncProviderClient struct {
|
||||
client pluginv1.WatchSyncProviderClient
|
||||
deviceAuthorization pluginv1.WatchSyncDeviceAuthorizationServiceClient
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func newClient(installationID int, rpc *sdkruntime.Client, manifest *pluginv1.PluginManifest) *Client {
|
||||
capabilities := make(map[string]*pluginv1.CapabilityDescriptor, len(manifest.GetCapabilities()))
|
||||
for _, capability := range manifest.GetCapabilities() {
|
||||
@@ -208,6 +214,17 @@ func (c *Client) HTTPRoutes(capabilityID string) (*HTTPRoutesClient, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) WatchSyncProvider(capabilityID string) (*WatchSyncProviderClient, error) {
|
||||
if err := c.requireCapability("watch_sync_provider.v1", capabilityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WatchSyncProviderClient{
|
||||
client: c.rpc.WatchSyncProvider(),
|
||||
deviceAuthorization: c.rpc.WatchSyncDeviceAuthorization(),
|
||||
timeout: DefaultWatchSyncTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) markUnhealthy() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -385,6 +402,48 @@ func (c *HTTPRoutesClient) Handle(ctx context.Context, req *pluginv1.HandleHTTPR
|
||||
return c.client.Handle(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) ExchangeAPIKey(ctx context.Context, req *pluginv1.WatchSyncExchangeAPIKeyRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.ExchangeAPIKey(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) StartDeviceAuthorization(ctx context.Context, req *pluginv1.WatchSyncDeviceAuthorizationServiceStartRequest) (*pluginv1.WatchSyncDeviceAuthorizationServiceStartResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.deviceAuthorization.Start(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) PollDeviceAuthorization(ctx context.Context, req *pluginv1.WatchSyncDeviceAuthorizationServicePollRequest) (*pluginv1.WatchSyncDeviceAuthorizationServicePollResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.deviceAuthorization.Poll(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) RefreshCredentials(ctx context.Context, req *pluginv1.WatchSyncRefreshCredentialsRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.RefreshCredentials(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) GetAccount(ctx context.Context, req *pluginv1.WatchSyncGetAccountRequest) (*pluginv1.WatchSyncGetAccountResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.GetAccount(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) ApplyEvents(ctx context.Context, req *pluginv1.WatchSyncApplyEventsRequest) (*pluginv1.WatchSyncApplyEventsResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.ApplyEvents(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) ListRemoteState(ctx context.Context, req *pluginv1.WatchSyncListRemoteStateRequest) (*pluginv1.WatchSyncListRemoteStateResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.ListRemoteState(callCtx, req)
|
||||
}
|
||||
|
||||
func ensureDeadline(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
|
||||
@@ -23,6 +23,9 @@ const (
|
||||
DefaultEventTimeout = 10 * time.Second
|
||||
DefaultAuthTimeout = 10 * time.Second
|
||||
DefaultRouteTimeout = 10 * time.Second
|
||||
// Watch-sync providers may resolve cold external metadata before applying
|
||||
// an event. Bound that work without cutting off valid slow provider calls.
|
||||
DefaultWatchSyncTimeout = 2 * time.Minute
|
||||
// DefaultRequestRouterTimeout bounds a single request_router RPC.
|
||||
// Fulfillment hits remote arr instances, so allow generous headroom.
|
||||
DefaultRequestRouterTimeout = 60 * time.Second
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// ConfigSchemaView is the transport-safe representation of a manifest config
|
||||
// schema used by non-admin setup surfaces.
|
||||
type ConfigSchemaView struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
JSONSchema string `json:"json_schema"`
|
||||
Required bool `json:"required"`
|
||||
AdminForm *AdminFormView `json:"admin_form,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormView struct {
|
||||
Fields []AdminFormFieldView `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []AdminFormSectionView `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormFieldView struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []AdminFormOptionView `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []AdminFormConditionView `json:"show_when,omitempty"`
|
||||
Validation *AdminFormValidationView `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormOptionView struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormConditionView struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type AdminFormValidationView struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormSectionView struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []AdminFormConditionView `json:"show_when,omitempty"`
|
||||
}
|
||||
|
||||
func ConfigSchemaViews(schemas []*pluginv1.ConfigSchema) []ConfigSchemaView {
|
||||
views := make([]ConfigSchemaView, 0, len(schemas))
|
||||
for _, schema := range schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, ConfigSchemaView{
|
||||
Key: schema.GetKey(),
|
||||
Title: schema.GetTitle(),
|
||||
Description: schema.GetDescription(),
|
||||
JSONSchema: schema.GetJsonSchema(),
|
||||
Required: schema.GetRequired(),
|
||||
AdminForm: AdminFormViewFromProto(schema.GetAdminForm()),
|
||||
})
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func AdminFormViewFromProto(form *pluginv1.AdminFormDescriptor) *AdminFormView {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]AdminFormFieldView, 0, len(form.GetFields()))
|
||||
for _, field := range form.GetFields() {
|
||||
if field == nil {
|
||||
continue
|
||||
}
|
||||
options := make([]AdminFormOptionView, 0, len(field.GetOptions()))
|
||||
for _, option := range field.GetOptions() {
|
||||
if option != nil {
|
||||
options = append(options, AdminFormOptionView{Value: option.GetValue(), Label: option.GetLabel(), Description: option.GetDescription()})
|
||||
}
|
||||
}
|
||||
var defaultValue any
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *AdminFormValidationView
|
||||
if value := field.GetValidation(); value != nil {
|
||||
validation = &AdminFormValidationView{
|
||||
HasMin: value.GetHasMin(), Min: value.GetMin(), HasMax: value.GetHasMax(), Max: value.GetMax(),
|
||||
Pattern: value.GetPattern(), MinLength: value.GetMinLength(), MaxLength: value.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
fields = append(fields, AdminFormFieldView{
|
||||
Key: field.GetKey(), Label: field.GetLabel(), Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(), Required: field.GetRequired(), Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(), DefaultValue: defaultValue, Options: options, Rows: field.GetRows(),
|
||||
DynamicOptions: field.GetDynamicOptions(), ShowWhen: adminFormConditionViews(field.GetShowWhen()),
|
||||
Validation: validation, ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]AdminFormSectionView, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, AdminFormSectionView{
|
||||
Key: section.GetKey(), Title: section.GetTitle(), Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(), CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...), ShowWhen: adminFormConditionViews(section.GetShowWhen()),
|
||||
})
|
||||
}
|
||||
return &AdminFormView{Fields: fields, SubmitLabel: form.GetSubmitLabel(), Sections: sections}
|
||||
}
|
||||
|
||||
func adminFormConditionViews(conditions []*pluginv1.AdminFormCondition) []AdminFormConditionView {
|
||||
views := make([]AdminFormConditionView, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition != nil {
|
||||
views = append(views, AdminFormConditionView{Field: condition.GetField(), Equals: append([]string(nil), condition.GetEquals()...)})
|
||||
}
|
||||
}
|
||||
return views
|
||||
}
|
||||
@@ -16,9 +16,16 @@ func GlobalConfigFieldSets(
|
||||
manifest *pluginv1.PluginManifest,
|
||||
configKey string,
|
||||
) (publicFields, secretFields []string) {
|
||||
return ConfigSchemaFieldSets(globalConfigSchema(manifest, configKey))
|
||||
}
|
||||
|
||||
// ConfigSchemaFieldSets returns the top-level public and secret fields for one
|
||||
// configuration schema. JSON Schema annotations and Admin form controls are
|
||||
// both authoritative; undeclared fields are intentionally absent from both
|
||||
// sets so callers can keep them on the protected path.
|
||||
func ConfigSchemaFieldSets(schema *pluginv1.ConfigSchema) (publicFields, secretFields []string) {
|
||||
declared := make(map[string]struct{})
|
||||
secrets := make(map[string]struct{})
|
||||
schema := globalConfigSchema(manifest, configKey)
|
||||
if schema != nil {
|
||||
if form := schema.GetAdminForm(); form != nil {
|
||||
for _, field := range form.GetFields() {
|
||||
|
||||
@@ -37,6 +37,7 @@ type pluginClient interface {
|
||||
EventConsumer(capabilityID string) (*pluginhost.EventConsumerClient, error)
|
||||
AuthProvider(capabilityID string) (*pluginhost.AuthProviderClient, error)
|
||||
HTTPRoutes(capabilityID string) (*pluginhost.HTTPRoutesClient, error)
|
||||
WatchSyncProvider(capabilityID string) (*pluginhost.WatchSyncProviderClient, error)
|
||||
}
|
||||
|
||||
type Host interface {
|
||||
@@ -648,6 +649,18 @@ func (s *Service) ScanSourceClientByPluginID(
|
||||
return s.ScanSourceClient(ctx, matches[0].ID, capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) WatchSyncProviderClient(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
capabilityID string,
|
||||
) (*pluginhost.WatchSyncProviderClient, error) {
|
||||
client, err := s.ensureClient(ctx, installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.WatchSyncProvider(capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) EventConsumerClient(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
|
||||
@@ -237,6 +237,10 @@ func (f *fakePluginClient) HTTPRoutes(string) (*pluginhost.HTTPRoutesClient, err
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePluginClient) WatchSyncProvider(string) (*pluginhost.WatchSyncProviderClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fakeServiceInstallationStore struct {
|
||||
byID map[int]*Installation
|
||||
byPluginID map[string][]*Installation
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// WatchSyncProviderConfig returns the installation's global configuration in
|
||||
// the transient, field-classified shape used by watch-sync RPCs. Undeclared
|
||||
// fields are treated as secret so manifest drift cannot expose credentials.
|
||||
func (s *Service) WatchSyncProviderConfig(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
) (*pluginv1.WatchSyncProviderConfig, error) {
|
||||
manifest, err := s.manifestForInstallation(ctx, installationID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.configs == nil {
|
||||
return &pluginv1.WatchSyncProviderConfig{}, nil
|
||||
}
|
||||
configs, err := s.configs.ListGlobalConfigs(ctx, installationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list watch sync plugin config: %w", err)
|
||||
}
|
||||
return watchSyncProviderConfig(manifest, configs)
|
||||
}
|
||||
|
||||
func watchSyncProviderConfig(
|
||||
manifest *pluginv1.PluginManifest,
|
||||
configs []*RuntimeConfig,
|
||||
) (*pluginv1.WatchSyncProviderConfig, error) {
|
||||
result := &pluginv1.WatchSyncProviderConfig{
|
||||
Values: make(map[string]string),
|
||||
SecretValues: make(map[string]string),
|
||||
}
|
||||
sort.Slice(configs, func(i, j int) bool {
|
||||
if configs[i] == nil {
|
||||
return false
|
||||
}
|
||||
if configs[j] == nil {
|
||||
return true
|
||||
}
|
||||
return configs[i].Key < configs[j].Key
|
||||
})
|
||||
for _, config := range configs {
|
||||
if config == nil {
|
||||
continue
|
||||
}
|
||||
configKey := strings.TrimSpace(config.Key)
|
||||
if configKey == "" {
|
||||
continue
|
||||
}
|
||||
publicFields, _ := GlobalConfigFieldSets(manifest, configKey)
|
||||
public := stringSet(publicFields)
|
||||
for field, raw := range config.Value {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
value, err := watchSyncConfigString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode watch sync plugin config %q.%s: %w", configKey, field, err)
|
||||
}
|
||||
key := configKey + "." + field
|
||||
if _, isPublic := public[field]; isPublic {
|
||||
result.Values[key] = value
|
||||
continue
|
||||
}
|
||||
// Explicitly secret and undeclared fields both take the protected path.
|
||||
result.SecretValues[key] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func stringSet(values []string) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func watchSyncConfigString(value any) (string, error) {
|
||||
if text, ok := value.(string); ok {
|
||||
return text, nil
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
func TestWatchSyncProviderConfigClassifiesManifestFields(t *testing.T) {
|
||||
manifest := &pluginv1.PluginManifest{GlobalConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "provider",
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{
|
||||
{Key: "base_url", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT},
|
||||
{Key: "client_secret", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD, Secret: true},
|
||||
}},
|
||||
}}}
|
||||
config, err := watchSyncProviderConfig(manifest, []*RuntimeConfig{nil, {
|
||||
Key: " provider ",
|
||||
Value: map[string]any{
|
||||
" base_url ": "https://floppy.example",
|
||||
"client_secret": "secret",
|
||||
"undeclared": map[string]any{"token": "also-secret"},
|
||||
" ": "ignored-empty-field",
|
||||
},
|
||||
}, {
|
||||
Key: "legacy",
|
||||
Value: map[string]any{
|
||||
"base_url": "https://legacy-floppy.example",
|
||||
},
|
||||
}, {
|
||||
Key: " ",
|
||||
Value: map[string]any{
|
||||
"base_url": "ignored-empty-key",
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.GetValues()["provider.base_url"] != "https://floppy.example" ||
|
||||
config.GetSecretValues()["provider.client_secret"] != "secret" ||
|
||||
config.GetSecretValues()["provider.undeclared"] != `{"token":"also-secret"}` ||
|
||||
config.GetSecretValues()["legacy.base_url"] != "https://legacy-floppy.example" {
|
||||
t.Fatalf("config = %#v", config)
|
||||
}
|
||||
if _, exposed := config.GetValues()["provider.undeclared"]; exposed {
|
||||
t.Fatal("undeclared plugin config was exposed as public")
|
||||
}
|
||||
if _, present := config.GetSecretValues()["."]; present {
|
||||
t.Fatal("empty plugin config key or field was emitted")
|
||||
}
|
||||
}
|
||||
+124
-8
@@ -266,10 +266,18 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
|
||||
|
||||
const (
|
||||
maxReasonableMediaDurationSeconds = 100_000
|
||||
// Corroborated metadata and packet-derived durations have stronger evidence
|
||||
// than a lone container timestamp, so they may use the same bounded ceiling
|
||||
// as long-form audio. This supports multi-day video without accepting the
|
||||
// multi-year timelines seen in malformed containers.
|
||||
maxValidatedMediaDurationSeconds = 1_000_000
|
||||
// Audio-only files (audiobooks, podcasts) legitimately exceed the video
|
||||
// ceiling, but still need a cap so malformed containers cannot persist
|
||||
// multi-year durations.
|
||||
maxReasonableAudioDurationSeconds = 1_000_000
|
||||
maxReasonableAudioDurationSeconds = maxValidatedMediaDurationSeconds
|
||||
|
||||
longVideoDurationAbsoluteToleranceSeconds = 1
|
||||
longVideoDurationRelativeTolerance = 0.001
|
||||
)
|
||||
|
||||
// A video duration is implausible when it is either far too short in absolute
|
||||
@@ -346,9 +354,90 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) {
|
||||
if duration > 0 && !durationLooksImplausible(raw, duration) {
|
||||
return truncatedDuration(duration), true
|
||||
}
|
||||
if duration, ok := corroboratedLongVideoDuration(raw, formatDuration); ok {
|
||||
return truncatedDuration(duration), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// corroboratedLongVideoDuration accepts an extended-range video duration only
|
||||
// when the container and the primary video stream independently report nearly the
|
||||
// same value. A small absolute/relative tolerance covers container rounding
|
||||
// and stream-boundary differences without trusting a lone malformed timeline.
|
||||
func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) (float64, bool) {
|
||||
if raw == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for _, stream := range raw.Streams {
|
||||
if !isMainVideoStream(stream) {
|
||||
continue
|
||||
}
|
||||
streamDuration := parseFloat(stream.Duration)
|
||||
formatStart := parseFloat(raw.Format.StartTime)
|
||||
streamStart := parseFloat(stream.StartTime)
|
||||
|
||||
// Some MPEG-TS/HLS timelines report duration as an absolute end
|
||||
// timestamp. Use normalized spans to reconcile raw end timestamps that
|
||||
// disagree, or when matching timestamps have a dominant start offset that
|
||||
// strongly indicates the absolute-end shape. Ordinary non-zero starts remain
|
||||
// part of an already corroborated raw duration.
|
||||
normalizedFormatDuration := durationAfterStartWithinValidatedLimit(
|
||||
formatDuration,
|
||||
formatStart,
|
||||
)
|
||||
normalizedStreamDuration := durationAfterStartWithinValidatedLimit(
|
||||
streamDuration,
|
||||
streamStart,
|
||||
)
|
||||
rawDurationsAgree := longVideoDurationsAgree(formatDuration, streamDuration)
|
||||
normalizedDurationsAgree := longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration)
|
||||
matchingAbsoluteEnds := rawDurationsAgree &&
|
||||
durationHasDominantStartOffset(formatDuration, formatStart) &&
|
||||
durationHasDominantStartOffset(streamDuration, streamStart)
|
||||
if normalizedDurationsAgree && (!rawDurationsAgree || matchingAbsoluteEnds) &&
|
||||
!durationLooksImplausible(raw, normalizedFormatDuration) {
|
||||
return normalizedFormatDuration, true
|
||||
}
|
||||
if matchingAbsoluteEnds {
|
||||
// Dominant starts identify the raw values as absolute end
|
||||
// timestamps. If their normalized spans do not corroborate, reject
|
||||
// the metadata for packet repair instead of persisting an inflated
|
||||
// raw end timestamp.
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if rawDurationsAgree &&
|
||||
!durationLooksImplausible(raw, formatDuration) {
|
||||
return formatDuration, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func longVideoDurationsAgree(first, second float64) bool {
|
||||
if first <= maxReasonableMediaDurationSeconds ||
|
||||
!durationIsWithinValidatedLimit(first) ||
|
||||
!durationIsWithinValidatedLimit(second) {
|
||||
return false
|
||||
}
|
||||
tolerance := max(
|
||||
longVideoDurationAbsoluteToleranceSeconds,
|
||||
max(first, second)*longVideoDurationRelativeTolerance,
|
||||
)
|
||||
return math.Abs(first-second) <= tolerance
|
||||
}
|
||||
|
||||
// durationHasDominantStartOffset identifies the conservative absolute-end
|
||||
// shape where the start timestamp occupies at least half of the reported end.
|
||||
// Smaller starts are common media offsets and cannot disambiguate a duration
|
||||
// field from an absolute end timestamp.
|
||||
func durationHasDominantStartOffset(end, start float64) bool {
|
||||
return start > 0 && end > start && start >= end-start
|
||||
}
|
||||
|
||||
func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool {
|
||||
if raw == nil {
|
||||
return false
|
||||
@@ -368,10 +457,25 @@ func durationAfterStart(end, start float64) float64 {
|
||||
return duration
|
||||
}
|
||||
|
||||
func durationAfterStartWithinValidatedLimit(end, start float64) float64 {
|
||||
if start <= 0 || end <= start {
|
||||
return 0
|
||||
}
|
||||
duration := end - start
|
||||
if !durationIsWithinValidatedLimit(duration) {
|
||||
return 0
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
func durationIsReasonable(duration float64) bool {
|
||||
return durationIsPositiveFinite(duration) && duration <= maxReasonableMediaDurationSeconds
|
||||
}
|
||||
|
||||
func durationIsWithinValidatedLimit(duration float64) bool {
|
||||
return durationIsPositiveFinite(duration) && duration <= maxValidatedMediaDurationSeconds
|
||||
}
|
||||
|
||||
func durationIsPositiveFinite(duration float64) bool {
|
||||
return duration > 0 && !math.IsNaN(duration) && !math.IsInf(duration, 0)
|
||||
}
|
||||
@@ -458,18 +562,30 @@ func estimateVideoPacketDuration(reader io.Reader, frameRate string) int {
|
||||
maxTimestamp = max(maxTimestamp, timestamp)
|
||||
}
|
||||
|
||||
best := 0.0
|
||||
packetSpan := 0.0
|
||||
if !math.IsInf(minTimestamp, 1) && !math.IsInf(maxTimestamp, -1) {
|
||||
span := maxTimestamp - minTimestamp
|
||||
if durationIsReasonable(span) {
|
||||
best = span
|
||||
if durationIsWithinValidatedLimit(span) {
|
||||
packetSpan = span
|
||||
}
|
||||
}
|
||||
|
||||
frameDuration := 0.0
|
||||
if fps := parseFrameRate(frameRate); fps > 0 && packetCount > 0 {
|
||||
frameDuration := float64(packetCount) / fps
|
||||
if durationIsReasonable(frameDuration) && frameDuration > best {
|
||||
best = frameDuration
|
||||
}
|
||||
frameDuration = float64(packetCount) / fps
|
||||
}
|
||||
|
||||
best := packetSpan
|
||||
if packetSpan > maxReasonableMediaDurationSeconds &&
|
||||
durationIsReasonable(frameDuration) &&
|
||||
!longVideoDurationsAgree(packetSpan, frameDuration) {
|
||||
// A long PTS span is strong evidence only when a sane frame-count
|
||||
// estimate contradicts it. Malformed frame rates can produce finite but
|
||||
// unusable estimates and must not veto an otherwise valid packet span.
|
||||
best = 0
|
||||
}
|
||||
if durationIsReasonable(frameDuration) && frameDuration > best {
|
||||
best = frameDuration
|
||||
}
|
||||
if best <= 0 {
|
||||
return 0
|
||||
|
||||
@@ -96,6 +96,241 @@ func TestDurationFromProbeMetadataKeepsLongAudioDurationInSeconds(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
Duration: "182930.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
Duration: "182930.196000",
|
||||
AvgFrameRate: "24/1",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 182930 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithOrdinaryStartTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "30.000000",
|
||||
Duration: "182930.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "30.000000",
|
||||
Duration: "182930.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 182930 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithMaterialStartTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "300.000000",
|
||||
Duration: "182930.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "300.000000",
|
||||
Duration: "182930.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 182930 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataNormalizesOffsetBeforeCorroboratingLongVideo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "180000.000000",
|
||||
Duration: "182930.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "180000.000000",
|
||||
Duration: "182930.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 2930 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 2930, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataNormalizesCorroboratedLongOffsetSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350000.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "200000.000000",
|
||||
Duration: "370000.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 170000 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataNormalizesMatchingLongAbsoluteEndTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350000.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350000.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 170000 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataRejectsDisagreeingAbsoluteEndSpans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350000.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350300.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if ok || got != 0 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataRejectsUncorroboratedLongVideoDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
formatDuration string
|
||||
streamDuration string
|
||||
}{
|
||||
{name: "missing stream duration", formatDuration: "182930.275000"},
|
||||
{name: "disagreeing durations", formatDuration: "182930.275000", streamDuration: "150000.000000"},
|
||||
{name: "beyond validated limit", formatDuration: "1000001.000000", streamDuration: "1000001.000000"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{Duration: tc.formatDuration, Size: "77507139196"},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
Duration: tc.streamDuration,
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if ok || got != 0 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataDoesNotUseSecondaryVideoForCorroboration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{Duration: "182930.275000", Size: "77507139196"},
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", Duration: "150000.000000"},
|
||||
{CodecType: "video", Duration: "182930.196000"},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if ok || got != 0 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeFileSkipsPacketScanForCorroboratedLongVideo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
ffprobePath := filepath.Join(tempDir, "ffprobe")
|
||||
packetScanMarker := filepath.Join(tempDir, "packet-scan-called")
|
||||
script := `#!/bin/sh
|
||||
case " $* " in
|
||||
*" -show_format "*)
|
||||
printf '%s\n' '{"format":{"duration":"182930.275000","size":"77507139196"},"streams":[{"codec_type":"video","duration":"182930.196000","avg_frame_rate":"24/1"}]}'
|
||||
;;
|
||||
*)
|
||||
: > "` + packetScanMarker + `"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(ffprobePath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("writing fake ffprobe: %v", err)
|
||||
}
|
||||
|
||||
probe, err := ProbeFile(context.Background(), ffprobePath, "long.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeFile() returned error: %v", err)
|
||||
}
|
||||
if probe.Duration != 182930 {
|
||||
t.Fatalf("ProbeFile() duration = %d, want 182930", probe.Duration)
|
||||
}
|
||||
if _, err := os.Stat(packetScanMarker); !os.IsNotExist(err) {
|
||||
t.Fatalf("packet fallback ran for corroborated metadata; stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -106,6 +341,72 @@ func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationKeepsValidatedLongDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
packets := strings.NewReader("0.000000\n182930.196000\n")
|
||||
got := estimateVideoPacketDuration(packets, "")
|
||||
if got != 182930 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 182930", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationRejectsDurationBeyondValidatedLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
packets := strings.NewReader("0.000000\n1000001.000000\n")
|
||||
got := estimateVideoPacketDuration(packets, "")
|
||||
if got != 0 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationKeepsOrdinaryCapForFrameRateEstimate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var packets strings.Builder
|
||||
for range 900 {
|
||||
packets.WriteString("3.022000\n")
|
||||
}
|
||||
|
||||
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "1/1000")
|
||||
if got != 0 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationIgnoresUnusableFrameEstimateForLongSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var packets strings.Builder
|
||||
packets.WriteString("0.000000\n")
|
||||
for range 898 {
|
||||
packets.WriteString("5.000000\n")
|
||||
}
|
||||
packets.WriteString("182930.196000\n")
|
||||
|
||||
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "1/1000")
|
||||
if got != 182930 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 182930", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationRejectsOutlierSpanWhenFrameCountDisagrees(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var packets strings.Builder
|
||||
packets.WriteString("0.000000\n")
|
||||
for range 298 {
|
||||
packets.WriteString("5.000000\n")
|
||||
}
|
||||
packets.WriteString("500000.000000\n")
|
||||
|
||||
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "30/1")
|
||||
if got != 10 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationUsesFrameCountForCollapsedTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -102,6 +102,44 @@ func TestNeedsCriticalProbeRepair_ImplausiblyShortLargeVideoRepairs(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsCriticalProbeRepair_LongVideoConverges(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
f := &models.MediaFile{
|
||||
ProbeSource: "local",
|
||||
ProbeUpdatedAt: &now,
|
||||
FileSize: 77_507_139_196,
|
||||
Duration: 182930,
|
||||
Container: "mp4",
|
||||
CodecAudio: "aac",
|
||||
AudioTracks: []models.AudioTrack{{Language: "eng"}},
|
||||
CodecVideo: "h264",
|
||||
Resolution: "1080p",
|
||||
VideoTracks: []models.VideoTrack{{Codec: "h264", ColorRange: "unknown"}},
|
||||
Chapters: []models.MediaChapter{},
|
||||
}
|
||||
|
||||
if NeedsCriticalProbeRepair(f) {
|
||||
t.Fatal("an accepted long video must not need request-time probe repair")
|
||||
}
|
||||
|
||||
scanFile := &scanStateFile{
|
||||
ProbeSource: f.ProbeSource,
|
||||
ProbeUpdatedAt: f.ProbeUpdatedAt,
|
||||
FileSize: f.FileSize,
|
||||
Duration: f.Duration,
|
||||
Container: f.Container,
|
||||
CodecVideo: f.CodecVideo,
|
||||
CodecAudio: f.CodecAudio,
|
||||
Resolution: f.Resolution,
|
||||
HasVideoTracks: true,
|
||||
HasAudioTracks: true,
|
||||
HasChapters: true,
|
||||
}
|
||||
if needsCriticalProbeRepairScanState(scanFile) {
|
||||
t.Fatal("an accepted long video must not be reprobed on subsequent library scans")
|
||||
}
|
||||
}
|
||||
|
||||
// A short duration re-derived by the fixed parser (packet scan) is
|
||||
// authoritative: re-flagging it would reprobe genuinely short clips on every
|
||||
// playback decision forever.
|
||||
|
||||
@@ -22,6 +22,7 @@ type Scope string
|
||||
const (
|
||||
ScopeAccount Scope = "account"
|
||||
ScopeProfile Scope = "profile"
|
||||
ScopeProfileClient Scope = "profile_client"
|
||||
ScopeProfileDevice Scope = "profile_device"
|
||||
ScopeProfileLibrary Scope = "profile_library"
|
||||
ScopeProfileSeries Scope = "profile_series"
|
||||
@@ -37,6 +38,7 @@ const (
|
||||
var remoteScopes = map[Scope]struct{}{
|
||||
ScopeAccount: {},
|
||||
ScopeProfile: {},
|
||||
ScopeProfileClient: {},
|
||||
ScopeProfileDevice: {},
|
||||
ScopeProfileLibrary: {},
|
||||
ScopeProfileSeries: {},
|
||||
@@ -48,6 +50,31 @@ func (s Scope) IsRemote() bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// ClientFamily identifies a class of like clients whose presentation and
|
||||
// navigation preferences should roam together. It is explicit request
|
||||
// metadata rather than an inference from the device platform string: platform
|
||||
// names are free-form registry metadata, while this value participates in a
|
||||
// canonical storage identity.
|
||||
type ClientFamily string
|
||||
|
||||
const (
|
||||
ClientFamilyTV ClientFamily = "tv"
|
||||
ClientFamilyMobile ClientFamily = "mobile"
|
||||
ClientFamilyTablet ClientFamily = "tablet"
|
||||
ClientFamilyDesktop ClientFamily = "desktop"
|
||||
ClientFamilyWeb ClientFamily = "web"
|
||||
)
|
||||
|
||||
// Valid reports whether f is one of the canonical lower-case wire values.
|
||||
func (f ClientFamily) Valid() bool {
|
||||
switch f {
|
||||
case ClientFamilyTV, ClientFamilyMobile, ClientFamilyTablet, ClientFamilyDesktop, ClientFamilyWeb:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Persistence declares who stores a setting's value.
|
||||
type Persistence string
|
||||
|
||||
|
||||
@@ -1588,6 +1588,119 @@ func TestObjectSchemasAcceptTheShapesClientsStore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUICustomizationObjectSchemasAreStrict(t *testing.T) {
|
||||
manifest, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("loading manifest: %v", err)
|
||||
}
|
||||
|
||||
valid := map[string]string{
|
||||
"nav.primary_menu": `{"items":[` +
|
||||
`{"type":"builtin","destination":"home"},` +
|
||||
`{"type":"library","library_id":7,"label":"Movies"},` +
|
||||
`{"type":"section","library_id":7,"section_id":"recently-added","label":"Recently Added"},` +
|
||||
`{"type":"collection","collection_id":"horror","library_id":7,"label":"Horror"}]}`,
|
||||
"nav.shortcuts": `{"items":[` +
|
||||
`{"type":"library","library_id":7,"label":"Movies"},` +
|
||||
`{"type":"section","library_id":7,"section_id":"recently-added","label":"Recently Added"},` +
|
||||
`{"type":"collection","collection_id":"horror","label":"Horror"}]}`,
|
||||
"ui.card_presentation": `{"poster_size":"large","caption":"artwork"}`,
|
||||
}
|
||||
for key, value := range valid {
|
||||
t.Run(key+" accepts canonical shape", func(t *testing.T) {
|
||||
def, ok := manifest.Lookup(key)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not registered", key)
|
||||
}
|
||||
if err := def.ValueSchema.ValidateValue(json.RawMessage(value), objSchemas); err != nil {
|
||||
t.Fatalf("valid %s value rejected: %v", key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
key string
|
||||
value string
|
||||
}{
|
||||
{
|
||||
name: "primary menu requires home",
|
||||
key: "nav.primary_menu",
|
||||
value: `{"items":[{"type":"builtin","destination":"movies"}]}`,
|
||||
},
|
||||
{
|
||||
name: "primary menu allows home exactly once",
|
||||
key: "nav.primary_menu",
|
||||
value: `{"items":[{"type":"builtin","destination":"home"},{"type":"builtin","destination":"home"}]}`,
|
||||
},
|
||||
{
|
||||
name: "menu labels cannot be empty",
|
||||
key: "nav.primary_menu",
|
||||
value: `{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":""}]}`,
|
||||
},
|
||||
{
|
||||
name: "menu labels cannot be only whitespace",
|
||||
key: "nav.primary_menu",
|
||||
value: `{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":" "}]}`,
|
||||
},
|
||||
{
|
||||
name: "menu target ids cannot be only whitespace",
|
||||
key: "nav.primary_menu",
|
||||
value: `{"items":[{"type":"builtin","destination":"home"},{"type":"section","library_id":7,"section_id":" ","label":"Recent"}]}`,
|
||||
},
|
||||
{
|
||||
name: "menu destination identity ignores labels",
|
||||
key: "nav.primary_menu",
|
||||
value: `{"items":[{"type":"builtin","destination":"home"},` +
|
||||
`{"type":"library","library_id":7,"label":"Movies"},` +
|
||||
`{"type":"library","library_id":7,"label":"Films"}]}`,
|
||||
},
|
||||
{
|
||||
name: "shortcuts cannot contain builtins",
|
||||
key: "nav.shortcuts",
|
||||
value: `{"items":[{"type":"builtin","destination":"home"}]}`,
|
||||
},
|
||||
{
|
||||
name: "shortcuts require positive library ids",
|
||||
key: "nav.shortcuts",
|
||||
value: `{"items":[{"type":"library","library_id":0,"label":"Movies"}]}`,
|
||||
},
|
||||
{
|
||||
name: "shortcut target ids cannot be only whitespace",
|
||||
key: "nav.shortcuts",
|
||||
value: `{"items":[{"type":"collection","collection_id":"\t ","label":"Collection"}]}`,
|
||||
},
|
||||
{
|
||||
name: "shortcut destination identity includes collection context",
|
||||
key: "nav.shortcuts",
|
||||
value: `{"items":[` +
|
||||
`{"type":"collection","collection_id":"horror","library_id":7,"label":"Horror"},` +
|
||||
`{"type":"collection","collection_id":"horror","library_id":7,"label":"Scary"}]}`,
|
||||
},
|
||||
{
|
||||
name: "card presets reject unknown sizes",
|
||||
key: "ui.card_presentation",
|
||||
value: `{"poster_size":"huge","caption":"artwork"}`,
|
||||
},
|
||||
{
|
||||
name: "card presets reject extension fields",
|
||||
key: "ui.card_presentation",
|
||||
value: `{"poster_size":"large","caption":"artwork","columns":8}`,
|
||||
},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
def, ok := manifest.Lookup(tc.key)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not registered", tc.key)
|
||||
}
|
||||
if err := def.ValueSchema.ValidateValue(json.RawMessage(tc.value), objSchemas); err == nil {
|
||||
t.Fatalf("invalid %s value was accepted: %s", tc.key, tc.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQualityIsTwoIndependentAxes pins the split that replaced the compound
|
||||
// ladder values. A client composes its own presets from these two, so the
|
||||
// contract must not constrain them jointly.
|
||||
|
||||
@@ -553,6 +553,9 @@ func (v *ValueSchema) ValidateValue(raw json.RawMessage, objectSchemas map[strin
|
||||
if err := schema.Validate(doc); err != nil {
|
||||
return fmt.Errorf("does not satisfy %s: %w", v.SchemaRef, err)
|
||||
}
|
||||
if err := validateObjectSemantics(v.SchemaRef, trimmed); err != nil {
|
||||
return fmt.Errorf("does not satisfy %s: %w", v.SchemaRef, err)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown value type %q", v.Type)
|
||||
@@ -561,6 +564,86 @@ func (v *ValueSchema) ValidateValue(raw json.RawMessage, objectSchemas map[strin
|
||||
return nil
|
||||
}
|
||||
|
||||
type navigationDocument struct {
|
||||
Items []navigationItem `json:"items"`
|
||||
}
|
||||
|
||||
type navigationItem struct {
|
||||
Type string `json:"type"`
|
||||
Destination string `json:"destination"`
|
||||
LibraryID *int `json:"library_id"`
|
||||
SectionID string `json:"section_id"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
type navigationIdentity struct {
|
||||
Type string
|
||||
Destination string
|
||||
LibraryID int
|
||||
HasLibraryID bool
|
||||
SectionID string
|
||||
CollectionID string
|
||||
}
|
||||
|
||||
// validateObjectSemantics holds the few cross-item invariants JSON Schema
|
||||
// cannot express. uniqueItems rejects byte-for-byte duplicate objects, but a
|
||||
// renamed library is still the same destination and must not appear twice in a
|
||||
// menu. Keeping this beside ValidateValue means API writes, migrations, and
|
||||
// manifest defaults all enforce the same identity rule.
|
||||
func validateObjectSemantics(schemaRef string, raw json.RawMessage) error {
|
||||
if schemaRef != "primary-menu.json" && schemaRef != "navigation-shortcuts.json" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var document navigationDocument
|
||||
if err := strictUnmarshal(raw, &document); err != nil {
|
||||
return fmt.Errorf("decoding navigation document: %w", err)
|
||||
}
|
||||
seen := make(map[navigationIdentity]int, len(document.Items))
|
||||
for index, item := range document.Items {
|
||||
identity, err := item.identity()
|
||||
if err != nil {
|
||||
return fmt.Errorf("items[%d]: %w", index, err)
|
||||
}
|
||||
if first, duplicate := seen[identity]; duplicate {
|
||||
return fmt.Errorf("items[%d] repeats the destination from items[%d]", index, first)
|
||||
}
|
||||
seen[identity] = index
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (item navigationItem) identity() (navigationIdentity, error) {
|
||||
identity := navigationIdentity{Type: item.Type}
|
||||
switch item.Type {
|
||||
case "builtin":
|
||||
identity.Destination = item.Destination
|
||||
case "library":
|
||||
if item.LibraryID == nil {
|
||||
return navigationIdentity{}, errors.New("library is missing library_id")
|
||||
}
|
||||
identity.LibraryID = *item.LibraryID
|
||||
identity.HasLibraryID = true
|
||||
case "section":
|
||||
if item.LibraryID == nil {
|
||||
return navigationIdentity{}, errors.New("section is missing library_id")
|
||||
}
|
||||
identity.LibraryID = *item.LibraryID
|
||||
identity.HasLibraryID = true
|
||||
identity.SectionID = item.SectionID
|
||||
case "collection":
|
||||
identity.CollectionID = item.CollectionID
|
||||
if item.LibraryID != nil {
|
||||
identity.LibraryID = *item.LibraryID
|
||||
identity.HasLibraryID = true
|
||||
}
|
||||
default:
|
||||
return navigationIdentity{}, fmt.Errorf("unknown navigation item type %q", item.Type)
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
// NormalizeValue validates a value and returns the form that should be stored.
|
||||
//
|
||||
// Anything that persists a value goes through here rather than ValidateValue,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
package settingskeys
|
||||
|
||||
// Revision is the manifest revision these bindings were generated from.
|
||||
const Revision = 4
|
||||
const Revision = 5
|
||||
|
||||
// Setting keys, one constant per definition.
|
||||
const (
|
||||
@@ -23,6 +23,10 @@ const (
|
||||
DownloadsKeepWatched = "downloads.keep_watched"
|
||||
// Download over Wi-Fi only
|
||||
DownloadsWifiOnly = "downloads.wifi_only"
|
||||
// Primary menu
|
||||
NavPrimaryMenu = "nav.primary_menu"
|
||||
// Navigation shortcuts
|
||||
NavShortcuts = "nav.shortcuts"
|
||||
// Show audiobooks
|
||||
NavShowAudiobooks = "nav.show_audiobooks"
|
||||
// Preferred audio language
|
||||
@@ -85,6 +89,8 @@ const (
|
||||
SubtitleMatchesDevice = "subtitle.matches_device"
|
||||
// Poster badges
|
||||
UiCardOverlays = "ui.card_overlays"
|
||||
// Media cards
|
||||
UiCardPresentation = "ui.card_presentation"
|
||||
// Custom CSS
|
||||
UiCustomCss = "ui.custom_css"
|
||||
// Custom theme variables
|
||||
@@ -119,6 +125,8 @@ const (
|
||||
var Remote = []string{
|
||||
CatalogMetadataLanguage,
|
||||
CatalogMetadataLanguageOverrides,
|
||||
NavPrimaryMenu,
|
||||
NavShortcuts,
|
||||
PlaybackAudioLanguage,
|
||||
PlaybackAutoPlayNext,
|
||||
PlaybackAutoPlayNextPreview,
|
||||
@@ -145,6 +153,7 @@ var Remote = []string{
|
||||
PlayerVideoGravity,
|
||||
SearchMediaScope,
|
||||
UiCardOverlays,
|
||||
UiCardPresentation,
|
||||
UiCustomCss,
|
||||
UiCustomThemeVars,
|
||||
UiDateFormat,
|
||||
|
||||
@@ -47,20 +47,22 @@ type conformanceCase struct {
|
||||
}
|
||||
|
||||
type conformanceContext struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
SeriesIDs []string `json:"series_ids"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
ClientFamily string `json:"client_family"`
|
||||
DeviceID string `json:"device_id"`
|
||||
LibraryIDs []int `json:"library_ids"`
|
||||
SeriesIDs []string `json:"series_ids"`
|
||||
}
|
||||
|
||||
type conformanceRow struct {
|
||||
Key string `json:"key"`
|
||||
Scope string `json:"scope"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
LibraryID int `json:"library_id"`
|
||||
SeriesID string `json:"series_id"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
Key string `json:"key"`
|
||||
Scope string `json:"scope"`
|
||||
ProfileID string `json:"profile_id"`
|
||||
ClientFamily string `json:"client_family"`
|
||||
DeviceID string `json:"device_id"`
|
||||
LibraryID int `json:"library_id"`
|
||||
SeriesID string `json:"series_id"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
}
|
||||
|
||||
type conformanceBinding struct {
|
||||
@@ -146,10 +148,11 @@ func runConformanceCase(t *testing.T, manifest *settingscontract.Manifest, tc co
|
||||
rc := Context{}
|
||||
if tc.Context != nil {
|
||||
rc = Context{
|
||||
ProfileID: tc.Context.ProfileID,
|
||||
DeviceID: tc.Context.DeviceID,
|
||||
LibraryIDs: tc.Context.LibraryIDs,
|
||||
SeriesIDs: tc.Context.SeriesIDs,
|
||||
ProfileID: tc.Context.ProfileID,
|
||||
ClientFamily: settingscontract.ClientFamily(tc.Context.ClientFamily),
|
||||
DeviceID: tc.Context.DeviceID,
|
||||
LibraryIDs: tc.Context.LibraryIDs,
|
||||
SeriesIDs: tc.Context.SeriesIDs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,12 +163,13 @@ func runConformanceCase(t *testing.T, manifest *settingscontract.Manifest, tc co
|
||||
}
|
||||
rows = append(rows, userstore.SettingValue{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: stored.Key,
|
||||
Scope: settingscontract.Scope(stored.Scope),
|
||||
ProfileID: stored.ProfileID,
|
||||
DeviceID: stored.DeviceID,
|
||||
LibraryID: stored.LibraryID,
|
||||
SeriesID: stored.SeriesID,
|
||||
Key: stored.Key,
|
||||
Scope: settingscontract.Scope(stored.Scope),
|
||||
ProfileID: stored.ProfileID,
|
||||
ClientFamily: settingscontract.ClientFamily(stored.ClientFamily),
|
||||
DeviceID: stored.DeviceID,
|
||||
LibraryID: stored.LibraryID,
|
||||
SeriesID: stored.SeriesID,
|
||||
},
|
||||
Value: stored.Value,
|
||||
})
|
||||
|
||||
@@ -32,8 +32,9 @@ import (
|
||||
// profile_series. That is what lets one code path serve an identified client,
|
||||
// an anonymous jellycompat seed, and a batch spanning many series.
|
||||
type Context struct {
|
||||
ProfileID string
|
||||
DeviceID string
|
||||
ProfileID string
|
||||
ClientFamily settingscontract.ClientFamily
|
||||
DeviceID string
|
||||
// LibraryIDs and SeriesIDs are the content contexts in play. A batch
|
||||
// resolving a season passes every id once rather than resolving per item.
|
||||
LibraryIDs []int
|
||||
@@ -131,11 +132,12 @@ func (r *Resolver) Resolve(
|
||||
}
|
||||
|
||||
stored, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
|
||||
Keys: known,
|
||||
ProfileIDs: nonEmpty(rc.ProfileID),
|
||||
DeviceID: rc.DeviceID,
|
||||
LibraryIDs: rc.LibraryIDs,
|
||||
SeriesIDs: rc.SeriesIDs,
|
||||
Keys: known,
|
||||
ProfileIDs: nonEmpty(rc.ProfileID),
|
||||
ClientFamily: rc.ClientFamily,
|
||||
DeviceID: rc.DeviceID,
|
||||
LibraryIDs: rc.LibraryIDs,
|
||||
SeriesIDs: rc.SeriesIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("settingsresolve: reading candidates: %w", err)
|
||||
@@ -168,11 +170,15 @@ func (r *Resolver) ResolveContexts(
|
||||
}
|
||||
|
||||
deviceID := contexts[0].DeviceID
|
||||
query := userstore.SettingResolutionQuery{Keys: known, DeviceID: deviceID}
|
||||
clientFamily := contexts[0].ClientFamily
|
||||
query := userstore.SettingResolutionQuery{Keys: known, ClientFamily: clientFamily, DeviceID: deviceID}
|
||||
for _, rc := range contexts {
|
||||
if rc.DeviceID != deviceID {
|
||||
return nil, fmt.Errorf("settingsresolve: batched contexts use different devices")
|
||||
}
|
||||
if rc.ClientFamily != clientFamily {
|
||||
return nil, fmt.Errorf("settingsresolve: batched contexts use different client families")
|
||||
}
|
||||
if rc.ProfileID != "" {
|
||||
query.ProfileIDs = append(query.ProfileIDs, rc.ProfileID)
|
||||
}
|
||||
@@ -352,6 +358,10 @@ func pickForScope(
|
||||
if row.ProfileID == rc.ProfileID {
|
||||
matches = append(matches, row)
|
||||
}
|
||||
case settingscontract.ScopeProfileClient:
|
||||
if row.ProfileID == rc.ProfileID && row.ClientFamily == rc.ClientFamily && rc.ClientFamily.Valid() {
|
||||
matches = append(matches, row)
|
||||
}
|
||||
case settingscontract.ScopeProfileDevice:
|
||||
if row.ProfileID == rc.ProfileID && row.DeviceID == rc.DeviceID && rc.DeviceID != "" {
|
||||
matches = append(matches, row)
|
||||
|
||||
@@ -493,16 +493,19 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
SegmentDuration: req.SegmentDuration,
|
||||
FFmpegPath: cfg.Playback.FFmpegPath,
|
||||
HWAccel: req.HWAccel,
|
||||
HWDevice: "",
|
||||
AudioTrackIndex: req.AudioTrackIndex,
|
||||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||||
SubtitleCodec: req.SubtitleCodec,
|
||||
TotalDuration: req.TotalDuration,
|
||||
FastStart: true,
|
||||
NodeType: "transcode",
|
||||
ExecutionMode: "transcode_node",
|
||||
FFmpegLogSink: s.ffmpegSink,
|
||||
// This node's configured device (or device list — StartTranscode
|
||||
// resolves it to one GPU), matching what reconstruction uses so fresh
|
||||
// and reconstructed sessions balance identically.
|
||||
HWDevice: cfg.Playback.HWDevice,
|
||||
AudioTrackIndex: req.AudioTrackIndex,
|
||||
SubtitleTrackIndex: req.SubtitleTrackIndex,
|
||||
SubtitleBurnIn: req.SubtitleBurnIn,
|
||||
SubtitleCodec: req.SubtitleCodec,
|
||||
TotalDuration: req.TotalDuration,
|
||||
FastStart: true,
|
||||
NodeType: "transcode",
|
||||
ExecutionMode: "transcode_node",
|
||||
FFmpegLogSink: s.ffmpegSink,
|
||||
}
|
||||
|
||||
if opts.HWAccel == "" && cfg.Playback.HWAccel != "" {
|
||||
@@ -843,7 +846,13 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
s.touchSession(sessionID)
|
||||
}
|
||||
|
||||
manifest, err := session.BuildPlaybackManifest("segment/", r.URL.RawQuery)
|
||||
var manifest []byte
|
||||
var err error
|
||||
if r.URL.Query().Get(playback.SourceTimelineQueryParam) == "1" {
|
||||
manifest, err = session.BuildSourceAlignedPlaybackManifest("segment/", r.URL.RawQuery)
|
||||
} else {
|
||||
manifest, err = session.BuildPlaybackManifest("segment/", r.URL.RawQuery)
|
||||
}
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "get manifest", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID)
|
||||
http.Error(w, "manifest not ready", http.StatusServiceUnavailable)
|
||||
|
||||
@@ -402,3 +402,50 @@ func TestCopyModeReconstruct_SkipsFastSeek(t *testing.T) {
|
||||
t.Fatalf("encoded card must apply the seg×dur fast seek")
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh /transcode/start must resolve this node's configured hw_device list
|
||||
// through the shared GPU pool — the same path reconstruction uses — rather
|
||||
// than bypassing it with an empty device.
|
||||
func TestHandleStartUsesConfiguredHWDeviceList(t *testing.T) {
|
||||
server := newTestServer(t)
|
||||
ffmpegPath := filepath.Join(t.TempDir(), "looping-ffmpeg.sh")
|
||||
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nwhile :; do sleep 0.1; done\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// This test reaches the spawn/track path, so it needs a (no-op) tracker.
|
||||
server.tracker = nodesessions.NewTracker(nil, "http://node", "node", "transcode")
|
||||
cfg := server.watcher.Config()
|
||||
cfg.Playback.FFmpegPath = ffmpegPath
|
||||
// Neither device exists, so resolution deterministically lands on the
|
||||
// first entry; the point is that the configured list reaches the session.
|
||||
cfg.Playback.HWDevice = "/dev/dri/renderD888,/dev/dri/renderD889"
|
||||
|
||||
requestBody, err := json.Marshal(TranscodeStartRequest{
|
||||
SessionID: "hwdevice-start-1",
|
||||
InputPath: "/media/movie.mkv",
|
||||
TargetCodecVideo: "h264",
|
||||
TargetCodecAudio: "aac",
|
||||
SegmentDuration: 2,
|
||||
HWAccel: "vaapi",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(requestBody))
|
||||
rr := httptest.NewRecorder()
|
||||
server.handleStart(rr, req)
|
||||
if rr.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
server.mu.RLock()
|
||||
session := server.sessions["hwdevice-start-1"]
|
||||
server.mu.RUnlock()
|
||||
if session == nil {
|
||||
t.Fatal("session was not registered")
|
||||
}
|
||||
defer session.CloseProcess()
|
||||
if got := session.Opts().HWDevice; got != "/dev/dri/renderD888" {
|
||||
t.Fatalf("session HWDevice = %q, want one concrete device from the configured list", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package userdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -12,15 +12,12 @@ import (
|
||||
|
||||
func newConformanceStore(t *testing.T) userstore.UserStore {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
db, err := NewUserDB(filepath.Join(t.TempDir(), "user.db"), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
t.Fatalf("open user database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := InitSchema(db); err != nil {
|
||||
t.Fatalf("InitSchema: %v", err)
|
||||
}
|
||||
return NewSQLiteUserStore(db)
|
||||
return NewSQLiteUserStore(db.DB)
|
||||
}
|
||||
|
||||
// TestSQLiteProgressSince runs the offline-sync progress-reconciliation
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const schemaVersion = 17
|
||||
const schemaVersion = 18
|
||||
|
||||
func runMigrations(db *sql.DB) error {
|
||||
version, err := userVersion(db)
|
||||
@@ -169,9 +169,27 @@ func runMigrations(db *sql.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
if version < 18 {
|
||||
if err := migrateToV18(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec("PRAGMA user_version = 18"); err != nil {
|
||||
return fmt.Errorf("setting sqlite user_version 18: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// migrateToV18 seeds the family-neutral navigation shortcut catalog from the
|
||||
// existing web sidebar pins. InitSchema has already rebuilt
|
||||
// user_setting_values with the profile_client identity before this versioned
|
||||
// migration runs; keeping the data conversion here makes it one-time and
|
||||
// transactional with the schema-version bump.
|
||||
func migrateToV18(tx *sql.Tx) error {
|
||||
return migrateSidebarPinsToNavigationShortcuts(tx)
|
||||
}
|
||||
|
||||
// migrateToV17 rehomes the Jellyfin DisplayPreferences blobs from
|
||||
// user_settings (jellycompat:* keys) into the dedicated
|
||||
// jellycompat_displayprefs table, removing the last non-settings tenant of the
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package userdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
const settingValuesSchemaV17 = `
|
||||
CREATE TABLE user_setting_values (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
profile_id TEXT,
|
||||
device_id TEXT,
|
||||
library_id INTEGER,
|
||||
series_id TEXT,
|
||||
value TEXT NOT NULL CHECK (json_valid(value)),
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
|
||||
CHECK (
|
||||
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
|
||||
)
|
||||
);`
|
||||
|
||||
func TestMigrateToV18AddsClientFamilyAndSeedsShortcuts(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := InitSchema(db); err != nil {
|
||||
t.Fatalf("initial InitSchema: %v", err)
|
||||
}
|
||||
|
||||
// Replace only the canonical values table with its released v17 shape. On a
|
||||
// real open, InitSchema sees exactly this table before runMigrations.
|
||||
for _, index := range []string{
|
||||
"user_setting_values_account_uq", "user_setting_values_profile_uq",
|
||||
"user_setting_values_profile_client_uq", "user_setting_values_profile_device_uq",
|
||||
"user_setting_values_profile_library_uq", "user_setting_values_profile_series_uq",
|
||||
"user_setting_values_resolution_idx", "user_setting_values_series_idx",
|
||||
"user_setting_values_library_idx",
|
||||
} {
|
||||
if _, err := db.Exec("DROP INDEX IF EXISTS " + index); err != nil {
|
||||
t.Fatalf("drop %s: %v", index, err)
|
||||
}
|
||||
}
|
||||
if _, err := db.Exec("DROP TABLE user_setting_values"); err != nil {
|
||||
t.Fatalf("drop current values table: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(settingValuesSchemaV17); err != nil {
|
||||
t.Fatalf("create v17 values table: %v", err)
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA user_version = 17"); err != nil {
|
||||
t.Fatalf("set v17: %v", err)
|
||||
}
|
||||
|
||||
const created = "2026-07-28T10:00:00Z"
|
||||
const updated = "2026-08-01T12:00:00Z"
|
||||
legacy := `{"12":[{"type":"collection","id":"classics","label":"Classics"}],"7":[{"type":"section","id":"recently-added","label":"Recently Added"},{"type":"section","id":" ","label":"Whitespace ID"},{"type":"collection","id":"horror","label":"Horror"}],"favorites":[{"type":"collection","id":"keep-me","label":"Legacy group"}]}`
|
||||
encodedLegacy, err := json.Marshal(legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("encode legacy JSON string: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO user_setting_values
|
||||
(key, scope, profile_id, value, revision, created_at, updated_at)
|
||||
VALUES ('ui.sidebar_pins', 'profile', 'p1', ?, 4, ?, ?),
|
||||
('ui.sidebar_pins', 'profile', 'p2', ?, 2, ?, ?),
|
||||
('ui.sidebar_pins', 'profile', 'p3', ?, 5, ?, ?),
|
||||
('nav.shortcuts', 'profile', 'p2', '{"items":[]}', 3, ?, ?)`,
|
||||
legacy, created, updated,
|
||||
legacy, created, updated,
|
||||
string(encodedLegacy), created, updated,
|
||||
created, updated,
|
||||
); err != nil {
|
||||
t.Fatalf("seed v17 rows: %v", err)
|
||||
}
|
||||
|
||||
if err := InitSchema(db); err != nil {
|
||||
t.Fatalf("upgrade InitSchema: %v", err)
|
||||
}
|
||||
if err := runMigrations(db); err != nil {
|
||||
t.Fatalf("runMigrations: %v", err)
|
||||
}
|
||||
version, err := userVersion(db)
|
||||
if err != nil || version != schemaVersion {
|
||||
t.Fatalf("user_version = %d (%v), want %d", version, err, schemaVersion)
|
||||
}
|
||||
|
||||
var columnCount int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('user_setting_values')
|
||||
WHERE name = 'client_family'`).Scan(&columnCount); err != nil || columnCount != 1 {
|
||||
t.Fatalf("client_family column count = %d (%v), want 1", columnCount, err)
|
||||
}
|
||||
|
||||
// The legacy row is preserved byte-for-byte and keeps its revision.
|
||||
var kept string
|
||||
var revision int
|
||||
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
|
||||
WHERE key = 'ui.sidebar_pins' AND profile_id = 'p1'`).Scan(&kept, &revision); err != nil {
|
||||
t.Fatalf("read legacy row: %v", err)
|
||||
}
|
||||
if kept != legacy || revision != 4 {
|
||||
t.Fatalf("legacy row changed to %s at revision %d", kept, revision)
|
||||
}
|
||||
|
||||
var migrated string
|
||||
if err := db.QueryRow(`SELECT value FROM user_setting_values
|
||||
WHERE key = 'nav.shortcuts' AND profile_id = 'p1'`).Scan(&migrated); err != nil {
|
||||
t.Fatalf("read migrated shortcuts: %v", err)
|
||||
}
|
||||
assertJSONValueEqual(t, migrated, `{"items":[{"label":"Recently Added","library_id":7,"section_id":"recently-added","type":"section"},{"collection_id":"horror","label":"Horror","library_id":7,"type":"collection"},{"collection_id":"classics","label":"Classics","library_id":12,"type":"collection"}]}`)
|
||||
|
||||
// Legacy web builds also wrote the grouped object as one JSON string. It is
|
||||
// preserved as authored while seeding the same canonical shortcut document.
|
||||
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
|
||||
WHERE key = 'ui.sidebar_pins' AND profile_id = 'p3'`).Scan(&kept, &revision); err != nil {
|
||||
t.Fatalf("read JSON-string legacy row: %v", err)
|
||||
}
|
||||
if kept != string(encodedLegacy) || revision != 5 {
|
||||
t.Fatalf("JSON-string legacy row changed to %s at revision %d", kept, revision)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT value FROM user_setting_values
|
||||
WHERE key = 'nav.shortcuts' AND profile_id = 'p3'`).Scan(&migrated); err != nil {
|
||||
t.Fatalf("read shortcuts migrated from JSON string: %v", err)
|
||||
}
|
||||
assertJSONValueEqual(t, migrated, `{"items":[{"label":"Recently Added","library_id":7,"section_id":"recently-added","type":"section"},{"collection_id":"horror","label":"Horror","library_id":7,"type":"collection"},{"collection_id":"classics","label":"Classics","library_id":12,"type":"collection"}]}`)
|
||||
|
||||
// Existing new-format data wins rather than being overwritten by legacy.
|
||||
if err := db.QueryRow(`SELECT value, revision FROM user_setting_values
|
||||
WHERE key = 'nav.shortcuts' AND profile_id = 'p2'`).Scan(&migrated, &revision); err != nil {
|
||||
t.Fatalf("read pre-existing shortcuts: %v", err)
|
||||
}
|
||||
assertJSONValueEqual(t, migrated, `{"items":[]}`)
|
||||
if revision != 3 {
|
||||
t.Fatalf("pre-existing shortcut revision = %d, want 3", revision)
|
||||
}
|
||||
|
||||
store := NewSQLiteUserStore(db)
|
||||
id := userstore.SettingIdentity{
|
||||
Key: "nav.primary_menu", Scope: settingscontract.ScopeProfileClient,
|
||||
ProfileID: "p1", ClientFamily: settingscontract.ClientFamilyTV,
|
||||
}
|
||||
if _, err := store.UpsertSettingValue(context.Background(), id,
|
||||
json.RawMessage(`{"items":[{"type":"builtin","destination":"home"}]}`)); err != nil {
|
||||
t.Fatalf("write profile_client value: %v", err)
|
||||
}
|
||||
got, err := store.GetSettingValue(context.Background(), id)
|
||||
if err != nil || got == nil || got.ClientFamily != settingscontract.ClientFamilyTV {
|
||||
t.Fatalf("profile_client round trip = %+v (%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavigationShortcutsFromSidebarPinsSkipsMalformedSiblingsAndBoundsLibraryIDs(t *testing.T) {
|
||||
raw := `{
|
||||
"7":[
|
||||
{"type":"section","id":"recent","label":"Recent"},
|
||||
{"type":"section","id":99,"label":"Wrong ID type"},
|
||||
{"type":"collection","id":"horror","label":"Horror"},
|
||||
{"type":"collection","id":"bad-label","label":42}
|
||||
],
|
||||
"8":{"type":"collection","id":"not-an-array","label":"Skipped group"},
|
||||
"9":[null,{"type":"collection","id":"drama","label":"Drama"}],
|
||||
"2147483647":[{"type":"section","id":"boundary","label":"Boundary"}],
|
||||
"2147483648":[{"type":"section","id":"overflow","label":"Overflow"}],
|
||||
"999999999999999999999999999999":[{"type":"section","id":"huge","label":"Huge"}],
|
||||
"07":[{"type":"section","id":"leading-zero","label":"Leading Zero"}]
|
||||
}`
|
||||
|
||||
value, ok := navigationShortcutsFromSidebarPins(raw)
|
||||
if !ok {
|
||||
t.Fatal("migration rejected valid siblings because malformed groups/items were present")
|
||||
}
|
||||
assertJSONValueEqual(t, string(value), `{"items":[
|
||||
{"type":"section","library_id":7,"section_id":"recent","label":"Recent"},
|
||||
{"type":"collection","library_id":7,"collection_id":"horror","label":"Horror"},
|
||||
{"type":"collection","library_id":9,"collection_id":"drama","label":"Drama"},
|
||||
{"type":"section","library_id":2147483647,"section_id":"boundary","label":"Boundary"}
|
||||
]}`)
|
||||
}
|
||||
|
||||
func TestNavigationShortcutsFromSidebarPinsRejectsOnlyOverflowLibraryIDs(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"one over int32": `{"2147483648":[{"type":"section","id":"overflow","label":"Overflow"}]}`,
|
||||
"parse overflow": `{"999999999999999999999999999999":[{"type":"section","id":"huge","label":"Huge"}]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if value, ok := navigationShortcutsFromSidebarPins(raw); ok || value != nil {
|
||||
t.Fatalf("overflow-only pins migrated as %s", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertJSONValueEqual(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
var gotValue, wantValue any
|
||||
if err := json.Unmarshal([]byte(got), &gotValue); err != nil {
|
||||
t.Fatalf("decode got JSON: %v", err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(want), &wantValue); err != nil {
|
||||
t.Fatalf("decode want JSON: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(gotValue, wantValue) {
|
||||
t.Fatalf("JSON = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package userdb
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
maxNavigationShortcuts = 256
|
||||
maxMigratedNavigationLibraryID = int64(2_147_483_647)
|
||||
)
|
||||
|
||||
type legacySidebarPin struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
// migrateSidebarPinsToNavigationShortcuts copies the subset of the legacy web
|
||||
// map that has a concrete positive library id into the new cross-client item
|
||||
// catalog. The old ui.sidebar_pins row remains untouched, including
|
||||
// non-numeric well-known groups; a pre-existing nav.shortcuts row always wins.
|
||||
func migrateSidebarPinsToNavigationShortcuts(tx *sql.Tx) error {
|
||||
rows, err := tx.Query(`
|
||||
SELECT legacy.profile_id, legacy.value, legacy.created_at, legacy.updated_at
|
||||
FROM user_setting_values AS legacy
|
||||
WHERE legacy.key = 'ui.sidebar_pins'
|
||||
AND legacy.scope = 'profile'
|
||||
AND legacy.value <> 'null'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM user_setting_values AS current
|
||||
WHERE current.key = 'nav.shortcuts'
|
||||
AND current.scope = 'profile'
|
||||
AND current.profile_id = legacy.profile_id
|
||||
)
|
||||
ORDER BY legacy.profile_id`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading sidebar pins for navigation shortcut migration: %w", err)
|
||||
}
|
||||
type source struct {
|
||||
profileID, raw, createdAt, updatedAt string
|
||||
}
|
||||
var sources []source
|
||||
for rows.Next() {
|
||||
var entry source
|
||||
if err := rows.Scan(&entry.profileID, &entry.raw, &entry.createdAt, &entry.updatedAt); err != nil {
|
||||
_ = rows.Close()
|
||||
return fmt.Errorf("scanning sidebar pins for navigation shortcut migration: %w", err)
|
||||
}
|
||||
sources = append(sources, entry)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return fmt.Errorf("closing sidebar pins migration rows: %w", err)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("iterating sidebar pins for navigation shortcut migration: %w", err)
|
||||
}
|
||||
|
||||
for _, source := range sources {
|
||||
value, ok := navigationShortcutsFromSidebarPins(source.raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT OR IGNORE INTO user_setting_values
|
||||
(key, scope, profile_id, client_family, device_id, library_id, series_id,
|
||||
value, revision, created_at, updated_at)
|
||||
VALUES ('nav.shortcuts', 'profile', ?, NULL, NULL, NULL, NULL, ?, 1, ?, ?)`,
|
||||
source.profileID, string(value), source.createdAt, source.updatedAt,
|
||||
); err != nil {
|
||||
return fmt.Errorf("seeding navigation shortcuts for profile %q: %w", source.profileID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func navigationShortcutsFromSidebarPins(raw string) (json.RawMessage, bool) {
|
||||
payload := []byte(raw)
|
||||
// The legacy web client sometimes persisted the whole object as a JSON
|
||||
// string. Match parseSidebarPins by unwrapping exactly one such layer before
|
||||
// interpreting the grouped pin map.
|
||||
var encoded string
|
||||
if err := json.Unmarshal(payload, &encoded); err == nil {
|
||||
payload = []byte(encoded)
|
||||
}
|
||||
|
||||
// Decode the object one group at a time. A typed map[string][]T makes one
|
||||
// non-array group or one malformed member reject every valid sibling in the
|
||||
// profile. Raw group/member boundaries match PostgreSQL's jsonb_each plus
|
||||
// jsonb_array_elements behavior: malformed siblings are skipped in place.
|
||||
var legacy map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &legacy); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0)
|
||||
seen := make(map[string]struct{})
|
||||
// JSON object order is not semantic and Go deliberately randomizes map
|
||||
// iteration. Sort valid numeric groups by library id, matching the
|
||||
// PostgreSQL migration's ORDER BY rather than lexical string order.
|
||||
type libraryGroup struct {
|
||||
key string
|
||||
id int
|
||||
}
|
||||
groups := make([]libraryGroup, 0, len(legacy))
|
||||
for group := range legacy {
|
||||
libraryID, err := strconv.ParseInt(group, 10, 32)
|
||||
if err != nil || libraryID <= 0 || libraryID > maxMigratedNavigationLibraryID ||
|
||||
strconv.FormatInt(libraryID, 10) != group {
|
||||
continue
|
||||
}
|
||||
groups = append(groups, libraryGroup{key: group, id: int(libraryID)})
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].id < groups[j].id })
|
||||
|
||||
for _, group := range groups {
|
||||
var encodedPins []json.RawMessage
|
||||
if err := json.Unmarshal(legacy[group.key], &encodedPins); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, encodedPin := range encodedPins {
|
||||
var pin legacySidebarPin
|
||||
if err := json.Unmarshal(encodedPin, &pin); err != nil {
|
||||
continue
|
||||
}
|
||||
id := pin.ID
|
||||
label := pin.Label
|
||||
if strings.TrimSpace(id) == "" || len([]rune(id)) > 128 ||
|
||||
strings.TrimSpace(label) == "" || len([]rune(label)) > 256 {
|
||||
continue
|
||||
}
|
||||
identity := pin.Type + "\x00" + strconv.Itoa(group.id) + "\x00" + id
|
||||
if _, duplicate := seen[identity]; duplicate {
|
||||
continue
|
||||
}
|
||||
var item map[string]any
|
||||
switch pin.Type {
|
||||
case "section":
|
||||
item = map[string]any{
|
||||
"type": "section", "library_id": group.id,
|
||||
"section_id": id, "label": label,
|
||||
}
|
||||
case "collection":
|
||||
item = map[string]any{
|
||||
"type": "collection", "library_id": group.id,
|
||||
"collection_id": id, "label": label,
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
items = append(items, item)
|
||||
if len(items) == maxNavigationShortcuts {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(items) == maxNavigationShortcuts {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
value, err := json.Marshal(map[string]any{"items": items})
|
||||
return value, err == nil
|
||||
}
|
||||
@@ -16,8 +16,11 @@ import (
|
||||
// ordinary store calls.
|
||||
type preferenceSettingsExecutor interface {
|
||||
Exec(string, ...any) (sql.Result, error)
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
Query(string, ...any) (*sql.Rows, error)
|
||||
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||
QueryRow(string, ...any) *sql.Row
|
||||
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||
}
|
||||
|
||||
type preferenceSettingsTx struct {
|
||||
|
||||
+101
-6
@@ -301,6 +301,7 @@ CREATE TABLE IF NOT EXISTS user_setting_values (
|
||||
key TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
profile_id TEXT,
|
||||
client_family TEXT,
|
||||
device_id TEXT,
|
||||
library_id INTEGER,
|
||||
series_id TEXT,
|
||||
@@ -308,13 +309,15 @@ CREATE TABLE IF NOT EXISTS user_setting_values (
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (scope IN ('account', 'profile', 'profile_device', 'profile_library', 'profile_series')),
|
||||
CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series')),
|
||||
CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web')),
|
||||
CHECK (
|
||||
(scope = 'account' AND profile_id IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_device' AND profile_id IS NOT NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_library' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_series' AND profile_id IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
|
||||
(scope = 'account' AND profile_id IS NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_device' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_library' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_series' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -367,6 +370,9 @@ func InitSchema(db *sql.DB) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureSettingValuesClientFamily(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureProfileSectionOverridesRemovedColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -400,6 +406,95 @@ func InitSchema(db *sql.DB) error {
|
||||
return backfillUserDevices(db)
|
||||
}
|
||||
|
||||
// ensureSettingValuesClientFamily upgrades the canonical settings table before
|
||||
// runMigrations reads it. InitSchema runs first on every open, and SQLite
|
||||
// cannot widen a table CHECK constraint with ALTER TABLE, so an existing table
|
||||
// must be rebuilt here rather than merely gaining a nullable column. The
|
||||
// rebuild is transactional and preserves ids, revisions, values, and
|
||||
// timestamps verbatim.
|
||||
func ensureSettingValuesClientFamily(db *sql.DB) error {
|
||||
var count int
|
||||
if err := db.QueryRow(
|
||||
"SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?",
|
||||
"user_setting_values", "client_family",
|
||||
).Scan(&count); err != nil {
|
||||
return fmt.Errorf("checking user_setting_values.client_family column: %w", err)
|
||||
}
|
||||
if count > 0 {
|
||||
_, err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS user_setting_values_profile_client_uq
|
||||
ON user_setting_values (profile_id, client_family, key) WHERE scope = 'profile_client'`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating profile_client setting index: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning profile_client settings migration: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
if _, err := tx.Exec(`
|
||||
CREATE TABLE user_setting_values_profile_client_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
profile_id TEXT,
|
||||
client_family TEXT,
|
||||
device_id TEXT,
|
||||
library_id INTEGER,
|
||||
series_id TEXT,
|
||||
value TEXT NOT NULL CHECK (json_valid(value)),
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK (scope IN ('account', 'profile', 'profile_client', 'profile_device', 'profile_library', 'profile_series')),
|
||||
CHECK (client_family IS NULL OR client_family IN ('tv', 'mobile', 'tablet', 'desktop', 'web')),
|
||||
CHECK (
|
||||
(scope = 'account' AND profile_id IS NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_client' AND profile_id IS NOT NULL AND client_family IS NOT NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_device' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NOT NULL AND library_id IS NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_library' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NOT NULL AND series_id IS NULL) OR
|
||||
(scope = 'profile_series' AND profile_id IS NOT NULL AND client_family IS NULL AND device_id IS NULL AND library_id IS NULL AND series_id IS NOT NULL)
|
||||
)
|
||||
);
|
||||
INSERT INTO user_setting_values_profile_client_new
|
||||
(id, key, scope, profile_id, client_family, device_id, library_id, series_id,
|
||||
value, revision, created_at, updated_at)
|
||||
SELECT id, key, scope, profile_id, NULL, device_id, library_id, series_id,
|
||||
value, revision, created_at, updated_at
|
||||
FROM user_setting_values;
|
||||
DROP TABLE user_setting_values;
|
||||
ALTER TABLE user_setting_values_profile_client_new RENAME TO user_setting_values;
|
||||
CREATE UNIQUE INDEX user_setting_values_account_uq
|
||||
ON user_setting_values (key) WHERE scope = 'account';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_uq
|
||||
ON user_setting_values (profile_id, key) WHERE scope = 'profile';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_client_uq
|
||||
ON user_setting_values (profile_id, client_family, key) WHERE scope = 'profile_client';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_device_uq
|
||||
ON user_setting_values (profile_id, device_id, key) WHERE scope = 'profile_device';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_library_uq
|
||||
ON user_setting_values (profile_id, library_id, key) WHERE scope = 'profile_library';
|
||||
CREATE UNIQUE INDEX user_setting_values_profile_series_uq
|
||||
ON user_setting_values (profile_id, series_id, key) WHERE scope = 'profile_series';
|
||||
CREATE INDEX user_setting_values_resolution_idx
|
||||
ON user_setting_values (profile_id, key, scope);
|
||||
CREATE INDEX user_setting_values_series_idx
|
||||
ON user_setting_values (profile_id, series_id);
|
||||
CREATE INDEX user_setting_values_library_idx
|
||||
ON user_setting_values (profile_id, library_id);
|
||||
`); err != nil {
|
||||
return fmt.Errorf("rebuilding user_setting_values for profile_client: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("committing profile_client settings migration: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// watchProgressSyncTriggers stamps the server-owned cursor (synced_seq) on every
|
||||
// watch_progress write and owns the LWW key: event_at defaults to the write
|
||||
// time, and an UPDATE that changed updated_at without explicitly changing
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package userdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
// settingMutationConnExecutor pins every statement to one database/sql
|
||||
// connection. SQLite cannot request BEGIN IMMEDIATE through sql.Tx without a
|
||||
// DSN-wide policy, so this adapter lets the mutation transaction acquire its
|
||||
// write reservation before it reads the receipt.
|
||||
type settingMutationConnExecutor struct {
|
||||
ctx context.Context
|
||||
conn *sql.Conn
|
||||
}
|
||||
|
||||
func (e settingMutationConnExecutor) Exec(query string, args ...any) (sql.Result, error) {
|
||||
return e.conn.ExecContext(e.ctx, query, args...)
|
||||
}
|
||||
|
||||
func (e settingMutationConnExecutor) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
return e.conn.ExecContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
func (e settingMutationConnExecutor) Query(query string, args ...any) (*sql.Rows, error) {
|
||||
return e.conn.QueryContext(e.ctx, query, args...)
|
||||
}
|
||||
|
||||
func (e settingMutationConnExecutor) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||
return e.conn.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
func (e settingMutationConnExecutor) QueryRow(query string, args ...any) *sql.Row {
|
||||
return e.conn.QueryRowContext(e.ctx, query, args...)
|
||||
}
|
||||
|
||||
func (e settingMutationConnExecutor) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
|
||||
return e.conn.QueryRowContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
type sqliteSettingMutationWriter struct {
|
||||
exec preferenceSettingsExecutor
|
||||
}
|
||||
|
||||
var _ userstore.SettingMutationTransactioner = (*SQLiteUserStore)(nil)
|
||||
var _ userstore.SettingMutationWriter = (*sqliteSettingMutationWriter)(nil)
|
||||
|
||||
// WithSettingMutationTransaction uses BEGIN IMMEDIATE so same-database
|
||||
// contenders serialize before checking mutation_id. The setting row and its
|
||||
// receipt then commit together; rollback covers callback errors and crashes.
|
||||
func (s *SQLiteUserStore) WithSettingMutationTransaction(
|
||||
ctx context.Context,
|
||||
mutationID string,
|
||||
fn func(userstore.SettingMutationWriter) error,
|
||||
) (err error) {
|
||||
if mutationID == "" {
|
||||
return fmt.Errorf("setting mutation transaction requires a mutation id")
|
||||
}
|
||||
conn, err := s.db.Conn(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening setting mutation connection: %w", err)
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck
|
||||
|
||||
exec := settingMutationConnExecutor{ctx: ctx, conn: conn}
|
||||
if _, err := exec.ExecContext(ctx, "BEGIN IMMEDIATE"); err != nil {
|
||||
return fmt.Errorf("beginning setting mutation transaction: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
rollbackCtx := context.WithoutCancel(ctx)
|
||||
if _, rollbackErr := exec.ExecContext(rollbackCtx, "ROLLBACK"); rollbackErr != nil {
|
||||
slog.ErrorContext(rollbackCtx, "rolling back setting mutation transaction failed",
|
||||
"component", "userdb.settings", "error", rollbackErr)
|
||||
// A failed rollback can leave SQLite's write reservation attached to
|
||||
// the pooled driver connection. Mark it bad so database/sql discards
|
||||
// it instead of returning a poisoned connection to the pool.
|
||||
_ = conn.Raw(func(any) error { return driver.ErrBadConn })
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(&sqliteSettingMutationWriter{exec: exec}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := exec.ExecContext(ctx, "COMMIT"); err != nil {
|
||||
return fmt.Errorf("committing setting mutation transaction: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *sqliteSettingMutationWriter) GetSettingValue(
|
||||
_ context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return getSettingValue(w.exec, id)
|
||||
}
|
||||
|
||||
func (w *sqliteSettingMutationWriter) UpsertSettingValue(
|
||||
_ context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return upsertSettingValue(w.exec, id, value)
|
||||
}
|
||||
|
||||
func (w *sqliteSettingMutationWriter) CompareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return compareAndSetSettingValue(ctx, w.exec, id, value, expectedRevision)
|
||||
}
|
||||
|
||||
func (w *sqliteSettingMutationWriter) GetSettingMutation(
|
||||
_ context.Context,
|
||||
mutationID string,
|
||||
) (*userstore.SettingMutationRecord, error) {
|
||||
return getSettingMutation(w.exec, mutationID)
|
||||
}
|
||||
|
||||
func (w *sqliteSettingMutationWriter) PutSettingMutation(
|
||||
_ context.Context,
|
||||
record userstore.SettingMutationRecord,
|
||||
) (userstore.SettingMutationRecord, bool, error) {
|
||||
return putSettingMutation(w.exec, record)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package userdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
|
||||
// settingValueColumns is the projection every read shares, in the order
|
||||
// scanSettingValue expects.
|
||||
const settingValueColumns = `key, scope, profile_id, device_id, library_id, series_id,
|
||||
const settingValueColumns = `key, scope, profile_id, client_family, device_id, library_id, series_id,
|
||||
value, revision, created_at, updated_at`
|
||||
|
||||
// settingConflictTargets maps a scope to the partial unique index that enforces
|
||||
@@ -23,6 +24,7 @@ const settingValueColumns = `key, scope, profile_id, device_id, library_id, seri
|
||||
var settingConflictTargets = map[settingscontract.Scope]string{
|
||||
settingscontract.ScopeAccount: "(key) WHERE scope = 'account'",
|
||||
settingscontract.ScopeProfile: "(profile_id, key) WHERE scope = 'profile'",
|
||||
settingscontract.ScopeProfileClient: "(profile_id, client_family, key) WHERE scope = 'profile_client'",
|
||||
settingscontract.ScopeProfileDevice: "(profile_id, device_id, key) WHERE scope = 'profile_device'",
|
||||
settingscontract.ScopeProfileLibrary: "(profile_id, library_id, key) WHERE scope = 'profile_library'",
|
||||
settingscontract.ScopeProfileSeries: "(profile_id, series_id, key) WHERE scope = 'profile_series'",
|
||||
@@ -38,6 +40,9 @@ func settingIdentityPredicate(id userstore.SettingIdentity) (string, []any) {
|
||||
case settingscontract.ScopeProfile:
|
||||
args = append(args, id.ProfileID)
|
||||
clause += " AND profile_id = ?"
|
||||
case settingscontract.ScopeProfileClient:
|
||||
args = append(args, id.ProfileID, string(id.ClientFamily))
|
||||
clause += " AND profile_id = ? AND client_family = ?"
|
||||
case settingscontract.ScopeProfileDevice:
|
||||
args = append(args, id.ProfileID, id.DeviceID)
|
||||
clause += " AND profile_id = ? AND device_id = ?"
|
||||
@@ -54,11 +59,15 @@ func settingIdentityPredicate(id userstore.SettingIdentity) (string, []any) {
|
||||
// GetSettingValue returns the explicit value at exactly one scope, or nil when
|
||||
// that identity is unset.
|
||||
func GetSettingValue(db *sql.DB, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
|
||||
return getSettingValue(db, id)
|
||||
}
|
||||
|
||||
func getSettingValue(exec preferenceSettingsExecutor, id userstore.SettingIdentity) (*userstore.SettingValue, error) {
|
||||
if err := id.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clause, args := settingIdentityPredicate(id)
|
||||
row := db.QueryRow("SELECT "+settingValueColumns+" FROM user_setting_values WHERE "+clause, args...)
|
||||
row := exec.QueryRow("SELECT "+settingValueColumns+" FROM user_setting_values WHERE "+clause, args...)
|
||||
value, err := scanSettingValue(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
@@ -70,9 +79,9 @@ func GetSettingValue(db *sql.DB, id userstore.SettingIdentity) (*userstore.Setti
|
||||
}
|
||||
|
||||
// ListSettingValuesForResolution collects every candidate row for a resolution
|
||||
// request in one query. The predicate covers all five scopes at once: ranking by
|
||||
// each definition's resolution order happens in Go, so a four-scope chain still
|
||||
// costs one round trip rather than four lookups per key.
|
||||
// request in one query. The predicate covers all six scopes at once: ranking by
|
||||
// each definition's resolution order happens in Go, so a multi-scope chain still
|
||||
// costs one round trip rather than one lookup per scope and key.
|
||||
func ListSettingValuesForResolution(
|
||||
db *sql.DB,
|
||||
query userstore.SettingResolutionQuery,
|
||||
@@ -82,14 +91,14 @@ func ListSettingValuesForResolution(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(q.Keys)+len(q.ProfileIDs)+len(q.LibraryIDs)+len(q.SeriesIDs)+1)
|
||||
args := make([]any, 0, len(q.Keys)+len(q.ProfileIDs)+len(q.LibraryIDs)+len(q.SeriesIDs)+2)
|
||||
for _, key := range q.Keys {
|
||||
args = append(args, key)
|
||||
}
|
||||
for _, profileID := range q.ProfileIDs {
|
||||
args = append(args, profileID)
|
||||
}
|
||||
args = append(args, q.DeviceID)
|
||||
args = append(args, string(q.ClientFamily), q.DeviceID)
|
||||
for _, libraryID := range q.LibraryIDs {
|
||||
args = append(args, libraryID)
|
||||
}
|
||||
@@ -123,13 +132,14 @@ func ListSettingValuesForResolution(
|
||||
`+profileClause+`
|
||||
AND (
|
||||
scope = 'profile'
|
||||
OR (scope = 'profile_client' AND client_family = ?)
|
||||
OR (scope = 'profile_device' AND device_id = ?)
|
||||
OR (`+libraryClause+`)
|
||||
OR (`+seriesClause+`)
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
|
||||
COALESCE(library_id, 0), COALESCE(series_id, '')`,
|
||||
args...,
|
||||
)
|
||||
@@ -157,7 +167,7 @@ func ListAllSettingValues(db *sql.DB) ([]userstore.SettingValue, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT ` + settingValueColumns + `
|
||||
FROM user_setting_values
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
|
||||
COALESCE(library_id, 0), COALESCE(series_id, '')`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing all setting values: %w", err)
|
||||
@@ -204,15 +214,15 @@ func upsertSettingValue(
|
||||
now := nowRFC3339()
|
||||
row := exec.QueryRow(fmt.Sprintf(`
|
||||
INSERT INTO user_setting_values
|
||||
(key, scope, profile_id, device_id, library_id, series_id, value, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(key, scope, profile_id, client_family, device_id, library_id, series_id, value, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT %s DO UPDATE SET
|
||||
value = excluded.value,
|
||||
revision = user_setting_values.revision + 1,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING %s`, target, settingValueColumns),
|
||||
id.Key, string(id.Scope),
|
||||
nullableText(id.ProfileID), nullableText(id.DeviceID),
|
||||
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
|
||||
nullableInt(id.LibraryID), nullableText(id.SeriesID),
|
||||
string(value), now, now,
|
||||
)
|
||||
@@ -223,6 +233,76 @@ func upsertSettingValue(
|
||||
return &stored, nil
|
||||
}
|
||||
|
||||
// CompareAndSetSettingValue writes only when expectedRevision still names the
|
||||
// current row. Revision zero is the insert-if-absent case. Each branch is one
|
||||
// SQLite statement, so the comparison and write are indivisible even when two
|
||||
// clients read the same starting document.
|
||||
func CompareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
db *sql.DB,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return compareAndSetSettingValue(ctx, db, id, value, expectedRevision)
|
||||
}
|
||||
|
||||
func compareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
exec preferenceSettingsExecutor,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*userstore.SettingValue, error) {
|
||||
if err := id.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := userstore.ValidateSettingValueJSON(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expectedRevision < 0 {
|
||||
return nil, fmt.Errorf("%w: expected revision must be non-negative", userstore.ErrInvalidSettingIdentity)
|
||||
}
|
||||
|
||||
now := nowRFC3339()
|
||||
var row *sql.Row
|
||||
if expectedRevision == 0 {
|
||||
target, ok := settingConflictTargets[id.Scope]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q has no storage identity", userstore.ErrInvalidSettingIdentity, id.Scope)
|
||||
}
|
||||
row = exec.QueryRowContext(ctx, fmt.Sprintf(`
|
||||
INSERT INTO user_setting_values
|
||||
(key, scope, profile_id, client_family, device_id, library_id, series_id, value, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT %s DO NOTHING
|
||||
RETURNING %s`, target, settingValueColumns),
|
||||
id.Key, string(id.Scope),
|
||||
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
|
||||
nullableInt(id.LibraryID), nullableText(id.SeriesID),
|
||||
string(value), now, now,
|
||||
)
|
||||
} else {
|
||||
clause, args := settingIdentityPredicate(id)
|
||||
args = append([]any{string(value), now}, args...)
|
||||
args = append(args, expectedRevision)
|
||||
row = exec.QueryRowContext(ctx, `
|
||||
UPDATE user_setting_values
|
||||
SET value = ?, revision = revision + 1, updated_at = ?
|
||||
WHERE `+clause+` AND revision = ?
|
||||
RETURNING `+settingValueColumns, args...)
|
||||
}
|
||||
|
||||
stored, err := scanSettingValue(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, userstore.ErrSettingValueRevisionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compare-and-setting value %q at %s: %w", id.Key, id.Scope, err)
|
||||
}
|
||||
return &stored, nil
|
||||
}
|
||||
|
||||
// DeleteSettingValue removes the explicit value at one scope — the `unset`
|
||||
// operation — and reports whether a row existed.
|
||||
func DeleteSettingValue(db *sql.DB, id userstore.SettingIdentity) (bool, error) {
|
||||
@@ -286,7 +366,11 @@ func execSettingValueDelete(db *sql.DB, query, subject string, args ...any) (int
|
||||
|
||||
// GetSettingMutation returns a recorded idempotency receipt, or nil.
|
||||
func GetSettingMutation(db *sql.DB, mutationID string) (*userstore.SettingMutationRecord, error) {
|
||||
row := db.QueryRow(`
|
||||
return getSettingMutation(db, mutationID)
|
||||
}
|
||||
|
||||
func getSettingMutation(exec preferenceSettingsExecutor, mutationID string) (*userstore.SettingMutationRecord, error) {
|
||||
row := exec.QueryRow(`
|
||||
SELECT mutation_id, request_hash, result, created_at, expires_at
|
||||
FROM user_setting_mutations
|
||||
WHERE mutation_id = ?`, mutationID)
|
||||
@@ -307,12 +391,19 @@ func GetSettingMutation(db *sql.DB, mutationID string) (*userstore.SettingMutati
|
||||
func PutSettingMutation(
|
||||
db *sql.DB,
|
||||
record userstore.SettingMutationRecord,
|
||||
) (userstore.SettingMutationRecord, bool, error) {
|
||||
return putSettingMutation(db, record)
|
||||
}
|
||||
|
||||
func putSettingMutation(
|
||||
exec preferenceSettingsExecutor,
|
||||
record userstore.SettingMutationRecord,
|
||||
) (userstore.SettingMutationRecord, bool, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return userstore.SettingMutationRecord{}, false, err
|
||||
}
|
||||
|
||||
row := db.QueryRow(`
|
||||
row := exec.QueryRow(`
|
||||
INSERT INTO user_setting_mutations (mutation_id, request_hash, result, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT (mutation_id) DO NOTHING
|
||||
@@ -328,7 +419,7 @@ func PutSettingMutation(
|
||||
return userstore.SettingMutationRecord{}, false, fmt.Errorf("recording setting mutation %q: %w", record.MutationID, err)
|
||||
}
|
||||
|
||||
existing, err := GetSettingMutation(db, record.MutationID)
|
||||
existing, err := getSettingMutation(exec, record.MutationID)
|
||||
if err != nil {
|
||||
return userstore.SettingMutationRecord{}, false, err
|
||||
}
|
||||
@@ -365,22 +456,24 @@ type sqlRow interface {
|
||||
|
||||
func scanSettingValue(row sqlRow) (userstore.SettingValue, error) {
|
||||
var (
|
||||
value userstore.SettingValue
|
||||
scope string
|
||||
profileID sql.NullString
|
||||
deviceID sql.NullString
|
||||
libraryID sql.NullInt64
|
||||
seriesID sql.NullString
|
||||
raw string
|
||||
value userstore.SettingValue
|
||||
scope string
|
||||
profileID sql.NullString
|
||||
clientFamily sql.NullString
|
||||
deviceID sql.NullString
|
||||
libraryID sql.NullInt64
|
||||
seriesID sql.NullString
|
||||
raw string
|
||||
)
|
||||
if err := row.Scan(
|
||||
&value.Key, &scope, &profileID, &deviceID, &libraryID, &seriesID,
|
||||
&value.Key, &scope, &profileID, &clientFamily, &deviceID, &libraryID, &seriesID,
|
||||
&raw, &value.Revision, &value.CreatedAt, &value.UpdatedAt,
|
||||
); err != nil {
|
||||
return userstore.SettingValue{}, err
|
||||
}
|
||||
value.Scope = settingscontract.Scope(scope)
|
||||
value.ProfileID = profileID.String
|
||||
value.ClientFamily = settingscontract.ClientFamily(clientFamily.String)
|
||||
value.DeviceID = deviceID.String
|
||||
value.LibraryID = int(libraryID.Int64)
|
||||
value.SeriesID = seriesID.String
|
||||
|
||||
@@ -466,6 +466,10 @@ func (s *SQLiteUserStore) UpsertSettingValue(_ context.Context, id userstore.Set
|
||||
return UpsertSettingValue(s.db, id, value)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) CompareAndSetSettingValue(ctx context.Context, id userstore.SettingIdentity, value json.RawMessage, expectedRevision int64) (*userstore.SettingValue, error) {
|
||||
return CompareAndSetSettingValue(ctx, s.db, id, value, expectedRevision)
|
||||
}
|
||||
|
||||
func (s *SQLiteUserStore) DeleteSettingValue(_ context.Context, id userstore.SettingIdentity) (bool, error) {
|
||||
return DeleteSettingValue(s.db, id)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package pgstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"strconv"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
const settingMutationAdvisoryClass int32 = 0x534d5554 // "SMUT"
|
||||
|
||||
type postgresSettingMutationWriter struct {
|
||||
exec preferenceSettingsExecutor
|
||||
userID int
|
||||
}
|
||||
|
||||
var _ userstore.SettingMutationTransactioner = (*PostgresUserStore)(nil)
|
||||
var _ userstore.SettingMutationWriter = (*postgresSettingMutationWriter)(nil)
|
||||
|
||||
// WithSettingMutationTransaction takes a transaction-scoped advisory lock
|
||||
// before reading mutation_id. The hash includes the user, so unrelated
|
||||
// accounts and mutation ids remain concurrent; a hash collision only causes
|
||||
// harmless extra serialization. The setting and receipt commit together.
|
||||
func (s *PostgresUserStore) WithSettingMutationTransaction(
|
||||
ctx context.Context,
|
||||
mutationID string,
|
||||
fn func(userstore.SettingMutationWriter) error,
|
||||
) error {
|
||||
if mutationID == "" {
|
||||
return fmt.Errorf("setting mutation transaction requires a mutation id")
|
||||
}
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning setting mutation transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx) //nolint:errcheck
|
||||
|
||||
lockHash := fnv.New32a()
|
||||
_, _ = lockHash.Write([]byte(strconv.Itoa(s.userID)))
|
||||
_, _ = lockHash.Write([]byte{0})
|
||||
_, _ = lockHash.Write([]byte(mutationID))
|
||||
if _, err := tx.Exec(ctx, "SELECT pg_advisory_xact_lock($1, $2)",
|
||||
settingMutationAdvisoryClass, int32(lockHash.Sum32())); err != nil {
|
||||
return fmt.Errorf("locking setting mutation transaction: %w", err)
|
||||
}
|
||||
|
||||
writer := &postgresSettingMutationWriter{exec: tx, userID: s.userID}
|
||||
if err := fn(writer); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("committing setting mutation transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *postgresSettingMutationWriter) GetSettingValue(
|
||||
ctx context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return getSettingValue(ctx, w.exec, w.userID, id)
|
||||
}
|
||||
|
||||
func (w *postgresSettingMutationWriter) UpsertSettingValue(
|
||||
ctx context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return upsertSettingValue(ctx, w.exec, w.userID, id, value)
|
||||
}
|
||||
|
||||
func (w *postgresSettingMutationWriter) CompareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return compareAndSetSettingValue(ctx, w.exec, w.userID, id, value, expectedRevision)
|
||||
}
|
||||
|
||||
func (w *postgresSettingMutationWriter) GetSettingMutation(
|
||||
ctx context.Context,
|
||||
mutationID string,
|
||||
) (*userstore.SettingMutationRecord, error) {
|
||||
return getSettingMutation(ctx, w.exec, w.userID, mutationID)
|
||||
}
|
||||
|
||||
func (w *postgresSettingMutationWriter) PutSettingMutation(
|
||||
ctx context.Context,
|
||||
record userstore.SettingMutationRecord,
|
||||
) (userstore.SettingMutationRecord, bool, error) {
|
||||
return putSettingMutation(ctx, w.exec, w.userID, record)
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
|
||||
// settingValueColumns is the projection every read shares, in the order
|
||||
// scanSettingValue expects.
|
||||
const settingValueColumns = `key, scope, profile_id, device_id, library_id, series_id,
|
||||
const settingValueColumns = `key, scope, profile_id, client_family, device_id, library_id, series_id,
|
||||
value, revision, created_at, updated_at`
|
||||
|
||||
// settingConflictTargets maps a scope to the partial unique index that enforces
|
||||
@@ -24,6 +24,7 @@ const settingValueColumns = `key, scope, profile_id, device_id, library_id, seri
|
||||
var settingConflictTargets = map[settingscontract.Scope]string{
|
||||
settingscontract.ScopeAccount: "(user_id, key) WHERE scope = 'account'",
|
||||
settingscontract.ScopeProfile: "(user_id, profile_id, key) WHERE scope = 'profile'",
|
||||
settingscontract.ScopeProfileClient: "(user_id, profile_id, client_family, key) WHERE scope = 'profile_client'",
|
||||
settingscontract.ScopeProfileDevice: "(user_id, profile_id, device_id, key) WHERE scope = 'profile_device'",
|
||||
settingscontract.ScopeProfileLibrary: "(user_id, profile_id, library_id, key) WHERE scope = 'profile_library'",
|
||||
settingscontract.ScopeProfileSeries: "(user_id, profile_id, series_id, key) WHERE scope = 'profile_series'",
|
||||
@@ -39,6 +40,9 @@ func settingIdentityPredicate(userID int, id userstore.SettingIdentity) (string,
|
||||
case settingscontract.ScopeProfile:
|
||||
args = append(args, id.ProfileID)
|
||||
clause += " AND profile_id = $4"
|
||||
case settingscontract.ScopeProfileClient:
|
||||
args = append(args, id.ProfileID, string(id.ClientFamily))
|
||||
clause += " AND profile_id = $4 AND client_family = $5"
|
||||
case settingscontract.ScopeProfileDevice:
|
||||
args = append(args, id.ProfileID, id.DeviceID)
|
||||
clause += " AND profile_id = $4 AND device_id = $5"
|
||||
@@ -55,12 +59,21 @@ func settingIdentityPredicate(userID int, id userstore.SettingIdentity) (string,
|
||||
func (s *PostgresUserStore) GetSettingValue(
|
||||
ctx context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return getSettingValue(ctx, s.pool, s.userID, id)
|
||||
}
|
||||
|
||||
func getSettingValue(
|
||||
ctx context.Context,
|
||||
exec preferenceSettingsExecutor,
|
||||
userID int,
|
||||
id userstore.SettingIdentity,
|
||||
) (*userstore.SettingValue, error) {
|
||||
if err := id.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clause, args := settingIdentityPredicate(s.userID, id)
|
||||
row := s.pool.QueryRow(ctx,
|
||||
clause, args := settingIdentityPredicate(userID, id)
|
||||
row := exec.QueryRow(ctx,
|
||||
"SELECT "+settingValueColumns+" FROM user_setting_values WHERE "+clause,
|
||||
args...,
|
||||
)
|
||||
@@ -75,9 +88,9 @@ func (s *PostgresUserStore) GetSettingValue(
|
||||
}
|
||||
|
||||
// ListSettingValuesForResolution collects every candidate row for a resolution
|
||||
// request in one query. The predicate covers all five scopes at once: ranking by
|
||||
// each definition's resolution order happens in Go, so a four-scope chain still
|
||||
// costs one round trip and one index scan rather than four lookups per key.
|
||||
// request in one query. The predicate covers all six scopes at once: ranking by
|
||||
// each definition's resolution order happens in Go, so a multi-scope chain still
|
||||
// costs one round trip and one index scan rather than one lookup per scope and key.
|
||||
func (s *PostgresUserStore) ListSettingValuesForResolution(
|
||||
ctx context.Context,
|
||||
query userstore.SettingResolutionQuery,
|
||||
@@ -96,17 +109,18 @@ func (s *PostgresUserStore) ListSettingValuesForResolution(
|
||||
scope = 'account'
|
||||
OR (
|
||||
profile_id = ANY($3::text[])
|
||||
AND (
|
||||
scope = 'profile'
|
||||
OR (scope = 'profile_device' AND device_id = $4)
|
||||
OR (scope = 'profile_library' AND library_id = ANY($5::int[]))
|
||||
OR (scope = 'profile_series' AND series_id = ANY($6::text[]))
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
|
||||
AND (
|
||||
scope = 'profile'
|
||||
OR (scope = 'profile_client' AND client_family = $4)
|
||||
OR (scope = 'profile_device' AND device_id = $5)
|
||||
OR (scope = 'profile_library' AND library_id = ANY($6::int[]))
|
||||
OR (scope = 'profile_series' AND series_id = ANY($7::text[]))
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
|
||||
COALESCE(library_id, 0), COALESCE(series_id, '')`,
|
||||
s.userID, q.Keys, q.ProfileIDs, q.DeviceID, q.LibraryIDs, q.SeriesIDs,
|
||||
s.userID, q.Keys, q.ProfileIDs, string(q.ClientFamily), q.DeviceID, q.LibraryIDs, q.SeriesIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing setting values for resolution: %w", err)
|
||||
@@ -133,7 +147,7 @@ func (s *PostgresUserStore) ListAllSettingValues(ctx context.Context) ([]usersto
|
||||
SELECT `+settingValueColumns+`
|
||||
FROM user_setting_values
|
||||
WHERE user_id = $1
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(device_id, ''),
|
||||
ORDER BY key, scope, COALESCE(profile_id, ''), COALESCE(client_family, ''), COALESCE(device_id, ''),
|
||||
COALESCE(library_id, 0), COALESCE(series_id, '')`,
|
||||
s.userID,
|
||||
)
|
||||
@@ -181,15 +195,15 @@ func upsertSettingValue(
|
||||
|
||||
row := exec.QueryRow(ctx, fmt.Sprintf(`
|
||||
INSERT INTO user_setting_values
|
||||
(user_id, key, scope, profile_id, device_id, library_id, series_id, value)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
(user_id, key, scope, profile_id, client_family, device_id, library_id, series_id, value)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT %s DO UPDATE SET
|
||||
value = excluded.value,
|
||||
revision = user_setting_values.revision + 1,
|
||||
updated_at = now()
|
||||
RETURNING %s`, target, settingValueColumns),
|
||||
userID, id.Key, string(id.Scope),
|
||||
nullableText(id.ProfileID), nullableText(id.DeviceID),
|
||||
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
|
||||
nullableInt(id.LibraryID), nullableText(id.SeriesID),
|
||||
[]byte(value),
|
||||
)
|
||||
@@ -200,6 +214,75 @@ func upsertSettingValue(
|
||||
return &stored, nil
|
||||
}
|
||||
|
||||
// CompareAndSetSettingValue writes only when expectedRevision still names the
|
||||
// current row. Revision zero is insert-if-absent. PostgreSQL performs either
|
||||
// comparison and write in one statement, so concurrent document mutations can
|
||||
// retry without overwriting one another.
|
||||
func (s *PostgresUserStore) CompareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*userstore.SettingValue, error) {
|
||||
return compareAndSetSettingValue(ctx, s.pool, s.userID, id, value, expectedRevision)
|
||||
}
|
||||
|
||||
func compareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
exec preferenceSettingsExecutor,
|
||||
userID int,
|
||||
id userstore.SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*userstore.SettingValue, error) {
|
||||
if err := id.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := userstore.ValidateSettingValueJSON(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expectedRevision < 0 {
|
||||
return nil, fmt.Errorf("%w: expected revision must be non-negative", userstore.ErrInvalidSettingIdentity)
|
||||
}
|
||||
|
||||
var row pgx.Row
|
||||
if expectedRevision == 0 {
|
||||
target, ok := settingConflictTargets[id.Scope]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q has no storage identity", userstore.ErrInvalidSettingIdentity, id.Scope)
|
||||
}
|
||||
row = exec.QueryRow(ctx, fmt.Sprintf(`
|
||||
INSERT INTO user_setting_values
|
||||
(user_id, key, scope, profile_id, client_family, device_id, library_id, series_id, value)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT %s DO NOTHING
|
||||
RETURNING %s`, target, settingValueColumns),
|
||||
userID, id.Key, string(id.Scope),
|
||||
nullableText(id.ProfileID), nullableText(string(id.ClientFamily)), nullableText(id.DeviceID),
|
||||
nullableInt(id.LibraryID), nullableText(id.SeriesID), []byte(value),
|
||||
)
|
||||
} else {
|
||||
clause, args := settingIdentityPredicate(userID, id)
|
||||
valuePosition := len(args) + 1
|
||||
revisionPosition := valuePosition + 1
|
||||
args = append(args, []byte(value), expectedRevision)
|
||||
row = exec.QueryRow(ctx, fmt.Sprintf(`
|
||||
UPDATE user_setting_values
|
||||
SET value = $%d, revision = revision + 1, updated_at = now()
|
||||
WHERE %s AND revision = $%d
|
||||
RETURNING %s`, valuePosition, clause, revisionPosition, settingValueColumns), args...)
|
||||
}
|
||||
|
||||
stored, err := scanSettingValue(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, userstore.ErrSettingValueRevisionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compare-and-setting value %q at %s: %w", id.Key, id.Scope, err)
|
||||
}
|
||||
return &stored, nil
|
||||
}
|
||||
|
||||
func (s *PostgresUserStore) DeleteSettingValue(ctx context.Context, id userstore.SettingIdentity) (bool, error) {
|
||||
return deleteSettingValue(ctx, s.pool, s.userID, id)
|
||||
}
|
||||
@@ -275,11 +358,20 @@ func (s *PostgresUserStore) GetSettingMutation(
|
||||
ctx context.Context,
|
||||
mutationID string,
|
||||
) (*userstore.SettingMutationRecord, error) {
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
return getSettingMutation(ctx, s.pool, s.userID, mutationID)
|
||||
}
|
||||
|
||||
func getSettingMutation(
|
||||
ctx context.Context,
|
||||
exec preferenceSettingsExecutor,
|
||||
userID int,
|
||||
mutationID string,
|
||||
) (*userstore.SettingMutationRecord, error) {
|
||||
row := exec.QueryRow(ctx, `
|
||||
SELECT mutation_id, request_hash, result, created_at, expires_at
|
||||
FROM user_setting_mutations
|
||||
WHERE user_id = $1 AND mutation_id = $2`,
|
||||
s.userID, mutationID,
|
||||
userID, mutationID,
|
||||
)
|
||||
record, err := scanSettingMutation(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -298,17 +390,26 @@ func (s *PostgresUserStore) GetSettingMutation(
|
||||
func (s *PostgresUserStore) PutSettingMutation(
|
||||
ctx context.Context,
|
||||
record userstore.SettingMutationRecord,
|
||||
) (userstore.SettingMutationRecord, bool, error) {
|
||||
return putSettingMutation(ctx, s.pool, s.userID, record)
|
||||
}
|
||||
|
||||
func putSettingMutation(
|
||||
ctx context.Context,
|
||||
exec preferenceSettingsExecutor,
|
||||
userID int,
|
||||
record userstore.SettingMutationRecord,
|
||||
) (userstore.SettingMutationRecord, bool, error) {
|
||||
if err := record.Validate(); err != nil {
|
||||
return userstore.SettingMutationRecord{}, false, err
|
||||
}
|
||||
|
||||
row := s.pool.QueryRow(ctx, `
|
||||
row := exec.QueryRow(ctx, `
|
||||
INSERT INTO user_setting_mutations (user_id, mutation_id, request_hash, result, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (user_id, mutation_id) DO NOTHING
|
||||
RETURNING mutation_id, request_hash, result, created_at, expires_at`,
|
||||
s.userID, record.MutationID, record.RequestHash, []byte(record.Result), record.ExpiresAt,
|
||||
userID, record.MutationID, record.RequestHash, []byte(record.Result), record.ExpiresAt,
|
||||
)
|
||||
stored, err := scanSettingMutation(row)
|
||||
if err == nil {
|
||||
@@ -318,7 +419,7 @@ func (s *PostgresUserStore) PutSettingMutation(
|
||||
return userstore.SettingMutationRecord{}, false, fmt.Errorf("recording setting mutation %q: %w", record.MutationID, err)
|
||||
}
|
||||
|
||||
existing, err := s.GetSettingMutation(ctx, record.MutationID)
|
||||
existing, err := getSettingMutation(ctx, exec, userID, record.MutationID)
|
||||
if err != nil {
|
||||
return userstore.SettingMutationRecord{}, false, err
|
||||
}
|
||||
@@ -349,18 +450,19 @@ type pgxRow interface {
|
||||
|
||||
func scanSettingValue(row pgxRow) (userstore.SettingValue, error) {
|
||||
var (
|
||||
value userstore.SettingValue
|
||||
scope string
|
||||
profileID *string
|
||||
deviceID *string
|
||||
libraryID *int
|
||||
seriesID *string
|
||||
raw []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
value userstore.SettingValue
|
||||
scope string
|
||||
profileID *string
|
||||
clientFamily *string
|
||||
deviceID *string
|
||||
libraryID *int
|
||||
seriesID *string
|
||||
raw []byte
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
)
|
||||
if err := row.Scan(
|
||||
&value.Key, &scope, &profileID, &deviceID, &libraryID, &seriesID,
|
||||
&value.Key, &scope, &profileID, &clientFamily, &deviceID, &libraryID, &seriesID,
|
||||
&raw, &value.Revision, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
return userstore.SettingValue{}, err
|
||||
@@ -369,6 +471,9 @@ func scanSettingValue(row pgxRow) (userstore.SettingValue, error) {
|
||||
if profileID != nil {
|
||||
value.ProfileID = *profileID
|
||||
}
|
||||
if clientFamily != nil {
|
||||
value.ClientFamily = settingscontract.ClientFamily(*clientFamily)
|
||||
}
|
||||
if deviceID != nil {
|
||||
value.DeviceID = *deviceID
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package userstore
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -23,6 +24,13 @@ var ErrInvalidSettingIdentity = errors.New("invalid setting identity")
|
||||
// validation path.
|
||||
var ErrInvalidSettingValue = errors.New("invalid setting value")
|
||||
|
||||
// ErrSettingValueRevisionConflict reports that a compare-and-set write did
|
||||
// not observe the revision it expected. It is deliberately a storage-level
|
||||
// primitive rather than a public settings precondition: semantic mutation
|
||||
// endpoints use it to merge concurrent edits without making every client
|
||||
// implement a read/rebase/retry loop.
|
||||
var ErrSettingValueRevisionConflict = errors.New("setting value revision conflict")
|
||||
|
||||
// SettingIdentity addresses exactly one canonical setting row: the key plus the
|
||||
// context columns its scope requires.
|
||||
//
|
||||
@@ -33,10 +41,11 @@ type SettingIdentity struct {
|
||||
Key string
|
||||
Scope settingscontract.Scope
|
||||
|
||||
ProfileID string
|
||||
DeviceID string
|
||||
LibraryID int
|
||||
SeriesID string
|
||||
ProfileID string
|
||||
ClientFamily settingscontract.ClientFamily
|
||||
DeviceID string
|
||||
LibraryID int
|
||||
SeriesID string
|
||||
}
|
||||
|
||||
// Validate reports whether the identity is addressable. It mirrors the scope
|
||||
@@ -51,10 +60,11 @@ type SettingIdentity struct {
|
||||
// p1 — a silently orphaned row.
|
||||
func (id SettingIdentity) Validate() error {
|
||||
for field, value := range map[string]string{
|
||||
"key": id.Key,
|
||||
"profile id": id.ProfileID,
|
||||
"device id": id.DeviceID,
|
||||
"series id": id.SeriesID,
|
||||
"key": id.Key,
|
||||
"profile id": id.ProfileID,
|
||||
"client family": string(id.ClientFamily),
|
||||
"device id": id.DeviceID,
|
||||
"series id": id.SeriesID,
|
||||
} {
|
||||
if value != strings.TrimSpace(value) {
|
||||
return fmt.Errorf("%w: %s %q has surrounding whitespace",
|
||||
@@ -76,6 +86,15 @@ func (id SettingIdentity) Validate() error {
|
||||
return fmt.Errorf("%w: scope %q must not carry a profile id", ErrInvalidSettingIdentity, id.Scope)
|
||||
}
|
||||
|
||||
wantClientFamily := id.Scope == settingscontract.ScopeProfileClient
|
||||
if wantClientFamily && !id.ClientFamily.Valid() {
|
||||
return fmt.Errorf("%w: scope %q requires client family tv, mobile, tablet, desktop or web",
|
||||
ErrInvalidSettingIdentity, id.Scope)
|
||||
}
|
||||
if !wantClientFamily && id.ClientFamily != "" {
|
||||
return fmt.Errorf("%w: scope %q must not carry a client family", ErrInvalidSettingIdentity, id.Scope)
|
||||
}
|
||||
|
||||
wantDevice := id.Scope == settingscontract.ScopeProfileDevice
|
||||
if wantDevice && id.DeviceID == "" {
|
||||
return fmt.Errorf("%w: scope %q requires a device id", ErrInvalidSettingIdentity, id.Scope)
|
||||
@@ -117,6 +136,55 @@ type SettingValue struct {
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
// SettingValueCompareAndSetter is the optional atomic-write capability used
|
||||
// by settings whose public API mutates one member of a shared document. An
|
||||
// expected revision of zero means the row must not exist; a positive revision
|
||||
// means that exact row revision must still be current.
|
||||
//
|
||||
// UserStore intentionally does not embed this interface. Transaction-scoped
|
||||
// and test-only stores that never serve semantic mutation endpoints need not
|
||||
// expose an operation they cannot perform, while both production backends do.
|
||||
type SettingValueCompareAndSetter interface {
|
||||
CompareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
id SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*SettingValue, error)
|
||||
}
|
||||
|
||||
// SettingMutationWriter is the transaction-scoped settings surface used by an
|
||||
// idempotent mutation. Implementations must keep every call made by one
|
||||
// WithSettingMutationTransaction callback on the same database transaction.
|
||||
// That makes the setting write and its replay receipt one durable unit.
|
||||
type SettingMutationWriter interface {
|
||||
GetSettingValue(ctx context.Context, id SettingIdentity) (*SettingValue, error)
|
||||
UpsertSettingValue(ctx context.Context, id SettingIdentity, value json.RawMessage) (*SettingValue, error)
|
||||
CompareAndSetSettingValue(
|
||||
ctx context.Context,
|
||||
id SettingIdentity,
|
||||
value json.RawMessage,
|
||||
expectedRevision int64,
|
||||
) (*SettingValue, error)
|
||||
GetSettingMutation(ctx context.Context, mutationID string) (*SettingMutationRecord, error)
|
||||
PutSettingMutation(ctx context.Context, record SettingMutationRecord) (SettingMutationRecord, bool, error)
|
||||
}
|
||||
|
||||
// SettingMutationTransactioner atomically serializes one mutation id, applies
|
||||
// its setting write, and records its receipt. The callback commits only when it
|
||||
// returns nil. A crash or callback error therefore leaves neither half applied.
|
||||
// Concurrent callbacks for the same mutationID must execute one at a time.
|
||||
//
|
||||
// This is optional rather than embedded in UserStore because only production
|
||||
// stores serving idempotent mutation routes need to expose transaction state.
|
||||
type SettingMutationTransactioner interface {
|
||||
WithSettingMutationTransaction(
|
||||
ctx context.Context,
|
||||
mutationID string,
|
||||
fn func(SettingMutationWriter) error,
|
||||
) error
|
||||
}
|
||||
|
||||
// SettingResolutionQuery describes one resolution request: the keys to resolve
|
||||
// and every identity they may resolve against.
|
||||
//
|
||||
@@ -138,6 +206,10 @@ type SettingResolutionQuery struct {
|
||||
// DeviceID drops profile_device candidates when empty, which is what an
|
||||
// unidentified client (jellycompat's DisplayPreferences seed) needs.
|
||||
DeviceID string
|
||||
// ClientFamily drops profile_client candidates when empty. Unlike the
|
||||
// device registry's platform metadata, this is a closed canonical enum and
|
||||
// is supplied explicitly by first-party clients.
|
||||
ClientFamily settingscontract.ClientFamily
|
||||
// LibraryIDs and SeriesIDs carry the content contexts of a batch. Empty
|
||||
// slices drop their scope from the candidate set.
|
||||
LibraryIDs []int
|
||||
@@ -150,11 +222,12 @@ type SettingResolutionQuery struct {
|
||||
// the same predicate for the same request.
|
||||
func (q SettingResolutionQuery) Normalized() SettingResolutionQuery {
|
||||
return SettingResolutionQuery{
|
||||
Keys: compactStrings(q.Keys),
|
||||
ProfileIDs: compactStrings(q.ProfileIDs),
|
||||
DeviceID: strings.TrimSpace(q.DeviceID),
|
||||
LibraryIDs: compactPositiveInts(q.LibraryIDs),
|
||||
SeriesIDs: compactStrings(q.SeriesIDs),
|
||||
Keys: compactStrings(q.Keys),
|
||||
ProfileIDs: compactStrings(q.ProfileIDs),
|
||||
ClientFamily: settingscontract.ClientFamily(strings.TrimSpace(string(q.ClientFamily))),
|
||||
DeviceID: strings.TrimSpace(q.DeviceID),
|
||||
LibraryIDs: compactPositiveInts(q.LibraryIDs),
|
||||
SeriesIDs: compactStrings(q.SeriesIDs),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +29,9 @@ func RunSettingValues(t *testing.T, newStore func(t *testing.T) userstore.UserSt
|
||||
t.Run("RevisionIncrements", func(t *testing.T) {
|
||||
testSettingValueRevisions(t, newStore)
|
||||
})
|
||||
t.Run("CompareAndSet", func(t *testing.T) {
|
||||
testSettingValueCompareAndSet(t, newStore)
|
||||
})
|
||||
t.Run("PartialUniqueness", func(t *testing.T) {
|
||||
testSettingValuePartialUniqueness(t, newStore)
|
||||
})
|
||||
@@ -49,11 +53,62 @@ func RunSettingValues(t *testing.T, newStore func(t *testing.T) userstore.UserSt
|
||||
t.Run("MutationIdempotency", func(t *testing.T) {
|
||||
testSettingMutationIdempotency(t, newStore)
|
||||
})
|
||||
t.Run("MutationTransaction", func(t *testing.T) {
|
||||
testSettingMutationTransaction(t, newStore)
|
||||
})
|
||||
t.Run("DeviceExists", func(t *testing.T) {
|
||||
testDeviceExists(t, newStore)
|
||||
})
|
||||
}
|
||||
|
||||
// testSettingValueCompareAndSet pins the internal primitive used by semantic
|
||||
// document mutations. A stale writer must lose without changing the row, and
|
||||
// an insert-if-absent race must have exactly one winner.
|
||||
func testSettingValueCompareAndSet(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
cas, ok := store.(userstore.SettingValueCompareAndSetter)
|
||||
if !ok {
|
||||
t.Skip("store does not implement SettingValueCompareAndSetter")
|
||||
}
|
||||
seedSettingProfiles(t, ctx, store, "p1")
|
||||
id := profileID(audioKey, "p1")
|
||||
|
||||
first, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"en"`), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert-if-absent: %v", err)
|
||||
}
|
||||
if first == nil || first.Revision != 1 || !jsonEqual(first.Value, json.RawMessage(`"en"`)) {
|
||||
t.Fatalf("insert-if-absent = %+v, want en at revision 1", first)
|
||||
}
|
||||
|
||||
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"fr"`), 0); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
|
||||
t.Fatalf("second insert-if-absent error = %v, want ErrSettingValueRevisionConflict", err)
|
||||
}
|
||||
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"de"`), first.Revision+1); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
|
||||
t.Fatalf("future revision error = %v, want ErrSettingValueRevisionConflict", err)
|
||||
}
|
||||
|
||||
second, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"ja"`), first.Revision)
|
||||
if err != nil {
|
||||
t.Fatalf("compare-and-set current revision: %v", err)
|
||||
}
|
||||
if second == nil || second.Revision != 2 || !jsonEqual(second.Value, json.RawMessage(`"ja"`)) {
|
||||
t.Fatalf("compare-and-set = %+v, want ja at revision 2", second)
|
||||
}
|
||||
if _, err := cas.CompareAndSetSettingValue(ctx, id, json.RawMessage(`"it"`), first.Revision); !errors.Is(err, userstore.ErrSettingValueRevisionConflict) {
|
||||
t.Fatalf("stale revision error = %v, want ErrSettingValueRevisionConflict", err)
|
||||
}
|
||||
|
||||
got, err := store.GetSettingValue(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSettingValue: %v", err)
|
||||
}
|
||||
if got == nil || got.Revision != 2 || !jsonEqual(got.Value, json.RawMessage(`"ja"`)) {
|
||||
t.Fatalf("row after stale CAS = %+v, want ja at revision 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// testDeviceExists pins the check that authorizes a device named in a request
|
||||
// rather than taken from its headers. It must be scoped to the profile, not
|
||||
// merely to the account: two profiles in one household register the same TV
|
||||
@@ -204,6 +259,12 @@ func profileID(key, profile string) userstore.SettingIdentity {
|
||||
return userstore.SettingIdentity{Key: key, Scope: settingscontract.ScopeProfile, ProfileID: profile}
|
||||
}
|
||||
|
||||
func clientID(key, profile string, family settingscontract.ClientFamily) userstore.SettingIdentity {
|
||||
return userstore.SettingIdentity{
|
||||
Key: key, Scope: settingscontract.ScopeProfileClient, ProfileID: profile, ClientFamily: family,
|
||||
}
|
||||
}
|
||||
|
||||
func deviceID(key, profile, device string) userstore.SettingIdentity {
|
||||
return userstore.SettingIdentity{
|
||||
Key: key, Scope: settingscontract.ScopeProfileDevice, ProfileID: profile, DeviceID: device,
|
||||
@@ -261,6 +322,7 @@ func testSettingValueScopes(t *testing.T, newStore func(t *testing.T) userstore.
|
||||
}{
|
||||
{"account", accountID(audioKey), `"en"`},
|
||||
{"profile", profileID(audioKey, "p1"), `"fr"`},
|
||||
{"profile_client", clientID(audioKey, "p1", settingscontract.ClientFamilyTV), `"it"`},
|
||||
{"profile_device", deviceID(audioKey, "p1", deviceApple), `"de"`},
|
||||
{"profile_library", libraryID(audioKey, "p1", 42), `"es"`},
|
||||
{"profile_series", seriesID(audioKey, "p1", "series-1"), `"ja"`},
|
||||
@@ -307,6 +369,7 @@ func testSettingValueScopes(t *testing.T, newStore func(t *testing.T) userstore.
|
||||
seedSettingProfiles(t, ctx, store, "p2")
|
||||
for _, id := range []userstore.SettingIdentity{
|
||||
profileID(audioKey, "p2"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
|
||||
deviceID(audioKey, "p1", "iphone"),
|
||||
libraryID(audioKey, "p1", 43),
|
||||
seriesID(audioKey, "p1", "series-2"),
|
||||
@@ -338,12 +401,13 @@ func testSettingValueListAll(t *testing.T, newStore func(t *testing.T) userstore
|
||||
|
||||
seedSettingProfiles(t, ctx, store, "p1", "p2")
|
||||
seeded := map[userstore.SettingIdentity]string{
|
||||
accountID(audioKey): `"en"`,
|
||||
profileID(audioKey, "p1"): `"fr"`,
|
||||
profileID(subtitleKey, "p2"): `"always"`,
|
||||
deviceID(audioKey, "p1", deviceApple): `"de"`,
|
||||
libraryID(audioKey, "p1", 42): `"es"`,
|
||||
seriesID(audioKey, "p1", seriesOne): `"ja"`,
|
||||
accountID(audioKey): `"en"`,
|
||||
profileID(audioKey, "p1"): `"fr"`,
|
||||
profileID(subtitleKey, "p2"): `"always"`,
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV): `"it"`,
|
||||
deviceID(audioKey, "p1", deviceApple): `"de"`,
|
||||
libraryID(audioKey, "p1", 42): `"es"`,
|
||||
seriesID(audioKey, "p1", seriesOne): `"ja"`,
|
||||
}
|
||||
for id, value := range seeded {
|
||||
mustUpsert(t, ctx, store, id, value)
|
||||
@@ -476,7 +540,7 @@ func testSettingValueRevisions(t *testing.T, newStore func(t *testing.T) usersto
|
||||
}
|
||||
}
|
||||
|
||||
// testSettingValuePartialUniqueness pins the five partial unique indexes: one
|
||||
// testSettingValuePartialUniqueness pins the six partial unique indexes: one
|
||||
// explicit value per identity, and identities that differ in any one context
|
||||
// column coexist.
|
||||
func testSettingValuePartialUniqueness(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
||||
@@ -488,6 +552,9 @@ func testSettingValuePartialUniqueness(t *testing.T, newStore func(t *testing.T)
|
||||
accountID(audioKey),
|
||||
profileID(audioKey, "p1"),
|
||||
profileID(audioKey, "p2"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
|
||||
clientID(audioKey, "p2", settingscontract.ClientFamilyTV),
|
||||
deviceID(audioKey, "p1", deviceApple),
|
||||
deviceID(audioKey, "p1", "iphone"),
|
||||
deviceID(audioKey, "p2", deviceApple),
|
||||
@@ -505,19 +572,22 @@ func testSettingValuePartialUniqueness(t *testing.T, newStore func(t *testing.T)
|
||||
}
|
||||
|
||||
rows, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
|
||||
Keys: []string{audioKey},
|
||||
ProfileIDs: []string{"p1"},
|
||||
DeviceID: deviceApple,
|
||||
LibraryIDs: []int{1, 2},
|
||||
SeriesIDs: []string{seriesOne, seriesTwo},
|
||||
Keys: []string{audioKey},
|
||||
ProfileIDs: []string{"p1"},
|
||||
ClientFamily: settingscontract.ClientFamilyTV,
|
||||
DeviceID: deviceApple,
|
||||
LibraryIDs: []int{1, 2},
|
||||
SeriesIDs: []string{seriesOne, seriesTwo},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSettingValuesForResolution: %v", err)
|
||||
}
|
||||
// p1's candidates: account, profile, one device, two libraries, two series.
|
||||
// p1's candidates: account, profile, one client family, one device, two
|
||||
// libraries, and two series.
|
||||
want := []userstore.SettingIdentity{
|
||||
accountID(audioKey),
|
||||
profileID(audioKey, "p1"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
deviceID(audioKey, "p1", deviceApple),
|
||||
libraryID(audioKey, "p1", 1),
|
||||
libraryID(audioKey, "p1", 2),
|
||||
@@ -555,6 +625,15 @@ func testSettingValueIdentityValidation(t *testing.T, newStore func(t *testing.T
|
||||
{"account scope with profile", userstore.SettingIdentity{
|
||||
Key: audioKey, Scope: settingscontract.ScopeAccount, ProfileID: "p1",
|
||||
}},
|
||||
{"client scope without family", userstore.SettingIdentity{
|
||||
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1",
|
||||
}},
|
||||
{"client scope with unknown family", userstore.SettingIdentity{
|
||||
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1", ClientFamily: "car",
|
||||
}},
|
||||
{"profile scope with family", userstore.SettingIdentity{
|
||||
Key: audioKey, Scope: settingscontract.ScopeProfile, ProfileID: "p1", ClientFamily: settingscontract.ClientFamilyTV,
|
||||
}},
|
||||
{"device scope without device", userstore.SettingIdentity{
|
||||
Key: audioKey, Scope: settingscontract.ScopeProfileDevice, ProfileID: "p1",
|
||||
}},
|
||||
@@ -582,6 +661,9 @@ func testSettingValueIdentityValidation(t *testing.T, newStore func(t *testing.T
|
||||
{"padded device id", userstore.SettingIdentity{
|
||||
Key: audioKey, Scope: settingscontract.ScopeProfileDevice, ProfileID: "p1", DeviceID: deviceApple + " ",
|
||||
}},
|
||||
{"padded client family", userstore.SettingIdentity{
|
||||
Key: audioKey, Scope: settingscontract.ScopeProfileClient, ProfileID: "p1", ClientFamily: " tv ",
|
||||
}},
|
||||
{"padded series id", userstore.SettingIdentity{
|
||||
Key: audioKey, Scope: settingscontract.ScopeProfileSeries, ProfileID: "p1", SeriesID: " " + seriesOne,
|
||||
}},
|
||||
@@ -624,6 +706,7 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
|
||||
|
||||
mustUpsert(t, ctx, store, accountID(audioKey), `"en"`)
|
||||
mustUpsert(t, ctx, store, profileID(audioKey, "p1"), `"fr"`)
|
||||
mustUpsert(t, ctx, store, clientID(audioKey, "p1", settingscontract.ClientFamilyTV), `"it"`)
|
||||
mustUpsert(t, ctx, store, deviceID(audioKey, "p1", deviceApple), `"de"`)
|
||||
mustUpsert(t, ctx, store, libraryID(audioKey, "p1", 42), `"es"`)
|
||||
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", seriesOne), `"ja"`)
|
||||
@@ -633,6 +716,8 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
|
||||
// Decoys: another profile, another device, another library, another series,
|
||||
// and a key nobody asked for.
|
||||
mustUpsert(t, ctx, store, profileID(audioKey, "p2"), `"pt"`)
|
||||
mustUpsert(t, ctx, store, clientID(audioKey, "p1", settingscontract.ClientFamilyMobile), `"pl"`)
|
||||
mustUpsert(t, ctx, store, clientID(audioKey, "p2", settingscontract.ClientFamilyTV), `"cs"`)
|
||||
mustUpsert(t, ctx, store, deviceID(audioKey, "p1", "iphone"), `"nl"`)
|
||||
mustUpsert(t, ctx, store, libraryID(audioKey, "p1", 43), `"sv"`)
|
||||
mustUpsert(t, ctx, store, seriesID(audioKey, "p1", "s-3"), `"da"`)
|
||||
@@ -640,11 +725,12 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
|
||||
|
||||
// The batched shape: two content contexts resolved in one call.
|
||||
rows, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
|
||||
Keys: []string{audioKey, subtitleKey},
|
||||
ProfileIDs: []string{"p1"},
|
||||
DeviceID: deviceApple,
|
||||
LibraryIDs: []int{42},
|
||||
SeriesIDs: []string{seriesOne, seriesTwo},
|
||||
Keys: []string{audioKey, subtitleKey},
|
||||
ProfileIDs: []string{"p1"},
|
||||
ClientFamily: settingscontract.ClientFamilyTV,
|
||||
DeviceID: deviceApple,
|
||||
LibraryIDs: []int{42},
|
||||
SeriesIDs: []string{seriesOne, seriesTwo},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSettingValuesForResolution: %v", err)
|
||||
@@ -652,6 +738,7 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
|
||||
assertIdentitySet(t, rows, []userstore.SettingIdentity{
|
||||
accountID(audioKey),
|
||||
profileID(audioKey, "p1"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
deviceID(audioKey, "p1", deviceApple),
|
||||
libraryID(audioKey, "p1", 42),
|
||||
seriesID(audioKey, "p1", seriesOne),
|
||||
@@ -663,10 +750,11 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
|
||||
// lets a list view make one round trip instead of one per item.
|
||||
for _, series := range []string{seriesOne, seriesTwo} {
|
||||
single, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
|
||||
Keys: []string{audioKey},
|
||||
ProfileIDs: []string{"p1"},
|
||||
DeviceID: deviceApple,
|
||||
SeriesIDs: []string{series},
|
||||
Keys: []string{audioKey},
|
||||
ProfileIDs: []string{"p1"},
|
||||
ClientFamily: settingscontract.ClientFamilyTV,
|
||||
DeviceID: deviceApple,
|
||||
SeriesIDs: []string{series},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSettingValuesForResolution(%s): %v", series, err)
|
||||
@@ -674,13 +762,30 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
|
||||
assertIdentitySet(t, single, []userstore.SettingIdentity{
|
||||
accountID(audioKey),
|
||||
profileID(audioKey, "p1"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
deviceID(audioKey, "p1", deviceApple),
|
||||
seriesID(audioKey, "p1", series),
|
||||
})
|
||||
}
|
||||
|
||||
// No device identity — an incognito window, or jellycompat's seed — drops
|
||||
// profile_device candidates without touching the roaming ones.
|
||||
// A family without a device still sees the like-client value while dropping
|
||||
// exact-device candidates.
|
||||
familyOnly, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
|
||||
Keys: []string{audioKey},
|
||||
ProfileIDs: []string{"p1"},
|
||||
ClientFamily: settingscontract.ClientFamilyTV,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSettingValuesForResolution(family only): %v", err)
|
||||
}
|
||||
assertIdentitySet(t, familyOnly, []userstore.SettingIdentity{
|
||||
accountID(audioKey),
|
||||
profileID(audioKey, "p1"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
})
|
||||
|
||||
// No device or family identity — an incognito window, or jellycompat's seed
|
||||
// — drops both contextual candidates without touching profile roaming.
|
||||
noDevice, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
|
||||
Keys: []string{audioKey},
|
||||
ProfileIDs: []string{"p1"},
|
||||
@@ -722,11 +827,12 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
|
||||
// Blank and duplicate context ids are compacted rather than bound as
|
||||
// literals, so they neither match a '' row nor multiply the result set.
|
||||
dirty, err := store.ListSettingValuesForResolution(ctx, userstore.SettingResolutionQuery{
|
||||
Keys: []string{audioKey, "", audioKey, " "},
|
||||
ProfileIDs: []string{"p1", "p1", "", " "},
|
||||
DeviceID: deviceApple,
|
||||
LibraryIDs: []int{42, 42, 0, -1},
|
||||
SeriesIDs: []string{seriesOne, seriesOne, "", " "},
|
||||
Keys: []string{audioKey, "", audioKey, " "},
|
||||
ProfileIDs: []string{"p1", "p1", "", " "},
|
||||
ClientFamily: settingscontract.ClientFamilyTV,
|
||||
DeviceID: deviceApple,
|
||||
LibraryIDs: []int{42, 42, 0, -1},
|
||||
SeriesIDs: []string{seriesOne, seriesOne, "", " "},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListSettingValuesForResolution(dirty): %v", err)
|
||||
@@ -734,6 +840,7 @@ func testSettingValueResolution(t *testing.T, newStore func(t *testing.T) userst
|
||||
assertIdentitySet(t, dirty, []userstore.SettingIdentity{
|
||||
accountID(audioKey),
|
||||
profileID(audioKey, "p1"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
deviceID(audioKey, "p1", deviceApple),
|
||||
libraryID(audioKey, "p1", 42),
|
||||
seriesID(audioKey, "p1", seriesOne),
|
||||
@@ -780,6 +887,9 @@ func testSettingValueDeletePaths(t *testing.T, newStore func(t *testing.T) users
|
||||
accountID(audioKey),
|
||||
profileID(audioKey, "p1"),
|
||||
profileID(audioKey, "p2"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
|
||||
clientID(audioKey, "p2", settingscontract.ClientFamilyTV),
|
||||
deviceID(audioKey, "p1", deviceApple),
|
||||
deviceID(audioKey, "p1", "iphone"),
|
||||
deviceID(audioKey, "p2", deviceApple),
|
||||
@@ -887,11 +997,13 @@ func testSettingValueDeletePaths(t *testing.T, newStore func(t *testing.T) users
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteSettingValuesForProfile: %v", err)
|
||||
}
|
||||
if removed != 7 {
|
||||
t.Fatalf("DeleteSettingValuesForProfile removed %d rows, want 7", removed)
|
||||
if removed != 9 {
|
||||
t.Fatalf("DeleteSettingValuesForProfile removed %d rows, want 9", removed)
|
||||
}
|
||||
assertRemaining(t, store, all, []userstore.SettingIdentity{
|
||||
profileID(audioKey, "p1"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
|
||||
deviceID(audioKey, "p1", deviceApple),
|
||||
deviceID(audioKey, "p1", "iphone"),
|
||||
libraryID(audioKey, "p1", 1),
|
||||
@@ -909,6 +1021,8 @@ func testSettingValueDeletePaths(t *testing.T, newStore func(t *testing.T) users
|
||||
// Account scope belongs to the account, not to any one household member.
|
||||
assertRemaining(t, store, all, []userstore.SettingIdentity{
|
||||
profileID(audioKey, "p1"),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyTV),
|
||||
clientID(audioKey, "p1", settingscontract.ClientFamilyMobile),
|
||||
deviceID(audioKey, "p1", deviceApple),
|
||||
deviceID(audioKey, "p1", "iphone"),
|
||||
libraryID(audioKey, "p1", 1),
|
||||
@@ -1029,6 +1143,152 @@ func testSettingMutationIdempotency(t *testing.T, newStore func(t *testing.T) us
|
||||
}
|
||||
}
|
||||
|
||||
// testSettingMutationTransaction proves the storage primitive that handlers
|
||||
// rely on: setting + receipt roll back together, and same-id contenders check
|
||||
// the winning receipt before either can apply a second setting write.
|
||||
func testSettingMutationTransaction(t *testing.T, newStore func(t *testing.T) userstore.UserStore) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("RollbackIsAtomic", func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
transactioner, ok := store.(userstore.SettingMutationTransactioner)
|
||||
if !ok {
|
||||
t.Skip("store does not implement SettingMutationTransactioner")
|
||||
}
|
||||
seedSettingProfiles(t, ctx, store, "p1")
|
||||
id := profileID(audioKey, "p1")
|
||||
rollbackErr := errors.New("simulate process failure before commit")
|
||||
err := transactioner.WithSettingMutationTransaction(ctx, "tx-rollback",
|
||||
func(writer userstore.SettingMutationWriter) error {
|
||||
if _, err := writer.UpsertSettingValue(ctx, id, json.RawMessage(`"en"`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
|
||||
MutationID: "tx-rollback", RequestHash: mutationHash,
|
||||
Result: json.RawMessage(`{"winner":"en"}`), ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return rollbackErr
|
||||
})
|
||||
if !errors.Is(err, rollbackErr) {
|
||||
t.Fatalf("transaction error = %v, want rollback sentinel", err)
|
||||
}
|
||||
if got, err := store.GetSettingValue(ctx, id); err != nil || got != nil {
|
||||
t.Fatalf("setting after rollback = %+v (%v), want nil", got, err)
|
||||
}
|
||||
if got, err := store.GetSettingMutation(ctx, "tx-rollback"); err != nil || got != nil {
|
||||
t.Fatalf("receipt after rollback = %+v (%v), want nil", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
secondHash string
|
||||
second json.RawMessage
|
||||
wantReplay int
|
||||
wantConflict int
|
||||
}{
|
||||
{name: "SameHashReplays", secondHash: "hash-a", second: json.RawMessage(`"en"`), wantReplay: 1},
|
||||
{name: "DifferentHashConflicts", secondHash: "hash-b", second: json.RawMessage(`"fr"`), wantConflict: 1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
store := newStore(t)
|
||||
transactioner, ok := store.(userstore.SettingMutationTransactioner)
|
||||
if !ok {
|
||||
t.Skip("store does not implement SettingMutationTransactioner")
|
||||
}
|
||||
seedSettingProfiles(t, ctx, store, "p1")
|
||||
id := profileID(audioKey, "p1")
|
||||
type attempt struct {
|
||||
hash string
|
||||
value json.RawMessage
|
||||
}
|
||||
attempts := []attempt{{hash: "hash-a", value: json.RawMessage(`"en"`)}, {hash: tc.secondHash, value: tc.second}}
|
||||
type result struct {
|
||||
status string
|
||||
receipt json.RawMessage
|
||||
err error
|
||||
}
|
||||
results := make(chan result, len(attempts))
|
||||
start := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(len(attempts))
|
||||
for _, candidate := range attempts {
|
||||
candidate := candidate
|
||||
go func() {
|
||||
ready.Done()
|
||||
<-start
|
||||
out := result{}
|
||||
out.err = transactioner.WithSettingMutationTransaction(ctx, "tx-concurrent",
|
||||
func(writer userstore.SettingMutationWriter) error {
|
||||
prior, err := writer.GetSettingMutation(ctx, "tx-concurrent")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prior != nil {
|
||||
out.receipt = prior.Result
|
||||
if prior.RequestHash == candidate.hash {
|
||||
out.status = "replay"
|
||||
} else {
|
||||
out.status = "conflict"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
stored, err := writer.UpsertSettingValue(ctx, id, candidate.value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.receipt, err = json.Marshal(map[string]any{"revision": stored.Revision, "value": candidate.value})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, inserted, err := writer.PutSettingMutation(ctx, userstore.SettingMutationRecord{
|
||||
MutationID: "tx-concurrent", RequestHash: candidate.hash,
|
||||
Result: out.receipt, ExpiresAt: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
if err == nil && !inserted {
|
||||
return errors.New("serialized transaction unexpectedly lost receipt insert")
|
||||
}
|
||||
out.status = "applied"
|
||||
return err
|
||||
})
|
||||
results <- out
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
|
||||
counts := map[string]int{}
|
||||
var receipts []json.RawMessage
|
||||
for range attempts {
|
||||
out := <-results
|
||||
if out.err != nil {
|
||||
t.Fatalf("concurrent transaction: %v", out.err)
|
||||
}
|
||||
counts[out.status]++
|
||||
receipts = append(receipts, out.receipt)
|
||||
}
|
||||
if counts["applied"] != 1 || counts["replay"] != tc.wantReplay || counts["conflict"] != tc.wantConflict {
|
||||
t.Fatalf("outcomes = %+v, want one applied, %d replay, %d conflict", counts, tc.wantReplay, tc.wantConflict)
|
||||
}
|
||||
stored, err := store.GetSettingValue(ctx, id)
|
||||
if err != nil || stored == nil || stored.Revision != 1 {
|
||||
t.Fatalf("stored setting = %+v (%v), want exactly one revision", stored, err)
|
||||
}
|
||||
receipt, err := store.GetSettingMutation(ctx, "tx-concurrent")
|
||||
if err != nil || receipt == nil {
|
||||
t.Fatalf("stored receipt = %+v (%v)", receipt, err)
|
||||
}
|
||||
for _, got := range receipts {
|
||||
if !jsonEqual(got, receipt.Result) {
|
||||
t.Fatalf("attempt receipt = %s, want coherent winner %s", got, receipt.Result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertIdentitySet compares the returned rows to the expected identities as a
|
||||
// set. Row order is deliberately not asserted: the two backends sort text under
|
||||
// different collations, and ranking is the resolver's job anyway.
|
||||
@@ -1050,8 +1310,8 @@ func assertIdentitySet(t *testing.T, rows []userstore.SettingValue, want []users
|
||||
}
|
||||
|
||||
func identityToken(id userstore.SettingIdentity) string {
|
||||
return fmt.Sprintf("%s|%s|%s|%s|%d|%s",
|
||||
id.Key, id.Scope, id.ProfileID, id.DeviceID, id.LibraryID, id.SeriesID)
|
||||
return fmt.Sprintf("%s|%s|%s|%s|%s|%d|%s",
|
||||
id.Key, id.Scope, id.ProfileID, id.ClientFamily, id.DeviceID, id.LibraryID, id.SeriesID)
|
||||
}
|
||||
|
||||
// jsonEqual compares two JSON documents by value. PostgreSQL stores jsonb, which
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
@@ -248,6 +249,10 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
|
||||
}
|
||||
rows := batch.Rows
|
||||
result := ImportListResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
|
||||
existingByRemoteKey, err := s.listItemStatesByRemoteKey(ctx, conn.ID, b.kind, rows)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
seenRemoteKeys := make(map[string]bool, len(rows))
|
||||
states := make([]ListItemState, 0, len(rows))
|
||||
// orderedIDs records matched local ids in the order the provider returned
|
||||
@@ -255,6 +260,12 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
|
||||
// sort locally.
|
||||
orderedIDs := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.Removed {
|
||||
if err := s.applyRemoteListTombstone(ctx, conn, store, b, row, existingByRemoteKey, &result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
|
||||
if err != nil {
|
||||
return result, err
|
||||
@@ -296,10 +307,14 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
|
||||
if err := s.repo.UpsertListItemStates(ctx, states); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := s.reconcileMissingRemoteListItems(ctx, conn, store, b, seenRemoteKeys, &result); err != nil {
|
||||
return result, err
|
||||
if !batch.Incremental {
|
||||
if err := s.reconcileMissingRemoteListItems(ctx, conn, store, b, seenRemoteKeys, &result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if b.applyOrder != nil && b.orderEnabled != nil && b.capOrder != nil &&
|
||||
// An incremental response is only a delta; replacing the whole local order
|
||||
// with that subset would discard every item absent from the delta.
|
||||
if !batch.Incremental && b.applyOrder != nil && b.orderEnabled != nil && b.capOrder != nil &&
|
||||
b.orderEnabled(conn) && b.capOrder(provider.Capabilities()) {
|
||||
if err := b.applyOrder(ctx, store, conn.ProfileID, orderedIDs); err != nil {
|
||||
return result, err
|
||||
@@ -315,6 +330,69 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) listItemStatesByRemoteKey(
|
||||
ctx context.Context,
|
||||
connectionID string,
|
||||
kind ListKind,
|
||||
rows []RemoteFavorite,
|
||||
) (map[string]ListItemState, error) {
|
||||
hasTombstone := false
|
||||
for _, row := range rows {
|
||||
if row.Removed {
|
||||
hasTombstone = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTombstone {
|
||||
return nil, nil
|
||||
}
|
||||
states, err := s.repo.ListListItemStates(ctx, connectionID, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byRemoteKey := make(map[string]ListItemState, len(states))
|
||||
for _, state := range states {
|
||||
if key := strings.TrimSpace(state.ProviderItemKey); key != "" {
|
||||
byRemoteKey[key] = state
|
||||
}
|
||||
}
|
||||
return byRemoteKey, nil
|
||||
}
|
||||
|
||||
func (s *Service) applyRemoteListTombstone(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
store userstore.UserStore,
|
||||
b listBinding,
|
||||
row RemoteFavorite,
|
||||
existingByRemoteKey map[string]ListItemState,
|
||||
result *ImportListResult,
|
||||
) error {
|
||||
key := strings.TrimSpace(row.ProviderItemKey)
|
||||
state, ok := existingByRemoteKey[key]
|
||||
if key == "" || !ok {
|
||||
result.Warnings = append(result.Warnings, "watch sync provider returned an unknown list removal")
|
||||
return nil
|
||||
}
|
||||
now := s.now()
|
||||
if b.removalsEnabled(conn) && state.LocalPresent {
|
||||
if err := b.localRemove(ctx, store, conn.ProfileID, state.MediaItemID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.MarkListItemLocalRemoved(ctx, conn.ID, b.kind, state.MediaItemID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Removed++
|
||||
state.LocalPresent = false
|
||||
}
|
||||
if err := s.repo.MarkListItemRemoteRemoved(ctx, conn.ID, b.kind, state.MediaItemID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
state.RemotePresent = false
|
||||
existingByRemoteKey[key] = state
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) reconcileMissingRemoteListItems(ctx context.Context, conn Connection, store userstore.UserStore, b listBinding, seenRemoteKeys map[string]bool, result *ImportListResult) error {
|
||||
states, err := s.repo.ListListItemStates(ctx, conn.ID, b.kind)
|
||||
if err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
package watchsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
pluginWatchedCursorKey = "plugin.remote.watched"
|
||||
pluginProgressCursorKey = "plugin.remote.progress"
|
||||
pluginFavoritesCursorKey = "plugin.remote.favorites"
|
||||
pluginWatchlistCursorKey = "plugin.remote.watchlist"
|
||||
maxRemoteStatePages = 10_000
|
||||
maxRemoteStateItems = 100_000
|
||||
)
|
||||
|
||||
type pluginRemoteTraversal struct {
|
||||
items []*pluginv1.WatchSyncRemoteState
|
||||
nextCursor string
|
||||
completeSnapshot bool
|
||||
warnings []string
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchWatched(
|
||||
ctx context.Context,
|
||||
cfg ServerConfig,
|
||||
conn Connection,
|
||||
) ([]RemoteWatch, error) {
|
||||
batch, err := p.FetchWatchedBatch(ctx, cfg, conn)
|
||||
return batch.Rows, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchWatchedBatch(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
) (WatchedImportBatch, error) {
|
||||
traversal, err := p.listRemoteState(ctx, conn, pluginWatchedCursorKey,
|
||||
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHED)
|
||||
if err != nil {
|
||||
return WatchedImportBatch{}, err
|
||||
}
|
||||
batch := WatchedImportBatch{
|
||||
UpdatedCursors: cursorUpdate(pluginWatchedCursorKey, traversal.nextCursor),
|
||||
Warnings: traversal.warnings,
|
||||
}
|
||||
for _, state := range traversal.items {
|
||||
if state.GetWatched() == nil {
|
||||
continue
|
||||
}
|
||||
row, err := remoteWatchFromProto(p.Key(), state)
|
||||
if err != nil {
|
||||
batch.Warnings = append(batch.Warnings, err.Error())
|
||||
continue
|
||||
}
|
||||
batch.Rows = append(batch.Rows, row)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchProgress(
|
||||
ctx context.Context,
|
||||
cfg ServerConfig,
|
||||
conn Connection,
|
||||
) ([]RemoteProgress, error) {
|
||||
batch, err := p.FetchProgressBatch(ctx, cfg, conn)
|
||||
return batch.Rows, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchProgressBatch(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
) (ProgressImportBatch, error) {
|
||||
traversal, err := p.listRemoteState(ctx, conn, pluginProgressCursorKey,
|
||||
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_PROGRESS)
|
||||
if err != nil {
|
||||
return ProgressImportBatch{}, err
|
||||
}
|
||||
batch := ProgressImportBatch{
|
||||
UpdatedCursors: cursorUpdate(pluginProgressCursorKey, traversal.nextCursor),
|
||||
Warnings: traversal.warnings,
|
||||
}
|
||||
for _, state := range traversal.items {
|
||||
if state.GetProgress() == nil {
|
||||
continue
|
||||
}
|
||||
row, err := remoteProgressFromProto(p.Key(), state)
|
||||
if err != nil {
|
||||
batch.Warnings = append(batch.Warnings, err.Error())
|
||||
continue
|
||||
}
|
||||
batch.Rows = append(batch.Rows, row)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchFavorites(
|
||||
ctx context.Context,
|
||||
cfg ServerConfig,
|
||||
conn Connection,
|
||||
) ([]RemoteFavorite, error) {
|
||||
batch, err := p.FetchFavoritesBatch(ctx, cfg, conn)
|
||||
return batch.Rows, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchFavoritesBatch(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
) (FavoriteImportBatch, error) {
|
||||
return p.fetchListState(ctx, conn, pluginFavoritesCursorKey,
|
||||
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchWatchlist(
|
||||
ctx context.Context,
|
||||
cfg ServerConfig,
|
||||
conn Connection,
|
||||
) ([]RemoteFavorite, error) {
|
||||
batch, err := p.FetchWatchlistBatch(ctx, cfg, conn)
|
||||
return batch.Rows, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchWatchlistBatch(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
) (FavoriteImportBatch, error) {
|
||||
return p.fetchListState(ctx, conn, pluginWatchlistCursorKey,
|
||||
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) fetchListState(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
cursorKey string,
|
||||
kind pluginv1.WatchSyncRemoteStateKind,
|
||||
) (FavoriteImportBatch, error) {
|
||||
traversal, err := p.listRemoteState(ctx, conn, cursorKey, kind)
|
||||
if err != nil {
|
||||
return FavoriteImportBatch{}, err
|
||||
}
|
||||
batch := FavoriteImportBatch{
|
||||
UpdatedCursors: cursorUpdate(cursorKey, traversal.nextCursor),
|
||||
Warnings: traversal.warnings,
|
||||
Incremental: !traversal.completeSnapshot,
|
||||
}
|
||||
for _, state := range traversal.items {
|
||||
var listed *pluginv1.WatchSyncRemoteListState
|
||||
if kind == pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE {
|
||||
listed = state.GetFavorite()
|
||||
} else {
|
||||
listed = state.GetWatchlist()
|
||||
}
|
||||
if listed == nil {
|
||||
continue
|
||||
}
|
||||
row, err := remoteFavoriteFromProto(p.Key(), state, listed)
|
||||
if err != nil {
|
||||
batch.Warnings = append(batch.Warnings, err.Error())
|
||||
continue
|
||||
}
|
||||
batch.Rows = append(batch.Rows, row)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (p *PluginProvider) listRemoteState(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
cursorKey string,
|
||||
kind pluginv1.WatchSyncRemoteStateKind,
|
||||
) (pluginRemoteTraversal, error) {
|
||||
client, err := p.resolveClient(ctx, p.installationID, p.capabilityID)
|
||||
if err != nil {
|
||||
return pluginRemoteTraversal{}, watchSyncUnavailableError()
|
||||
}
|
||||
cursor := conn.SyncCursors[cursorKey]
|
||||
pageToken := ""
|
||||
seenPageTokens := make(map[string]struct{})
|
||||
result := pluginRemoteTraversal{}
|
||||
snapshotSet := false
|
||||
for page := 0; page < maxRemoteStatePages; page++ {
|
||||
authContext, err := p.authenticatedContext(ctx, conn)
|
||||
if err != nil {
|
||||
return pluginRemoteTraversal{}, err
|
||||
}
|
||||
response, err := client.ListRemoteState(ctx, &pluginv1.WatchSyncListRemoteStateRequest{
|
||||
Context: authContext,
|
||||
Cursor: cursor,
|
||||
PageToken: pageToken,
|
||||
PageSize: int32(max(1, p.ExportBatchSize())),
|
||||
StateKinds: []pluginv1.WatchSyncRemoteStateKind{kind},
|
||||
})
|
||||
if err != nil {
|
||||
return pluginRemoteTraversal{}, watchSyncRPCError()
|
||||
}
|
||||
if response.GetUpdatedCredentials() != nil {
|
||||
conn, err = p.persistUpdatedCredentials(ctx, conn, response.GetUpdatedCredentials())
|
||||
if err != nil {
|
||||
return pluginRemoteTraversal{}, err
|
||||
}
|
||||
}
|
||||
if err := watchSyncFaultError(p.Key(), response.GetFault(), conn.AccessToken, conn.RefreshToken); err != nil {
|
||||
return pluginRemoteTraversal{}, err
|
||||
}
|
||||
if kind == pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST &&
|
||||
p.descriptor.GetProvidesWatchlistOrder() && !response.GetCompleteSnapshot() {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin returned an incremental traversal for an ordered watchlist")
|
||||
}
|
||||
if snapshotSet && result.completeSnapshot != response.GetCompleteSnapshot() {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin changed snapshot mode during pagination")
|
||||
}
|
||||
result.completeSnapshot = response.GetCompleteSnapshot()
|
||||
snapshotSet = true
|
||||
items := response.GetItems()
|
||||
if len(items) > maxRemoteStateItems-len(result.items) {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin exceeded the remote-state item limit")
|
||||
}
|
||||
result.items = append(result.items, items...)
|
||||
|
||||
nextPage := strings.TrimSpace(response.GetNextPageToken())
|
||||
if nextPage == "" {
|
||||
result.nextCursor = response.GetNextCursor()
|
||||
return result, nil
|
||||
}
|
||||
if strings.TrimSpace(response.GetNextCursor()) != "" {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin returned a durable cursor before the final page")
|
||||
}
|
||||
if _, duplicate := seenPageTokens[nextPage]; duplicate {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin repeated a page token")
|
||||
}
|
||||
seenPageTokens[nextPage] = struct{}{}
|
||||
pageToken = nextPage
|
||||
}
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin exceeded the remote-state page limit")
|
||||
}
|
||||
|
||||
func (p *PluginProvider) RemoveHistory(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
plays []LocalPlay,
|
||||
) (ExportResult, error) {
|
||||
result := ExportResult{Failed: make(map[string]string)}
|
||||
events := make([]*pluginv1.WatchSyncEvent, 0, len(plays))
|
||||
keys := make([]string, 0, len(plays))
|
||||
for _, play := range plays {
|
||||
event := watchEventFromLocalPlay(play, pluginv1.WatchSyncOrigin_WATCH_SYNC_ORIGIN_MANUAL)
|
||||
event.EventId = "unwatched:" + play.HistoryID
|
||||
event.Operation = pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_MARK_UNWATCHED
|
||||
event.ProviderItemKey = play.ProviderItemKey
|
||||
if !p.supportsMedia(event.GetMedia().GetMediaType()) {
|
||||
result.Failed[play.HistoryID] = unsupportedWatchSyncMediaMessage(event.GetMedia().GetMediaType())
|
||||
continue
|
||||
}
|
||||
events = append(events, event)
|
||||
keys = append(keys, play.HistoryID)
|
||||
}
|
||||
applied, err := p.applyPluginEvents(ctx, conn, events, keys)
|
||||
return mergeExportFailures(applied, result.Failed), err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) ExportFavorites(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
|
||||
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_FAVORITE)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) RemoveFavorites(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
|
||||
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_FAVORITE)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) ExportWatchlist(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
|
||||
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) RemoveWatchlist(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
|
||||
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_FROM_WATCHLIST)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) applyListEvents(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
items []LocalFavorite,
|
||||
operation pluginv1.WatchSyncOperation,
|
||||
) (ExportResult, error) {
|
||||
result := ExportResult{Failed: make(map[string]string)}
|
||||
events := make([]*pluginv1.WatchSyncEvent, 0, len(items))
|
||||
keys := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
media := mediaFromIdentity(item.MediaItemID, item.Kind, item.Title, item.Year,
|
||||
item.IMDbID, item.TMDBID, item.TVDBID, "", 0,
|
||||
item.SeriesIMDbID, item.SeriesTMDBID, item.SeriesTVDBID, 0, 0)
|
||||
if !p.supportsMedia(media.GetMediaType()) {
|
||||
result.Failed[item.MediaItemID] = unsupportedWatchSyncMediaMessage(media.GetMediaType())
|
||||
continue
|
||||
}
|
||||
event := &pluginv1.WatchSyncEvent{
|
||||
EventId: fmt.Sprintf("%s:%s", operation.String(), item.MediaItemID),
|
||||
Operation: operation,
|
||||
Origin: pluginv1.WatchSyncOrigin_WATCH_SYNC_ORIGIN_MANUAL,
|
||||
OccurredAt: timestampOrNil(item.FavoritedAt),
|
||||
Media: media,
|
||||
ProviderItemKey: item.ProviderItemKey,
|
||||
}
|
||||
if operation == pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST {
|
||||
position := int32(len(events))
|
||||
event.ListPosition = &position
|
||||
}
|
||||
events = append(events, event)
|
||||
keys = append(keys, item.MediaItemID)
|
||||
}
|
||||
applied, err := p.applyPluginEvents(ctx, conn, events, keys)
|
||||
return mergeExportFailures(applied, result.Failed), err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) applyPluginEvents(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
events []*pluginv1.WatchSyncEvent,
|
||||
keys []string,
|
||||
) (ExportResult, error) {
|
||||
result, _, err := p.applyPluginEventsDetailed(ctx, conn, events, keys)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) applyPluginEventsDetailed(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
events []*pluginv1.WatchSyncEvent,
|
||||
keys []string,
|
||||
) (ExportResult, map[string]pluginv1.WatchSyncApplyStatus, error) {
|
||||
result := ExportResult{Failed: make(map[string]string)}
|
||||
statuses := make(map[string]pluginv1.WatchSyncApplyStatus, len(events))
|
||||
if len(events) == 0 {
|
||||
return result, statuses, nil
|
||||
}
|
||||
client, err := p.resolveClient(ctx, p.installationID, p.capabilityID)
|
||||
if err != nil {
|
||||
return result, statuses, watchSyncUnavailableError()
|
||||
}
|
||||
for offset := 0; offset < len(events); offset += max(1, p.ExportBatchSize()) {
|
||||
end := min(len(events), offset+max(1, p.ExportBatchSize()))
|
||||
authContext, err := p.authenticatedContext(ctx, conn)
|
||||
if err != nil {
|
||||
return result, statuses, err
|
||||
}
|
||||
response, err := client.ApplyEvents(ctx, &pluginv1.WatchSyncApplyEventsRequest{
|
||||
Context: authContext,
|
||||
Events: events[offset:end],
|
||||
})
|
||||
if err != nil {
|
||||
return result, statuses, watchSyncRPCError()
|
||||
}
|
||||
if response.GetUpdatedCredentials() != nil {
|
||||
conn, err = p.persistUpdatedCredentials(ctx, conn, response.GetUpdatedCredentials())
|
||||
if err != nil {
|
||||
return result, statuses, err
|
||||
}
|
||||
}
|
||||
if err := watchSyncFaultError(p.Key(), response.GetFault(), conn.AccessToken, conn.RefreshToken); err != nil {
|
||||
return result, statuses, err
|
||||
}
|
||||
for index, event := range events[offset:end] {
|
||||
key := keys[offset+index]
|
||||
apply := resultForEvent(response.GetResults(), event.GetEventId())
|
||||
statuses[key] = apply.GetStatus()
|
||||
switch apply.GetStatus() {
|
||||
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_APPLIED,
|
||||
pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_NO_CHANGE:
|
||||
result.Sent = append(result.Sent, key)
|
||||
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_REJECTED:
|
||||
result.NotFound = append(result.NotFound, key)
|
||||
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_RETRY:
|
||||
fault := apply.GetFault()
|
||||
if fault.GetCode() == pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_RATE_LIMITED {
|
||||
retry := time.Duration(0)
|
||||
if fault.GetRetryAfter() != nil {
|
||||
retry = fault.GetRetryAfter().AsDuration()
|
||||
}
|
||||
return result, statuses, RateLimitedError{Provider: p.Key(), RetryAfter: retry}
|
||||
}
|
||||
result.Failed[key] = safeApplyMessage(apply, conn.AccessToken, conn.RefreshToken)
|
||||
default:
|
||||
result.Failed[key] = "watch sync plugin omitted a valid event result"
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, statuses, nil
|
||||
}
|
||||
|
||||
func mergeExportFailures(result ExportResult, failures map[string]string) ExportResult {
|
||||
if result.Failed == nil {
|
||||
result.Failed = make(map[string]string, len(failures))
|
||||
}
|
||||
for key, message := range failures {
|
||||
result.Failed[key] = message
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func remoteWatchFromProto(provider string, state *pluginv1.WatchSyncRemoteState) (RemoteWatch, error) {
|
||||
identity, err := remoteIdentityFromProto(state)
|
||||
if err != nil {
|
||||
return RemoteWatch{}, err
|
||||
}
|
||||
watched := state.GetWatched()
|
||||
if watched.GetPlayCount() < 1 {
|
||||
return RemoteWatch{}, errors.New("watch sync plugin returned watched state with no plays")
|
||||
}
|
||||
return RemoteWatch{
|
||||
Provider: provider, ProviderItemKey: state.GetProviderItemKey(),
|
||||
Kind: identity.kind, Title: identity.title, Year: identity.year,
|
||||
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
|
||||
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
|
||||
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
|
||||
SeasonNumber: identity.season, EpisodeNumber: identity.episode,
|
||||
PlayCount: int(watched.GetPlayCount()), LastWatchedAt: timePointer(watched.GetLastWatchedAt()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func remoteProgressFromProto(provider string, state *pluginv1.WatchSyncRemoteState) (RemoteProgress, error) {
|
||||
identity, err := remoteIdentityFromProto(state)
|
||||
if err != nil {
|
||||
return RemoteProgress{}, err
|
||||
}
|
||||
progress := state.GetProgress()
|
||||
if progress.GetProgressPercent() < 0 || progress.GetProgressPercent() >= 100 {
|
||||
return RemoteProgress{}, errors.New("watch sync plugin returned progress outside [0,100)")
|
||||
}
|
||||
pausedAt := timePointer(progress.GetPausedAt())
|
||||
if pausedAt == nil {
|
||||
return RemoteProgress{}, errors.New("watch sync plugin returned progress without a valid timestamp")
|
||||
}
|
||||
return RemoteProgress{
|
||||
Provider: provider, ProviderItemKey: state.GetProviderItemKey(),
|
||||
Kind: identity.kind, Title: identity.title, Year: identity.year,
|
||||
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
|
||||
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
|
||||
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
|
||||
SeasonNumber: identity.season, EpisodeNumber: identity.episode,
|
||||
ProgressPercent: progress.GetProgressPercent(), PausedAt: *pausedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func remoteFavoriteFromProto(provider string, state *pluginv1.WatchSyncRemoteState, listed *pluginv1.WatchSyncRemoteListState) (RemoteFavorite, error) {
|
||||
if state == nil || listed == nil {
|
||||
return RemoteFavorite{}, errors.New("watch sync plugin returned list state without identity")
|
||||
}
|
||||
providerItemKey := strings.TrimSpace(state.GetProviderItemKey())
|
||||
if listed.GetRemoved() {
|
||||
if providerItemKey == "" {
|
||||
return RemoteFavorite{}, errors.New("watch sync plugin returned a list tombstone without provider identity")
|
||||
}
|
||||
return RemoteFavorite{
|
||||
Provider: provider,
|
||||
ProviderItemKey: providerItemKey,
|
||||
Removed: true,
|
||||
}, nil
|
||||
}
|
||||
identity, err := remoteIdentityFromProto(state)
|
||||
if err != nil {
|
||||
return RemoteFavorite{}, err
|
||||
}
|
||||
listedAt := time.Now().UTC()
|
||||
if value := timePointer(listed.GetListedAt()); value != nil {
|
||||
listedAt = *value
|
||||
}
|
||||
return RemoteFavorite{
|
||||
Provider: provider, ProviderItemKey: providerItemKey,
|
||||
Kind: identity.kind, Title: identity.title, Year: identity.year,
|
||||
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
|
||||
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
|
||||
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
|
||||
SeasonNumber: identity.season, EpisodeNumber: identity.episode, FavoritedAt: listedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type remoteIdentity struct {
|
||||
kind, title, imdbID, tmdbID, tvdbID string
|
||||
seriesTitle, seriesIMDbID, seriesTMDBID, seriesTVDBID string
|
||||
year, seriesYear, season, episode int
|
||||
}
|
||||
|
||||
func remoteIdentityFromProto(state *pluginv1.WatchSyncRemoteState) (remoteIdentity, error) {
|
||||
if state == nil || state.GetMedia() == nil || strings.TrimSpace(state.GetProviderItemKey()) == "" {
|
||||
return remoteIdentity{}, errors.New("watch sync plugin returned remote state without identity")
|
||||
}
|
||||
media := state.GetMedia()
|
||||
var kind string
|
||||
switch media.GetMediaType() {
|
||||
case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE:
|
||||
kind = historyimport.KindMovie
|
||||
case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE:
|
||||
kind = historyimport.KindEpisode
|
||||
default:
|
||||
return remoteIdentity{}, errors.New("watch sync plugin returned unsupported remote media")
|
||||
}
|
||||
return remoteIdentity{
|
||||
kind: kind, title: media.GetTitle(), year: int(media.GetYear()),
|
||||
imdbID: media.GetExternalIds()["imdb"], tmdbID: media.GetExternalIds()["tmdb"], tvdbID: media.GetExternalIds()["tvdb"],
|
||||
seriesTitle: media.GetSeriesTitle(), seriesYear: int(media.GetSeriesYear()),
|
||||
seriesIMDbID: media.GetSeriesExternalIds()["imdb"], seriesTMDBID: media.GetSeriesExternalIds()["tmdb"], seriesTVDBID: media.GetSeriesExternalIds()["tvdb"],
|
||||
season: int(media.GetSeasonNumber()), episode: int(media.GetEpisodeNumber()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cursorUpdate(key, value string) map[string]string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{key: value}
|
||||
}
|
||||
|
||||
func timePointer(value interface {
|
||||
AsTime() time.Time
|
||||
CheckValid() error
|
||||
}) *time.Time {
|
||||
if value == nil || value.CheckValid() != nil {
|
||||
return nil
|
||||
}
|
||||
result := value.AsTime()
|
||||
return &result
|
||||
}
|
||||
|
||||
func timestampOrNil(value time.Time) *timestamppb.Timestamp {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return timestamppb.New(value)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user