Compare commits

...
Author SHA1 Message Date
Quick104 f39667b469 fix(watchsync): harden connection form contracts 2026-08-07 09:35:07 -04:00
Quick104 936f1990af fix(watchsync): tighten connection schema handling 2026-08-06 16:26:51 -04:00
Quick104 f1a909e7ad fix(watchsync): close connection validation gaps 2026-08-06 16:21:42 -04:00
Quick104 2dd4069009 fix(watchsync): harden plugin connection schemas 2026-08-06 16:05:52 -04:00
Quick104 3d12f467dd fix(watchsync): allow cold provider resolution 2026-08-06 15:26:04 -04:00
Quick104 5400717c3e fix(watchsync): preserve provider event semantics 2026-08-06 15:11:19 -04:00
Quick104 10d4624222 chore(deps): use plugin SDK v0.13.1 2026-08-06 13:45:23 -04:00
Quick104 6f445c1cda fix(watchsync): support declarative setup fallbacks 2026-08-06 13:12:39 -04:00
Quick104 d4edf0068e chore(deps): update plugin SDK snapshot 2026-08-06 12:53:41 -04:00
Quick104 9c9e10e373 fix(watchsync): harden connection config forms 2026-08-06 12:50:45 -04:00
Quick104 c39737e456 fix(watchsync): preserve plugin connection forms 2026-08-06 12:21:51 -04:00
Quick104 371d959cc8 fix(watchsync): address connection form review 2026-08-06 11:46:57 -04:00
Quick104 b4542bb53c feat(watchsync): add per-connection plugin config 2026-08-06 11:37:45 -04:00
fa1d7ba2b4 fix(playback): freeze v3 seek reanchor recipes (#548)
* fix(playback): freeze v3 seek reanchor recipes

* fix(playback): address v3 recipe review feedback

* test(playback): isolate v3 fallback route fixture

* fix(database): split v3 recipe constraint validation

* fix(playback): preserve frozen seek recipe identity

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-08-06 10:43:21 -04:00
40a9de7f26 feat(watchsync): add plugin-backed providers (#475)
* feat(watchsync): add plugin-backed providers

* fix(watchsync): address plugin review findings

* fix(watchsync): harden plugin provider failures

* feat(watchsync): complete plugin provider contract

* fix(watchsync): address provider review feedback

* fix(watchsync): keep device state host-private

* fix(watchsync): build reconciliation index concurrently

* fix(watchsync): preserve empty device state updates

* chore(deps): use released watch-sync SDK

---------

Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
2026-08-06 10:30:49 -04:00
QuickandGitHub 31a26b2554 fix(scanner): accept corroborated long video durations (#545)
* fix(scanner): accept corroborated long video durations

* fix(scanner): harden long duration fallbacks

* fix(scanner): address long duration review feedback

* fix(scanner): align long duration seek semantics

* fix(scanner): align legacy long media seeking

* fix(scanner): preserve long timeline origins

* fix(scanner): preserve long audio-switch timelines

* fix(scanner): distinguish absolute-end timelines

* fix(scanner): preserve long resume semantics

* fix(scanner): reject ambiguous absolute ends

* fix(scanner): preserve compat real-manifest resumes
2026-08-05 20:27:42 -04:00
70 changed files with 9363 additions and 701 deletions
+104 -5
View File
@@ -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
+106
View File
@@ -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())
}
}
@@ -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.
+1 -1
View File
@@ -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 -2
View File
@@ -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=
+40 -10
View File
@@ -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,
+185 -1
View File
@@ -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{
+311 -61
View File
@@ -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
}
+633 -3
View File
@@ -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
}
+6 -171
View File
@@ -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 {
+60 -26
View File
@@ -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
+49
View File
@@ -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)
+16 -2
View File
@@ -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 &copy, 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) {
+22 -4
View File
@@ -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
}
+53 -2
View File
@@ -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{
+1 -1
View File
@@ -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)
}
+42 -15
View File
@@ -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
+48
View File
@@ -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",
+110
View File
@@ -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)
}
}
+21 -6
View File
@@ -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 {
+21 -9
View File
@@ -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.
+1
View File
@@ -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
+51 -3
View File
@@ -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,
+25 -17
View File
@@ -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 {
+163 -5
View File
@@ -152,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
@@ -383,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.
@@ -1023,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
@@ -1039,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)
+18
View File
@@ -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",
@@ -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{
+59
View File
@@ -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() {}
+3
View File
@@ -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
+150
View File
@@ -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
}
+8 -1
View File
@@ -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() {
+13
View File
@@ -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
+101
View File
@@ -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
View File
@@ -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
+301
View File
@@ -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.
+7 -1
View File
@@ -846,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)
+81 -3
View File
@@ -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
+538
View File
@@ -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
+42 -2
View File
@@ -6,6 +6,12 @@ import (
"sync"
)
const providerSourcePlugin = "plugin"
type sourcedProvider interface {
ProviderSource() string
}
type Registry struct {
mu sync.RWMutex
providers map[string]Provider
@@ -35,6 +41,36 @@ func (r *Registry) Register(provider Provider) error {
return nil
}
// ReplacePluginProviders atomically replaces providers discovered from enabled
// plugin installations while preserving built-in providers. A plugin may not
// shadow a built-in key, and duplicate plugin keys reject the entire reload.
func (r *Registry) ReplacePluginProviders(providers []Provider) error {
if r == nil {
return fmt.Errorf("watchsync registry is nil")
}
r.mu.Lock()
defer r.mu.Unlock()
next := make(map[string]Provider, len(r.providers)+len(providers))
for key, provider := range r.providers {
if sourced, ok := provider.(sourcedProvider); ok && sourced.ProviderSource() == providerSourcePlugin {
continue
}
next[key] = provider
}
for _, provider := range providers {
if provider == nil || provider.Key() == "" {
return fmt.Errorf("watchsync plugin provider and key are required")
}
if _, exists := next[provider.Key()]; exists {
return fmt.Errorf("watchsync plugin provider key %q conflicts with another provider", provider.Key())
}
next[provider.Key()] = provider
}
r.providers = next
return nil
}
func (r *Registry) Get(key string) (Provider, bool) {
if r == nil {
return nil, false
@@ -55,11 +91,15 @@ func (r *Registry) List() []ProviderSummary {
summaries := make([]ProviderSummary, 0, len(r.providers))
for key, provider := range r.providers {
summaries = append(summaries, ProviderSummary{
summary := ProviderSummary{
Key: key,
DisplayName: provider.DisplayName(),
Capabilities: provider.Capabilities(),
})
}
if configurable, ok := provider.(connectionConfigProvider); ok {
summary.ConnectionConfigSchema = configurable.ConnectionConfigSchema()
}
summaries = append(summaries, summary)
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].Key < summaries[j].Key
+36
View File
@@ -24,6 +24,42 @@ func TestRegistryRejectsDuplicateProvider(t *testing.T) {
}
}
type stubPluginProvider struct{ stubProvider }
func (stubPluginProvider) ProviderSource() string { return providerSourcePlugin }
func TestRegistryReplacePluginProviders(t *testing.T) {
registry := NewRegistry()
if err := registry.Register(stubProvider{key: "trakt"}); err != nil {
t.Fatal(err)
}
if err := registry.ReplacePluginProviders([]Provider{stubPluginProvider{stubProvider{key: testPluginCapabilityID}}}); err != nil {
t.Fatal(err)
}
if _, ok := registry.Get("anilist"); !ok {
t.Fatal("plugin provider was not registered")
}
if err := registry.ReplacePluginProviders([]Provider{stubPluginProvider{stubProvider{key: "simkl-plugin"}}}); err != nil {
t.Fatal(err)
}
if _, ok := registry.Get("anilist"); ok {
t.Fatal("stale plugin provider was not removed")
}
if _, ok := registry.Get("trakt"); !ok {
t.Fatal("built-in provider was removed")
}
}
func TestRegistryReplacePluginProvidersRejectsBuiltinCollision(t *testing.T) {
registry := NewRegistry()
if err := registry.Register(stubProvider{key: "trakt"}); err != nil {
t.Fatal(err)
}
if err := registry.ReplacePluginProviders([]Provider{stubPluginProvider{stubProvider{key: "trakt"}}}); err == nil {
t.Fatal("expected built-in collision to fail")
}
}
func TestRegistryListWithCapabilities(t *testing.T) {
registry := NewRegistry()
+244 -9
View File
@@ -34,7 +34,9 @@ type Repository interface {
ListListEventConnections(ctx context.Context, userID int, profileID string, list ListKind) ([]Connection, error)
UpsertHistoryExports(ctx context.Context, exports []HistoryExport) error
ListPendingHistoryExports(ctx context.Context, connectionID string, limit int) ([]HistoryExport, error)
ListPendingHistoryExportsByHistoryIDs(ctx context.Context, connectionID string, historyIDs []string, limit int) ([]HistoryExport, error)
MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error
MarkHistoryExportSatisfiedByScrobble(ctx context.Context, connectionID string, historyID string) error
UpsertListItemStates(ctx context.Context, states []ListItemState) error
ListListItemStates(ctx context.Context, connectionID string, kind ListKind) ([]ListItemState, error)
ListPendingListItemExports(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error)
@@ -50,6 +52,8 @@ type Repository interface {
FailConfirmedScrobbleStop(ctx context.Context, playbackSessionID string, connectionID string, progress float64, historyID string, claimVersion time.Time, lastError string) error
UpdateScrobbleSession(ctx context.Context, playbackSessionID string, connectionID string, action string, progress float64, historyID string, lastError string, stopSentAt *time.Time) error
ListOpenScrobbleSessions(ctx context.Context) ([]ScrobbleSession, error)
ListPendingScrobbleReconciliations(ctx context.Context) ([]ScrobbleSession, error)
MarkScrobbleHistoryReconciled(ctx context.Context, playbackSessionID, connectionID string, reconciledAt time.Time) error
}
type confirmedStopPreparation uint8
@@ -67,7 +71,7 @@ var errConfirmedStopClaimLost = errors.New("confirmed scrobble stop claim lost")
// it across every read query keeps the column set and scan order in lockstep.
const connectionColumns = `
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
access_token, refresh_token, token_expires_at, plugin_credentials, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
import_watchlist_enabled, export_watchlist_enabled, sync_watchlist_removals_enabled,
@@ -115,6 +119,10 @@ func TokenAAD(column, provider string, userID int, profileID string) string {
return secret.RowAAD("watch_provider_connections", column, provider+":"+strconv.Itoa(userID)+":"+profileID)
}
func authStateAAD(provider string, userID int, profileID string) string {
return secret.RowAAD("watch_provider_auth_sessions", "device_code", provider+":"+strconv.Itoa(userID)+":"+profileID)
}
func (r *PostgresRepository) GetServerSetting(ctx context.Context, key string) (string, error) {
var value string
err := r.pool.QueryRow(ctx, `SELECT value FROM server_settings WHERE key = $1`, key).Scan(&value)
@@ -138,6 +146,10 @@ func (r *PostgresRepository) UpsertAuthSession(
ctx context.Context,
session DeviceAuthSession,
) (DeviceAuthSession, error) {
deviceCode, err := r.cipher.Encrypt(session.DeviceCode, authStateAAD(session.Provider, session.UserID, session.ProfileID))
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("encrypt watch provider authorization state: %w", err)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO watch_provider_auth_sessions (
id, provider, user_id, profile_id, device_code, user_code,
@@ -166,14 +178,14 @@ func (r *PostgresRepository) UpsertAuthSession(
session.Provider,
session.UserID,
session.ProfileID,
session.DeviceCode,
deviceCode,
session.UserCode,
session.VerificationURL,
session.IntervalSeconds,
session.ExpiresAt,
session.CompletedAt,
)
saved, err := scanDeviceAuthSession(row)
saved, err := r.scanDeviceAuthSession(row)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("upsert watch provider auth session: %w", err)
}
@@ -188,7 +200,7 @@ func (r *PostgresRepository) GetAuthSession(ctx context.Context, id string) (Dev
FROM watch_provider_auth_sessions
WHERE id = $1::uuid
`, id)
session, err := scanDeviceAuthSession(row)
session, err := r.scanDeviceAuthSession(row)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("get watch provider auth session %q: %w", id, err)
}
@@ -204,10 +216,14 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
if err != nil {
return Connection{}, fmt.Errorf("encrypt watch refresh token: %w", err)
}
pluginCredentials, err := r.pluginCredentialsForConnection(conn)
if err != nil {
return Connection{}, err
}
row := r.pool.QueryRow(ctx, `
INSERT INTO watch_provider_connections (
id, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
access_token, refresh_token, token_expires_at, plugin_credentials, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
import_watchlist_enabled, export_watchlist_enabled, sync_watchlist_removals_enabled,
@@ -217,8 +233,8 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
)
VALUES (
COALESCE(NULLIF($1, '')::uuid, gen_random_uuid()),
$2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30::jsonb
$2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
$15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31::jsonb
)
ON CONFLICT (provider, user_id, profile_id) DO UPDATE SET
provider_account_id = EXCLUDED.provider_account_id,
@@ -226,6 +242,7 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
token_expires_at = EXCLUDED.token_expires_at,
plugin_credentials = EXCLUDED.plugin_credentials,
import_watched_enabled = EXCLUDED.import_watched_enabled,
import_progress_enabled = EXCLUDED.import_progress_enabled,
export_watched_enabled = EXCLUDED.export_watched_enabled,
@@ -259,6 +276,7 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
accessToken,
refreshToken,
conn.TokenExpiresAt,
pluginCredentials,
conn.ImportWatchedEnabled,
conn.ImportProgressEnabled,
conn.ExportWatchedEnabled,
@@ -688,7 +706,9 @@ func (r *PostgresRepository) UpsertHistoryExports(ctx context.Context, exports [
ON CONFLICT (connection_id, history_id) DO UPDATE SET
provider_item_key = EXCLUDED.provider_item_key,
status = CASE
WHEN watch_provider_history_exports.status IN ('sent', 'satisfied_by_scrobble') THEN watch_provider_history_exports.status
WHEN watch_provider_history_exports.status IN ('sent', 'satisfied_by_scrobble', 'not_found')
OR watch_provider_history_exports.attempt_count >= 5
THEN watch_provider_history_exports.status
ELSE EXCLUDED.status
END,
updated_at = now()
@@ -746,6 +766,60 @@ func (r *PostgresRepository) ListPendingHistoryExports(ctx context.Context, conn
return exports, nil
}
func (r *PostgresRepository) ListPendingHistoryExportsByHistoryIDs(
ctx context.Context,
connectionID string,
historyIDs []string,
limit int,
) ([]HistoryExport, error) {
if len(historyIDs) == 0 {
return nil, nil
}
if limit <= 0 || limit > len(historyIDs) {
limit = len(historyIDs)
}
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, history_id, media_item_id, watched_at,
provider_item_key, status, attempt_count, last_attempt_at, last_error, created_at, updated_at
FROM watch_provider_history_exports
WHERE connection_id = $1::uuid
AND history_id = ANY($2::text[])
AND status IN ('pending', 'failed')
AND attempt_count < 5
ORDER BY watched_at ASC
LIMIT $3
`, connectionID, historyIDs, limit)
if err != nil {
return nil, fmt.Errorf("list pending history exports by history ids: %w", err)
}
defer rows.Close()
var exports []HistoryExport
for rows.Next() {
var export HistoryExport
if err := rows.Scan(
&export.ID,
&export.ConnectionID,
&export.HistoryID,
&export.MediaItemID,
&export.WatchedAt,
&export.ProviderItemKey,
&export.Status,
&export.AttemptCount,
&export.LastAttemptAt,
&export.LastError,
&export.CreatedAt,
&export.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan pending history export by history ids: %w", err)
}
exports = append(exports, export)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending history exports by history ids: %w", err)
}
return exports, nil
}
func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_history_exports
@@ -755,6 +829,7 @@ func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id str
last_error = $3,
updated_at = now()
WHERE id = $1::uuid
AND status NOT IN ('sent', 'satisfied_by_scrobble', 'not_found')
`, id, status, lastError)
if err != nil {
return fmt.Errorf("mark history export status: %w", err)
@@ -762,6 +837,22 @@ func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id str
return nil
}
func (r *PostgresRepository) MarkHistoryExportSatisfiedByScrobble(ctx context.Context, connectionID string, historyID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_history_exports
SET status = 'satisfied_by_scrobble',
last_attempt_at = now(),
last_error = '',
updated_at = now()
WHERE connection_id = $1::uuid AND history_id = $2
AND status NOT IN ('sent', 'not_found')
`, connectionID, historyID)
if err != nil {
return fmt.Errorf("mark history export satisfied by scrobble: %w", err)
}
return nil
}
func (r *PostgresRepository) UpsertListItemStates(ctx context.Context, states []ListItemState) error {
for _, state := range states {
kind := state.ListKind
@@ -1152,7 +1243,62 @@ func (r *PostgresRepository) ListOpenScrobbleSessions(ctx context.Context) ([]Sc
return sessions, nil
}
func scanDeviceAuthSession(row pgx.Row) (DeviceAuthSession, error) {
func (r *PostgresRepository) ListPendingScrobbleReconciliations(ctx context.Context) ([]ScrobbleSession, error) {
rows, err := r.pool.Query(ctx, `
SELECT playback_session_id, connection_id::text, media_item_id, provider_item_key, kind,
imdb_id, tmdb_id, tvdb_id, series_imdb_id, series_tmdb_id, series_tvdb_id,
season_number, episode_number, history_id, started_at, last_progress,
duration_seconds, completed, last_action, stop_sent_at, history_reconciled_at, last_error
FROM watch_provider_scrobble_sessions
WHERE stop_sent_at IS NOT NULL AND completed = true AND history_id <> ''
AND history_reconciled_at IS NULL
ORDER BY stop_sent_at ASC
LIMIT 100
`)
if err != nil {
return nil, fmt.Errorf("list pending scrobble reconciliations: %w", err)
}
defer rows.Close()
var sessions []ScrobbleSession
for rows.Next() {
var session ScrobbleSession
if err := rows.Scan(
&session.PlaybackSessionID, &session.ConnectionID, &session.MediaItemID,
&session.ProviderItemKey, &session.Kind, &session.IMDbID, &session.TMDBID,
&session.TVDBID, &session.SeriesIMDbID, &session.SeriesTMDBID,
&session.SeriesTVDBID, &session.SeasonNumber, &session.EpisodeNumber,
&session.HistoryID, &session.StartedAt, &session.LastProgress,
&session.DurationSeconds, &session.Completed, &session.LastAction,
&session.StopSentAt, &session.HistoryReconciledAt, &session.LastError,
); err != nil {
return nil, fmt.Errorf("scan pending scrobble reconciliation: %w", err)
}
sessions = append(sessions, session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending scrobble reconciliations: %w", err)
}
return sessions, nil
}
func (r *PostgresRepository) MarkScrobbleHistoryReconciled(
ctx context.Context,
playbackSessionID string,
connectionID string,
reconciledAt time.Time,
) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_scrobble_sessions
SET history_reconciled_at = $3, last_error = '', updated_at = now()
WHERE playback_session_id = $1 AND connection_id = $2::uuid
`, playbackSessionID, connectionID, reconciledAt)
if err != nil {
return fmt.Errorf("mark scrobble history reconciled: %w", err)
}
return nil
}
func (r *PostgresRepository) scanDeviceAuthSession(row pgx.Row) (DeviceAuthSession, error) {
var session DeviceAuthSession
err := row.Scan(
&session.ID,
@@ -1169,6 +1315,14 @@ func scanDeviceAuthSession(row pgx.Row) (DeviceAuthSession, error) {
if err != nil {
return DeviceAuthSession{}, err
}
decrypted, err := r.cipher.DecryptIfEncrypted(
session.DeviceCode,
authStateAAD(session.Provider, session.UserID, session.ProfileID),
)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("decrypt watch provider authorization state: %w", err)
}
session.DeviceCode = decrypted
return session, nil
}
@@ -1211,6 +1365,7 @@ func scanSyncRun(row pgx.Row) (SyncRun, error) {
func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
var conn Connection
var rawSyncCursors []byte
var rawPluginCredentials string
err := row.Scan(
&conn.ID,
&conn.Provider,
@@ -1221,6 +1376,7 @@ func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
&conn.AccessToken,
&conn.RefreshToken,
&conn.TokenExpiresAt,
&rawPluginCredentials,
&conn.ImportWatchedEnabled,
&conn.ImportProgressEnabled,
&conn.ExportWatchedEnabled,
@@ -1256,10 +1412,89 @@ func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
if conn.RefreshToken, err = r.cipher.DecryptIfEncrypted(conn.RefreshToken, TokenAAD("refresh_token", conn.Provider, conn.UserID, conn.ProfileID)); err != nil {
return Connection{}, fmt.Errorf("decrypt watch refresh token: %w", err)
}
if err := r.decodePluginCredentials(&conn, rawPluginCredentials); err != nil {
return Connection{}, err
}
conn.SyncCursors = decodeSyncCursors(rawSyncCursors)
return conn, nil
}
type storedPluginCredentials struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenExpiresAt *time.Time `json:"expires_at,omitempty"`
TokenType string `json:"token_type,omitempty"`
Scopes []string `json:"scopes,omitempty"`
SecretAttributes map[string]string `json:"secret_attributes,omitempty"`
}
func (r *PostgresRepository) pluginCredentialsForConnection(conn Connection) (string, error) {
if !strings.HasPrefix(conn.Provider, providerSourcePlugin+":") {
// Built-in providers have legacy token writers that update the dedicated
// token columns directly. Keeping a second authoritative bundle for them
// would let that bundle become stale and overwrite freshly rotated tokens.
return "", nil
}
return r.encodePluginCredentials(conn)
}
func (r *PostgresRepository) encodePluginCredentials(conn Connection) (string, error) {
payload, err := json.Marshal(storedPluginCredentials{
AccessToken: conn.AccessToken,
RefreshToken: conn.RefreshToken,
TokenExpiresAt: conn.TokenExpiresAt,
TokenType: conn.TokenType,
Scopes: conn.Scopes,
SecretAttributes: conn.SecretAttributes,
})
if err != nil {
return "", fmt.Errorf("encode watch provider credentials: %w", err)
}
encoded, err := r.cipher.Encrypt(
string(payload),
TokenAAD("plugin_credentials", conn.Provider, conn.UserID, conn.ProfileID),
)
if err != nil {
return "", fmt.Errorf("encrypt watch provider credentials: %w", err)
}
return encoded, nil
}
func (r *PostgresRepository) decodePluginCredentials(conn *Connection, encoded string) error {
if conn == nil || strings.TrimSpace(encoded) == "" {
return nil
}
plaintext, err := r.cipher.DecryptIfEncrypted(
encoded,
TokenAAD("plugin_credentials", conn.Provider, conn.UserID, conn.ProfileID),
)
if err != nil {
return fmt.Errorf("decrypt watch provider credentials: %w", err)
}
var credentials storedPluginCredentials
if err := json.Unmarshal([]byte(plaintext), &credentials); err != nil {
return fmt.Errorf("decode watch provider credentials: %w", err)
}
conn.AccessToken = credentials.AccessToken
conn.RefreshToken = credentials.RefreshToken
conn.TokenExpiresAt = credentials.TokenExpiresAt
conn.TokenType = credentials.TokenType
conn.Scopes = append([]string(nil), credentials.Scopes...)
conn.SecretAttributes = cloneStringMap(credentials.SecretAttributes)
return nil
}
func cloneStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
result := make(map[string]string, len(values))
for key, value := range values {
result[key] = value
}
return result
}
type listItemStateRows interface {
Next() bool
Scan(dest ...any) error
+83
View File
@@ -1,8 +1,12 @@
package watchsync
import (
"reflect"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/secret"
)
func TestMediaDurationQueryUsesActiveMediaFilesPredicate(t *testing.T) {
@@ -13,3 +17,82 @@ func TestMediaDurationQueryUsesActiveMediaFilesPredicate(t *testing.T) {
t.Fatalf("media duration query references removed media_files.missing column:\n%s", mediaDurationQuery)
}
}
func TestPluginCredentialBundleRoundTrip(t *testing.T) {
cipher, err := secret.New([]byte("01234567890123456789012345678901"))
if err != nil {
t.Fatal(err)
}
repository := NewPostgresRepository(nil, cipher)
expiresAt := time.Now().UTC().Truncate(time.Second)
input := Connection{
Provider: "plugin:4:tracker", UserID: 7, ProfileID: "profile",
AccessToken: testAccessToken, RefreshToken: testRefreshToken, TokenExpiresAt: &expiresAt,
TokenType: testDPoPTokenType, Scopes: []string{testHistoryScope, "watchlist"},
SecretAttributes: map[string]string{"instance": testOneValue},
}
encoded, err := repository.encodePluginCredentials(input)
if err != nil {
t.Fatal(err)
}
if strings.Contains(encoded, input.AccessToken) || !strings.HasPrefix(encoded, "enc:v1:") {
t.Fatalf("credential bundle was not encrypted: %q", encoded)
}
output := Connection{Provider: input.Provider, UserID: input.UserID, ProfileID: input.ProfileID}
if err := repository.decodePluginCredentials(&output, encoded); err != nil {
t.Fatal(err)
}
if output.AccessToken != input.AccessToken || output.RefreshToken != input.RefreshToken ||
output.TokenType != input.TokenType || !output.TokenExpiresAt.Equal(expiresAt) ||
!reflect.DeepEqual(output.Scopes, input.Scopes) || !reflect.DeepEqual(output.SecretAttributes, input.SecretAttributes) {
t.Fatalf("decoded credentials = %#v", output)
}
}
func TestPluginCredentialBundleUsesConnectionIdentityAsAAD(t *testing.T) {
cipher, err := secret.New([]byte("01234567890123456789012345678901"))
if err != nil {
t.Fatal(err)
}
repository := NewPostgresRepository(nil, cipher)
input := Connection{
Provider: "plugin:4:tracker", UserID: 7, ProfileID: "profile-a",
AccessToken: testAccessToken,
}
encoded, err := repository.encodePluginCredentials(input)
if err != nil {
t.Fatal(err)
}
wrongIdentity := Connection{Provider: input.Provider, UserID: input.UserID, ProfileID: "profile-b"}
if err := repository.decodePluginCredentials(&wrongIdentity, encoded); err == nil {
t.Fatal("decodePluginCredentials with different profile identity succeeded")
}
}
func TestPluginCredentialBundleIsOnlyWrittenForPluginProviders(t *testing.T) {
cipher, err := secret.New([]byte("01234567890123456789012345678901"))
if err != nil {
t.Fatal(err)
}
repository := NewPostgresRepository(nil, cipher)
for _, provider := range []string{"trakt", "simkl", "mdblist"} {
encoded, err := repository.pluginCredentialsForConnection(Connection{
Provider: provider, UserID: 7, ProfileID: "profile", AccessToken: testAccessToken,
})
if err != nil {
t.Fatalf("pluginCredentialsForConnection(%q): %v", provider, err)
}
if encoded != "" {
t.Fatalf("pluginCredentialsForConnection(%q) = %q, want empty", provider, encoded)
}
}
encoded, err := repository.pluginCredentialsForConnection(Connection{
Provider: "plugin:4:tracker", UserID: 7, ProfileID: "profile", AccessToken: testAccessToken,
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(encoded, "enc:v1:") {
t.Fatalf("plugin credential bundle = %q, want encrypted value", encoded)
}
}
+324 -71
View File
@@ -49,11 +49,13 @@ type watchStateImporter interface {
const (
manualSyncCooldown = time.Hour
manualSyncTimeout = 10 * time.Minute
// Built-in providers bind requests to this context and use HTTP client
// timeouts of at most 20 seconds. Keep dispatch below the reclaim lease;
// the per-session queue remains occupied until the worker itself exits.
confirmedStopDispatchTimeout = 25 * time.Second
confirmedStopLease = time.Minute
// A completed stop may require a cold metadata lookup in a provider plugin.
// Match the plugin-host watch-sync deadline so the durable confirmation path
// does not cancel valid provider work before the RPC can finish. The reclaim
// lease must remain longer than that entire dispatch and finalization window
// so a second request cannot take over while the first is still valid.
confirmedStopDispatchTimeout = 2 * time.Minute
confirmedStopLease = confirmedStopDispatchTimeout + 30*time.Second
)
var errConfirmedStopInProgress = errors.New("watch provider stop confirmation already in progress")
@@ -102,6 +104,9 @@ func (s *Service) GetConnectionStatus(ctx context.Context, userID int, profileID
}
authMethod := authMethodOf(provider)
credentialsConfigured := authMethod == AuthMethodAPIKey
if _, pluginConfig := provider.(interface{ usesHostPluginConfig() }); pluginConfig {
credentialsConfigured = true
}
if !credentialsConfigured {
cfg, _ := s.serverConfig(ctx, providerKey)
credentialsConfigured = cfg.Configured()
@@ -131,6 +136,9 @@ func (s *Service) GetConnectionStatus(ctx context.Context, userID int, profileID
SyncWatchlistOrderEnabled: true,
ScrobbleEnabled: true,
}
if configurable, ok := provider.(connectionConfigProvider); ok {
status.ConnectionConfigSchema = configurable.ConnectionConfigSchema()
}
if connected {
status.ProviderUsername = conn.ProviderUsername
status.ImportWatchedEnabled = conn.ImportWatchedEnabled
@@ -374,7 +382,13 @@ func (s *Service) processLocalWatchEvent(ctx context.Context, event LocalWatchEv
continue
}
if err := s.exportLocalPlays(ctx, conn, cfg, exporter, event.Plays); err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
if limited, ok := AsRateLimited(err); ok {
if deferErr := s.deferRateLimitedConnection(ctx, conn, limited); deferErr != nil {
s.recordLocalWatchEventError(ctx, conn, errors.Join(err, deferErr))
}
} else {
s.recordLocalWatchEventError(ctx, conn, err)
}
}
case LocalWatchEventMarkedUnwatched:
if !provider.Capabilities().ExportUnwatched {
@@ -490,10 +504,26 @@ func (s *Service) PollDeviceAuth(
}
tokens, err := authProvider.PollDeviceAuth(ctx, cfg, session)
if err != nil {
var pending deviceAuthorizationPendingError
if errors.As(err, &pending) {
updated := pending.session
// Keep host-owned scope and identity authoritative even if a provider
// accidentally copied or zeroed those fields in its pending state.
updated.ID = session.ID
updated.Provider = session.Provider
updated.UserID = session.UserID
updated.ProfileID = session.ProfileID
updated.UserCode = session.UserCode
updated.VerificationURL = session.VerificationURL
updated.CompletedAt = session.CompletedAt
if _, persistErr := s.repo.UpsertAuthSession(ctx, updated); persistErr != nil {
return Connection{}, errors.Join(err, fmt.Errorf("persist pending auth session: %w", persistErr))
}
}
return Connection{}, err
}
account, err := authProvider.LookupAccount(ctx, cfg, Connection{AccessToken: tokens.AccessToken})
account, err := authProvider.LookupAccount(ctx, cfg, connectionWithTokens(Connection{}, tokens))
if err != nil {
return Connection{}, err
}
@@ -517,6 +547,17 @@ func (s *Service) ConnectAPIKey(
profileID string,
providerKey string,
apiKey string,
) (Connection, error) {
return s.ConnectAPIKeyWithConfig(ctx, userID, profileID, providerKey, apiKey, nil)
}
func (s *Service) ConnectAPIKeyWithConfig(
ctx context.Context,
userID int,
profileID string,
providerKey string,
apiKey string,
connectionConfig ConnectionConfigValues,
) (Connection, error) {
if userID <= 0 {
return Connection{}, fmt.Errorf("user id is required")
@@ -537,11 +578,24 @@ func (s *Service) ConnectAPIKey(
return Connection{}, fmt.Errorf("provider %q does not support api-key auth", providerKey)
}
tokens, account, err := authProvider.ConnectWithAPIKey(ctx, apiKey)
var tokens TokenSet
var account ProviderAccount
var err error
if configured, ok := provider.(configuredAPIKeyAuthProvider); ok {
tokens, account, err = configured.ConnectWithAPIKeyConfig(ctx, apiKey, connectionConfig)
} else {
if len(connectionConfig) > 0 {
return Connection{}, fmt.Errorf("provider %q does not accept connection configuration", providerKey)
}
tokens, account, err = authProvider.ConnectWithAPIKey(ctx, apiKey)
}
if err != nil {
return Connection{}, err
}
if strings.TrimSpace(tokens.AccessToken) == "" {
if sourced, ok := provider.(sourcedProvider); ok && sourced.ProviderSource() == providerSourcePlugin {
return Connection{}, errors.New("watch sync plugin returned no access token")
}
tokens.AccessToken = apiKey
}
return s.persistConnection(ctx, providerKey, userID, profileID, tokens, account)
@@ -578,11 +632,10 @@ func (s *Service) persistConnection(
conn.Provider = providerKey
conn.UserID = userID
conn.ProfileID = profileID
conn.AccessToken = tokens.AccessToken
conn.RefreshToken = tokens.RefreshToken
conn.TokenExpiresAt = tokens.TokenExpiresAt
conn = connectionWithTokens(conn, tokens)
conn.ProviderAccountID = account.ID
conn.ProviderUsername = account.Username
conn.LastError = ""
return s.repo.UpsertConnection(ctx, conn)
}
@@ -843,9 +896,15 @@ func (s *Service) deferRateLimitedConnection(ctx context.Context, conn Connectio
return err
}
retryAfter := rle.RetryAfter
if retryAfter <= 0 {
switch {
case retryAfter <= 0:
retryAfter = time.Hour
case retryAfter < time.Second:
retryAfter = time.Second
case retryAfter > 24*time.Hour:
retryAfter = 24 * time.Hour
}
rle.RetryAfter = retryAfter
until := s.now().Add(retryAfter)
lastError := fmt.Sprintf("%s; sync deferred until %s", rle.Error(), until.Format(time.RFC3339))
deferred := 1
@@ -1246,20 +1305,47 @@ func (s *Service) refreshConnectionIfNeeded(ctx context.Context, provider Provid
return Connection{}, fmt.Errorf("provider %q does not support token refresh", conn.Provider)
}
tokens, err := authProvider.RefreshToken(ctx, cfg, conn)
_, authoritative := provider.(authoritativeRefreshProvider)
if authoritative && strings.TrimSpace(tokens.AccessToken) != "" {
conn = connectionWithTokens(conn, tokens)
} else if err == nil {
if tokens.AccessToken != "" {
conn.AccessToken = tokens.AccessToken
}
if tokens.RefreshToken != "" {
conn.RefreshToken = tokens.RefreshToken
}
if tokens.TokenExpiresAt != nil {
conn.TokenExpiresAt = tokens.TokenExpiresAt
}
}
if err != nil && isWatchSyncInvalidCredentialError(err) {
conn.LastError = err.Error()
} else if err == nil {
conn.LastError = ""
}
credentialsReturned := authoritative && strings.TrimSpace(tokens.AccessToken) != ""
if err == nil || credentialsReturned || isWatchSyncInvalidCredentialError(err) {
persisted, persistErr := s.repo.UpsertConnection(ctx, conn)
if persistErr != nil {
return Connection{}, fmt.Errorf("persist refreshed %s connection: %w", conn.Provider, persistErr)
}
conn = persisted
}
if err != nil {
return Connection{}, fmt.Errorf("refresh %s token: %w", conn.Provider, err)
}
if tokens.AccessToken != "" {
conn.AccessToken = tokens.AccessToken
}
if tokens.RefreshToken != "" {
conn.RefreshToken = tokens.RefreshToken
}
if tokens.TokenExpiresAt != nil {
conn.TokenExpiresAt = tokens.TokenExpiresAt
}
conn.LastError = ""
return s.repo.UpsertConnection(ctx, conn)
return conn, nil
}
func connectionWithTokens(conn Connection, tokens TokenSet) Connection {
conn.AccessToken = tokens.AccessToken
conn.RefreshToken = tokens.RefreshToken
conn.TokenExpiresAt = tokens.TokenExpiresAt
conn.TokenType = tokens.TokenType
conn.Scopes = append([]string(nil), tokens.Scopes...)
conn.SecretAttributes = cloneStringMap(tokens.SecretAttributes)
return conn
}
type ExportWatchedResult struct {
@@ -1309,9 +1395,10 @@ func (s *Service) ExportWatched(
exports := reconcileHistoryExports(conn.ID, local, remote)
for _, export := range exports {
if export.Status == "remote_present" {
switch export.Status {
case historyExportStatusRemotePresent:
result.RemotePresent++
} else if export.Status == "pending" {
case historyExportStatusPending:
result.Queued++
}
}
@@ -1337,7 +1424,7 @@ func (s *Service) ExportWatched(
for _, export := range pending {
play, ok := localByHistoryID[export.HistoryID]
if !ok {
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "local history entry not found"); err != nil {
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusNotFound, "local history entry not found"); err != nil {
return result, err
}
progressed = true
@@ -1349,26 +1436,36 @@ func (s *Service) ExportWatched(
if len(pendingPlays) == 0 {
continue
}
pendingPlays, singleBatch := limitWatchedExportBatch(exporter, pendingPlays)
exportResult, err := exporter.ExportHistory(ctx, cfg, conn, pendingPlays)
if err != nil {
// Rate-limited plays are not failures: leave them pending so the
// next run (after the deferral) retries without churning state.
if _, limited := AsRateLimited(err); limited {
return result, err
}
for _, export := range pending {
_ = s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", err.Error())
}
result.Failed += len(pending)
return result, err
_, limited := AsRateLimited(err)
retryable := isRetryableProviderError(err)
if err != nil && isWatchSyncInvalidCredentialError(err) {
return result, errors.Join(err, s.persistConnectionError(ctx, conn, err.Error()))
}
if err != nil && !limited && !retryable {
var markErr error
for _, play := range pendingPlays {
export := exportByHistoryID[play.HistoryID]
if export.ID != "" {
if statusErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusFailed, err.Error()); statusErr != nil {
markErr = errors.Join(markErr, statusErr)
}
}
}
result.Failed += len(pendingPlays)
return result, errors.Join(err, markErr)
}
// Commit per-event outcomes even when the provider also returned a
// connection-wide rate limit so successfully applied events are not
// retried after the deferral.
for _, historyID := range exportResult.Sent {
export := exportByHistoryID[historyID]
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "sent", ""); err != nil {
return result, err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusSent, ""); markErr != nil {
return result, markErr
}
result.Sent++
progressed = true
@@ -1378,8 +1475,8 @@ func (s *Service) ExportWatched(
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "provider item not found"); err != nil {
return result, err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusNotFound, "provider item not found"); markErr != nil {
return result, markErr
}
progressed = true
}
@@ -1388,13 +1485,17 @@ func (s *Service) ExportWatched(
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", message); err != nil {
return result, err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusFailed, message); markErr != nil {
return result, markErr
}
result.Failed++
progressed = true
}
if !progressed {
if limited || retryable {
// Leave unmentioned events pending for a later retry.
return result, err
}
if !progressed || singleBatch {
break
}
}
@@ -1426,7 +1527,7 @@ func (s *Service) exportLocalPlays(
MediaItemID: play.MediaItemID,
WatchedAt: play.WatchedAt,
ProviderItemKey: play.ProviderItemKey,
Status: "pending",
Status: historyExportStatusPending,
})
}
if len(exports) == 0 {
@@ -1435,7 +1536,19 @@ func (s *Service) exportLocalPlays(
if err := s.repo.UpsertHistoryExports(ctx, exports); err != nil {
return err
}
pending, err := s.repo.ListPendingHistoryExports(ctx, conn.ID, 100)
historyIDs := make([]string, 0, len(exports))
for _, export := range exports {
historyIDs = append(historyIDs, export.HistoryID)
}
// A live watch event must not wait behind a connection's historical
// backlog. Scheduled sync still drains that backlog oldest-first, while
// this path selects only the events the user just created.
pending, err := s.repo.ListPendingHistoryExportsByHistoryIDs(
ctx,
conn.ID,
historyIDs,
watchedExportBatchSize(exporter, len(historyIDs)),
)
if err != nil {
return err
}
@@ -1456,24 +1569,32 @@ func (s *Service) exportLocalPlays(
if len(pendingPlays) == 0 {
return nil
}
pendingPlays, _ = limitWatchedExportBatch(exporter, pendingPlays)
exportResult, err := exporter.ExportHistory(ctx, cfg, conn, pendingPlays)
if err != nil {
// Leave rate-limited plays pending; the next scheduled sync retries.
if _, limited := AsRateLimited(err); limited {
return err
_, limited := AsRateLimited(err)
retryable := isRetryableProviderError(err)
if err != nil && isWatchSyncInvalidCredentialError(err) {
return errors.Join(err, s.persistConnectionError(ctx, conn, err.Error()))
}
if err != nil && !limited && !retryable {
var markErr error
for _, play := range pendingPlays {
export := exportByHistoryID[play.HistoryID]
if export.ID != "" {
if statusErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusFailed, err.Error()); statusErr != nil {
markErr = errors.Join(markErr, statusErr)
}
}
}
for _, export := range exportByHistoryID {
_ = s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", err.Error())
}
return err
return errors.Join(err, markErr)
}
for _, historyID := range exportResult.Sent {
export := exportByHistoryID[historyID]
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "sent", ""); err != nil {
return err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusSent, ""); markErr != nil {
return markErr
}
}
for _, historyID := range exportResult.NotFound {
@@ -1481,8 +1602,8 @@ func (s *Service) exportLocalPlays(
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "provider item not found"); err != nil {
return err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusNotFound, "provider item not found"); markErr != nil {
return markErr
}
}
for historyID, message := range exportResult.Failed {
@@ -1490,10 +1611,13 @@ func (s *Service) exportLocalPlays(
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", message); err != nil {
return err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusFailed, message); markErr != nil {
return markErr
}
}
if limited || retryable {
return err
}
now := s.now()
conn.LastOutboundSyncAt = &now
conn.LastError = ""
@@ -1503,6 +1627,39 @@ func (s *Service) exportLocalPlays(
return nil
}
func (s *Service) persistConnectionError(ctx context.Context, conn Connection, message string) error {
if conn.ID != "" {
fresh, err := s.reloadConnection(ctx, conn)
if err != nil {
return err
}
conn = fresh
}
conn.LastError = message
_, err := s.repo.UpsertConnection(ctx, conn)
return err
}
func limitWatchedExportBatch(exporter WatchedExporter, plays []LocalPlay) ([]LocalPlay, bool) {
_, ok := exporter.(singleBatchWatchedExporter)
if !ok {
return plays, false
}
limit := watchedExportBatchSize(exporter, len(plays))
if len(plays) > limit {
plays = plays[:limit]
}
return plays, true
}
func watchedExportBatchSize(exporter WatchedExporter, fallback int) int {
bounded, ok := exporter.(singleBatchWatchedExporter)
if !ok {
return max(1, fallback)
}
return max(1, bounded.ExportBatchSize())
}
func reconcileHistoryExports(connectionID string, local []LocalPlay, remote []RemotePlay) []HistoryExport {
remoteExact := make(map[string]struct{}, len(remote))
for _, play := range remote {
@@ -1510,9 +1667,9 @@ func reconcileHistoryExports(connectionID string, local []LocalPlay, remote []Re
}
exports := make([]HistoryExport, 0, len(local))
for _, play := range local {
status := "pending"
status := historyExportStatusPending
if _, ok := remoteExact[remotePlayKey(play.ProviderItemKey, play.WatchedAt)]; ok {
status = "remote_present"
status = historyExportStatusRemotePresent
}
exports = append(exports, HistoryExport{
ConnectionID: connectionID,
@@ -1683,6 +1840,8 @@ func parseInt(value string) int {
return parsed
}
const scrobbleActionStop = "stop"
func (s *Service) ScrobbleStart(ctx context.Context, event ScrobbleEvent) error {
return s.scrobble(ctx, event, "start", false)
}
@@ -1692,7 +1851,7 @@ func (s *Service) ScrobblePause(ctx context.Context, event ScrobbleEvent) error
}
func (s *Service) ScrobbleStop(ctx context.Context, event ScrobbleEvent) error {
return s.scrobble(ctx, event, "stop", false)
return s.scrobble(ctx, event, scrobbleActionStop, false)
}
// ScrobbleStopConfirmed waits for each provider dispatch and reports provider
@@ -1753,6 +1912,19 @@ func (s *Service) scrobble(ctx context.Context, event ScrobbleEvent, action stri
return err
}
}
// Persist completed playback before provider I/O. Immediate scrobbling
// remains the low-latency path; the normal history-export reconciliation
// retries this desired state after crashes, plugin downtime, or uncertain
// upstream outcomes.
if action == scrobbleActionStop && event.Completed && provider.Capabilities().ExportWatched {
if err := s.persistCompletedScrobbleExport(ctx, conn, event); err != nil {
if confirm {
dispatchErrors = append(dispatchErrors, err)
continue
}
return err
}
}
if confirm {
confirmedTargets = append(confirmedTargets, confirmedScrobbleTarget{
provider: provider, scrobbler: scrobbler, connection: conn,
@@ -1795,6 +1967,38 @@ func (s *Service) scrobble(ctx context.Context, event ScrobbleEvent, action stri
return errors.Join(dispatchErrors...)
}
func (s *Service) persistCompletedScrobbleExport(ctx context.Context, conn Connection, event ScrobbleEvent) error {
if event.HistoryID == "" {
return nil
}
providerItemKey := event.ProviderItemKey
if providerItemKey == "" {
providerItemKey = providerItemKeyForLocalPlay(LocalPlay{
MediaItemID: event.MediaItemID,
Kind: event.Kind,
IMDbID: event.IMDbID,
TMDBID: event.TMDBID,
TVDBID: event.TVDBID,
SeriesIMDbID: event.SeriesIMDbID,
SeriesTMDBID: event.SeriesTMDBID,
SeriesTVDBID: event.SeriesTVDBID,
SeasonNumber: event.SeasonNumber,
EpisodeNumber: event.EpisodeNumber,
})
}
if providerItemKey == "" {
return nil
}
return s.repo.UpsertHistoryExports(ctx, []HistoryExport{{
ConnectionID: conn.ID,
HistoryID: event.HistoryID,
MediaItemID: event.MediaItemID,
WatchedAt: event.OccurredAt,
ProviderItemKey: providerItemKey,
Status: historyExportStatusPending,
}})
}
func (s *Service) dispatchScrobbleAsync(scrobbler Scrobbler, cfg ServerConfig, conn Connection, event ScrobbleEvent, action string) {
s.enqueueOrderedScrobble(scrobbleDispatchKey(scrobbler, conn, event), func() {
_ = s.dispatchScrobble(context.Background(), scrobbler, cfg, conn, event, action, nil)
@@ -1879,12 +2083,21 @@ func (s *Service) dispatchScrobble(ctx context.Context, scrobbler Scrobbler, cfg
switch action {
case "pause":
err = scrobbler.Pause(ctx, cfg, conn, event)
case "stop":
case scrobbleActionStop:
err = scrobbler.Stop(ctx, cfg, conn, event)
default:
err = scrobbler.Start(ctx, cfg, conn, event)
}
if err != nil {
if limited, ok := AsRateLimited(err); ok {
if deferErr := s.deferRateLimitedConnection(ctx, conn, limited); deferErr != nil {
err = errors.Join(err, deferErr)
}
}
var persistErr error
if isWatchSyncInvalidCredentialError(err) {
persistErr = s.persistConnectionError(ctx, conn, err.Error())
}
if confirmedClaim != nil {
_ = s.repo.FailConfirmedScrobbleStop(
ctx, event.PlaybackSessionID, conn.ID,
@@ -1893,27 +2106,46 @@ func (s *Service) dispatchScrobble(ctx context.Context, scrobbler Scrobbler, cfg
} else {
_ = s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, err.Error(), nil)
}
return err
return errors.Join(err, persistErr)
}
if action == "stop" {
if action == scrobbleActionStop {
stopSentAt := s.now()
if confirmedClaim != nil {
return s.repo.CompleteConfirmedScrobbleStop(
if err := s.repo.CompleteConfirmedScrobbleStop(
ctx, event.PlaybackSessionID, conn.ID,
event.PositionSeconds, event.HistoryID, *confirmedClaim, stopSentAt,
)
}
if err := s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, "", &stopSentAt); err != nil {
); err != nil {
return err
}
} else if err := s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, "", &stopSentAt); err != nil {
return err
}
if event.Completed && event.HistoryID != "" {
return s.reconcileScrobbleHistory(ctx, ScrobbleSession{
PlaybackSessionID: event.PlaybackSessionID,
ConnectionID: conn.ID,
HistoryID: event.HistoryID,
})
}
}
return nil
}
func (s *Service) SweepOpenScrobbles(ctx context.Context) error {
var reconciliationErr error
pending, err := s.repo.ListPendingScrobbleReconciliations(ctx)
if err != nil {
reconciliationErr = err
} else {
for _, session := range pending {
if err := s.reconcileScrobbleHistory(ctx, session); err != nil {
reconciliationErr = errors.Join(reconciliationErr, err)
}
}
}
sessions, err := s.repo.ListOpenScrobbleSessions(ctx)
if err != nil {
return err
return errors.Join(reconciliationErr, err)
}
for _, session := range sessions {
conn, ok, err := s.repo.GetConnectionByID(ctx, session.ConnectionID)
@@ -1943,6 +2175,19 @@ func (s *Service) SweepOpenScrobbles(ctx context.Context) error {
}
_ = s.dispatchScrobble(ctx, scrobbler, cfg, conn, scrobbleEventFromSession(session, conn, s.now()), "stop", nil)
}
return reconciliationErr
}
func (s *Service) reconcileScrobbleHistory(ctx context.Context, session ScrobbleSession) error {
if session.PlaybackSessionID == "" || session.ConnectionID == "" || session.HistoryID == "" {
return errors.New("scrobble history reconciliation identity is incomplete")
}
if err := s.repo.MarkHistoryExportSatisfiedByScrobble(ctx, session.ConnectionID, session.HistoryID); err != nil {
return fmt.Errorf("mark history export satisfied by scrobble: %w", err)
}
if err := s.repo.MarkScrobbleHistoryReconciled(ctx, session.PlaybackSessionID, session.ConnectionID, s.now()); err != nil {
return err
}
return nil
}
@@ -1971,6 +2216,9 @@ func scrobbleEventFromSession(session ScrobbleSession, conn Connection, occurred
}
func authMethodOf(provider Provider) string {
if provider, ok := provider.(interface{ AuthMethod() string }); ok {
return provider.AuthMethod()
}
if _, ok := provider.(APIKeyAuthProvider); ok {
return AuthMethodAPIKey
}
@@ -1978,6 +2226,11 @@ func authMethodOf(provider Provider) string {
}
func (s *Service) serverConfig(ctx context.Context, providerKey string) (ServerConfig, error) {
if provider, ok := s.registry.Get(providerKey); ok {
if _, pluginConfig := provider.(interface{ usesHostPluginConfig() }); pluginConfig {
return ServerConfig{}, nil
}
}
if provider, ok := s.registry.Get(providerKey); ok && authMethodOf(provider) == AuthMethodAPIKey {
// API-key providers carry their credential on the connection itself
// and don't consult server settings. Return a zero config so sync
File diff suppressed because it is too large Load Diff
+141 -67
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -58,6 +59,13 @@ type AuthProvider interface {
LookupAccount(ctx context.Context, cfg ServerConfig, conn Connection) (ProviderAccount, error)
}
// authoritativeRefreshProvider marks providers whose refreshed credentials are
// a complete replacement rather than a patch. The unexported method keeps this
// contract internal to watchsync until all providers can share it.
type authoritativeRefreshProvider interface {
authoritativeRefreshProvider()
}
// APIKeyAuthProvider is implemented by providers that authenticate via a
// user-supplied API key rather than an OAuth device flow. The key itself is
// stored in Connection.AccessToken; LookupAccount and RefreshToken (no-op)
@@ -67,6 +75,18 @@ type APIKeyAuthProvider interface {
ConnectWithAPIKey(ctx context.Context, apiKey string) (TokenSet, ProviderAccount, error)
}
// ConnectionConfigValues contains manifest-declared, per-connection setup
// values keyed first by config-schema key and then by field key.
type ConnectionConfigValues map[string]map[string]any
type configuredAPIKeyAuthProvider interface {
ConnectWithAPIKeyConfig(ctx context.Context, apiKey string, config ConnectionConfigValues) (TokenSet, ProviderAccount, error)
}
type connectionConfigProvider interface {
ConnectionConfigSchema() []plugins.ConfigSchemaView
}
type WatchedImporter interface {
FetchWatched(ctx context.Context, cfg ServerConfig, conn Connection) ([]RemoteWatch, error)
}
@@ -100,6 +120,12 @@ type WatchedExporter interface {
ExportHistory(ctx context.Context, cfg ServerConfig, conn Connection, plays []LocalPlay) (ExportResult, error)
}
// singleBatchWatchedExporter bounds one synchronization run to one provider
// batch. This is used for plugin RPCs with independent per-call deadlines.
type singleBatchWatchedExporter interface {
ExportBatchSize() int
}
type UnwatchedExporter interface {
RemoveHistory(ctx context.Context, cfg ServerConfig, conn Connection, plays []LocalPlay) (ExportResult, error)
}
@@ -112,6 +138,9 @@ type FavoriteImportBatch struct {
Rows []RemoteFavorite
UpdatedCursors map[string]string
Warnings []string
// Incremental means absent remote items are not deletions. The zero value is
// an authoritative snapshot, preserving existing built-in provider behavior.
Incremental bool
}
type FavoriteBatchImporter interface {
@@ -175,6 +204,9 @@ type Connection struct {
AccessToken string
RefreshToken string
TokenExpiresAt *time.Time
TokenType string
Scopes []string
SecretAttributes map[string]string
ImportWatchedEnabled bool
ImportProgressEnabled bool
ExportWatchedEnabled bool
@@ -278,6 +310,19 @@ func AsRateLimited(err error) (RateLimitedError, bool) {
return rle, ok
}
type retryableProviderError struct {
message string
}
func (e retryableProviderError) Error() string {
return e.message
}
func isRetryableProviderError(err error) bool {
var retryable retryableProviderError
return errors.As(err, &retryable)
}
type DeviceAuthSession struct {
ID string `json:"id"`
Provider string `json:"provider"`
@@ -291,10 +336,24 @@ type DeviceAuthSession struct {
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// deviceAuthorizationPendingError carries an authoritative replacement for a
// device challenge that must be persisted before the next poll. It remains an
// error so the existing HTTP contract continues to report an incomplete flow.
type deviceAuthorizationPendingError struct {
session DeviceAuthSession
}
func (deviceAuthorizationPendingError) Error() string {
return "watch sync plugin device authorization is pending"
}
type TokenSet struct {
AccessToken string
RefreshToken string
TokenExpiresAt *time.Time
AccessToken string
RefreshToken string
TokenExpiresAt *time.Time
TokenType string
Scopes []string
SecretAttributes map[string]string
}
type ProviderAccount struct {
@@ -345,20 +404,23 @@ type RemoteProgress struct {
type RemoteFavorite struct {
Provider string
ProviderItemKey string
Kind string
Title string
Year int
IMDbID string
TMDBID string
TVDBID string
SeriesTitle string
SeriesYear int
SeriesIMDbID string
SeriesTMDBID string
SeriesTVDBID string
SeasonNumber int
EpisodeNumber int
FavoritedAt time.Time
// Removed is an explicit provider tombstone used by incremental list syncs.
// Tombstones are resolved through ProviderItemKey and do not require media.
Removed bool
Kind string
Title string
Year int
IMDbID string
TMDBID string
TVDBID string
SeriesTitle string
SeriesYear int
SeriesIMDbID string
SeriesTMDBID string
SeriesTVDBID string
SeasonNumber int
EpisodeNumber int
FavoritedAt time.Time
}
type RemotePlay struct {
@@ -449,6 +511,15 @@ type LocalListEvent struct {
Items []LocalFavorite
}
const (
historyExportStatusPending = "pending"
historyExportStatusFailed = "failed"
historyExportStatusSent = "sent"
historyExportStatusNotFound = "not_found"
historyExportStatusRemotePresent = "remote_present"
historyExportStatusSatisfiedByScrobble = "satisfied_by_scrobble"
)
type HistoryExport struct {
ID string
ConnectionID string
@@ -490,27 +561,28 @@ type ListItemState struct {
}
type ScrobbleSession struct {
PlaybackSessionID string
ConnectionID string
MediaItemID string
ProviderItemKey string
Kind string
IMDbID string
TMDBID string
TVDBID string
SeriesIMDbID string
SeriesTMDBID string
SeriesTVDBID string
SeasonNumber int
EpisodeNumber int
HistoryID string
StartedAt time.Time
LastProgress float64
DurationSeconds float64
Completed bool
LastAction string
StopSentAt *time.Time
LastError string
PlaybackSessionID string
ConnectionID string
MediaItemID string
ProviderItemKey string
Kind string
IMDbID string
TMDBID string
TVDBID string
SeriesIMDbID string
SeriesTMDBID string
SeriesTVDBID string
SeasonNumber int
EpisodeNumber int
HistoryID string
StartedAt time.Time
LastProgress float64
DurationSeconds float64
Completed bool
LastAction string
StopSentAt *time.Time
HistoryReconciledAt *time.Time
LastError string
}
type ExportResult struct {
@@ -601,38 +673,40 @@ func (f RemoteFavorite) HistoryRecord() historyimport.Record {
}
type ProviderSummary struct {
Key string `json:"key"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
Key string `json:"key"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
ConnectionConfigSchema []plugins.ConfigSchemaView `json:"connection_config_schema,omitempty"`
}
type ConnectionStatus struct {
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
AuthMethod string `json:"auth_method"`
Connected bool `json:"connected"`
ProviderUsername string `json:"provider_username,omitempty"`
ImportWatchedEnabled bool `json:"import_watched_enabled"`
ImportProgressEnabled bool `json:"import_progress_enabled"`
ExportWatchedEnabled bool `json:"export_watched_enabled"`
ExportUnwatchedEnabled bool `json:"export_unwatched_enabled"`
ImportFavoritesEnabled bool `json:"import_favorites_enabled"`
ExportFavoritesEnabled bool `json:"export_favorites_enabled"`
SyncFavoriteRemovalsEnabled bool `json:"sync_favorite_removals_enabled"`
ImportWatchlistEnabled bool `json:"import_watchlist_enabled"`
ExportWatchlistEnabled bool `json:"export_watchlist_enabled"`
SyncWatchlistRemovalsEnabled bool `json:"sync_watchlist_removals_enabled"`
SyncWatchlistOrderEnabled bool `json:"sync_watchlist_order_enabled"`
ScrobbleEnabled bool `json:"scrobble_enabled"`
CredentialsConfigured bool `json:"credentials_configured"`
LastInboundSyncAt *time.Time `json:"last_inbound_sync_at,omitempty"`
LastProgressSyncAt *time.Time `json:"last_progress_sync_at,omitempty"`
LastOutboundSyncAt *time.Time `json:"last_outbound_sync_at,omitempty"`
LastFavoritesSyncAt *time.Time `json:"last_favorites_sync_at,omitempty"`
LastWatchlistSyncAt *time.Time `json:"last_watchlist_sync_at,omitempty"`
LastScrobbleErrorAt *time.Time `json:"last_scrobble_error_at,omitempty"`
LastError string `json:"last_error,omitempty"`
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
AuthMethod string `json:"auth_method"`
Connected bool `json:"connected"`
ProviderUsername string `json:"provider_username,omitempty"`
ImportWatchedEnabled bool `json:"import_watched_enabled"`
ImportProgressEnabled bool `json:"import_progress_enabled"`
ExportWatchedEnabled bool `json:"export_watched_enabled"`
ExportUnwatchedEnabled bool `json:"export_unwatched_enabled"`
ImportFavoritesEnabled bool `json:"import_favorites_enabled"`
ExportFavoritesEnabled bool `json:"export_favorites_enabled"`
SyncFavoriteRemovalsEnabled bool `json:"sync_favorite_removals_enabled"`
ImportWatchlistEnabled bool `json:"import_watchlist_enabled"`
ExportWatchlistEnabled bool `json:"export_watchlist_enabled"`
SyncWatchlistRemovalsEnabled bool `json:"sync_watchlist_removals_enabled"`
SyncWatchlistOrderEnabled bool `json:"sync_watchlist_order_enabled"`
ScrobbleEnabled bool `json:"scrobble_enabled"`
CredentialsConfigured bool `json:"credentials_configured"`
ConnectionConfigSchema []plugins.ConfigSchemaView `json:"connection_config_schema,omitempty"`
LastInboundSyncAt *time.Time `json:"last_inbound_sync_at,omitempty"`
LastProgressSyncAt *time.Time `json:"last_progress_sync_at,omitempty"`
LastOutboundSyncAt *time.Time `json:"last_outbound_sync_at,omitempty"`
LastFavoritesSyncAt *time.Time `json:"last_favorites_sync_at,omitempty"`
LastWatchlistSyncAt *time.Time `json:"last_watchlist_sync_at,omitempty"`
LastScrobbleErrorAt *time.Time `json:"last_scrobble_error_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type ConnectionUpdate struct {
@@ -0,0 +1,16 @@
-- +goose Up
ALTER TABLE watch_provider_connections
ADD COLUMN plugin_credentials text NOT NULL DEFAULT '';
ALTER TABLE watch_provider_scrobble_sessions
ADD COLUMN history_reconciled_at timestamptz;
-- +goose Down
-- Irreversible for plugin connections: TokenType, Scopes, and
-- SecretAttributes exist only in plugin_credentials, so rolling back may
-- require those providers to reconnect.
ALTER TABLE watch_provider_connections
DROP COLUMN IF EXISTS plugin_credentials;
ALTER TABLE watch_provider_scrobble_sessions
DROP COLUMN IF EXISTS history_reconciled_at;
@@ -0,0 +1,32 @@
-- +goose NO TRANSACTION
-- +goose Up
-- A failed concurrent build can leave an invalid index that causes
-- IF NOT EXISTS to skip every retry. Remove only that unusable artifact.
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_index i ON i.indexrelid = c.oid
WHERE n.nspname = 'public'
AND c.relname = 'watch_provider_scrobble_reconcile_pending_idx'
AND NOT i.indisvalid
) THEN
DROP INDEX public.watch_provider_scrobble_reconcile_pending_idx;
END IF;
END;
$$;
-- +goose StatementEnd
CREATE INDEX CONCURRENTLY IF NOT EXISTS watch_provider_scrobble_reconcile_pending_idx
ON public.watch_provider_scrobble_sessions (stop_sent_at)
WHERE stop_sent_at IS NOT NULL
AND completed = true
AND history_id <> ''
AND history_reconciled_at IS NULL;
-- +goose Down
DROP INDEX CONCURRENTLY IF EXISTS public.watch_provider_scrobble_reconcile_pending_idx;
@@ -0,0 +1,12 @@
-- +goose Up
ALTER TABLE playback_v3_attempts
ADD COLUMN frozen_recipe JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE playback_v3_attempts
ADD CONSTRAINT playback_v3_attempts_frozen_recipe_object
CHECK (jsonb_typeof(frozen_recipe) = 'object') NOT VALID;
-- +goose Down
ALTER TABLE playback_v3_attempts
DROP CONSTRAINT IF EXISTS playback_v3_attempts_frozen_recipe_object,
DROP COLUMN IF EXISTS frozen_recipe;
@@ -0,0 +1,8 @@
-- +goose Up
ALTER TABLE playback_v3_attempts
VALIDATE CONSTRAINT playback_v3_attempts_frozen_recipe_object;
-- +goose Down
-- PostgreSQL cannot mark a validated CHECK constraint NOT VALID again. The
-- preceding migration's Down step drops the constraint with its column.
SELECT 1;
@@ -36,6 +36,30 @@ const schema: PluginConfigSchema = {
};
describe("PluginConfigForm secrets", () => {
it("derives a form when a plugin only supplies JSON Schema", () => {
render(
<PluginConfigForm
schema={{
key: "server",
title: "Server",
json_schema: JSON.stringify({
type: "object",
properties: {
base_url: { type: "string", title: "Base URL" },
api_key: { type: "string", format: "password" },
},
required: ["base_url", "api_key"],
}),
required: true,
}}
onSave={vi.fn()}
/>,
);
expect(screen.getByLabelText("Base URL")).toBeInTheDocument();
expect(screen.getByLabelText("Api Key")).toHaveAttribute("type", "password");
});
it("shows redacted saved state and only clears through an explicit action", async () => {
const onSave = vi.fn();
render(
@@ -10,11 +10,14 @@ import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction"
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { adminFormForConfigSchema, humanizeConfigKey } from "./configSchemaAdminForm";
import { SchemaForm } from "./SchemaForm";
import { buildSchemaValues } from "./schemaFormUtils";
type PluginConfigValue = Record<string, unknown>;
const EMPTY_FIELDS: PluginAdminFormField[] = [];
type Props = {
schema: PluginConfigSchema;
value?: PluginConfigValue;
@@ -29,82 +32,7 @@ type Props = {
isTesting?: boolean;
};
type SupportedField = PluginAdminFormField & {
inferredType?: "string" | "number" | "integer" | "boolean";
};
type ParsedObjectSchema = {
supported: boolean;
fields: SupportedField[];
};
function humanizeKey(value: string) {
return value
.split("_")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function parseJSONSchema(schema: PluginConfigSchema): ParsedObjectSchema {
try {
const parsed = JSON.parse(schema.json_schema) as {
type?: string;
required?: string[];
properties?: Record<
string,
{
type?: string;
title?: string;
description?: string;
writeOnly?: boolean;
format?: string;
}
>;
};
if (parsed.type !== "object" || !parsed.properties) {
return { supported: false, fields: [] };
}
const fields = Object.entries(parsed.properties).map(([key, property]) => {
const propertyType = property.type;
if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) {
return null;
}
const isSensitive = property.writeOnly === true || property.format === "password";
const control =
propertyType === "boolean"
? "SWITCH"
: propertyType === "number" || propertyType === "integer"
? "NUMBER"
: isSensitive
? "PASSWORD"
: "TEXT";
return {
key,
label: property.title || humanizeKey(key),
description: property.description,
control,
placeholder: "",
required: parsed.required?.includes(key) ?? false,
secret: isSensitive,
multiline: false,
options: [],
rows: 0,
inferredType: propertyType as "string" | "number" | "integer" | "boolean",
} satisfies SupportedField;
});
if (fields.some((field) => field == null)) {
return { supported: false, fields: [] };
}
return { supported: true, fields: fields.filter(Boolean) as SupportedField[] };
} catch {
return { supported: false, fields: [] };
}
}
function defaultValueForField(field: SupportedField): string | boolean {
function defaultValueForField(field: PluginAdminFormField): string | boolean {
if (field.default_value !== undefined) {
if (typeof field.default_value === "boolean") {
return field.default_value;
@@ -122,7 +50,10 @@ function defaultValueForField(field: SupportedField): string | boolean {
return "";
}
function valueForField(field: SupportedField, configValue?: PluginConfigValue): string | boolean {
function valueForField(
field: PluginAdminFormField,
configValue?: PluginConfigValue,
): string | boolean {
const raw = configValue?.[field.key];
if (typeof raw === "boolean") {
return raw;
@@ -145,19 +76,12 @@ export function PluginConfigForm({
isSaving = false,
isTesting = false,
}: Props) {
const parsedFallback = useMemo(() => parseJSONSchema(schema), [schema]);
const fields = useMemo<SupportedField[]>(() => {
if (schema.admin_form?.fields?.length) {
return schema.admin_form.fields;
}
return parsedFallback.fields;
}, [parsedFallback.fields, schema.admin_form?.fields]);
const supported =
fields.length > 0 && (schema.admin_form?.fields?.length ? true : parsedFallback.supported);
const inferredDescriptor = useMemo(() => adminFormForConfigSchema(schema), [schema]);
const fields = inferredDescriptor?.fields ?? EMPTY_FIELDS;
const supported = inferredDescriptor != null;
const descriptor = useMemo<PluginAdminForm>(() => {
const base = schema.admin_form ?? { fields };
const base = inferredDescriptor ?? { fields };
const configured = new Set(configuredSecrets);
return {
...base,
@@ -167,7 +91,7 @@ export function PluginConfigForm({
: field,
),
};
}, [configuredSecrets, fields, schema.admin_form]);
}, [configuredSecrets, fields, inferredDescriptor]);
const [values, setValues] = useState<PluginConfigValue>(() =>
Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)])),
@@ -248,7 +172,7 @@ export function PluginConfigForm({
return (
<div key={key} className="flex items-center justify-between gap-3 text-xs">
<span className={clearing ? "text-destructive" : "text-muted-foreground"}>
{field?.label || humanizeKey(key)}: {clearing ? "will be cleared" : "saved"}
{field?.label || humanizeConfigKey(key)}: {clearing ? "will be cleared" : "saved"}
{required ? " (required)" : ""}
</span>
{!required ? (
@@ -98,6 +98,32 @@ describe("SchemaForm", () => {
"true",
);
});
it("uses a controlling field default when rendering a conditional field", () => {
const d: PluginAdminForm = {
fields: [
{
key: "advanced_enabled",
label: "Advanced",
control: "SWITCH",
required: false,
secret: false,
multiline: false,
default_value: true,
},
{
key: "endpoint",
label: "Endpoint",
control: "TEXT",
required: false,
secret: false,
multiline: false,
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
},
],
};
render(<SchemaForm descriptor={d} values={{}} onChange={vi.fn()} />);
expect(screen.getByText("Endpoint")).toBeTruthy();
});
it("reports validity through onValidityChange (#14)", () => {
const onValidityChange = vi.fn();
const d: PluginAdminForm = {
@@ -193,6 +219,43 @@ describe("SchemaForm collapsible sections", () => {
fireEvent.click(screen.getByText("Show"));
expect(screen.getByText("Verbose")).toBeTruthy();
});
it("uses a controlling field default when rendering a conditional section", () => {
const d: PluginAdminForm = {
fields: [
{
key: "advanced_enabled",
label: "Advanced",
control: "SWITCH",
required: false,
secret: false,
multiline: false,
default_value: true,
},
{
key: "endpoint",
label: "Endpoint",
control: "TEXT",
required: false,
secret: false,
multiline: false,
},
],
sections: [
{
key: "advanced",
title: "Advanced options",
collapsible: false,
collapsed_default: false,
field_keys: ["endpoint"],
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
},
],
};
render(<SchemaForm descriptor={d} values={{}} onChange={vi.fn()} />);
expect(screen.getByText("Advanced options")).toBeTruthy();
expect(screen.getByText("Endpoint")).toBeTruthy();
});
});
it("marks a show_when-gated field as nested when it is revealed", () => {
@@ -97,11 +97,13 @@ function ChipsSkeleton() {
function SchemaFormSection({
section,
values,
fields,
forceOpen,
renderFields,
}: {
section: PluginAdminFormSection;
values: Record<string, unknown>;
fields: PluginAdminFormField[];
forceOpen: boolean;
renderFields: (keys: string[]) => React.ReactNode;
}) {
@@ -109,7 +111,7 @@ function SchemaFormSection({
// (the section has unresolved errors) always wins so setup can't be hidden.
const [userOpen, setUserOpen] = useState<boolean | null>(null);
if (!evaluateShowWhen(section.show_when, values)) {
if (!evaluateShowWhen(section.show_when, values, fields)) {
return null;
}
@@ -331,7 +333,9 @@ export function SchemaForm({
// bordered, divided container so toggles read as a cohesive group instead of
// a column of separate boxes. Honors show_when on each field.
function renderFieldList(fields: PluginAdminFormField[]): React.ReactNode {
const visible = fields.filter((field) => evaluateShowWhen(field.show_when, values));
const visible = fields.filter((field) =>
evaluateShowWhen(field.show_when, values, descriptor.fields),
);
const nodes: React.ReactNode[] = [];
let run: PluginAdminFormField[] = [];
// Key switch groups by their position in the list, not by their first
@@ -389,6 +393,7 @@ export function SchemaForm({
key={section.key}
section={section}
values={values}
fields={descriptor.fields}
forceOpen={section.field_keys.some((key) => mergedErrors[key] != null)}
renderFields={(keys) => renderFieldList(resolveKeys(keys))}
/>
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import type { PluginConfigSchema } from "@/api/types";
import { adminFormForConfigSchema } from "./configSchemaAdminForm";
function schema(overrides: Partial<PluginConfigSchema> = {}): PluginConfigSchema {
return {
key: "connection",
title: "Connection",
json_schema: JSON.stringify({
type: "object",
properties: {},
additionalProperties: false,
}),
required: true,
...overrides,
};
}
describe("adminFormForConfigSchema", () => {
it("preserves primitive JSON Schema defaults on inferred fields", () => {
const form = adminFormForConfigSchema(
schema({
json_schema: JSON.stringify({
type: "object",
properties: {
base_url: { type: "string", default: "https://floppy.example.com" },
port: { type: "integer", default: 8080 },
verify_tls: { type: "boolean", default: true },
},
}),
}),
);
expect(form?.fields.map(({ key, default_value }) => ({ key, default_value }))).toEqual([
{ key: "base_url", default_value: "https://floppy.example.com" },
{ key: "port", default_value: 8080 },
{ key: "verify_tls", default_value: true },
]);
});
it("adds scalar schema properties omitted by a partial admin form", () => {
const form = adminFormForConfigSchema(
schema({
json_schema: JSON.stringify({
type: "object",
properties: {
base_url: { type: "string" },
username: { type: "string" },
},
required: ["base_url", "username"],
}),
admin_form: {
fields: [
{
key: "base_url",
label: "Custom URL",
control: "TEXT",
required: true,
secret: false,
multiline: false,
},
],
},
}),
);
expect(form?.fields.map(({ key, label, required }) => ({ key, label, required }))).toEqual([
{ key: "base_url", label: "Custom URL", required: true },
{ key: "username", label: "Username", required: true },
]);
});
it("preserves an explicit form when its schema cannot be inferred", () => {
const explicit = {
fields: [
{
key: "api_key",
label: "API Key",
control: "PASSWORD" as const,
required: true,
secret: true,
multiline: false,
},
],
};
expect(adminFormForConfigSchema(schema({ json_schema: "", admin_form: explicit }))).toBe(
explicit,
);
});
it("applies JSON Schema sensitivity to matching explicit fields", () => {
const form = adminFormForConfigSchema(
schema({
json_schema: JSON.stringify({
type: "object",
properties: {
api_key: { type: "string", writeOnly: true },
password: { type: "string", format: "password" },
advanced: { type: "object", writeOnly: true },
},
}),
admin_form: {
fields: [
{
key: "api_key",
label: "API Key",
control: "TEXT",
required: false,
secret: false,
multiline: false,
},
{
key: "password",
label: "Password",
control: "TEXT",
required: false,
secret: false,
multiline: false,
},
{
key: "advanced",
label: "Advanced",
control: "TEXTAREA",
required: false,
secret: false,
multiline: true,
},
],
},
}),
);
expect(form?.fields.map(({ key, secret }) => ({ key, secret }))).toEqual([
{ key: "api_key", secret: true },
{ key: "password", secret: true },
{ key: "advanced", secret: true },
]);
});
});
@@ -0,0 +1,98 @@
import type { PluginAdminForm, PluginAdminFormField, PluginConfigSchema } from "@/api/types";
export function humanizeConfigKey(value: string) {
return value
.split("_")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function adminFormForConfigSchema(schema: PluginConfigSchema): PluginAdminForm | null {
const explicitFields = schema.admin_form?.fields ?? [];
try {
const parsed = JSON.parse(schema.json_schema) as {
type?: string;
required?: string[];
properties?: Record<
string,
{
type?: string;
title?: string;
description?: string;
writeOnly?: boolean;
format?: string;
default?: unknown;
}
>;
};
if (parsed.type !== "object" || !parsed.properties) {
return explicitFields.length > 0 ? schema.admin_form! : null;
}
const inferredFields = Object.entries(parsed.properties).map(
([key, property]): PluginAdminFormField | null => {
const propertyType = property.type;
if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) {
return null;
}
const secret = property.writeOnly === true || property.format === "password";
const control =
propertyType === "boolean"
? "SWITCH"
: propertyType === "number" || propertyType === "integer"
? "NUMBER"
: secret
? "PASSWORD"
: "TEXT";
return {
key,
label: property.title || humanizeConfigKey(key),
description: property.description,
control,
placeholder: "",
required: parsed.required?.includes(key) ?? false,
secret,
multiline: false,
default_value:
typeof property.default === "string" ||
typeof property.default === "number" ||
typeof property.default === "boolean"
? property.default
: undefined,
options: [],
rows: 0,
};
},
);
if (explicitFields.length > 0) {
const explicitKeys = new Set(explicitFields.map((field) => field.key));
const sensitiveKeys = new Set(
Object.entries(parsed.properties)
.filter(([, property]) => property.writeOnly === true || property.format === "password")
.map(([key]) => key),
);
return {
...schema.admin_form,
fields: [
...explicitFields.map((field) =>
sensitiveKeys.has(field.key) ? { ...field, secret: true } : field,
),
...inferredFields.filter(
(field): field is PluginAdminFormField => field != null && !explicitKeys.has(field.key),
),
],
};
}
if (inferredFields.some((field) => field == null)) return null;
return {
...schema.admin_form,
fields: inferredFields.filter((field): field is PluginAdminFormField => field != null),
};
} catch {
return explicitFields.length > 0 ? schema.admin_form! : null;
}
}
@@ -172,6 +172,7 @@ describe("parseFieldTypes (#15)", () => {
root_folder: { type: "string" },
tags: { type: "array", items: { type: "integer" } },
labels: { type: "array", items: { type: "string" } },
flags: { type: "array", items: { type: "boolean" } },
enabled: { type: "boolean" },
},
});
@@ -180,6 +181,7 @@ describe("parseFieldTypes (#15)", () => {
root_folder: "string",
tags: "array:int",
labels: "array",
flags: "array:bool",
enabled: "boolean",
});
});
@@ -443,3 +445,63 @@ describe("coerceFieldValue array:num coercion (CodeRabbit #5)", () => {
expect(coerceFieldValue(numArrayField, ["abc"], "array:num")).toEqual(["abc"]);
});
});
describe("coerceFieldValue array:bool coercion", () => {
it("coerces boolean multi-select values using their declared item type", () => {
expect(coerceFieldValue(numArrayField, ["true", "false", true], "array:bool")).toEqual([
true,
false,
true,
]);
});
});
describe("section visibility", () => {
const sectionDescriptor: PluginAdminForm = {
fields: [
{
key: "advanced_enabled",
label: "Advanced",
control: "SWITCH",
required: false,
secret: false,
multiline: false,
default_value: false,
},
{
key: "endpoint",
label: "Endpoint",
control: "TEXT",
required: true,
secret: false,
multiline: false,
},
],
sections: [
{
key: "advanced",
title: "Advanced",
collapsible: false,
collapsed_default: false,
field_keys: ["endpoint"],
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
},
],
};
it("does not validate required fields in a hidden section", () => {
expect(validateSchemaValues(sectionDescriptor, { endpoint: "stale" })).toEqual({});
expect(validateSchemaValues(sectionDescriptor, { advanced_enabled: true }).endpoint).toMatch(
/required/i,
);
});
it("does not persist stale values from a hidden section", () => {
expect(buildSchemaValues(sectionDescriptor, { endpoint: "stale" })).toEqual({
advanced_enabled: false,
});
expect(
buildSchemaValues(sectionDescriptor, { advanced_enabled: true, endpoint: "active" }),
).toEqual({ advanced_enabled: true, endpoint: "active" });
});
});
@@ -45,7 +45,7 @@ export function validateSchemaValues(
): Record<string, string> {
const errors: Record<string, string> = {};
for (const field of descriptor.fields) {
if (!evaluateShowWhen(field.show_when, values, descriptor.fields)) continue;
if (!fieldIsVisible(descriptor, field, values)) continue;
const raw = effectiveValue(field, values);
if (field.required && isEmpty(raw)) {
errors[field.key] = `${field.label || field.key} is required`;
@@ -120,6 +120,7 @@ export type FieldType =
| "number"
| "boolean"
| "array"
| "array:bool"
| "array:int"
| "array:num";
@@ -150,6 +151,7 @@ export function parseFieldTypes(jsonSchema: string | undefined | null): Record<s
items && typeof items === "object" ? (items as { type?: unknown }).type : undefined;
if (itemType === "integer") out[key] = "array:int";
else if (itemType === "number") out[key] = "array:num";
else if (itemType === "boolean") out[key] = "array:bool";
else out[key] = "array";
} else if (type === "string" || type === "integer" || type === "number" || type === "boolean") {
out[key] = type;
@@ -191,10 +193,12 @@ export function coerceFieldValue(
return raw;
}
case "array":
case "array:bool":
case "array:int":
case "array:num": {
const arr = Array.isArray(raw) ? raw : [];
if (fieldType === "array") return arr;
if (fieldType === "array:bool") return arr.map((v) => coerceBoolean(v));
if (fieldType === "array:int") return arr.map((v) => coerceNumericString(v));
return arr.map((v) => coerceNumberString(v));
}
@@ -237,6 +241,23 @@ export function effectiveValue(
return values[field.key] !== undefined ? values[field.key] : field.default_value;
}
export function fieldIsVisible(
descriptor: PluginAdminForm,
field: PluginAdminFormField,
values: Record<string, unknown>,
): boolean {
if (!evaluateShowWhen(field.show_when, values, descriptor.fields)) return false;
const containingSections = (descriptor.sections ?? []).filter((section) =>
section.field_keys.includes(field.key),
);
return (
containingSections.length === 0 ||
containingSections.some((section) =>
evaluateShowWhen(section.show_when, values, descriptor.fields),
)
);
}
export function buildSchemaValues(
descriptor: PluginAdminForm,
draft: Record<string, unknown>,
@@ -244,7 +265,7 @@ export function buildSchemaValues(
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of descriptor.fields) {
if (!evaluateShowWhen(field.show_when, draft, descriptor.fields)) continue; // don't persist hidden fields' stale values
if (!fieldIsVisible(descriptor, field, draft)) continue; // don't persist hidden fields' stale values
// Fall back to the declared default for untouched fields so an unmodified
// default persists exactly as it is displayed.
const rawSource = draft[field.key] !== undefined ? draft[field.key] : field.default_value;
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
connectWatchProviderAPIKey,
fetchWatchProviders,
pollWatchProviderDeviceAuth,
startWatchProviderDeviceAuth,
@@ -42,6 +43,7 @@ vi.mock("@/api/client", () => ({
return {
path,
method: init?.method ?? "GET",
body: init?.body,
};
}),
}));
@@ -61,6 +63,18 @@ describe("watch provider queries", () => {
path: "/watch-providers/trakt/auth/poll",
method: "POST",
});
await expect(
connectWatchProviderAPIKey("plugin:4:floppy", "token", {
floppy: { base_url: "https://floppy.example.com" },
}),
).resolves.toMatchObject({
path: "/watch-providers/plugin:4:floppy/auth/api-key",
method: "POST",
body: JSON.stringify({
api_key: "token",
connection_config: { floppy: { base_url: "https://floppy.example.com" } },
}),
});
await expect(
updateWatchProviderConnection("trakt", { scrobble_enabled: true }),
).resolves.toMatchObject({
+18 -3
View File
@@ -3,11 +3,15 @@ import { api, ApiClientError } from "@/api/client";
import { favoriteKeys, watchlistKeys, watchProviderKeys } from "./keys";
import { toast } from "sonner";
import { storage } from "@/utils/storage";
import type { PluginConfigSchema } from "@/api/types";
export type WatchProviderConnectionConfig = Record<string, Record<string, unknown>>;
export interface WatchProviderSummary {
key: string;
display_name: string;
capabilities: WatchProviderCapabilities;
connection_config_schema?: PluginConfigSchema[];
}
export const WatchProviderAuthMethod = {
@@ -52,6 +56,7 @@ export interface WatchProviderConnection {
sync_watchlist_order_enabled: boolean;
scrobble_enabled: boolean;
credentials_configured: boolean;
connection_config_schema?: PluginConfigSchema[];
last_inbound_sync_at?: string;
last_progress_sync_at?: string;
last_outbound_sync_at?: string;
@@ -159,10 +164,14 @@ export function pollWatchProviderDeviceAuth(provider: string, authSessionId: str
});
}
export function connectWatchProviderAPIKey(provider: string, apiKey: string) {
export function connectWatchProviderAPIKey(
provider: string,
apiKey: string,
connectionConfig: WatchProviderConnectionConfig = {},
) {
return api<WatchProviderConnection>(`/watch-providers/${provider}/auth/api-key`, {
method: "POST",
body: JSON.stringify({ api_key: apiKey }),
body: JSON.stringify({ api_key: apiKey, connection_config: connectionConfig }),
});
}
@@ -248,7 +257,13 @@ export function usePollWatchProviderDeviceAuth(provider: string) {
export function useConnectWatchProviderAPIKey(provider: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (apiKey: string) => connectWatchProviderAPIKey(provider, apiKey),
mutationFn: ({
apiKey,
connectionConfig,
}: {
apiKey: string;
connectionConfig?: WatchProviderConnectionConfig;
}) => connectWatchProviderAPIKey(provider, apiKey, connectionConfig),
onSuccess: (connection) => {
const profileId = getActiveProfileId();
queryClient.setQueryData(watchProviderKeys.connection(profileId, provider), connection);
@@ -32,6 +32,14 @@ import {
type WatchProviderSyncRun,
} from "@/hooks/queries/watchProviders";
import { Input } from "@/components/ui/input";
import { SchemaForm } from "@/components/admin/plugins/SchemaForm";
import type { PluginConfigSchema } from "@/api/types";
import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProviders";
import {
buildConnectionConfig,
connectionSchemasAreValid,
renderableConnectionSchemas,
} from "./watchProviderConnectionConfig";
function formatRelativeTime(value?: string) {
if (!value) return "Never";
@@ -214,17 +222,29 @@ function AuthCodeBlock({
function APIKeyBlock({
displayName,
providerKey,
configSchemas,
pending,
onSubmit,
onCancel,
}: {
displayName: string;
providerKey: string;
configSchemas: PluginConfigSchema[];
pending: boolean;
onSubmit: (apiKey: string) => void;
onSubmit: (apiKey: string, connectionConfig: WatchProviderConnectionConfig) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState("");
const [connectionConfig, setConnectionConfig] = useState<WatchProviderConnectionConfig>({});
const [configValidity, setConfigValidity] = useState<Record<string, boolean>>({});
const trimmed = value.trim();
const renderableSchemas = renderableConnectionSchemas(configSchemas);
const configValid = connectionSchemasAreValid(
renderableSchemas,
connectionConfig,
configValidity,
);
return (
<div className="border-primary/30 bg-primary/5 rounded-xl border border-dashed p-4">
@@ -245,6 +265,29 @@ function APIKeyBlock({
<X className="h-4 w-4" />
</Button>
</div>
{renderableSchemas.map((schema) => (
<div key={schema.key} className="mt-4 space-y-2">
<div>
<div className="text-sm font-medium">{schema.title || schema.key}</div>
{schema.description ? (
<div className="text-muted-foreground mt-0.5 text-xs leading-snug">
{schema.description}
</div>
) : null}
</div>
<SchemaForm
descriptor={schema.admin_form}
values={connectionConfig[schema.key] ?? {}}
onChange={(next) =>
setConnectionConfig((current) => ({ ...current, [schema.key]: next }))
}
idPrefix={`watch-provider-${providerKey}-${schema.key}`}
onValidityChange={(valid) =>
setConfigValidity((current) => ({ ...current, [schema.key]: valid }))
}
/>
</div>
))}
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-stretch">
<Input
type="password"
@@ -258,8 +301,10 @@ function APIKeyBlock({
<Button
type="button"
size="sm"
disabled={pending || trimmed.length === 0}
onClick={() => onSubmit(trimmed)}
disabled={pending || trimmed.length === 0 || !configValid}
onClick={() =>
onSubmit(trimmed, buildConnectionConfig(renderableSchemas, connectionConfig))
}
className="sm:flex-none"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
@@ -454,12 +499,15 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
});
};
const handleSubmitAPIKey = (apiKey: string) => {
connectAPIKey.mutate(apiKey, {
onSuccess: () => {
setApiKeyPrompt(false);
const handleSubmitAPIKey = (apiKey: string, connectionConfig: WatchProviderConnectionConfig) => {
connectAPIKey.mutate(
{ apiKey, connectionConfig },
{
onSuccess: () => {
setApiKeyPrompt(false);
},
},
});
);
};
const handleCancelAPIKey = () => {
@@ -549,6 +597,8 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
<div className="mt-4">
<APIKeyBlock
displayName={displayName}
providerKey={providerKey}
configSchemas={connection.connection_config_schema ?? []}
pending={connectAPIKey.isPending}
onSubmit={handleSubmitAPIKey}
onCancel={handleCancelAPIKey}
@@ -622,14 +672,18 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
disabled={isBusy}
onChange={(checked) => updateConnection.mutate({ export_watched_enabled: checked })}
/>
<ToggleRow
id={`watch-provider-${providerKey}-export-unwatched`}
label="Send unwatched changes"
description="When you mark something unwatched, remove matching history from this provider."
checked={connection.export_unwatched_enabled}
disabled={isBusy}
onChange={(checked) => updateConnection.mutate({ export_unwatched_enabled: checked })}
/>
{connection.capabilities.export_unwatched ? (
<ToggleRow
id={`watch-provider-${providerKey}-export-unwatched`}
label="Send unwatched changes"
description="When you mark something unwatched, remove matching history from this provider."
checked={connection.export_unwatched_enabled}
disabled={isBusy}
onChange={(checked) =>
updateConnection.mutate({ export_unwatched_enabled: checked })
}
/>
) : null}
{connection.capabilities.import_favorites ||
connection.capabilities.export_favorites ? (
<ToggleRow
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { PluginConfigSchema } from "@/api/types";
import {
activeConnectionSchemas,
buildConnectionConfig,
connectionSchemasAreValid,
renderableConnectionSchemas,
} from "./watchProviderConnectionConfig";
const optionalSchema: PluginConfigSchema = {
key: "optional",
title: "Optional server settings",
description: "Only sent when configured.",
json_schema: JSON.stringify({
type: "object",
properties: { base_url: { type: "string" } },
required: ["base_url"],
}),
required: false,
admin_form: {
fields: [
{
key: "base_url",
label: "Server URL",
control: "TEXT",
required: true,
secret: false,
multiline: false,
},
],
},
};
describe("watch provider connection config", () => {
it("omits an untouched optional schema without blocking Connect", () => {
expect(activeConnectionSchemas([optionalSchema], {})).toEqual([]);
expect(connectionSchemasAreValid([optionalSchema], {}, { optional: false })).toBe(true);
expect(buildConnectionConfig([optionalSchema], {})).toEqual({});
});
it("validates and submits an optional schema after the user enters a value", () => {
const drafts = { optional: { base_url: "https://floppy.example.com" } };
expect(connectionSchemasAreValid([optionalSchema], drafts, { optional: false })).toBe(false);
expect(connectionSchemasAreValid([optionalSchema], drafts, { optional: true })).toBe(true);
expect(buildConnectionConfig([optionalSchema], drafts)).toEqual({
optional: { base_url: "https://floppy.example.com" },
});
});
it("keeps required schemas active before any values are entered", () => {
const requiredSchema = { ...optionalSchema, key: "required", required: true };
expect(activeConnectionSchemas([requiredSchema], {})).toEqual([requiredSchema]);
expect(connectionSchemasAreValid([requiredSchema], {}, {})).toBe(false);
});
it("derives a usable form for a required JSON-schema-only block", () => {
const headless = { ...optionalSchema, required: true, admin_form: undefined };
const schemas = renderableConnectionSchemas([headless]);
expect(schemas).toHaveLength(1);
const renderable = schemas[0]!;
expect(renderable.admin_form.fields).toEqual([
expect.objectContaining({
key: "base_url",
label: "Base Url",
control: "TEXT",
required: true,
}),
]);
expect(connectionSchemasAreValid([renderable], {}, {})).toBe(false);
});
});
@@ -0,0 +1,59 @@
import type { PluginConfigSchema } from "@/api/types";
import { adminFormForConfigSchema } from "@/components/admin/plugins/configSchemaAdminForm";
import { buildSchemaValues, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils";
import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProviders";
export type RenderableConnectionSchema = PluginConfigSchema & {
admin_form: NonNullable<PluginConfigSchema["admin_form"]>;
};
export function renderableConnectionSchemas(
schemas: PluginConfigSchema[],
): RenderableConnectionSchema[] {
return schemas.flatMap((schema) => {
const adminForm = adminFormForConfigSchema(schema);
return adminForm == null ? [] : [{ ...schema, admin_form: adminForm }];
});
}
function hasEnteredValue(value: unknown): boolean {
if (value == null) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.some(hasEnteredValue);
if (typeof value === "object") return Object.values(value).some(hasEnteredValue);
return true;
}
export function activeConnectionSchemas(
schemas: PluginConfigSchema[],
drafts: WatchProviderConnectionConfig,
): RenderableConnectionSchema[] {
return schemas.filter(
(schema): schema is RenderableConnectionSchema =>
schema.admin_form != null && (schema.required || hasEnteredValue(drafts[schema.key])),
);
}
export function connectionSchemasAreValid(
schemas: PluginConfigSchema[],
drafts: WatchProviderConnectionConfig,
validity: Record<string, boolean>,
): boolean {
return activeConnectionSchemas(schemas, drafts).every((schema) => validity[schema.key] ?? false);
}
export function buildConnectionConfig(
schemas: PluginConfigSchema[],
drafts: WatchProviderConnectionConfig,
): WatchProviderConnectionConfig {
return Object.fromEntries(
activeConnectionSchemas(schemas, drafts).map((schema) => [
schema.key,
buildSchemaValues(
schema.admin_form,
drafts[schema.key] ?? {},
parseFieldTypes(schema.json_schema),
),
]),
);
}