Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f39667b469 | ||
|
|
936f1990af | ||
|
|
f1a909e7ad | ||
|
|
2dd4069009 | ||
|
|
3d12f467dd | ||
|
|
5400717c3e | ||
|
|
10d4624222 | ||
|
|
6f445c1cda | ||
|
|
d4edf0068e | ||
|
|
9c9e10e373 | ||
|
|
c39737e456 | ||
|
|
371d959cc8 | ||
|
|
b4542bb53c | ||
|
|
fa1d7ba2b4 | ||
|
|
40a9de7f26 | ||
|
|
31a26b2554 |
+104
-5
@@ -19,6 +19,7 @@ import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -893,8 +894,10 @@ func main() {
|
||||
)
|
||||
}
|
||||
var watchProviderService *watchsync.Service
|
||||
var watchProviderRegistry *watchsync.Registry
|
||||
var watchProviderRepo *watchsync.PostgresRepository
|
||||
if deps.DB != nil {
|
||||
watchProviderRegistry := watchsync.NewRegistry()
|
||||
watchProviderRegistry = watchsync.NewRegistry()
|
||||
if err := watchProviderRegistry.Register(trakt.NewProvider(nil, "")); err != nil {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
@@ -904,10 +907,8 @@ func main() {
|
||||
if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil {
|
||||
log.Fatalf("register watch provider: %v", err)
|
||||
}
|
||||
watchProviderService = watchsync.NewService(
|
||||
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
|
||||
watchProviderRegistry,
|
||||
)
|
||||
watchProviderRepo = watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher)
|
||||
watchProviderService = watchsync.NewService(watchProviderRepo, watchProviderRegistry)
|
||||
deps.WatchProviderService = watchProviderService
|
||||
}
|
||||
|
||||
@@ -1119,6 +1120,15 @@ func main() {
|
||||
installer,
|
||||
plugins.NewHostAdapter(pluginHost),
|
||||
)
|
||||
if watchProviderRegistry != nil {
|
||||
reloadWatchProviders := func(ctx context.Context) {
|
||||
if err := reloadWatchSyncPluginProviders(ctx, watchProviderRegistry, installationStore, pluginService, watchProviderRepo); err != nil {
|
||||
slog.WarnContext(ctx, "failed to reload watch sync plugin providers", "component", "app", "error", err)
|
||||
}
|
||||
}
|
||||
pluginService.AddLifecycleHook(reloadWatchProviders)
|
||||
reloadWatchProviders(appCtx)
|
||||
}
|
||||
if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil {
|
||||
markerPluginResolver := markers.NewPluginResolverAdapter(pluginService)
|
||||
pluginService.AddLifecycleHook(func(ctx context.Context) {
|
||||
@@ -3088,11 +3098,100 @@ func metadataInt(value any) int {
|
||||
}
|
||||
}
|
||||
|
||||
var watchSyncPluginReloadMu sync.Mutex
|
||||
|
||||
type markerPluginCapabilityStore interface {
|
||||
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
|
||||
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
|
||||
}
|
||||
|
||||
func reloadWatchSyncPluginProviders(
|
||||
ctx context.Context,
|
||||
registry *watchsync.Registry,
|
||||
store markerPluginCapabilityStore,
|
||||
service *plugins.Service,
|
||||
repository watchsync.PluginCredentialRepository,
|
||||
) error {
|
||||
if registry == nil {
|
||||
return nil
|
||||
}
|
||||
watchSyncPluginReloadMu.Lock()
|
||||
defer watchSyncPluginReloadMu.Unlock()
|
||||
|
||||
var providers []watchsync.Provider
|
||||
if store == nil || service == nil {
|
||||
return registry.ReplacePluginProviders(providers)
|
||||
}
|
||||
installations, err := store.ListEnabled(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list enabled watch sync plugin installations: %w", err)
|
||||
}
|
||||
sort.Slice(installations, func(i, j int) bool {
|
||||
if installations[i] == nil {
|
||||
return false
|
||||
}
|
||||
if installations[j] == nil {
|
||||
return true
|
||||
}
|
||||
return installations[i].ID < installations[j].ID
|
||||
})
|
||||
for _, installation := range installations {
|
||||
if installation == nil || installation.IsBuiltin() {
|
||||
continue
|
||||
}
|
||||
capabilities, err := store.ListCapabilities(ctx, installation.ID)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip watch sync plugin with unreadable capabilities",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
for _, capability := range capabilities {
|
||||
if capability == nil || capability.Type != sdkcapability.WatchSyncProvider {
|
||||
continue
|
||||
}
|
||||
descriptor, err := plugins.DecodeCapability(capability)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip invalid watch sync plugin capability",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", capability.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
|
||||
InstallationID: installation.ID,
|
||||
ProviderKey: fmt.Sprintf("plugin:%d:%s", installation.ID, capability.ID),
|
||||
CapabilityID: capability.ID,
|
||||
DisplayName: descriptor.GetDisplayName(),
|
||||
Descriptor: descriptor.GetWatchSyncProvider(),
|
||||
ConnectionConfigSchema: descriptor.GetConfigSchema(),
|
||||
ResolveClient: func(callCtx context.Context, installationID int, capabilityID string) (watchsync.WatchSyncPluginClient, error) {
|
||||
return service.WatchSyncProviderClient(callCtx, installationID, capabilityID)
|
||||
},
|
||||
ResolveConfig: func(callCtx context.Context, installationID int) (*pluginv1.WatchSyncProviderConfig, error) {
|
||||
return service.WatchSyncProviderConfig(callCtx, installationID)
|
||||
},
|
||||
Repository: repository,
|
||||
})
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "skip unsupported watch sync plugin capability",
|
||||
"component", "app",
|
||||
"installation_id", installation.ID,
|
||||
"capability_id", capability.ID,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
}
|
||||
return registry.ReplacePluginProviders(providers)
|
||||
}
|
||||
|
||||
type markerPluginRuntimeConfigStore interface {
|
||||
ListGlobalConfigs(ctx context.Context, installationID int) ([]*plugins.RuntimeConfig, error)
|
||||
PutGlobalConfig(ctx context.Context, installationID int, key string, value map[string]any) error
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -8,9 +10,12 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"github.com/Silo-Server/silo-server/internal/api"
|
||||
"github.com/Silo-Server/silo-server/internal/config"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/plugins"
|
||||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||||
)
|
||||
|
||||
func TestConfigureS3Clients_SetsCORSOnPublicAssetsBucket(t *testing.T) {
|
||||
@@ -182,3 +187,104 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type failingWatchSyncCapabilityStore struct{}
|
||||
|
||||
func (failingWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
|
||||
return []*plugins.Installation{{ID: 2, Enabled: true, Kind: plugins.KindPlugin}}, nil
|
||||
}
|
||||
|
||||
func (failingWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
|
||||
return nil, errors.New("database unavailable")
|
||||
}
|
||||
|
||||
type staticWatchSyncCapabilityStore struct {
|
||||
capabilities []*plugins.Capability
|
||||
}
|
||||
|
||||
func (s staticWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
|
||||
return []*plugins.Installation{{ID: 4, Enabled: true, Kind: plugins.KindPlugin}}, nil
|
||||
}
|
||||
|
||||
func (s staticWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
|
||||
return s.capabilities, nil
|
||||
}
|
||||
|
||||
func TestReloadWatchSyncPluginProvidersPreservesConnectionForm(t *testing.T) {
|
||||
manifest := &pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{
|
||||
Type: "watch_sync_provider.v1", Id: "floppy", DisplayName: "Floppy",
|
||||
ConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "floppy", Title: "Your Floppy server", Required: true,
|
||||
JsonSchema: `{"type":"object","properties":{"base_url":{"type":"string"}},"required":["base_url"]}`,
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
|
||||
Key: "base_url", Label: "Server URL", Required: true,
|
||||
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT,
|
||||
}}},
|
||||
}},
|
||||
WatchSyncProvider: &pluginv1.WatchSyncProviderDescriptor{
|
||||
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
|
||||
},
|
||||
}}}
|
||||
records, err := plugins.CapabilityRecordsFromManifest(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capabilities := make([]*plugins.Capability, 0, len(records))
|
||||
for i := range records {
|
||||
record := records[i]
|
||||
capabilities = append(capabilities, &record)
|
||||
}
|
||||
|
||||
registry := watchsync.NewRegistry()
|
||||
if err := reloadWatchSyncPluginProviders(
|
||||
context.Background(), registry, staticWatchSyncCapabilityStore{capabilities: capabilities}, &plugins.Service{}, nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider, ok := registry.Get("plugin:4:floppy")
|
||||
if !ok {
|
||||
t.Fatal("Floppy provider was not registered")
|
||||
}
|
||||
configurable, ok := provider.(interface {
|
||||
ConnectionConfigSchema() []plugins.ConfigSchemaView
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("Floppy provider does not expose connection configuration")
|
||||
}
|
||||
schemas := configurable.ConnectionConfigSchema()
|
||||
if len(schemas) != 1 || schemas[0].AdminForm == nil || len(schemas[0].AdminForm.Fields) != 1 ||
|
||||
schemas[0].AdminForm.Fields[0].Control != "TEXT" {
|
||||
t.Fatalf("connection config schema = %#v", schemas)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadWatchSyncPluginProvidersDropsStaleProvidersOnCapabilityReadFailure(t *testing.T) {
|
||||
registry := watchsync.NewRegistry()
|
||||
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
|
||||
InstallationID: 1,
|
||||
ProviderKey: "plugin:1:tracker",
|
||||
CapabilityID: "tracker",
|
||||
Descriptor: &pluginv1.WatchSyncProviderDescriptor{
|
||||
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
|
||||
ExportWatched: true,
|
||||
},
|
||||
ResolveClient: func(context.Context, int, string) (watchsync.WatchSyncPluginClient, error) {
|
||||
return nil, errors.New("not used")
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := registry.Register(provider); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := reloadWatchSyncPluginProviders(
|
||||
context.Background(), registry, failingWatchSyncCapabilityStore{}, &plugins.Service{}, nil,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := registry.Get(provider.Key()); ok {
|
||||
t.Fatalf("stale provider %q remained registered", provider.Key())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# Plugin-backed watch sync providers
|
||||
|
||||
## Status
|
||||
|
||||
Implemented design for the expanded `silo-plugin-sdk` v0.13
|
||||
`watch_sync_provider.v1` contract. The server uses the immutable SDK commit
|
||||
associated with the v0.13 pull request until that version is released. This
|
||||
specification does not introduce a second watch synchronization pipeline.
|
||||
Plugin-backed providers adapt the public plugin RPC contract to Silo's existing
|
||||
`watchsync` service, connection repository, history exports, scrobble sessions,
|
||||
rate-limit handling, list reconciliation, and scheduled reconciliation.
|
||||
|
||||
## Goals
|
||||
|
||||
- Allow an enabled plugin capability to appear as a profile-scoped watch
|
||||
provider.
|
||||
- Reuse Silo's encrypted watch-provider connections for personal credentials.
|
||||
- Support API-key and device-code connection flows.
|
||||
- Import and export watched state, progress, favorites, and watchlists according
|
||||
to each plugin's advertised capabilities.
|
||||
- Deliver explicit watched/unwatched and list operations plus live playback
|
||||
start, pause, and stop events.
|
||||
- Preserve the existing durable history-export reconciliation path.
|
||||
- Preserve complete provider credential shapes, including token type, scopes,
|
||||
expiry, and opaque secret attributes.
|
||||
- Supply declared installation configuration to provider calls without mixing
|
||||
it with profile credentials.
|
||||
- Allow API-key providers to declare setup fields that are supplied per profile
|
||||
connection rather than forcing one installation-wide endpoint.
|
||||
- Keep provider plugins stateless and prevent credentials from entering generic
|
||||
plugin configuration.
|
||||
- Avoid behavior changes for built-in Trakt, Simkl, and MDBList providers.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Authorization-code OAuth UI and callback routes.
|
||||
- A second outbox or worker implementation.
|
||||
- Allowing plugins to persist or own personal provider credentials.
|
||||
- Removing built-in Trakt, Simkl, or MDBList providers in the same change. They
|
||||
remain available while equivalent plugins are built and connections are
|
||||
migrated deliberately.
|
||||
|
||||
Plugins may implement any subset of the state families and operations. A
|
||||
provider such as Floppy can advertise watched import/export, progress import,
|
||||
and live scrobbling without claiming favorites, watchlists, or unwatch support.
|
||||
Silo exposes only executable capabilities from the descriptor.
|
||||
|
||||
## SDK boundary
|
||||
|
||||
Plugins advertise `watch_sync_provider.v1` with a typed descriptor containing:
|
||||
|
||||
- supported authentication methods;
|
||||
- import/export capabilities;
|
||||
- supported media types and external-ID namespaces;
|
||||
- maximum apply batch size.
|
||||
|
||||
The host uses `WatchSyncProvider` for the stable provider operations and a
|
||||
separate `WatchSyncDeviceAuthorizationService` for device-code start and poll.
|
||||
The split keeps direct Go implementations of the released v0.12 provider
|
||||
interface source-compatible. Together the services support:
|
||||
|
||||
- device authorization `Start` and `Poll`;
|
||||
- `ExchangeAPIKey`;
|
||||
- `RefreshCredentials`;
|
||||
- `GetAccount`;
|
||||
- `ApplyEvents` for watched, unwatched, favorite, watchlist, and live scrobble
|
||||
operations;
|
||||
- `ListRemoteState` for watched, progress, favorite, and watchlist state.
|
||||
|
||||
Authorization-code descriptors remain valid SDK contracts, but the host does
|
||||
not register a plugin that offers only authorization-code authentication until
|
||||
the public watch-provider callback flow exists.
|
||||
|
||||
Every provider RPC receives the complete current credentials and relevant
|
||||
installation configuration only for that call. Plugins must not persist or log
|
||||
credentials. Generic `Runtime.Configure` data is not a personal credential
|
||||
store. Returned credentials are an authoritative replacement, and the host
|
||||
persists them before interpreting results, pages, or faults from the same RPC.
|
||||
|
||||
## Discovery and lifecycle
|
||||
|
||||
At startup and after plugin lifecycle changes, Silo:
|
||||
|
||||
1. lists enabled, non-builtin plugin installations;
|
||||
2. selects `watch_sync_provider.v1` capabilities;
|
||||
3. decodes and validates their typed descriptors;
|
||||
4. constructs thin `watchsync.Provider` adapters;
|
||||
5. atomically replaces the registry's previous plugin-backed providers while
|
||||
preserving built-ins.
|
||||
|
||||
Provider keys bind persisted credentials to the installation and capability
|
||||
(`plugin:<installation-id>:<capability-id>`), while RPC routing continues to use
|
||||
the manifest capability ID. This prevents a subsequently installed plugin from
|
||||
inheriting credentials merely by reusing another plugin's capability ID.
|
||||
Provider keys must not shadow built-ins or other plugin providers. A conflicting
|
||||
lifecycle reload fails without partially replacing the registry. Invalid,
|
||||
unreadable, or currently unsupported descriptors are logged and omitted, and
|
||||
the successfully discovered set still replaces the previous plugin providers.
|
||||
This fail-closed behavior prevents a disabled or removed plugin from remaining
|
||||
registered because another installation could not be decoded.
|
||||
|
||||
## Existing watchsync interfaces
|
||||
|
||||
The adapter implements the existing interfaces corresponding to the
|
||||
descriptor's advertised capabilities:
|
||||
|
||||
- `Provider`;
|
||||
- `AuthProvider`, `APIKeyAuthProvider`, and device authorization;
|
||||
- watched import/export and unwatch export;
|
||||
- progress import;
|
||||
- favorite and watchlist import/export/removal, including ordered watchlists;
|
||||
- `Scrobbler` and `OrderedScrobbler` for start, pause, and stop delivery.
|
||||
|
||||
`LocalPlay` and `ScrobbleEvent` values become rich desired-state plugin events.
|
||||
The host supplies movie and series external IDs, season and episode numbers,
|
||||
local history identity, playback identity, timestamps, and completion values.
|
||||
The plugin never needs a Silo API key or a callback into catalog HTTP routes.
|
||||
|
||||
## Authentication and credentials
|
||||
|
||||
The existing watch-provider API-key connection route passes the entered token
|
||||
to `ExchangeAPIKey`. A plugin capability may also declare `config_schema`
|
||||
entries; the host renders those fields beside the API key, validates submitted
|
||||
objects against the declared JSON schemas, classifies secret fields, and
|
||||
overlays the flattened values onto `provider_config` for that exchange only.
|
||||
The plugin validates or exchanges the credential and returns normalized
|
||||
credentials plus provider account identity. Provider-specific connection
|
||||
context needed by later calls is returned in encrypted opaque credential
|
||||
attributes. Device-code providers use the existing start and poll routes.
|
||||
|
||||
Credential refresh remains host-initiated and serialized by the existing
|
||||
watchsync service. The host encrypts an authoritative credential bundle
|
||||
containing access token, refresh token, expiry, token type, scopes, and opaque
|
||||
secret attributes. Legacy access/refresh columns remain readable during the
|
||||
migration. Device codes are also encrypted at rest. A pending device poll may
|
||||
rotate its opaque provider state, interval, or expiry; the host validates and
|
||||
persists the replacement before another poll. Invalid-credential faults are
|
||||
persisted using only the plugin's safe message.
|
||||
|
||||
Authorization-code OAuth can be added separately to the existing watch-
|
||||
provider service after its client-secret storage and callback-state design are
|
||||
reviewed.
|
||||
|
||||
## Watched export flow
|
||||
|
||||
### Explicit mark watched
|
||||
|
||||
The existing manual watch path records leaf history entries and invokes
|
||||
`HandleLocalWatchEvent`. The existing history-export table is populated before
|
||||
provider I/O. The plugin adapter sends each pending `LocalPlay` as an absolute
|
||||
`MARK_WATCHED` desired-state event. The interactive path sends one bounded
|
||||
plugin batch; additional queued events are delivered by scheduled
|
||||
reconciliation.
|
||||
|
||||
### Playback completion
|
||||
|
||||
The existing playback path invokes `ScrobbleStop` with rich identity. For a
|
||||
completed event and a provider that exports watched state, Silo first upserts a
|
||||
pending history-export record, then dispatches the immediate stop operation.
|
||||
|
||||
On successful immediate delivery, the history export is marked
|
||||
`satisfied_by_scrobble`. If immediate delivery fails or the process exits, the
|
||||
existing scheduled history reconciliation retries the same local history. A
|
||||
compatibility playback path without a local history ID retains its own durable
|
||||
terminal event until the confirmed stop succeeds. The plugin operation is
|
||||
convergent, so an uncertain duplicate cannot lower progress or increment a play
|
||||
counter.
|
||||
|
||||
Start, pause, and incomplete stop calls are no-ops for providers that only
|
||||
advertise watched export. Providers advertising live scrobbling receive all
|
||||
three operations with playback position and duration.
|
||||
|
||||
If the remote stop succeeds but the local history-export transition fails, the
|
||||
stop is not dispatched again. The scrobble session records a durable pending
|
||||
history-reconciliation marker, and the sweeper retries only the local database
|
||||
transition.
|
||||
|
||||
## Remote state import and lists
|
||||
|
||||
The scheduled sync path requests one state family at a time. Each request
|
||||
contains the stable cursor for that family and an opaque page token. The host:
|
||||
|
||||
1. persists any credentials returned by the page;
|
||||
2. discards the page and retains its cursor on a top-level fault;
|
||||
3. applies valid items idempotently through existing watch-state and list
|
||||
services;
|
||||
4. advances the page token until the plugin reports no next page;
|
||||
5. commits the family cursor only after the traversal succeeds.
|
||||
|
||||
Incremental list pages do not imply deletion of absent items; they use explicit
|
||||
provider-key tombstones for removals. Complete snapshots may reconcile removals.
|
||||
Ordered watchlists preserve the plugin's list position and require complete
|
||||
snapshots because an incremental subset cannot define positions relative to
|
||||
omitted items. Traversals are bounded by both page and item count before their
|
||||
durable cursor advances.
|
||||
Progress items without a valid provider timestamp are rejected rather than
|
||||
being stamped with host time.
|
||||
|
||||
Imported history is tagged with the stable plugin provider key, not a generic
|
||||
import source. Echo suppression therefore filters only export back to the
|
||||
originating plugin; the same local event remains eligible for other connected
|
||||
providers.
|
||||
|
||||
## Provider configuration
|
||||
|
||||
Manifest-declared global configuration is resolved per installation before each
|
||||
provider call. Public fields are sent in `values`; secret and undeclared fields
|
||||
are sent in `secret_values`. Nested manifest fields use the stable
|
||||
`<config-key>.<field>` key form, such as `floppy.base_url`. Profile credentials
|
||||
remain separate and encrypted in watch-provider connection storage.
|
||||
|
||||
Capability-level `config_schema` entries are connection setup fields, not
|
||||
installation defaults. For API-key authorization, the host validates and
|
||||
flattens them using the same `<config-key>.<field>` convention and lets the
|
||||
connection values override a same-named global value. This supports migrations
|
||||
from a legacy installation-wide endpoint while ensuring every new connection
|
||||
chooses its own endpoint. The submitted objects are not retained as generic
|
||||
plugin configuration; the plugin returns any context required after exchange
|
||||
inside its opaque encrypted credentials.
|
||||
|
||||
## Apply result handling
|
||||
|
||||
Each event has a stable ID. The adapter requires a matching result and maps it
|
||||
as follows:
|
||||
|
||||
- `APPLIED` and `NO_CHANGE`: export sent;
|
||||
- `REJECTED`: permanent not-found/unmappable export;
|
||||
- `RETRY` with a temporary fault: failed export eligible for existing retry handling;
|
||||
- top-level or per-event rate limit: existing `RateLimitedError` and account
|
||||
deferral, after committing any successfully applied events from the same RPC;
|
||||
- missing or unknown result: failed export eligible for retry, unless the same
|
||||
RPC reported a rate limit, in which case unmentioned events remain pending;
|
||||
- plugin transport, process availability, and top-level temporary faults:
|
||||
remain pending without consuming a per-event attempt.
|
||||
|
||||
A top-level invalid-credential fault records a typed safe connection error.
|
||||
A dedicated reconnect-required connection state is deferred as described
|
||||
above. Plugin messages persisted by Silo must be explicitly safe for operator
|
||||
display and must not contain response bodies or secrets. The host redacts the
|
||||
connection credentials, normalizes control characters and whitespace, and
|
||||
bounds the displayed message length; transport details are replaced with
|
||||
host-owned text.
|
||||
|
||||
## Ordering and batching
|
||||
|
||||
Immediate scrobbles are ordered by connection and stable series identity using
|
||||
the existing ordered-scrobble queue. History reconciliation is ordered by local
|
||||
watch timestamp.
|
||||
|
||||
Each synchronization run sends at most one plugin RPC, bounded by the
|
||||
descriptor's maximum batch size. The existing service commits every per-event
|
||||
result before a later run requests the next pending batch. Failures increment
|
||||
attempts only for events included in that RPC. This avoids partial-success loss,
|
||||
retry exhaustion within one run, and unbounded cumulative RPC time.
|
||||
|
||||
## Reliability boundaries
|
||||
|
||||
This design deliberately reuses the reliability guarantees currently accepted
|
||||
for built-in providers:
|
||||
|
||||
- encrypted profile-scoped connections;
|
||||
- durable local watch history;
|
||||
- durable history-export rows;
|
||||
- immediate scrobble sessions;
|
||||
- scheduled reconciliation;
|
||||
- bounded retry attempts and safe recorded errors;
|
||||
- provider/account rate-limit deferral.
|
||||
|
||||
It does not claim multi-node leased delivery or infinite retries. Those should
|
||||
be improved in the shared watchsync pipeline for built-in and plugin providers
|
||||
together rather than introduced only for plugins.
|
||||
|
||||
## Security requirements
|
||||
|
||||
- Personal tokens never enter plugin runtime config or user settings.
|
||||
- Tokens are decrypted only at the provider invocation boundary.
|
||||
- RPC request values containing credentials are never logged.
|
||||
- Plugin-returned errors are sanitized before persistence and API display;
|
||||
transport errors are replaced by a generic message.
|
||||
- Account/profile scope remains enforced by the existing connection lookup.
|
||||
- Credential AAD and provider lookup include installation identity, preventing
|
||||
sequential capability-ID reuse from exposing an old token to a new install.
|
||||
- Disabled plugins disappear from provider discovery; existing connections are
|
||||
unavailable until that same installation and capability return.
|
||||
|
||||
## Compatibility
|
||||
|
||||
The change is additive to `/api/v1`. Existing provider summaries and connection
|
||||
routes continue to work. A plugin provider appears through the same provider
|
||||
summary model and uses the existing API-key or device-code connection routes.
|
||||
No client changes are required because no existing fields or status codes
|
||||
change. SDK v0.12 plugins remain wire-compatible; descriptors only expose the
|
||||
capabilities the older contract can execute.
|
||||
|
||||
## Validation plan
|
||||
|
||||
- capability enforcement in the plugin host client;
|
||||
- atomic registry replacement and built-in collision tests;
|
||||
- API-key and device-code authentication, full credential replacement, and
|
||||
account identity conversion;
|
||||
- provider configuration routing and secret/public field classification;
|
||||
- rich movie and episode identity conversion;
|
||||
- watched, progress, favorite, and watchlist import pagination and cursor
|
||||
behavior;
|
||||
- unwatch, favorite, watchlist, and ordered-list event conversion;
|
||||
- provider-specific import source keys and cross-provider export behavior;
|
||||
- completed playback persistence before plugin I/O;
|
||||
- `satisfied_by_scrobble` transition after success;
|
||||
- durable retry of a failed local post-stop history transition without another
|
||||
remote stop;
|
||||
- immediate failure followed by scheduled history reconciliation;
|
||||
- typed rate-limit and invalid-credential handling;
|
||||
- complete credential encryption/decryption and legacy fallback;
|
||||
- plugin disable/re-enable lifecycle reload;
|
||||
- no regression in existing watchsync, pluginhost, and plugin service tests;
|
||||
- `go test`, targeted race tests, `go vet`, lint, formatting checks, and local
|
||||
path verification before proposing a merge request.
|
||||
@@ -109,7 +109,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||
|
||||
@@ -4,8 +4,10 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
|
||||
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0 h1:7MnaSIJBVdRE8FhfLXWx7Pe+Q2apJiaPMotfhb1VHoE=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.12.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.1 h1:3vMaV+aPT/vu47CPk/f3nODsjXCp1OseI0QIoLnOvOM=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.1/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2 h1:w7U0mmljVPauKfzRLNKusuiYFpuoEhuCSX4Hp3s9eRw=
|
||||
github.com/Silo-Server/silo-plugin-sdk v0.13.2/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
|
||||
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
|
||||
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
|
||||
@@ -462,7 +462,7 @@ type changeAudioResponse struct {
|
||||
PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"`
|
||||
}
|
||||
|
||||
func (resp *changeAudioResponse) setCopyTimeline(position, origin float64) {
|
||||
func (resp *changeAudioResponse) setWindowedTimeline(position, origin float64) {
|
||||
playerStart := max(0, position-origin)
|
||||
canSeekAnywhere := false
|
||||
resp.PlayerStartSeconds = &playerStart
|
||||
@@ -533,10 +533,20 @@ func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool {
|
||||
if file == nil || file.Duration <= 0 {
|
||||
return false
|
||||
}
|
||||
// Copy-video HLS sessions use FFmpeg's real manifest so the player only
|
||||
// seeks within the currently exposed window. Out-of-window seeks should
|
||||
// restart explicitly instead of relying on segment 404s to move FFmpeg.
|
||||
return !strings.EqualFold(req.TargetCodecVideo, "copy")
|
||||
return !usesRealTranscodeManifest(req, file)
|
||||
}
|
||||
|
||||
func usesRealTranscodeManifest(req transcodeStartRequest, file *models.MediaFile) bool {
|
||||
durationSeconds := 0.0
|
||||
if file != nil {
|
||||
durationSeconds = float64(file.Duration)
|
||||
}
|
||||
// Copy-video, unknown-duration, and oversized HLS sessions use FFmpeg's
|
||||
// real manifest so the player only seeks within the currently exposed
|
||||
// window. Out-of-window seeks should restart explicitly instead of relying
|
||||
// on segment 404s to move FFmpeg.
|
||||
return strings.EqualFold(req.TargetCodecVideo, "copy") ||
|
||||
!playback.CanGenerateSyntheticManifest(durationSeconds, req.SegmentDuration)
|
||||
}
|
||||
|
||||
func buildTranscodeStartResponse(
|
||||
@@ -2122,17 +2132,26 @@ func buildSubtitleURLs(
|
||||
Label: dl.ReleaseName + " (" + dl.Provider + ")",
|
||||
Source: "downloaded",
|
||||
HearingImpaired: dl.HearingImpaired,
|
||||
URL: subtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID),
|
||||
URL: downloadedSubtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID, dl.ID),
|
||||
})
|
||||
}
|
||||
|
||||
return urls
|
||||
}
|
||||
|
||||
const downloadedSubtitleIDParam = "downloaded_subtitle_id"
|
||||
|
||||
func subtitleStreamURL(sessionID string, trackIndex int, codec string, fileID int) string {
|
||||
return fmt.Sprintf("/stream/%s/subtitles/%d%s?file_id=%d", sessionID, trackIndex, subtitleURLExt(codec), fileID)
|
||||
}
|
||||
|
||||
// downloadedSubtitleStreamURL binds a downloaded subtitle URL to its stable
|
||||
// database identity while preserving the combined track index for clients.
|
||||
func downloadedSubtitleStreamURL(sessionID string, trackIndex int, codec string, fileID, downloadedID int) string {
|
||||
return subtitleStreamURL(sessionID, trackIndex, codec, fileID) +
|
||||
"&" + downloadedSubtitleIDParam + "=" + strconv.Itoa(downloadedID)
|
||||
}
|
||||
|
||||
func subtitleFontBundleURL(sessionID string, trackIndex int, codec string, fileID int) string {
|
||||
if !playback.IsASS(codec) {
|
||||
return ""
|
||||
@@ -2369,8 +2388,16 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
restartStartSegment := computeStartSegment(restartSeekSeconds, restartSegmentDuration)
|
||||
restartStreamOriginSeconds := 0.0
|
||||
restartCopyAnchorResolved := false
|
||||
legacyCopyRestart := session.PlayMethod == playback.PlayTranscode &&
|
||||
strings.EqualFold(targetVideoCodec, "copy") && isLegacyTransportSession(session)
|
||||
restartManifestRequest := transcodeStartRequest{
|
||||
TargetCodecVideo: targetVideoCodec,
|
||||
SegmentDuration: restartSegmentDuration,
|
||||
}
|
||||
legacyWindowedRestart := session.PlayMethod == playback.PlayTranscode &&
|
||||
isLegacyTransportSession(session) && usesRealTranscodeManifest(restartManifestRequest, file)
|
||||
if legacyWindowedRestart {
|
||||
restartStreamOriginSeconds = restartSeekSeconds
|
||||
}
|
||||
legacyCopyRestart := legacyWindowedRestart && strings.EqualFold(targetVideoCodec, "copy")
|
||||
if legacyCopyRestart {
|
||||
restartCopyAnchorResolved = true
|
||||
if req.Position > 0 {
|
||||
@@ -2779,8 +2806,8 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
|
||||
}
|
||||
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
|
||||
}
|
||||
if legacyCopyRestart {
|
||||
resp.setCopyTimeline(req.Position, restartStreamOriginSeconds)
|
||||
if legacyWindowedRestart {
|
||||
resp.setWindowedTimeline(req.Position, restartStreamOriginSeconds)
|
||||
}
|
||||
|
||||
h.syncSessionsNow(r.Context(), "audio_change")
|
||||
@@ -3231,6 +3258,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
|
||||
transportSeekSeconds := alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo)
|
||||
startSegmentNumber := computeStartSegment(transportSeekSeconds, req.SegmentDuration)
|
||||
streamOriginSeconds := 0.0
|
||||
if usesRealTranscodeManifest(req, file) {
|
||||
streamOriginSeconds = transportSeekSeconds
|
||||
}
|
||||
if videoCopy {
|
||||
streamOriginSeconds = req.SeekSeconds
|
||||
if req.SeekSeconds > 0 {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
func TestSubtitleURLExt(t *testing.T) {
|
||||
@@ -26,6 +27,17 @@ func TestSubtitleURLExt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLsBindsDownloadedSubtitleIdentity(t *testing.T) {
|
||||
file := &models.MediaFile{ID: 42}
|
||||
urls := buildSubtitleURLs("sess-downloaded", file, []subtitles.DownloadedSubtitle{{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT}}, true)
|
||||
if len(urls) != 1 {
|
||||
t.Fatalf("downloaded subtitle URLs = %#v", urls)
|
||||
}
|
||||
if want := "/stream/sess-downloaded/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71"; urls[0].URL != want {
|
||||
t.Fatalf("downloaded subtitle URL = %q, want %q", urls[0].URL, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSubtitleURLs_IncludesAllBitmapTracksForBurnInClients(t *testing.T) {
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
|
||||
@@ -616,6 +616,31 @@ func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
|
||||
if encodedResp.TimelineOffsetSeconds != 0 {
|
||||
t.Fatalf("encoded TimelineOffsetSeconds = %v, want 0", encodedResp.TimelineOffsetSeconds)
|
||||
}
|
||||
|
||||
longEncodedResp := buildTranscodeStartResponse(
|
||||
transcodeStartRequest{
|
||||
SessionID: "session-long-encoded",
|
||||
SeekSeconds: 18.261,
|
||||
TargetCodecVideo: "h264",
|
||||
SegmentDuration: 2,
|
||||
},
|
||||
&models.MediaFile{Duration: 1_000_000},
|
||||
nil,
|
||||
"/playback/transcode/session-long-encoded/master.m3u8",
|
||||
18,
|
||||
)
|
||||
if longEncodedResp.CanSeekAnywhere {
|
||||
t.Fatal("long encoded response should require explicit restart seeks")
|
||||
}
|
||||
if math.Abs(longEncodedResp.PlayerStartSeconds-0.261) > 0.0001 {
|
||||
t.Fatalf("long encoded PlayerStartSeconds = %v, want 0.261", longEncodedResp.PlayerStartSeconds)
|
||||
}
|
||||
if longEncodedResp.StreamOriginSeconds != 18 {
|
||||
t.Fatalf("long encoded StreamOriginSeconds = %v, want 18", longEncodedResp.StreamOriginSeconds)
|
||||
}
|
||||
if longEncodedResp.TimelineOffsetSeconds != 18 {
|
||||
t.Fatalf("long encoded TimelineOffsetSeconds = %v, want 18", longEncodedResp.TimelineOffsetSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartPlayback_PersistsSeriesPlaybackPreferenceForEpisodes(t *testing.T) {
|
||||
@@ -1614,7 +1639,7 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
|
||||
CodecAudio: "aac",
|
||||
Container: "mkv",
|
||||
Bitrate: 8000,
|
||||
Duration: 3600,
|
||||
Duration: 1_000_000,
|
||||
AudioTracks: []models.AudioTrack{
|
||||
{Codec: "aac", Default: true},
|
||||
{Codec: "ac3"},
|
||||
@@ -1703,6 +1728,9 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
|
||||
if remoteStartReq.SeekSeconds != 120 {
|
||||
t.Fatalf("remote SeekSeconds = %v, want aligned 120", remoteStartReq.SeekSeconds)
|
||||
}
|
||||
if remoteStartReq.StreamOriginSeconds != 120 {
|
||||
t.Fatalf("remote StreamOriginSeconds = %v, want aligned 120", remoteStartReq.StreamOriginSeconds)
|
||||
}
|
||||
if remoteStartReq.TargetResolution != "720p" || remoteStartReq.TargetCodecVideo != "h264" {
|
||||
t.Fatalf("remote target recipe = %q/%q, want 720p/h264", remoteStartReq.TargetResolution, remoteStartReq.TargetCodecVideo)
|
||||
}
|
||||
@@ -1739,6 +1767,15 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
|
||||
if claims.TargetBitrateKbps != 2000 {
|
||||
t.Fatalf("token TargetBitrateKbps = %d, want 2000 (recipe-complete)", claims.TargetBitrateKbps)
|
||||
}
|
||||
if claims.StreamOriginSeconds != 120 {
|
||||
t.Fatalf("token StreamOriginSeconds = %v, want aligned 120", claims.StreamOriginSeconds)
|
||||
}
|
||||
if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 ||
|
||||
resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 ||
|
||||
resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 ||
|
||||
resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere {
|
||||
t.Fatalf("remote long encoded response timeline = %+v", resp)
|
||||
}
|
||||
if claims.SeekSeconds != 120 {
|
||||
t.Fatalf("token SeekSeconds = %v, want aligned 120", claims.SeekSeconds)
|
||||
}
|
||||
@@ -2142,6 +2179,98 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChangeAudioTrack_LocalLongEncodedPreservesWindowedTimeline(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "movie-local-long-encoded.mkv"),
|
||||
Resolution: "1080p",
|
||||
CodecVideo: "hevc",
|
||||
CodecAudio: "ac3",
|
||||
Container: "mkv",
|
||||
Bitrate: 8000,
|
||||
Duration: 1_000_000,
|
||||
AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}, {Codec: "dts"}},
|
||||
}
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.JWTSecret = "test-secret"
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
|
||||
startReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/playback/transcode/start",
|
||||
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1}`),
|
||||
).WithContext(newAuthorizedPlaybackContext())
|
||||
startRR := httptest.NewRecorder()
|
||||
handler.HandleStartTranscode(startRR, startReq)
|
||||
if startRR.Code != http.StatusAccepted {
|
||||
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
|
||||
}
|
||||
|
||||
predecessor := handler.tm.GetTranscodeSession(session.ID)
|
||||
if predecessor == nil {
|
||||
t.Fatal("expected local long encoded session")
|
||||
}
|
||||
t.Cleanup(func() { handler.tm.CloseTranscodeSession(session.ID, "") })
|
||||
|
||||
changeReq := httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/playback/"+session.ID+"/audio",
|
||||
strings.NewReader(`{"audio_track_index":1,"position":121.5}`),
|
||||
).WithContext(newAuthorizedPlaybackContext())
|
||||
changeReq = withPlaybackRouteParam(changeReq, "session_id", session.ID)
|
||||
changeRR := httptest.NewRecorder()
|
||||
handler.HandleChangeAudioTrack(changeRR, changeReq)
|
||||
if changeRR.Code != http.StatusOK {
|
||||
t.Fatalf("change status = %d, body = %s", changeRR.Code, changeRR.Body.String())
|
||||
}
|
||||
|
||||
successor := handler.tm.GetTranscodeSession(session.ID)
|
||||
if successor == nil || successor == predecessor {
|
||||
t.Fatal("audio switch did not publish a prepared successor")
|
||||
}
|
||||
if predecessor.IsRunning() {
|
||||
t.Fatal("audio switch predecessor is still running after commit")
|
||||
}
|
||||
opts := successor.Opts()
|
||||
if opts.TargetCodecVideo != "h264" || opts.SeekSeconds != 120 ||
|
||||
opts.StreamOriginSeconds != 120 || opts.CopySeekAnchorResolved ||
|
||||
opts.StartSegmentNumber != 60 || opts.AudioTrackIndex != 1 {
|
||||
t.Fatalf("local long encoded restart opts = %+v", opts)
|
||||
}
|
||||
|
||||
var resp changeAudioResponse
|
||||
if err := json.NewDecoder(changeRR.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode change response: %v", err)
|
||||
}
|
||||
manifestURL, err := url.Parse(resp.StreamURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse stream URL: %v", err)
|
||||
}
|
||||
claims, err := streamtoken.Verify(manifestURL.Query().Get(streamTokenParam), handler.JWTSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("verify stream token: %v", err)
|
||||
}
|
||||
if claims.TargetCodec != "h264" || claims.SeekSeconds != 120 ||
|
||||
claims.StreamOriginSeconds != 120 || claims.CopySeekAnchorResolved ||
|
||||
claims.StartSegmentNumber != 60 || claims.AudioTrackIndex != 1 {
|
||||
t.Fatalf("local long encoded reconstruction claims = %+v", claims)
|
||||
}
|
||||
if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 ||
|
||||
resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 ||
|
||||
resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 ||
|
||||
resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere {
|
||||
t.Fatalf("local long encoded response timeline = %+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleChangeAudioTrack_LocalCopyRestartUsesFreshSeekAnchor(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
file := &models.MediaFile{
|
||||
@@ -2395,6 +2524,61 @@ func TestHandleStartTranscode_SeekedCopyRemainsCopyVideo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartTranscode_LongEncodedUsesAlignedRealManifestOrigin(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
file := &models.MediaFile{
|
||||
ID: 42,
|
||||
ContentID: "movie-1",
|
||||
FilePath: writePlaybackTestMediaFile(t, "movie-long-encoded.mkv"),
|
||||
Resolution: "1080p",
|
||||
CodecVideo: "hevc",
|
||||
CodecAudio: "dts",
|
||||
Container: "mkv",
|
||||
Bitrate: 25000,
|
||||
Duration: 1_000_000,
|
||||
AudioTracks: []models.AudioTrack{{Codec: "dts", Default: true}},
|
||||
}
|
||||
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
|
||||
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
|
||||
transcodeReq := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/playback/transcode/start",
|
||||
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
|
||||
).WithContext(newAuthorizedPlaybackContext())
|
||||
|
||||
transcodeRR := httptest.NewRecorder()
|
||||
handler.HandleStartTranscode(transcodeRR, transcodeReq)
|
||||
if transcodeRR.Code != http.StatusAccepted {
|
||||
t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String())
|
||||
}
|
||||
|
||||
var response transcodeStartResponse
|
||||
if err := json.NewDecoder(transcodeRR.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode transcode response: %v", err)
|
||||
}
|
||||
if math.Abs(response.PlayerStartSeconds-0.261) > 0.0001 || response.StreamOriginSeconds != 18 ||
|
||||
response.TimelineOffsetSeconds != 18 || response.CanSeekAnywhere {
|
||||
t.Fatalf("long encoded response timeline = %+v", response)
|
||||
}
|
||||
|
||||
transcodeSession := handler.tm.GetTranscodeSession(session.ID)
|
||||
if transcodeSession == nil {
|
||||
t.Fatal("expected local transcode session")
|
||||
}
|
||||
t.Cleanup(func() { _ = transcodeSession.Close() })
|
||||
opts := transcodeSession.Opts()
|
||||
if opts.SeekSeconds != 18 || opts.StreamOriginSeconds != 18 || opts.CopySeekAnchorResolved || opts.StartSegmentNumber != 9 {
|
||||
t.Fatalf("long encoded seek recipe = seek %v origin %v copy anchor %v segment %d", opts.SeekSeconds, opts.StreamOriginSeconds, opts.CopySeekAnchorResolved, opts.StartSegmentNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartTranscode_CopyAnchorFailureKeepsActiveTransport(t *testing.T) {
|
||||
sessionMgr := playback.NewSessionManager(0, 0)
|
||||
file := &models.MediaFile{
|
||||
|
||||
@@ -35,6 +35,11 @@ const (
|
||||
maxPlaybackV3EventBodyBytes = 32 << 10
|
||||
replanLeaseDurationV3 = 15 * time.Second
|
||||
v3NodeCapabilityTTL = time.Minute
|
||||
playbackNodeIntegratedV3 = "integrated"
|
||||
subtitleFormatVTTV3 = "vtt"
|
||||
subtitleMIMEVTTV3 = "text/vtt"
|
||||
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
|
||||
seekRestorationPlayerV3 = "player_position"
|
||||
// Failed capability fetches are memoized briefly so an unreachable node
|
||||
// costs one timeout per window instead of one per planning request.
|
||||
v3NodeCapabilityErrorTTL = 15 * time.Second
|
||||
@@ -44,6 +49,8 @@ const (
|
||||
v3NodeCapabilityPlanTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
var errSubtitleStoreUnavailableV3 = errors.New("subtitle store unavailable")
|
||||
|
||||
type v3NodeCapabilityCache struct {
|
||||
transformations []playback.TransformationV3
|
||||
err error
|
||||
@@ -67,6 +74,19 @@ type transportErrorV3 struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func subtitleArtifactErrorV3(message string, cause error) *transportErrorV3 {
|
||||
return &transportErrorV3{
|
||||
reason: subtitleUnavailableReasonV3,
|
||||
message: message,
|
||||
retryable: errors.Is(cause, errSubtitleStoreUnavailableV3),
|
||||
cause: cause,
|
||||
}
|
||||
}
|
||||
|
||||
func wrapSubtitleStoreErrorV3(err error) error {
|
||||
return fmt.Errorf("%w: %w", errSubtitleStoreUnavailableV3, err)
|
||||
}
|
||||
|
||||
type v3ReplanLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
@@ -525,19 +545,24 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
|
||||
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to load the initialized playback session.", cause: err}
|
||||
}
|
||||
result.Plan.SessionID = session.ID
|
||||
frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result)
|
||||
if frozenErr != nil {
|
||||
abort()
|
||||
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
|
||||
}
|
||||
transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result)
|
||||
if transportErr != nil {
|
||||
abort()
|
||||
return playback.DecisionResponseV3{}, transportErr
|
||||
}
|
||||
result.Plan.Stream.URL = transport.url
|
||||
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil {
|
||||
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &frozenRecipe); err != nil {
|
||||
transport.rollback()
|
||||
abort()
|
||||
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err}
|
||||
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
|
||||
}
|
||||
response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: playback.ServerFeaturesV3(), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan}
|
||||
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
|
||||
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
|
||||
if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil {
|
||||
transport.rollback()
|
||||
abort()
|
||||
@@ -708,9 +733,10 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
|
||||
if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 {
|
||||
videoCodec = "copy"
|
||||
}
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration))
|
||||
sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result)
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration)
|
||||
unlock := h.tm.LockSessionLifecycle(session.ID)
|
||||
ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), FastStart: true, NodeType: "integrated", ExecutionMode: "integrated", FFmpegLogSink: h.FFmpegLogSink})
|
||||
ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink})
|
||||
if err != nil {
|
||||
unlock()
|
||||
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
|
||||
@@ -765,8 +791,9 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
|
||||
if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 {
|
||||
videoCodec = "copy"
|
||||
}
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration))
|
||||
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), RequireReady: true}
|
||||
sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result)
|
||||
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration)
|
||||
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, RequireReady: true}
|
||||
nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req)
|
||||
if err != nil {
|
||||
// A timeout can fire after the node actually started the job; the
|
||||
@@ -812,6 +839,16 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func sourceExecutionMetadataV3(file *models.MediaFile, result playback.PlannerResultV3) (string, float64) {
|
||||
if result.FrozenSourceMetadata != nil {
|
||||
return result.FrozenSourceMetadata.VideoCodec, result.FrozenSourceMetadata.DurationSeconds
|
||||
}
|
||||
if file == nil {
|
||||
return "", 0
|
||||
}
|
||||
return file.CodecVideo, float64(file.Duration)
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) v3SessionStreamState(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, transport preparedTransportV3) playback.SessionStreamState {
|
||||
state := playback.SessionStreamState{PlayMethod: result.PlayMethod, BasePlayMethod: result.PlayMethod, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), TranscodeAudio: result.TranscodeAudio, RemuxDVMode: remuxDVModeForPlanV3(result.Plan), TranscodeNodeURL: transport.nodeURL, TranscodeTransportID: transport.transportID, TranscodeRouteSet: true, ClientIP: clientip.FromContext(ctx), ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientUserAgent: session.ClientUserAgent, StreamBitrateKbps: result.TargetBitrateKbps, TargetVideoCodec: result.TargetVideoCodec, TargetAudioCodec: result.TargetAudioCodec, TargetResolution: result.TargetResolution, SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn}
|
||||
if result.Plan != nil && (result.Plan.Delivery == playback.DeliveryTranscodeHLSV3 || result.Plan.Delivery == playback.DeliveryRemuxHLSV3) {
|
||||
@@ -842,16 +879,38 @@ func transportGenerationV3(sessionID, planID string) string {
|
||||
return sessionID + "-" + planSuffix + "-" + uuid.NewString()[:8]
|
||||
}
|
||||
|
||||
func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int) error {
|
||||
func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int, recipe *playback.ExecutableRecipeV3) error {
|
||||
if plan == nil || file == nil || selectedIndex < 0 || (plan.Subtitle.Mode != playback.SubtitleRenderV3 && plan.Subtitle.Mode != playback.SubtitleConvertV3) {
|
||||
return nil
|
||||
}
|
||||
if recipe != nil && recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 {
|
||||
if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 {
|
||||
return errors.New("the frozen downloaded subtitle is unavailable")
|
||||
}
|
||||
downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID)
|
||||
if err != nil {
|
||||
return wrapSubtitleStoreErrorV3(err)
|
||||
}
|
||||
if downloaded == nil || downloaded.MediaFileID != file.ID {
|
||||
return errors.New("the frozen downloaded subtitle is unavailable for the selected media file")
|
||||
}
|
||||
url := downloadedSubtitleStreamURL(sessionID, selectedIndex, string(downloaded.Format), file.ID, downloaded.ID)
|
||||
format := strings.ToLower(string(downloaded.Format))
|
||||
mime := subtitleMIMEV3(format)
|
||||
if plan.Subtitle.Mode == playback.SubtitleConvertV3 {
|
||||
format = subtitleFormatVTTV3
|
||||
mime = subtitleMIMEVTTV3
|
||||
url = forceSubtitleExtensionV3(url, ".vtt")
|
||||
}
|
||||
plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds}
|
||||
return nil
|
||||
}
|
||||
var downloaded []subtitles.DownloadedSubtitle
|
||||
if h.SubtitleRepo != nil {
|
||||
var err error
|
||||
downloaded, err = h.SubtitleRepo.ListDownloadedSubtitles(ctx, file.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
return wrapSubtitleStoreErrorV3(err)
|
||||
}
|
||||
}
|
||||
for _, value := range buildSubtitleURLs(sessionID, file, downloaded, true) {
|
||||
@@ -862,8 +921,8 @@ func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionI
|
||||
mime := subtitleMIMEV3(format)
|
||||
url := value.URL
|
||||
if plan.Subtitle.Mode == playback.SubtitleConvertV3 {
|
||||
format = "vtt"
|
||||
mime = "text/vtt"
|
||||
format = subtitleFormatVTTV3
|
||||
mime = subtitleMIMEVTTV3
|
||||
url = forceSubtitleExtensionV3(value.URL, ".vtt")
|
||||
}
|
||||
plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds}
|
||||
@@ -883,7 +942,7 @@ func (h *PlaybackHandler) downloadedSubtitleInventoryV3(ctx context.Context, fil
|
||||
base := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
|
||||
result := make([]playback.SubtitleInventoryEntryV3, 0, len(downloaded))
|
||||
for index, value := range downloaded {
|
||||
result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded"})
|
||||
result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded", DownloadedSubtitleID: value.ID})
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1182,7 +1241,11 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
cause: err,
|
||||
}
|
||||
}
|
||||
if seekScopedRecovery && effectiveFile.Duration > 0 && req.PositionSeconds > float64(effectiveFile.Duration) {
|
||||
seekDuration := float64(effectiveFile.Duration)
|
||||
if seekReanchor && record.FrozenRecipe.ValidFor(record.CurrentPlan) {
|
||||
seekDuration = record.FrozenRecipe.SourceDurationSeconds
|
||||
}
|
||||
if seekScopedRecovery && seekDuration > 0 && req.PositionSeconds > seekDuration {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
|
||||
reason: "invalid_seek_position",
|
||||
message: "The requested seek position is beyond the end of the selected media source.",
|
||||
@@ -1191,9 +1254,12 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
if _, err := start.NormalizeAndValidate(); err != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "invalid_replan", message: err.Error()}
|
||||
}
|
||||
audioIndex, err := resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
|
||||
if err != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
|
||||
audioIndex := 0
|
||||
if !seekReanchor {
|
||||
audioIndex, err = resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
|
||||
if err != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
|
||||
}
|
||||
}
|
||||
attemptedKeys := []string(nil)
|
||||
if !intentChange && !seekReanchor {
|
||||
@@ -1212,7 +1278,23 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
attemptedKeys = append(attemptedKeys, currentKey)
|
||||
}
|
||||
}
|
||||
result := playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
|
||||
var result playback.PlannerResultV3
|
||||
if seekReanchor {
|
||||
if err := h.validateFrozenSubtitleIdentityV3(r.Context(), effectiveFile, record.FrozenRecipe); err != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("The selected subtitle is no longer available at its frozen route.", err)
|
||||
}
|
||||
var frozenErr error
|
||||
result, frozenErr = frozenSeekReanchorResultV3(record, req.PositionSeconds, time.Now())
|
||||
if frozenErr != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
|
||||
reason: "seek_reanchor_recipe_unavailable",
|
||||
message: "The active playback recipe cannot be reopened; start a new playback attempt.",
|
||||
retryable: true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
|
||||
}
|
||||
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && replanAllowsAlternateFileV3(operation, start.QualityPreference) {
|
||||
if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil {
|
||||
alternate = h.ensurePlaybackProbe(r.Context(), alternate)
|
||||
@@ -1252,17 +1334,31 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
}
|
||||
}
|
||||
result.Plan.SessionID = session.ID
|
||||
artifactRecipe := record.FrozenRecipe
|
||||
if !seekReanchor {
|
||||
frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result)
|
||||
if frozenErr != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
|
||||
}
|
||||
artifactRecipe = frozenRecipe
|
||||
}
|
||||
transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result)
|
||||
if transportErr != nil {
|
||||
return playback.DecisionResponseV3{}, *record, nil, transportErr
|
||||
}
|
||||
result.Plan.Stream.URL = transport.url
|
||||
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil {
|
||||
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &artifactRecipe); err != nil {
|
||||
transport.rollback()
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err}
|
||||
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
|
||||
}
|
||||
if seekReanchor {
|
||||
if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil {
|
||||
changedFields := seekReanchorIdentityChangesV3(record, result.Plan)
|
||||
slog.ErrorContext(r.Context(), "protocol v3 seek reanchor changed route identity",
|
||||
"session", record.SessionID,
|
||||
"playback_attempt_id", record.PlaybackAttemptID,
|
||||
"changed_fields", changedFields,
|
||||
)
|
||||
transport.rollback()
|
||||
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
|
||||
reason: "seek_reanchor_route_changed",
|
||||
@@ -1274,6 +1370,15 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
updated := *record
|
||||
updated.CurrentPlanID = result.Plan.PlanID
|
||||
updated.CurrentPlan = *result.Plan
|
||||
// A seek reanchor replays the durable recipe verbatim (updated already
|
||||
// carries it); re-freezing from live inventory could only re-introduce
|
||||
// the drift this path exists to exclude. Every other replan just accepted
|
||||
// a freshly planned route and must freeze its recipe — loudly, because a
|
||||
// recipe with a silently missing subtitle identity would disable drift
|
||||
// detection for every later seek on this attempt.
|
||||
if !seekReanchor {
|
||||
updated.FrozenRecipe = artifactRecipe
|
||||
}
|
||||
updated.NormalizedRequest = start
|
||||
updated.EffectiveMediaFileID = effectiveFile.ID
|
||||
updated.ExpiresAt = time.Now().Add(playback.MaxTokenTTL)
|
||||
@@ -1314,6 +1419,133 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
|
||||
return response, updated, &transport, nil
|
||||
}
|
||||
|
||||
func frozenSeekReanchorResultV3(record *playback.AttemptRecordV3, position float64, now time.Time) (playback.PlannerResultV3, error) {
|
||||
if record == nil || !record.FrozenRecipe.ValidFor(record.CurrentPlan) {
|
||||
return playback.PlannerResultV3{}, errors.New("the active playback recipe is unavailable")
|
||||
}
|
||||
plan := record.CurrentPlan
|
||||
plan.ExpiresAt = playback.NewPlanExpiryV3(now)
|
||||
plan.Timeline = playback.TimelineV3{
|
||||
SourceStartSeconds: position,
|
||||
PlayerStartSeconds: position,
|
||||
CanSeekAnywhere: true,
|
||||
SeekRestoration: seekRestorationPlayerV3,
|
||||
}
|
||||
return record.FrozenRecipe.PlannerResult(&plan), nil
|
||||
}
|
||||
|
||||
type subtitleIndexLocationV3 struct {
|
||||
source string
|
||||
offset int
|
||||
}
|
||||
|
||||
// classifySubtitleIndexV3 maps the combined subtitle index used by
|
||||
// buildSubtitleURLs to its inventory segment and segment-local offset.
|
||||
func classifySubtitleIndexV3(file *models.MediaFile, index int) (subtitleIndexLocationV3, bool) {
|
||||
if file == nil || index < 0 {
|
||||
return subtitleIndexLocationV3{}, false
|
||||
}
|
||||
externalCount := len(file.ExternalSubtitles)
|
||||
if index < externalCount {
|
||||
return subtitleIndexLocationV3{source: playback.SubtitleSourceExternalV3, offset: index}, true
|
||||
}
|
||||
embeddedOffset := index - externalCount
|
||||
if embeddedOffset < len(file.SubtitleTracks) {
|
||||
return subtitleIndexLocationV3{source: playback.SubtitleSourceEmbeddedV3, offset: embeddedOffset}, true
|
||||
}
|
||||
return subtitleIndexLocationV3{
|
||||
source: playback.SubtitleSourceDownloadedV3,
|
||||
offset: embeddedOffset - len(file.SubtitleTracks),
|
||||
}, true
|
||||
}
|
||||
|
||||
// freezeExecutableRecipeV3 extends the pure planner freeze with the identity
|
||||
// of the selected sidecar subtitle. The combined subtitle index space
|
||||
// (externals, then embedded, then downloaded — see buildSubtitleURLs) is not
|
||||
// stable across inventory changes, so the index alone cannot anchor a durable
|
||||
// selection. A downloaded selection whose identity cannot be established is
|
||||
// an error: silently omitting it would disable drift detection for exactly
|
||||
// the seeks this recipe exists to protect.
|
||||
func (h *PlaybackHandler) freezeExecutableRecipeV3(_ context.Context, file *models.MediaFile, result playback.PlannerResultV3) (playback.ExecutableRecipeV3, error) {
|
||||
recipe := playback.FreezeExecutableRecipeV3(result)
|
||||
if file != nil {
|
||||
recipe.SourceVideoCodec = file.CodecVideo
|
||||
recipe.SourceDurationSeconds = float64(file.Duration)
|
||||
}
|
||||
if file == nil || result.SubtitleTrackIndex < 0 {
|
||||
return recipe, nil
|
||||
}
|
||||
// A downloaded row ID was selected from the planner's inventory snapshot.
|
||||
// Treat it as authoritative before consulting the mutable combined-index
|
||||
// segments: an external or embedded subtitle added after planning must not
|
||||
// make this downloaded selection look like a different source.
|
||||
if recipe.DownloadedSubtitleID > 0 {
|
||||
recipe.SubtitleSource = playback.SubtitleSourceDownloadedV3
|
||||
return recipe, nil
|
||||
}
|
||||
location, ok := classifySubtitleIndexV3(file, result.SubtitleTrackIndex)
|
||||
if !ok {
|
||||
return recipe, nil
|
||||
}
|
||||
switch location.source {
|
||||
case playback.SubtitleSourceExternalV3:
|
||||
recipe.SubtitleSource = playback.SubtitleSourceExternalV3
|
||||
recipe.ExternalSubtitlePath = file.ExternalSubtitles[location.offset].Path
|
||||
case playback.SubtitleSourceEmbeddedV3:
|
||||
recipe.SubtitleSource = playback.SubtitleSourceEmbeddedV3
|
||||
recipe.EmbeddedStreamIndex = file.SubtitleTracks[location.offset].Index
|
||||
case playback.SubtitleSourceDownloadedV3:
|
||||
if recipe.DownloadedSubtitleID <= 0 {
|
||||
return playback.ExecutableRecipeV3{}, errors.New("the selected downloaded subtitle has no stable identity")
|
||||
}
|
||||
}
|
||||
return recipe, nil
|
||||
}
|
||||
|
||||
// validateFrozenSubtitleIdentityV3 confirms the frozen combined subtitle
|
||||
// index still resolves to the identical inventory entry it was frozen
|
||||
// against. It mirrors the segment layout of buildSubtitleURLs so a change in
|
||||
// any earlier segment's size — which shifts every later index — is detected
|
||||
// as an identity mismatch rather than silently re-resolved.
|
||||
func (h *PlaybackHandler) validateFrozenSubtitleIdentityV3(ctx context.Context, file *models.MediaFile, recipe playback.ExecutableRecipeV3) error {
|
||||
if recipe.SubtitleSource == "" {
|
||||
return nil
|
||||
}
|
||||
if file == nil || recipe.SubtitleTrackIndex < 0 {
|
||||
return errors.New("the frozen subtitle selection is unavailable")
|
||||
}
|
||||
if recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 {
|
||||
if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 {
|
||||
return errors.New("the downloaded subtitle inventory is unavailable")
|
||||
}
|
||||
downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID)
|
||||
if err != nil {
|
||||
return wrapSubtitleStoreErrorV3(err)
|
||||
}
|
||||
if downloaded == nil || downloaded.MediaFileID != file.ID {
|
||||
return errors.New("the frozen downloaded subtitle identity changed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
location, ok := classifySubtitleIndexV3(file, recipe.SubtitleTrackIndex)
|
||||
if !ok || location.source != recipe.SubtitleSource {
|
||||
return errors.New("the frozen subtitle inventory segment changed")
|
||||
}
|
||||
switch recipe.SubtitleSource {
|
||||
case playback.SubtitleSourceExternalV3:
|
||||
if file.ExternalSubtitles[location.offset].Path != recipe.ExternalSubtitlePath {
|
||||
return errors.New("the frozen external subtitle identity changed")
|
||||
}
|
||||
case playback.SubtitleSourceEmbeddedV3:
|
||||
if file.SubtitleTracks[location.offset].Index != recipe.EmbeddedStreamIndex {
|
||||
return errors.New("the frozen embedded subtitle identity changed")
|
||||
}
|
||||
default:
|
||||
return errors.New("the frozen subtitle identity is unrecognized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playback.ReplanRequestV3) error {
|
||||
if record == nil {
|
||||
return errors.New("the current playback attempt is unavailable")
|
||||
@@ -1332,41 +1564,66 @@ func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playbac
|
||||
return nil
|
||||
}
|
||||
|
||||
// seekReanchorIdentityChangesV3 returns only bounded, non-secret field names.
|
||||
// It is safe for structured logs: values, URLs, headers, tokens, and subtitle
|
||||
// artifact locations are deliberately excluded.
|
||||
func seekReanchorIdentityChangesV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) []string {
|
||||
if record == nil || candidate == nil {
|
||||
return []string{"route"}
|
||||
}
|
||||
current := record.CurrentPlan
|
||||
changed := make([]string, 0, 16)
|
||||
add := func(name string, differs bool) {
|
||||
if differs {
|
||||
changed = append(changed, name)
|
||||
}
|
||||
}
|
||||
add("plan_id", candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID)
|
||||
add("requested_file_id", candidate.RequestedMediaFileID != record.RequestedMediaFileID)
|
||||
add("effective_file_id", candidate.EffectiveMediaFileID != record.EffectiveMediaFileID)
|
||||
add("delivery", candidate.Delivery != current.Delivery)
|
||||
add("engine", candidate.Engine != current.Engine)
|
||||
add("protocol", candidate.Stream.Protocol != current.Stream.Protocol)
|
||||
add("container", candidate.Stream.Container != current.Stream.Container)
|
||||
add("mime_type", candidate.Stream.MIMEType != current.Stream.MIMEType)
|
||||
add("header_refresh", candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh)
|
||||
add("video_codec", candidate.EffectiveRecipe.VideoCodec != current.EffectiveRecipe.VideoCodec)
|
||||
add("audio_codec", candidate.EffectiveRecipe.AudioCodec != current.EffectiveRecipe.AudioCodec)
|
||||
add("resolution", !optionalIntEqualV3(candidate.EffectiveRecipe.Width, current.EffectiveRecipe.Width) || !optionalIntEqualV3(candidate.EffectiveRecipe.Height, current.EffectiveRecipe.Height))
|
||||
add("frame_rate", !optionalFloatEqualV3(candidate.EffectiveRecipe.FrameRate, current.EffectiveRecipe.FrameRate))
|
||||
add("bitrate", !optionalIntEqualV3(candidate.EffectiveRecipe.BitrateKbps, current.EffectiveRecipe.BitrateKbps))
|
||||
add("dynamic_range", candidate.EffectiveRecipe.DynamicRange != current.EffectiveRecipe.DynamicRange)
|
||||
add("audio_channels", !optionalIntEqualV3(candidate.EffectiveRecipe.AudioChannels, current.EffectiveRecipe.AudioChannels) || candidate.EffectiveRecipe.AudioLayout != current.EffectiveRecipe.AudioLayout)
|
||||
add("selected_audio", !sameTrackIdentityV3(candidate.SelectedTracks.Audio, current.SelectedTracks.Audio))
|
||||
add("selected_subtitle", !sameTrackIdentityV3(candidate.SelectedTracks.Subtitle, current.SelectedTracks.Subtitle))
|
||||
add("subtitle_mode", candidate.Subtitle.Mode != current.Subtitle.Mode || candidate.Subtitle.TrackID != current.Subtitle.TrackID)
|
||||
add("subtitle_artifact_route", !sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact))
|
||||
add("subtitle_fidelity", candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy)
|
||||
add("transformations", !sameTransformationsV3(candidate.Transformations, current.Transformations))
|
||||
add("quirks", !sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks))
|
||||
add("runtime_corrections", !sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections))
|
||||
add("claims", candidate.Claims != current.Claims)
|
||||
return changed
|
||||
}
|
||||
|
||||
func validateSeekReanchorPlanV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) error {
|
||||
if record == nil || candidate == nil {
|
||||
return errors.New("seek reanchor produced no playback route")
|
||||
}
|
||||
current := record.CurrentPlan
|
||||
if candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID {
|
||||
changedFields := seekReanchorIdentityChangesV3(record, candidate)
|
||||
if len(changedFields) == 0 {
|
||||
return nil
|
||||
}
|
||||
if containsStringExactV3(changedFields, "plan_id") {
|
||||
return errors.New("seek reanchor changed the playback plan identity")
|
||||
}
|
||||
if candidate.RequestedMediaFileID != record.RequestedMediaFileID || candidate.EffectiveMediaFileID != record.EffectiveMediaFileID {
|
||||
if containsStringExactV3(changedFields, "requested_file_id") || containsStringExactV3(changedFields, "effective_file_id") {
|
||||
return errors.New("seek reanchor changed the selected media version")
|
||||
}
|
||||
if !sameSelectedTracksV3(candidate.SelectedTracks, current.SelectedTracks) {
|
||||
if containsStringExactV3(changedFields, "selected_audio") || containsStringExactV3(changedFields, "selected_subtitle") {
|
||||
return errors.New("seek reanchor changed selected tracks")
|
||||
}
|
||||
if candidate.Engine != current.Engine ||
|
||||
candidate.Stream.MIMEType != current.Stream.MIMEType ||
|
||||
candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh ||
|
||||
!sameEffectiveRecipeV3(candidate.EffectiveRecipe, current.EffectiveRecipe) ||
|
||||
candidate.Claims != current.Claims ||
|
||||
candidate.Subtitle.Mode != current.Subtitle.Mode ||
|
||||
candidate.Subtitle.TrackID != current.Subtitle.TrackID ||
|
||||
!sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact) ||
|
||||
candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy ||
|
||||
!sameTransformationsV3(candidate.Transformations, current.Transformations) ||
|
||||
!sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks) ||
|
||||
!sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections) {
|
||||
return errors.New("seek reanchor changed the playback route semantics")
|
||||
}
|
||||
generation := record.NormalizedRequest.OutputRouteGeneration
|
||||
currentKey := playback.PlanAttemptKeyV3(current, generation, nil)
|
||||
candidateKey := playback.PlanAttemptKeyV3(*candidate, generation, nil)
|
||||
if candidateKey != currentKey {
|
||||
return errors.New("seek reanchor changed the playback route recipe")
|
||||
}
|
||||
return nil
|
||||
return errors.New("seek reanchor changed the playback route semantics")
|
||||
}
|
||||
|
||||
func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool {
|
||||
@@ -1378,18 +1635,6 @@ func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool
|
||||
return left.MIMEType == right.MIMEType && left.Format == right.Format
|
||||
}
|
||||
|
||||
func sameEffectiveRecipeV3(left, right playback.EffectiveRecipeV3) bool {
|
||||
return left.VideoCodec == right.VideoCodec &&
|
||||
left.AudioCodec == right.AudioCodec &&
|
||||
optionalIntEqualV3(left.Width, right.Width) &&
|
||||
optionalIntEqualV3(left.Height, right.Height) &&
|
||||
optionalFloatEqualV3(left.FrameRate, right.FrameRate) &&
|
||||
optionalIntEqualV3(left.BitrateKbps, right.BitrateKbps) &&
|
||||
left.DynamicRange == right.DynamicRange &&
|
||||
optionalIntEqualV3(left.AudioChannels, right.AudioChannels) &&
|
||||
left.AudioLayout == right.AudioLayout
|
||||
}
|
||||
|
||||
func sameTransformationsV3(left, right []playback.TransformationV3) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
@@ -1934,7 +2179,7 @@ func subtitleMIMEV3(format string) string {
|
||||
case "pgs", "hdmv_pgs_subtitle":
|
||||
return "application/octet-stream"
|
||||
default:
|
||||
return "text/vtt"
|
||||
return subtitleMIMEVTTV3
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2021,13 +2266,18 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
|
||||
seek := alignedSeekSeconds(requested, segmentDuration, videoCodec)
|
||||
startSegment := computeStartSegment(seek, segmentDuration)
|
||||
plan.Timeline.SourceStartSeconds = requested
|
||||
if strings.EqualFold(videoCodec, "copy") {
|
||||
plan.Timeline.PlayerStartSeconds = 0
|
||||
usesGrowingManifest := strings.EqualFold(videoCodec, "copy") ||
|
||||
!playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
|
||||
if usesGrowingManifest {
|
||||
// Encoded streams seek to the preceding segment boundary. Preserve the
|
||||
// requested sub-segment offset so playback still begins at the exact
|
||||
// requested source position. Copy seeks are already exact, making this 0.
|
||||
plan.Timeline.PlayerStartSeconds = max(0, requested-seek)
|
||||
plan.Timeline.StreamOriginSeconds = seek
|
||||
plan.Timeline.TimelineOffsetSeconds = seek
|
||||
windowStart := seek
|
||||
plan.Timeline.SeekWindowStartSeconds = &windowStart
|
||||
// A copy remux is served from FFmpeg's live, still-growing playlist
|
||||
// This transport is served from FFmpeg's live, still-growing playlist
|
||||
// (see BuildPlaybackManifest), so the seekable extent is whatever has
|
||||
// been produced so far — a value this plan cannot know and could not
|
||||
// keep current if it did. Publishing the media runtime here instead
|
||||
@@ -2049,7 +2299,7 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
|
||||
plan.Timeline.SeekWindowStartSeconds = nil
|
||||
plan.Timeline.SeekWindowEndSeconds = nil
|
||||
plan.Timeline.CanSeekAnywhere = durationSeconds > 0
|
||||
plan.Timeline.SeekRestoration = "player_position"
|
||||
plan.Timeline.SeekRestoration = seekRestorationPlayerV3
|
||||
}
|
||||
return seek, startSegment
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -16,6 +18,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||||
)
|
||||
|
||||
@@ -605,6 +608,274 @@ func TestHandleReplanPlaybackV3SeekReanchorKeepsCurrentRecipeEligible(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplanPlaybackV3SeekReanchorIgnoresRefreshedProbeMetadata(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo"})
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
selectedAudio := 1
|
||||
startRequest.AudioTrackIndex = &selectedAudio
|
||||
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
|
||||
startRR := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRR, startReq)
|
||||
if startRR.Code != http.StatusCreated {
|
||||
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
|
||||
}
|
||||
var started playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if started.PlaybackPlan == nil || started.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 {
|
||||
t.Fatalf("start plan = %#v", started.PlaybackPlan)
|
||||
}
|
||||
|
||||
// Simulate a refreshed probe changing a live planner input after the route
|
||||
// was durably accepted. A seek is not authority to consume that drift.
|
||||
file.Container = "mkv"
|
||||
file.AudioTracks = file.AudioTracks[:1]
|
||||
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
|
||||
reanchor := playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: "seek-reanchor-probe-drift-0001", FailedPlanID: started.PlaybackPlan.PlanID,
|
||||
PlanAttemptID: "plan-attempt-seek-probe-0001", PlanAttemptKey: currentKey,
|
||||
AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 321,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: started.PlaybackPlan.SelectedTracks,
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
}
|
||||
body, err := json.Marshal(reanchor)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
|
||||
req = withPlaybackRouteParam(req, "session_id", started.SessionID)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReplanPlaybackV3(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("reanchor status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var response playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.PlaybackPlan == nil || response.PlaybackPlan.PlanID != started.PlaybackPlan.PlanID ||
|
||||
response.PlaybackPlan.Delivery != started.PlaybackPlan.Delivery ||
|
||||
response.PlaybackPlan.Timeline.SourceStartSeconds != 321 {
|
||||
t.Fatalf("reanchored plan = %#v, terminal = %#v", response.PlaybackPlan, response.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartPlaybackV3FreezesDownloadedSubtitleFromPlanningSnapshot(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
first := subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}
|
||||
reordered := subtitles.DownloadedSubtitle{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}
|
||||
repo.listResults = [][]subtitles.DownloadedSubtitle{{first}, {reordered, first}}
|
||||
repo.subtitles[first.ID] = &first
|
||||
repo.subtitles[reordered.ID] = &reordered
|
||||
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
handler.SubtitleRepo = repo
|
||||
request := v3HandlerStartRequest()
|
||||
downloadedIndex := 0
|
||||
request.SubtitleTrackIndex = &downloadedIndex
|
||||
request.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", downloadedIndex)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, request))).WithContext(newAuthorizedPlaybackContext())
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var response playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.PlaybackPlan == nil || response.PlaybackPlan.Subtitle.Artifact == nil ||
|
||||
!strings.Contains(response.PlaybackPlan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") {
|
||||
t.Fatalf("playback plan = %#v, want downloaded subtitle 71", response.PlaybackPlan)
|
||||
}
|
||||
if repo.listCalls != 1 {
|
||||
t.Fatalf("downloaded inventory listed %d times, want one planning snapshot", repo.listCalls)
|
||||
}
|
||||
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if record.FrozenRecipe.DownloadedSubtitleID != 71 {
|
||||
t.Fatalf("frozen downloaded subtitle = %d, want 71", record.FrozenRecipe.DownloadedSubtitleID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplanPlaybackV3SeekReanchorPreservesFallbackRecipe(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
|
||||
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
|
||||
{Name: "audio_to_aac", RecipeVersion: "1", Available: true},
|
||||
{Name: "video_to_h264", RecipeVersion: "1", Available: true},
|
||||
{Name: "server_dv7_to_hdr10", RecipeVersion: "1", Available: true},
|
||||
}))
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true}
|
||||
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3HLSV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true}
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
|
||||
startRR := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRR, startReq)
|
||||
if startRR.Code != http.StatusCreated {
|
||||
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
|
||||
}
|
||||
var active playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(startRR.Body.Bytes(), &active); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if active.PlaybackPlan == nil || active.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 {
|
||||
t.Fatalf("initial plan = %#v", active.PlaybackPlan)
|
||||
}
|
||||
|
||||
attempted := []string{}
|
||||
wantFallbacks := []playback.DeliveryV3{playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3}
|
||||
for index, classification := range []string{"playback_error", "decoder_error"} {
|
||||
currentKey := playback.PlanAttemptKeyV3(*active.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
|
||||
attempted = append(attempted, currentKey)
|
||||
response := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: fmt.Sprintf("fallback-replan-%04d", index+1),
|
||||
FailedPlanID: active.PlaybackPlan.PlanID,
|
||||
PlanAttemptID: fmt.Sprintf("fallback-plan-attempt-%04d", index+1),
|
||||
PlanAttemptKey: currentKey, AttemptedPlanKeys: append([]string(nil), attempted...),
|
||||
AttemptCount: index + 1, QualityPreference: startRequest.QualityPreference,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: active.PlaybackPlan.SelectedTracks,
|
||||
Failure: playback.FailureV3{Classification: classification},
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
})
|
||||
if response.PlaybackPlan == nil {
|
||||
t.Fatalf("fallback %d = %#v", index, response)
|
||||
}
|
||||
if response.PlaybackPlan.Delivery != wantFallbacks[index] {
|
||||
t.Fatalf("fallback %d delivery = %q, want %q; response=%#v", index, response.PlaybackPlan.Delivery, wantFallbacks[index], response)
|
||||
}
|
||||
active = response
|
||||
}
|
||||
if active.PlaybackPlan.Delivery != playback.DeliveryRemuxHLSV3 {
|
||||
t.Fatalf("fallback route = %#v", active.PlaybackPlan)
|
||||
}
|
||||
frozen := *active.PlaybackPlan
|
||||
currentKey := playback.PlanAttemptKeyV3(frozen, startRequest.OutputRouteGeneration, nil)
|
||||
reanchored := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: "fallback-seek-reanchor-0001", FailedPlanID: frozen.PlanID,
|
||||
PlanAttemptID: "fallback-seek-plan-0001", PlanAttemptKey: currentKey,
|
||||
AttemptCount: 3, QualityPreference: startRequest.QualityPreference, PositionSeconds: 819.185,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: frozen.SelectedTracks,
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
})
|
||||
if reanchored.PlaybackPlan == nil || reanchored.PlaybackPlan.PlanID != frozen.PlanID ||
|
||||
reanchored.PlaybackPlan.Delivery != frozen.Delivery || reanchored.PlaybackPlan.Engine != frozen.Engine ||
|
||||
reanchored.PlaybackPlan.Timeline.SourceStartSeconds != 819.185 {
|
||||
t.Fatalf("reanchored fallback = %#v, terminal = %#v", reanchored.PlaybackPlan, reanchored.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
// A pre-migration attempt row carries frozen_recipe = '{}', which decodes to
|
||||
// an invalid zero recipe. A seek reanchor against it must fail with the
|
||||
// dedicated retryable reason — telling the client to mint a fresh playback
|
||||
// attempt — while leaving the durable attempt fully usable for subsequent
|
||||
// failure replans.
|
||||
func TestHandleReplanPlaybackV3SeekReanchorWithoutFrozenRecipeFailsRetryably(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
|
||||
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
|
||||
handler.ItemAccess = allowAllPlaybackItemAccess{}
|
||||
startRequest := v3HandlerStartRequest()
|
||||
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}
|
||||
|
||||
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
|
||||
startRR := httptest.NewRecorder()
|
||||
handler.HandleStartPlayback(startRR, startReq)
|
||||
if startRR.Code != http.StatusCreated {
|
||||
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
|
||||
}
|
||||
var started playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if started.PlaybackPlan == nil {
|
||||
t.Fatalf("start response = %s", startRR.Body.String())
|
||||
}
|
||||
|
||||
// Simulate the row predating the frozen_recipe migration: the JSONB
|
||||
// default '{}' unmarshals to the zero recipe.
|
||||
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.FrozenRecipe = playback.ExecutableRecipeV3{}
|
||||
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
|
||||
|
||||
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
|
||||
seekResponse := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: "legacy-seek-reanchor-0001", FailedPlanID: started.PlaybackPlan.PlanID,
|
||||
PlanAttemptID: "legacy-seek-plan-0001", PlanAttemptKey: currentKey,
|
||||
AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: started.PlaybackPlan.SelectedTracks,
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
})
|
||||
if seekResponse.Terminal == nil || seekResponse.Terminal.Reason != "seek_reanchor_recipe_unavailable" || !seekResponse.Terminal.Retryable {
|
||||
t.Fatalf("legacy seek reanchor = %#v, terminal = %#v", seekResponse.PlaybackPlan, seekResponse.Terminal)
|
||||
}
|
||||
|
||||
// The failed seek must not have consumed the durable attempt: the current
|
||||
// plan is unchanged and an ordinary failure replan still succeeds.
|
||||
after, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.CurrentPlanID != started.PlaybackPlan.PlanID {
|
||||
t.Fatalf("failed legacy seek moved the current plan: %q -> %q", started.PlaybackPlan.PlanID, after.CurrentPlanID)
|
||||
}
|
||||
recovery := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
|
||||
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3,
|
||||
PlaybackAttemptID: startRequest.PlaybackAttemptID,
|
||||
ReplanRequestID: "legacy-recovery-0001", FailedPlanID: started.PlaybackPlan.PlanID,
|
||||
PlanAttemptID: "legacy-recovery-plan-0001", PlanAttemptKey: currentKey,
|
||||
AttemptedPlanKeys: []string{currentKey},
|
||||
AttemptCount: 2, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456,
|
||||
OutputRouteGeneration: startRequest.OutputRouteGeneration,
|
||||
SelectedTracks: started.PlaybackPlan.SelectedTracks,
|
||||
Failure: playback.FailureV3{Classification: "playback_error"},
|
||||
Capabilities: startRequest.Capabilities,
|
||||
ClientPlaybackContext: startRequest.ClientPlaybackContext,
|
||||
})
|
||||
if recovery.PlaybackPlan == nil {
|
||||
t.Fatalf("failure replan after legacy seek = %#v, terminal = %#v", recovery.PlaybackPlan, recovery.Terminal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *testing.T) {
|
||||
// This test has never passed. It fails at 854d07cf, the commit that
|
||||
// introduced it, so it describes behavior that was specified and not
|
||||
@@ -828,6 +1099,7 @@ func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGon
|
||||
record.CurrentPlan,
|
||||
)
|
||||
record.CurrentPlanID = record.CurrentPlan.PlanID
|
||||
record.FrozenRecipe.PlanID = record.CurrentPlan.PlanID
|
||||
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
|
||||
|
||||
currentKey := playback.PlanAttemptKeyV3(record.CurrentPlan, record.NormalizedRequest.OutputRouteGeneration, nil)
|
||||
@@ -1061,6 +1333,283 @@ func TestValidateSeekReanchorPlanV3RejectsRouteDrift(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeekReanchorIdentityChangesV3ReportsOnlyChangedFieldNames(t *testing.T) {
|
||||
current := playback.PlanV3{
|
||||
PlanID: "plan:current", Delivery: playback.DeliveryOriginalHTTPV3,
|
||||
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4"},
|
||||
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac"},
|
||||
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
|
||||
}
|
||||
record := &playback.AttemptRecordV3{
|
||||
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
|
||||
CurrentPlanID: current.PlanID, CurrentPlan: current,
|
||||
}
|
||||
candidate := current
|
||||
candidate.PlanID = "plan:candidate"
|
||||
candidate.Delivery = playback.DeliveryRemuxHLSV3
|
||||
candidate.Stream.Container = "hls"
|
||||
candidate.EffectiveRecipe.AudioCodec = "ac3"
|
||||
got := strings.Join(seekReanchorIdentityChangesV3(record, &candidate), ",")
|
||||
if want := "plan_id,delivery,container,audio_codec"; got != want {
|
||||
t.Fatalf("changed fields = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrozenSeekReanchorResultV3PreservesRouteMatrix(t *testing.T) {
|
||||
audioIndex := 0
|
||||
basePlan := func(name string) playback.PlanV3 {
|
||||
return playback.PlanV3{
|
||||
ProtocolVersion: playback.ProtocolV3, PlanID: "plan:" + name,
|
||||
Delivery: playback.DeliveryOriginalHTTPV3, Engine: playback.EngineMedia3DirectV3,
|
||||
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", HeaderRefresh: playback.HeaderRefreshSessionV3},
|
||||
SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", audioIndex), Index: &audioIndex}},
|
||||
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac", DynamicRange: "sdr"},
|
||||
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleOffV3},
|
||||
Transformations: []playback.TransformationV3{}, AppliedQuirks: []playback.AppliedQuirkV3{}, RuntimeCorrections: []string{},
|
||||
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
|
||||
}
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*playback.PlanV3, *playback.PlannerResultV3)
|
||||
}{
|
||||
{name: "direct"},
|
||||
{name: "progressive remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
plan.Delivery = playback.DeliveryRemuxProgressiveV3
|
||||
plan.Engine = playback.EngineMedia3ProgressiveRemuxV3
|
||||
result.PlayMethod = playback.PlayRemux
|
||||
}},
|
||||
{name: "HLS remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
plan.Delivery = playback.DeliveryRemuxHLSV3
|
||||
plan.Engine = playback.EngineMedia3HLSV3
|
||||
plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3}
|
||||
result.PlayMethod = playback.PlayRemux
|
||||
result.TargetVideoCodec = "copy"
|
||||
result.TargetAudioCodec = "copy"
|
||||
}},
|
||||
{name: "audio converting remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
plan.Delivery = playback.DeliveryRemuxProgressiveV3
|
||||
plan.Engine = playback.EngineMedia3ProgressiveRemuxV3
|
||||
plan.Transformations = []playback.TransformationV3{{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}}
|
||||
result.PlayMethod = playback.PlayRemux
|
||||
result.TranscodeAudio = true
|
||||
result.TargetAudioCodec = "aac"
|
||||
result.TargetAudioChannels = 2
|
||||
}},
|
||||
{name: "downloaded subtitle", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
subtitleIndex := 7
|
||||
plan.SelectedTracks.Subtitle = &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "subtitle", subtitleIndex), Index: &subtitleIndex}
|
||||
plan.Subtitle = playback.SubtitleDecisionV3{Mode: playback.SubtitleConvertV3, TrackID: plan.SelectedTracks.Subtitle.ID, Artifact: &playback.SubtitleArtifactV3{MIMEType: "text/vtt", Format: "vtt"}}
|
||||
result.SubtitleTrackIndex = subtitleIndex
|
||||
result.SubtitleTransportTrackIndex = subtitleIndex
|
||||
result.SubtitleCodec = "srt"
|
||||
result.DownloadedSubtitleID = 71
|
||||
}},
|
||||
{name: "Dolby Vision transformation", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) {
|
||||
plan.EffectiveRecipe.DynamicRange = "hdr10"
|
||||
plan.Transformations = []playback.TransformationV3{{Name: "server_dv7_to_hdr10", Executor: "server", RecipeVersion: "1"}}
|
||||
}},
|
||||
{name: "pooled node only transformation", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
|
||||
plan.Delivery = playback.DeliveryTranscodeHLSV3
|
||||
plan.Engine = playback.EngineMedia3HLSV3
|
||||
plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3}
|
||||
plan.Transformations = []playback.TransformationV3{{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"}}
|
||||
result.PlayMethod = playback.PlayTranscode
|
||||
result.TargetVideoCodec = "h264"
|
||||
result.TargetAudioCodec = "aac"
|
||||
result.TargetResolution = "1080p"
|
||||
}},
|
||||
{name: "device quirks and runtime corrections", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) {
|
||||
plan.AppliedQuirks = []playback.AppliedQuirkV3{{ID: "apple-hls-audio", RegistryRevision: "3", Action: "force-aac"}}
|
||||
plan.RuntimeCorrections = []string{"pcm_fallback"}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
plan := basePlan(strings.ReplaceAll(test.name, " ", "-"))
|
||||
operational := playback.PlannerResultV3{Plan: &plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
|
||||
if test.mutate != nil {
|
||||
test.mutate(&plan, &operational)
|
||||
}
|
||||
operational.Plan = &plan
|
||||
record := &playback.AttemptRecordV3{
|
||||
PlaybackAttemptID: "attempt-matrix-0001", RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
|
||||
CurrentPlanID: plan.PlanID, CurrentPlan: plan, FrozenRecipe: playback.FreezeExecutableRecipeV3(operational),
|
||||
}
|
||||
result, err := frozenSeekReanchorResultV3(record, 321.25, time.Unix(1_786_000_000, 0))
|
||||
if err != nil || result.Plan == nil {
|
||||
t.Fatalf("frozen reanchor: result=%#v err=%v", result, err)
|
||||
}
|
||||
if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil {
|
||||
t.Fatalf("route semantics changed: %v, plan=%#v", err, result.Plan)
|
||||
}
|
||||
if result.Plan.Timeline.SourceStartSeconds != 321.25 || result.PlayMethod != operational.PlayMethod ||
|
||||
result.TranscodeAudio != operational.TranscodeAudio || result.TargetVideoCodec != operational.TargetVideoCodec ||
|
||||
result.TargetAudioCodec != operational.TargetAudioCodec || result.TargetAudioChannels != operational.TargetAudioChannels ||
|
||||
result.TargetResolution != operational.TargetResolution || result.SubtitleTrackIndex != operational.SubtitleTrackIndex ||
|
||||
result.SubtitleTransportTrackIndex != operational.SubtitleTransportTrackIndex || result.SubtitleBurnIn != operational.SubtitleBurnIn ||
|
||||
result.SubtitleCodec != operational.SubtitleCodec || result.DownloadedSubtitleID != operational.DownloadedSubtitleID {
|
||||
t.Fatalf("operational recipe changed: got=%#v want=%#v", result, operational)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrozenDownloadedSubtitleV3AcceptsInventoryReordering(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.ExternalSubtitles = []models.ExternalSubtitle{{Format: "srt"}}
|
||||
file.SubtitleTracks = []models.SubtitleTrack{{Index: 0, Codec: "ass"}}
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.SubtitleRepo = repo
|
||||
downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
|
||||
plan := &playback.PlanV3{PlanID: "plan:downloaded", SelectedTracks: playback.SelectedTracksV3{Subtitle: &playback.TrackIdentityV3{ID: playback.TrackIDV3(file.ID, "subtitle", downloadedIndex), Index: &downloadedIndex}}}
|
||||
result := playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex, DownloadedSubtitleID: 71}
|
||||
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result)
|
||||
if err != nil {
|
||||
t.Fatalf("freeze: %v", err)
|
||||
}
|
||||
if recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 {
|
||||
t.Fatalf("frozen subtitle identity = %q/%d, want downloaded/71", recipe.SubtitleSource, recipe.DownloadedSubtitleID)
|
||||
}
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil {
|
||||
t.Fatalf("stable inventory rejected: %v", err)
|
||||
}
|
||||
repo.list = []subtitles.DownloadedSubtitle{{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}, {ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}}
|
||||
repo.listErr = errors.New("mutable inventory must not be consulted")
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil {
|
||||
t.Fatalf("reordered downloaded subtitle inventory rejected stable identity: %v", err)
|
||||
}
|
||||
repo.getErr = errors.New("database unavailable")
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); !errors.Is(err, errSubtitleStoreUnavailableV3) {
|
||||
t.Fatalf("validation error = %v, want wrapped subtitle-store failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachSubtitleArtifactV3UsesFrozenDownloadedIdentityWithoutOrdinalLookup(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.ExternalSubtitles = nil
|
||||
file.SubtitleTracks = nil
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[71] = &subtitles.DownloadedSubtitle{
|
||||
ID: 71, MediaFileID: file.ID, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt",
|
||||
}
|
||||
// A second ordinal lookup would either fail or select a different row.
|
||||
// Artifact attachment must use GetDownloadedSubtitle(71) exclusively.
|
||||
repo.listErr = errors.New("mutable inventory must not be consulted")
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.SubtitleRepo = repo
|
||||
selectedIndex := 0
|
||||
plan := &playback.PlanV3{
|
||||
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3},
|
||||
Timeline: playback.TimelineV3{StreamOriginSeconds: 321},
|
||||
}
|
||||
recipe := playback.ExecutableRecipeV3{
|
||||
SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71,
|
||||
SubtitleTrackIndex: selectedIndex, SubtitleCodec: "vtt",
|
||||
}
|
||||
if err := handler.attachSubtitleArtifactV3(context.Background(), "session-frozen-subtitle", file, plan, selectedIndex, &recipe); err != nil {
|
||||
t.Fatalf("attach frozen downloaded subtitle: %v", err)
|
||||
}
|
||||
if plan.Subtitle.Artifact == nil || !strings.Contains(plan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") {
|
||||
t.Fatalf("artifact = %#v, want frozen downloaded subtitle 71", plan.Subtitle.Artifact)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubtitleArtifactStoreFailuresAreRetryable(t *testing.T) {
|
||||
storeErr := errors.New("database unavailable")
|
||||
wantRetryable := subtitleArtifactErrorV3("subtitle lookup failed", wrapSubtitleStoreErrorV3(storeErr))
|
||||
if !wantRetryable.retryable || !errors.Is(wantRetryable.cause, errSubtitleStoreUnavailableV3) {
|
||||
t.Fatalf("store failure = %#v, want retryable subtitle error", wantRetryable)
|
||||
}
|
||||
wantPermanent := subtitleArtifactErrorV3("subtitle identity changed", errors.New("identity changed"))
|
||||
if wantPermanent.retryable {
|
||||
t.Fatalf("identity failure = %#v, want non-retryable subtitle error", wantPermanent)
|
||||
}
|
||||
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.ExternalSubtitles = nil
|
||||
file.SubtitleTracks = nil
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.getErr = storeErr
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.SubtitleRepo = repo
|
||||
plan := &playback.PlanV3{
|
||||
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3},
|
||||
}
|
||||
recipe := playback.ExecutableRecipeV3{
|
||||
SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71,
|
||||
SubtitleTrackIndex: 0, SubtitleCodec: "vtt",
|
||||
}
|
||||
err := handler.attachSubtitleArtifactV3(context.Background(), "session-store-error", file, plan, 0, &recipe)
|
||||
if !errors.Is(err, errSubtitleStoreUnavailableV3) {
|
||||
t.Fatalf("attach error = %v, want wrapped subtitle-store failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreezeExecutableRecipeV3FailsLoudlyWhenDownloadedIdentityUnavailable(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
|
||||
result := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:downloaded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex}
|
||||
if _, err := handler.freezeExecutableRecipeV3(context.Background(), file, result); err == nil {
|
||||
t.Fatal("downloaded subtitle without a planning-snapshot identity was frozen")
|
||||
}
|
||||
result.DownloadedSubtitleID = 71
|
||||
file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/added-after-planning.srt", Format: "srt"}}
|
||||
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result)
|
||||
if err != nil || recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 {
|
||||
t.Fatalf("freeze planning snapshot identity: recipe=%#v err=%v", recipe, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrozenSubtitleIdentityV3RejectsExternalAndEmbeddedInventoryDrift(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/en.srt", Format: "srt"}, {Path: "/subs/de.srt", Format: "srt"}}
|
||||
file.SubtitleTracks = []models.SubtitleTrack{{Index: 3, Codec: "ass"}}
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
|
||||
externalIndex := 1 // /subs/de.srt
|
||||
externalResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:external"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: externalIndex, SubtitleTransportTrackIndex: externalIndex}
|
||||
externalRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, externalResult)
|
||||
if err != nil {
|
||||
t.Fatalf("freeze external: %v", err)
|
||||
}
|
||||
if externalRecipe.SubtitleSource != playback.SubtitleSourceExternalV3 || externalRecipe.ExternalSubtitlePath != "/subs/de.srt" {
|
||||
t.Fatalf("frozen external identity = %q/%q", externalRecipe.SubtitleSource, externalRecipe.ExternalSubtitlePath)
|
||||
}
|
||||
|
||||
embeddedIndex := 2 // combined index of SubtitleTracks[0]
|
||||
embeddedResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:embedded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: embeddedIndex, SubtitleTransportTrackIndex: 3}
|
||||
embeddedRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, embeddedResult)
|
||||
if err != nil {
|
||||
t.Fatalf("freeze embedded: %v", err)
|
||||
}
|
||||
if embeddedRecipe.SubtitleSource != playback.SubtitleSourceEmbeddedV3 || embeddedRecipe.EmbeddedStreamIndex != 3 {
|
||||
t.Fatalf("frozen embedded identity = %q/%d", embeddedRecipe.SubtitleSource, embeddedRecipe.EmbeddedStreamIndex)
|
||||
}
|
||||
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err != nil {
|
||||
t.Fatalf("stable external inventory rejected: %v", err)
|
||||
}
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err != nil {
|
||||
t.Fatalf("stable embedded inventory rejected: %v", err)
|
||||
}
|
||||
|
||||
// Deleting the first external subtitle shifts every later combined index.
|
||||
// The frozen external selection now points past the external segment and
|
||||
// the frozen embedded selection resolves one entry early; both must be
|
||||
// rejected rather than silently re-resolved to a different artifact.
|
||||
file.ExternalSubtitles = file.ExternalSubtitles[1:]
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err == nil {
|
||||
t.Fatal("external subtitle deletion was accepted for the frozen external selection")
|
||||
}
|
||||
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err == nil {
|
||||
t.Fatal("external subtitle deletion was accepted for the frozen embedded selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) {
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.JWTSecret = "test-secret"
|
||||
@@ -1174,6 +1723,54 @@ func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareTransportV3UsesFrozenSourceMetadataAfterProbeDrift(t *testing.T) {
|
||||
var startRequest transcodenode.TranscodeStartRequest
|
||||
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities":
|
||||
writeJSON(w, http.StatusOK, playback.HWAccelInfo{})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/transcode/start":
|
||||
if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil {
|
||||
t.Errorf("decode remote start: %v", err)
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"})
|
||||
case r.Method == http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer remote.Close()
|
||||
|
||||
file := v3HandlerFixtureFile(t)
|
||||
file.CodecVideo = "hevc"
|
||||
file.Duration = 7_201
|
||||
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
|
||||
handler.JWTSecret = "test-secret"
|
||||
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}}
|
||||
plan := &playback.PlanV3{PlanID: "plan:frozen-source", Delivery: playback.DeliveryRemuxHLSV3}
|
||||
initial := playback.PlannerResultV3{
|
||||
Plan: plan, PlayMethod: playback.PlayRemux, TargetVideoCodec: "copy", TargetAudioCodec: "copy",
|
||||
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
|
||||
}
|
||||
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, initial)
|
||||
if err != nil {
|
||||
t.Fatalf("freeze executable recipe: %v", err)
|
||||
}
|
||||
file.CodecVideo = "mpeg2video"
|
||||
file.Duration = 99
|
||||
result := recipe.PlannerResult(plan)
|
||||
request := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
transport, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-frozen-source", UserID: 7, ProfileID: "profile-1"}, file, result)
|
||||
if transportErr != nil {
|
||||
t.Fatalf("prepare remote transport: %v", transportErr)
|
||||
}
|
||||
defer transport.rollback()
|
||||
if startRequest.SourceVideoCodec != "hevc" || startRequest.TotalDuration != 7_201 {
|
||||
t.Fatalf("remote start consumed refreshed probe metadata: %#v", startRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStartPlaybackUnknownProtocolUsesLegacyBranch(t *testing.T) {
|
||||
file := v3HandlerFixtureFile(t)
|
||||
manager := playback.NewSessionManager(0, 0)
|
||||
@@ -1232,10 +1829,23 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
|
||||
encodePlan.Timeline.SeekRestoration != "player_position" {
|
||||
t.Fatalf("encode timeline=%#v seek=%v segment=%d", encodePlan.Timeline, encodeSeek, encodeSegment)
|
||||
}
|
||||
|
||||
longEncodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
|
||||
longEncodeSeek, longEncodeSegment := configureHLSTimelineV3(longEncodePlan, "h264", 2, 1_000_000)
|
||||
if longEncodeSeek != 16 || longEncodeSegment != 8 || longEncodePlan.Timeline.StreamOriginSeconds != 16 || longEncodePlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(longEncodePlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || longEncodePlan.Timeline.CanSeekAnywhere ||
|
||||
longEncodePlan.Timeline.SeekWindowStartSeconds == nil || *longEncodePlan.Timeline.SeekWindowStartSeconds != 16 ||
|
||||
longEncodePlan.Timeline.SeekWindowEndSeconds != nil ||
|
||||
longEncodePlan.Timeline.SeekRestoration != "source_position" {
|
||||
t.Fatalf("long encode timeline=%#v seek=%v segment=%d", longEncodePlan.Timeline, longEncodeSeek, longEncodeSegment)
|
||||
}
|
||||
|
||||
unknownDurationPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
|
||||
configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
|
||||
if unknownDurationPlan.Timeline.CanSeekAnywhere {
|
||||
t.Fatalf("unknown-duration timeline = %#v", unknownDurationPlan.Timeline)
|
||||
unknownDurationSeek, unknownDurationSegment := configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
|
||||
if unknownDurationSeek != 16 || unknownDurationSegment != 8 || unknownDurationPlan.Timeline.StreamOriginSeconds != 16 || unknownDurationPlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(unknownDurationPlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || unknownDurationPlan.Timeline.CanSeekAnywhere ||
|
||||
unknownDurationPlan.Timeline.SeekWindowStartSeconds == nil || *unknownDurationPlan.Timeline.SeekWindowStartSeconds != 16 ||
|
||||
unknownDurationPlan.Timeline.SeekWindowEndSeconds != nil ||
|
||||
unknownDurationPlan.Timeline.SeekRestoration != "source_position" {
|
||||
t.Fatalf("unknown-duration timeline=%#v seek=%v segment=%d", unknownDurationPlan.Timeline, unknownDurationSeek, unknownDurationSegment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1360,3 +1970,23 @@ func marshalV3StartRequest(t *testing.T, request playback.StartRequestV3) string
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func postPlaybackReplanV3(t *testing.T, handler *PlaybackHandler, sessionID string, request playback.ReplanRequestV3) playback.DecisionResponseV3 {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+sessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
|
||||
req = withPlaybackRouteParam(req, "session_id", sessionID)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleReplanPlaybackV3(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("replan status = %d, body = %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var response playback.DecisionResponseV3
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
@@ -201,69 +201,10 @@ type pluginPresentationJSON struct {
|
||||
LicenseSPDX string `json:"license_spdx"`
|
||||
}
|
||||
|
||||
type pluginConfigSchemaJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
JSONSchema string `json:"json_schema"`
|
||||
Required bool `json:"required"`
|
||||
AdminForm *pluginAdminFormJSON `json:"admin_form,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormJSON struct {
|
||||
Fields []pluginAdminFormFieldJSON `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormFieldJSON struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormOptionJSON struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormConditionJSON struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type pluginAdminFormValidationJSON struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type pluginAdminFormSectionJSON struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
|
||||
}
|
||||
type pluginConfigSchemaJSON = plugins.ConfigSchemaView
|
||||
type pluginAdminFormJSON = plugins.AdminFormView
|
||||
type pluginAdminFormFieldJSON = plugins.AdminFormFieldView
|
||||
type pluginAdminFormSectionJSON = plugins.AdminFormSectionView
|
||||
|
||||
type pluginCapabilityJSON struct {
|
||||
Type string `json:"type"`
|
||||
@@ -1567,117 +1508,11 @@ func toUserPluginSettingsSummary(
|
||||
}
|
||||
|
||||
func configSchemasToJSON(schemas []*pluginv1.ConfigSchema) []pluginConfigSchemaJSON {
|
||||
response := make([]pluginConfigSchemaJSON, 0, len(schemas))
|
||||
for _, schema := range schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
response = append(response, pluginConfigSchemaJSON{
|
||||
Key: schema.GetKey(),
|
||||
Title: schema.GetTitle(),
|
||||
Description: schema.GetDescription(),
|
||||
JSONSchema: schema.GetJsonSchema(),
|
||||
Required: schema.GetRequired(),
|
||||
AdminForm: adminFormToJSON(schema.GetAdminForm()),
|
||||
})
|
||||
}
|
||||
return response
|
||||
return plugins.ConfigSchemaViews(schemas)
|
||||
}
|
||||
|
||||
func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]pluginAdminFormFieldJSON, 0, len(form.GetFields()))
|
||||
for _, field := range form.GetFields() {
|
||||
if field == nil {
|
||||
continue
|
||||
}
|
||||
options := make([]pluginAdminFormOptionJSON, 0, len(field.GetOptions()))
|
||||
for _, option := range field.GetOptions() {
|
||||
if option == nil {
|
||||
continue
|
||||
}
|
||||
options = append(options, pluginAdminFormOptionJSON{
|
||||
Value: option.GetValue(),
|
||||
Label: option.GetLabel(),
|
||||
Description: option.GetDescription(),
|
||||
})
|
||||
}
|
||||
var defaultValue any
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *pluginAdminFormValidationJSON
|
||||
if v := field.GetValidation(); v != nil {
|
||||
validation = &pluginAdminFormValidationJSON{
|
||||
HasMin: v.GetHasMin(),
|
||||
Min: v.GetMin(),
|
||||
HasMax: v.GetHasMax(),
|
||||
Max: v.GetMax(),
|
||||
Pattern: v.GetPattern(),
|
||||
MinLength: v.GetMinLength(),
|
||||
MaxLength: v.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
fields = append(fields, pluginAdminFormFieldJSON{
|
||||
Key: field.GetKey(),
|
||||
Label: field.GetLabel(),
|
||||
Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(),
|
||||
Required: field.GetRequired(),
|
||||
Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(),
|
||||
DefaultValue: defaultValue,
|
||||
Options: options,
|
||||
Rows: field.GetRows(),
|
||||
DynamicOptions: field.GetDynamicOptions(),
|
||||
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
|
||||
Validation: validation,
|
||||
ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, pluginAdminFormSectionJSON{
|
||||
Key: section.GetKey(),
|
||||
Title: section.GetTitle(),
|
||||
Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(),
|
||||
CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
|
||||
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
|
||||
})
|
||||
}
|
||||
return &pluginAdminFormJSON{
|
||||
Fields: fields,
|
||||
SubmitLabel: form.GetSubmitLabel(),
|
||||
Sections: sections,
|
||||
}
|
||||
}
|
||||
|
||||
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
|
||||
if len(conditions) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition == nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, pluginAdminFormConditionJSON{
|
||||
Field: condition.GetField(),
|
||||
Equals: append([]string(nil), condition.GetEquals()...),
|
||||
})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
return plugins.AdminFormViewFromProto(form)
|
||||
}
|
||||
|
||||
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
|
||||
|
||||
@@ -219,6 +219,37 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// New subtitle artifact URLs bind downloaded subtitles by their stable row
|
||||
// identity. The path ordinal remains for compatibility and display, but it
|
||||
// must not be re-resolved against a mutable inventory after a seek reanchor.
|
||||
if rawID := strings.TrimSpace(r.URL.Query().Get(downloadedSubtitleIDParam)); rawID != "" {
|
||||
downloadedID, parseErr := strconv.Atoi(rawID)
|
||||
if parseErr != nil || downloadedID <= 0 {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid downloaded subtitle identity")
|
||||
return
|
||||
}
|
||||
if h.SubtitleRepo == nil || h.S3Client == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
|
||||
return
|
||||
}
|
||||
downloaded, lookupErr := h.SubtitleRepo.GetDownloadedSubtitle(r.Context(), downloadedID)
|
||||
if lookupErr != nil {
|
||||
slog.ErrorContext(r.Context(), "get downloaded subtitle failed", "component", "api",
|
||||
"file_id", file.ID,
|
||||
"downloaded_subtitle_id", downloadedID,
|
||||
"error", lookupErr,
|
||||
)
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load downloaded subtitle")
|
||||
return
|
||||
}
|
||||
if downloaded == nil || downloaded.MediaFileID != file.ID {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
|
||||
return
|
||||
}
|
||||
h.serveDownloadedSubtitle(w, r, *downloaded, requestedFormat)
|
||||
return
|
||||
}
|
||||
|
||||
externalCount := len(file.ExternalSubtitles)
|
||||
if trackIndex < externalCount {
|
||||
sub := file.ExternalSubtitles[trackIndex]
|
||||
@@ -287,32 +318,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
downloadedIndex := embeddedIndex - len(file.SubtitleTracks)
|
||||
if downloadedIndex >= 0 && downloadedIndex < len(downloaded) {
|
||||
dl := downloaded[downloadedIndex]
|
||||
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, dl.S3Key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
|
||||
return
|
||||
}
|
||||
|
||||
// Serve ASS/SSA downloaded subtitles as raw data.
|
||||
if playback.IsASS(string(dl.Format)) && requestedFormat != "vtt" {
|
||||
playback.ServeSubtitle(w, data, "ass")
|
||||
return
|
||||
}
|
||||
|
||||
// If the subtitle is already VTT, serve directly.
|
||||
if dl.Format == subtitles.FormatVTT {
|
||||
playback.ServeSubtitle(w, data, "vtt")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to VTT using the playback conversion pipeline.
|
||||
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(dl.Format), h.ffmpegPath())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
|
||||
return
|
||||
}
|
||||
playback.ServeSubtitle(w, vttData, "vtt")
|
||||
h.serveDownloadedSubtitle(w, r, downloaded[downloadedIndex], requestedFormat)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -320,6 +326,34 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
|
||||
}
|
||||
|
||||
func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.Request, subtitle subtitles.DownloadedSubtitle, requestedFormat string) {
|
||||
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, subtitle.S3Key)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
|
||||
return
|
||||
}
|
||||
|
||||
// Serve ASS/SSA downloaded subtitles as raw data.
|
||||
if playback.IsASS(string(subtitle.Format)) && requestedFormat != "vtt" {
|
||||
playback.ServeSubtitle(w, data, "ass")
|
||||
return
|
||||
}
|
||||
|
||||
// If the subtitle is already VTT, serve directly.
|
||||
if subtitle.Format == subtitles.FormatVTT {
|
||||
playback.ServeSubtitle(w, data, "vtt")
|
||||
return
|
||||
}
|
||||
|
||||
// Convert other text formats to VTT using the playback conversion pipeline.
|
||||
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(subtitle.Format), h.ffmpegPath())
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
|
||||
return
|
||||
}
|
||||
playback.ServeSubtitle(w, vttData, "vtt")
|
||||
}
|
||||
|
||||
// subtitleSourceFileID pins a subtitle URL to the file whose track list was
|
||||
// used to create it. A quality/seek restart may change session.MediaFileID to
|
||||
// an alternate version; interpreting the old combined track index against the
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
)
|
||||
|
||||
type hookedSessionManager struct {
|
||||
@@ -246,6 +248,53 @@ func TestHandleSubtitle_ListDownloadedSubtitlesErrorReturns500(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSubtitleUsesBoundDownloadedIdentityAfterInventoryReorder(t *testing.T) {
|
||||
file := &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: "/tmp/movie.mkv", Duration: 3600}
|
||||
baseMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
repo := newMockSubtitleRepoForHandler()
|
||||
repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"}
|
||||
// The mutable ordinal now points at a different subtitle. An ID-bound URL
|
||||
// must still fetch 71 without consulting this reordered list.
|
||||
repo.list = []subtitles.DownloadedSubtitle{
|
||||
{ID: 72, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "other-72.vtt"},
|
||||
{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"},
|
||||
}
|
||||
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
|
||||
handler.SubtitleRepo = repo
|
||||
handler.S3Client = subtitleContentS3Client{objects: map[string][]byte{
|
||||
"selected-71.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nselected-71\n"),
|
||||
"other-72.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nother-72\n"),
|
||||
}}
|
||||
handler.S3Bucket = "test-bucket"
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71", nil)
|
||||
req = req.WithContext(newAuthorizedPlaybackContext())
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("session_id", session.ID)
|
||||
routeCtx.URLParams.Add("track", "0.vtt")
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler.HandleSubtitle(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "selected-71") || strings.Contains(rr.Body.String(), "other-72") {
|
||||
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
type subtitleContentS3Client struct {
|
||||
objects map[string][]byte
|
||||
}
|
||||
|
||||
func (subtitleContentS3Client) PutObject(context.Context, string, string, []byte) error { return nil }
|
||||
func (c subtitleContentS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
|
||||
return append([]byte(nil), c.objects[key]...), nil
|
||||
}
|
||||
func (subtitleContentS3Client) DeleteObject(context.Context, string, string) error { return nil }
|
||||
|
||||
func TestHandleSubtitle_NilMediaFileReturns404(t *testing.T) {
|
||||
baseMgr := playback.NewSessionManager(0, 0)
|
||||
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
|
||||
|
||||
@@ -239,9 +239,15 @@ type handlerMockSubtitleRepo struct {
|
||||
subtitles map[int]*subtitles.DownloadedSubtitle
|
||||
nextID int
|
||||
byKey map[string]*subtitles.DownloadedSubtitle
|
||||
// getErr, when set, is returned by GetDownloadedSubtitle to simulate a
|
||||
// backing-store failure.
|
||||
getErr error
|
||||
// listErr, when set, is returned by ListDownloadedSubtitles to simulate a
|
||||
// backing-store failure.
|
||||
listErr error
|
||||
listErr error
|
||||
list []subtitles.DownloadedSubtitle
|
||||
listResults [][]subtitles.DownloadedSubtitle
|
||||
listCalls int
|
||||
}
|
||||
|
||||
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
|
||||
@@ -260,6 +266,9 @@ func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, su
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
if m.getErr != nil {
|
||||
return nil, m.getErr
|
||||
}
|
||||
if sub, ok := m.subtitles[id]; ok {
|
||||
copy := *sub
|
||||
return ©, nil
|
||||
@@ -268,10 +277,15 @@ func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id in
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
|
||||
m.listCalls++
|
||||
if m.listErr != nil {
|
||||
return nil, m.listErr
|
||||
}
|
||||
return nil, nil
|
||||
if len(m.listResults) > 0 {
|
||||
index := min(m.listCalls-1, len(m.listResults)-1)
|
||||
return append([]subtitles.DownloadedSubtitle(nil), m.listResults[index]...), nil
|
||||
}
|
||||
return append([]subtitles.DownloadedSubtitle(nil), m.list...), nil
|
||||
}
|
||||
|
||||
func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
|
||||
"github.com/Silo-Server/silo-server/internal/watchsync"
|
||||
@@ -15,7 +16,7 @@ type WatchProviderService interface {
|
||||
ListProviders() []watchsync.ProviderSummary
|
||||
StartDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string) (watchsync.DeviceAuthSession, error)
|
||||
PollDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string, sessionID string) (watchsync.Connection, error)
|
||||
ConnectAPIKey(ctx context.Context, userID int, profileID string, providerKey string, apiKey string) (watchsync.Connection, error)
|
||||
ConnectAPIKeyWithConfig(ctx context.Context, userID int, profileID string, providerKey string, apiKey string, connectionConfig watchsync.ConnectionConfigValues) (watchsync.Connection, error)
|
||||
GetConnectionStatus(ctx context.Context, userID int, profileID string, provider string) (watchsync.ConnectionStatus, error)
|
||||
UpdateConnection(ctx context.Context, userID int, profileID string, provider string, update watchsync.ConnectionUpdate) (watchsync.ConnectionStatus, error)
|
||||
DeleteConnection(ctx context.Context, userID int, profileID string, provider string) error
|
||||
@@ -27,6 +28,15 @@ type WatchProviderHandler struct {
|
||||
service WatchProviderService
|
||||
}
|
||||
|
||||
type watchProviderDeviceAuthResponse struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
UserCode string `json:"user_code"`
|
||||
VerificationURL string `json:"verification_url"`
|
||||
IntervalSeconds int `json:"interval_seconds"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func NewWatchProviderHandler(service WatchProviderService) *WatchProviderHandler {
|
||||
return &WatchProviderHandler{service: service}
|
||||
}
|
||||
@@ -97,7 +107,14 @@ func (h *WatchProviderHandler) HandleStartDeviceAuth(w http.ResponseWriter, r *h
|
||||
writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, session)
|
||||
writeJSON(w, http.StatusOK, watchProviderDeviceAuthResponse{
|
||||
ID: session.ID,
|
||||
Provider: session.Provider,
|
||||
UserCode: session.UserCode,
|
||||
VerificationURL: session.VerificationURL,
|
||||
IntervalSeconds: session.IntervalSeconds,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *WatchProviderHandler) HandlePollDeviceAuth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -130,13 +147,14 @@ func (h *WatchProviderHandler) HandleConnectAPIKey(w http.ResponseWriter, r *htt
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
APIKey string `json:"api_key"`
|
||||
APIKey string `json:"api_key"`
|
||||
ConnectionConfig watchsync.ConnectionConfigValues `json:"connection_config"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
|
||||
return
|
||||
}
|
||||
if _, err := h.service.ConnectAPIKey(r.Context(), userID, profileID, provider, req.APIKey); err != nil {
|
||||
if _, err := h.service.ConnectAPIKeyWithConfig(r.Context(), userID, profileID, provider, req.APIKey, req.ConnectionConfig); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +25,25 @@ type stubWatchProviderService struct {
|
||||
runs []watchsync.SyncRun
|
||||
}
|
||||
|
||||
type capturingWatchProviderService struct {
|
||||
stubWatchProviderService
|
||||
apiKey string
|
||||
config watchsync.ConnectionConfigValues
|
||||
}
|
||||
|
||||
func (s *capturingWatchProviderService) ConnectAPIKeyWithConfig(
|
||||
_ context.Context,
|
||||
_ int,
|
||||
_ string,
|
||||
_ string,
|
||||
apiKey string,
|
||||
config watchsync.ConnectionConfigValues,
|
||||
) (watchsync.Connection, error) {
|
||||
s.apiKey = apiKey
|
||||
s.config = config
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
|
||||
func (s stubWatchProviderService) ListProviders() []watchsync.ProviderSummary {
|
||||
return s.providers
|
||||
}
|
||||
@@ -32,7 +53,7 @@ func (s stubWatchProviderService) StartDeviceAuth(context.Context, int, string,
|
||||
func (s stubWatchProviderService) PollDeviceAuth(context.Context, int, string, string, string) (watchsync.Connection, error) {
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
func (s stubWatchProviderService) ConnectAPIKey(context.Context, int, string, string, string) (watchsync.Connection, error) {
|
||||
func (s stubWatchProviderService) ConnectAPIKeyWithConfig(context.Context, int, string, string, string, watchsync.ConnectionConfigValues) (watchsync.Connection, error) {
|
||||
return watchsync.Connection{}, nil
|
||||
}
|
||||
func (s stubWatchProviderService) GetConnectionStatus(context.Context, int, string, string) (watchsync.ConnectionStatus, error) {
|
||||
@@ -89,7 +110,7 @@ func TestWatchProviderHandlerListsProviders(t *testing.T) {
|
||||
if len(resp.Providers) != 1 {
|
||||
t.Fatalf("providers length = %d, want 1", len(resp.Providers))
|
||||
}
|
||||
if resp.Providers[0] != service.providers[0] {
|
||||
if !reflect.DeepEqual(resp.Providers[0], service.providers[0]) {
|
||||
t.Fatalf("provider = %#v, want %#v", resp.Providers[0], service.providers[0])
|
||||
}
|
||||
}
|
||||
@@ -159,6 +180,11 @@ func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T)
|
||||
t.Fatalf("response contains Go-style key %q: %#v", key, resp)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_code", "user_id", "profile_id", "completed_at"} {
|
||||
if _, ok := resp[key]; ok {
|
||||
t.Fatalf("response exposes host-private field %q: %#v", key, resp)
|
||||
}
|
||||
}
|
||||
if resp["id"] != "auth-1" {
|
||||
t.Fatalf("id = %#v, want auth-1", resp["id"])
|
||||
}
|
||||
@@ -170,6 +196,31 @@ func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchProviderHandlerPassesConnectionConfigWithAPIKey(t *testing.T) {
|
||||
service := &capturingWatchProviderService{}
|
||||
handler := NewWatchProviderHandler(service)
|
||||
req := httptest.NewRequest(http.MethodPost, "/watch-providers/plugin:4:floppy/auth/api-key", strings.NewReader(`{
|
||||
"api_key":"token",
|
||||
"connection_config":{"floppy":{"base_url":"https://floppy.example.com"}}
|
||||
}`))
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("provider", "plugin:4:floppy")
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
|
||||
ctx = middleware.SetClaims(ctx, &auth.Claims{UserID: 7})
|
||||
ctx = middleware.SetProfileID(ctx, "profile-1")
|
||||
req = req.WithContext(ctx)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.HandleConnectAPIKey(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if service.apiKey != "token" || service.config["floppy"]["base_url"] != "https://floppy.example.com" {
|
||||
t.Fatalf("api key = %q, config = %#v", service.apiKey, service.config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchProviderHandlerManualSyncReturnsRun(t *testing.T) {
|
||||
startedAt := time.Date(2026, 5, 4, 16, 0, 0, 0, time.UTC)
|
||||
handler := NewWatchProviderHandler(stubWatchProviderService{
|
||||
|
||||
@@ -388,7 +388,7 @@ func (h *PlaybackHandler) buildProxyRedirectURL(
|
||||
}
|
||||
return redirectURL, nil
|
||||
case string(playback.PlayTranscode):
|
||||
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8", nil
|
||||
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8?" + playback.SourceTimelineQueryParam + "=1", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported proxy method %q", method)
|
||||
}
|
||||
|
||||
@@ -246,7 +246,8 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to bind transcode node")
|
||||
return
|
||||
}
|
||||
if err := h.startRemoteTranscode(r.Context(), playSession.ID, playSession.UpstreamSessionID, *source, file, playSession.InitialSeekSeconds, tcNode.URL); err != nil {
|
||||
initialSeekSeconds, _ := compatInitialTranscodePosition(*source, h.compatSegmentDuration(), playSession.InitialSeekSeconds)
|
||||
if err := h.startRemoteTranscode(r.Context(), playSession.ID, playSession.UpstreamSessionID, *source, file, initialSeekSeconds, tcNode.URL); err != nil {
|
||||
failRemoteStart()
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
@@ -279,7 +280,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
}
|
||||
|
||||
// Ensure the transcode process is running.
|
||||
_, err = h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
manifest, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
@@ -299,7 +300,9 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
|
||||
|
||||
segDuration := h.compatSegmentDuration()
|
||||
|
||||
manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
|
||||
if manifest == nil {
|
||||
manifest = generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -329,7 +332,7 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
// Ensure the transcode process is running.
|
||||
_, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
manifest, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
|
||||
if err != nil {
|
||||
if errors.Is(err, errTranscode4KDisallowed) {
|
||||
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
|
||||
@@ -349,7 +352,9 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
|
||||
|
||||
segDuration := h.compatSegmentDuration()
|
||||
|
||||
manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
|
||||
if manifest == nil {
|
||||
manifest = generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(rewriteManifest(manifest, playSession.RouteItemID, playSession.ID, source.ID))
|
||||
@@ -1564,10 +1569,11 @@ func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSes
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// When duration is known, Jellycompat serves its own synthetic VOD manifest.
|
||||
// We only need ffmpeg running; waiting for startup segments here adds
|
||||
// unnecessary latency before the player can request the actual target segment.
|
||||
if source.Version.Duration > 0 {
|
||||
// When the duration fits the shared segment-count bound, Jellycompat serves
|
||||
// its own synthetic VOD manifest. Longer media waits for FFmpeg's bounded
|
||||
// real playlist so one request cannot allocate hundreds of thousands of
|
||||
// segment entries.
|
||||
if shouldGenerateCompatFullManifest(source, h.compatSegmentDuration()) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -1579,7 +1585,7 @@ func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSes
|
||||
for {
|
||||
manifest, err := transcodeSession.GetManifest()
|
||||
if err == nil {
|
||||
return manifest, nil
|
||||
return playback.AlignRealManifestToSourceTimeline(manifest, transcodeSession.Opts(), "")
|
||||
}
|
||||
if !errors.Is(err, playback.ErrManifestNotReady) {
|
||||
return nil, err
|
||||
@@ -1628,11 +1634,11 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
initialSeekSeconds := 0.0
|
||||
startSegmentNumber := 0
|
||||
if playSession, ok := h.playbackStore.Get(playSessionID); ok {
|
||||
initialSeekSeconds = playSession.InitialSeekSeconds
|
||||
segDuration := h.compatSegmentDuration()
|
||||
if initialSeekSeconds > 0 && segDuration > 0 {
|
||||
startSegmentNumber = int(initialSeekSeconds / float64(segDuration))
|
||||
}
|
||||
initialSeekSeconds, startSegmentNumber = compatInitialTranscodePosition(
|
||||
source,
|
||||
h.compatSegmentDuration(),
|
||||
playSession.InitialSeekSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
opts := playback.TranscodeOpts{
|
||||
@@ -1646,6 +1652,7 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
FFmpegPath: h.FFmpegPath,
|
||||
HWAccel: h.HWAccel,
|
||||
AudioTrackIndex: compatAudioTrackIndexOrDefault(source),
|
||||
TotalDuration: float64(source.Version.Duration),
|
||||
FastStart: true,
|
||||
}
|
||||
if source.TranscodeAudio {
|
||||
@@ -1689,6 +1696,26 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
|
||||
return transcodeSession, nil
|
||||
}
|
||||
|
||||
func shouldGenerateCompatFullManifest(source PlaybackMediaSource, segmentDuration int) bool {
|
||||
return playback.CanGenerateSyntheticManifest(float64(source.Version.Duration), segmentDuration)
|
||||
}
|
||||
|
||||
// compatInitialTranscodePosition keeps FFmpeg close to the requested resume
|
||||
// position. Bounded synthetic manifests list the omitted source segments;
|
||||
// seeked real manifests receive an EXT-X-GAP timeline anchor before serving.
|
||||
func compatInitialTranscodePosition(source PlaybackMediaSource, segmentDuration int, requested float64) (float64, int) {
|
||||
if requested <= 0 {
|
||||
return 0, 0
|
||||
}
|
||||
if duration := float64(source.Version.Duration); duration > 0 && requested > duration {
|
||||
requested = duration
|
||||
}
|
||||
if segmentDuration <= 0 {
|
||||
segmentDuration = compatSegmentDuration
|
||||
}
|
||||
return requested, int(requested / float64(segmentDuration))
|
||||
}
|
||||
|
||||
// audioSelectionChanged reports whether an incoming AudioStreamIndex differs
|
||||
// from what the play session already records for the target media source.
|
||||
// Used to short-circuit progress reports that merely echo the current
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
)
|
||||
|
||||
@@ -81,6 +83,52 @@ func TestGenerateFullManifest_HLSVersionForResumeStartTag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldGenerateCompatFullManifestBoundsSegmentCount(t *testing.T) {
|
||||
short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}}
|
||||
if !shouldGenerateCompatFullManifest(short, 2) {
|
||||
t.Fatal("historical 50,000-segment compatibility manifest should remain supported")
|
||||
}
|
||||
|
||||
long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}}
|
||||
if shouldGenerateCompatFullManifest(long, 2) {
|
||||
t.Fatal("long compatibility playback should use FFmpeg's bounded real manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompatInitialTranscodePositionKeepsResumeNearRequestedSegment(t *testing.T) {
|
||||
short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}}
|
||||
seek, segment := compatInitialTranscodePosition(short, 2, 17.3)
|
||||
if seek != 17.3 || segment != 8 {
|
||||
t.Fatalf("bounded manifest position = (%v, %d), want (17.3, 8)", seek, segment)
|
||||
}
|
||||
|
||||
long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}}
|
||||
seek, segment = compatInitialTranscodePosition(long, 2, 17.3)
|
||||
if seek != 17.3 || segment != 8 {
|
||||
t.Fatalf("real manifest position = (%v, %d), want (17.3, 8)", seek, segment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildProxyRedirectURLRequestsSourceAlignedCompatManifest(t *testing.T) {
|
||||
h := &PlaybackHandler{JWTSecret: "test-secret"}
|
||||
redirectURL, err := h.buildProxyRedirectURL(
|
||||
"play-1",
|
||||
"upstream-1",
|
||||
string(playback.PlayTranscode),
|
||||
&models.MediaFile{FilePath: "/media/movie.mkv"},
|
||||
PlaybackMediaSource{},
|
||||
"http://transcode-1",
|
||||
0,
|
||||
&nodepool.Node{URL: "http://proxy-1"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildProxyRedirectURL: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(redirectURL, "/master.m3u8?"+playback.SourceTimelineQueryParam+"=1") {
|
||||
t.Fatalf("redirect URL = %q, want source-timeline opt-in", redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteManifest_PreservesPlaybackAndMediaSourceIDs(t *testing.T) {
|
||||
manifest := strings.Join([]string{
|
||||
"#EXTM3U",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,13 @@ type PlannerInputV3 struct {
|
||||
AdditionalSubtitles []SubtitleInventoryEntryV3
|
||||
}
|
||||
|
||||
// SourceExecutionMetadataV3 is the immutable source probe snapshot used to
|
||||
// reopen a frozen playback recipe without consuming later catalog drift.
|
||||
type SourceExecutionMetadataV3 struct {
|
||||
VideoCodec string
|
||||
DurationSeconds float64
|
||||
}
|
||||
|
||||
// dvRPUStrippable resolves the per-source strip verdict, defaulting to true
|
||||
// when no probe is wired in.
|
||||
func (input PlannerInputV3) dvRPUStrippable() bool {
|
||||
@@ -84,6 +91,14 @@ type PlannerResultV3 struct {
|
||||
SubtitleTransportTrackIndex int
|
||||
SubtitleBurnIn bool
|
||||
SubtitleCodec string
|
||||
// DownloadedSubtitleID comes from the same inventory snapshot used for
|
||||
// planning. Freezing must not re-list a mutable ordinal inventory after the
|
||||
// route has already been accepted.
|
||||
DownloadedSubtitleID int
|
||||
// FrozenSourceMetadata is set only when a durable executable recipe is
|
||||
// thawed for a seek reanchor. Transport construction must then use this
|
||||
// captured source snapshot instead of a freshly probed media row.
|
||||
FrozenSourceMetadata *SourceExecutionMetadataV3
|
||||
}
|
||||
|
||||
func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
@@ -254,7 +269,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
})
|
||||
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
|
||||
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
if clientHDR10Eligible {
|
||||
@@ -275,7 +290,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
})
|
||||
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
|
||||
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,7 +304,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
|
||||
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
|
||||
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +365,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
plan.Claims.Subtitles = remuxSubtitle.Claims
|
||||
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
|
||||
if engineAvailableV3(input.Request, EngineMedia3ProgressiveRemuxV3) && !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec, DownloadedSubtitleID: remuxSubtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
if engineAvailableV3(input.Request, EngineMedia3HLSV3) && hlsRemuxSubtitleOK {
|
||||
@@ -414,7 +429,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
|
||||
if hlsTranscodeAudio {
|
||||
targetAudio = "aac"
|
||||
}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec, DownloadedSubtitleID: hlsSubtitle.DownloadedSubtitleID}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,7 +505,7 @@ func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescri
|
||||
if planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
|
||||
return terminalPlannerResultV3("adaptation_exhausted", "All compatible playback recipes have already failed for this output route.", false)
|
||||
}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec}
|
||||
return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
|
||||
}
|
||||
|
||||
func canStripDolbyVisionToHDR10V3(source SourceDescriptorV3, request StartRequestV3, registry *TransformationRegistryV3) bool {
|
||||
|
||||
@@ -79,6 +79,10 @@ func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecor
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipeJSON, err := json.Marshal(record.FrozenRecipe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -98,12 +102,12 @@ func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecor
|
||||
INSERT INTO playback_v3_attempts (
|
||||
playback_attempt_id, session_id, user_id, profile_id,
|
||||
requested_media_file_id, effective_media_file_id,
|
||||
current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at
|
||||
) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
current_plan_id, current_replan_request_id, current_plan, frozen_recipe, normalized_request, request_digest, expires_at
|
||||
) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
record.PlaybackAttemptID, record.SessionID, record.UserID, record.ProfileID,
|
||||
record.RequestedMediaFileID, record.EffectiveMediaFileID,
|
||||
record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.RequestDigest, record.ExpiresAt)
|
||||
record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, recipeJSON, requestJSON, record.RequestDigest, record.ExpiresAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,16 +163,16 @@ func (s *Postgres) getAttemptIdentity(ctx context.Context, predicate string, val
|
||||
|
||||
func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any) (*playback.AttemptRecordV3, error) {
|
||||
var record playback.AttemptRecordV3
|
||||
var planJSON, requestJSON []byte
|
||||
var planJSON, recipeJSON, requestJSON []byte
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT playback_attempt_id, session_id::text, user_id, profile_id,
|
||||
requested_media_file_id, effective_media_file_id,
|
||||
current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at
|
||||
current_plan_id, current_replan_request_id, current_plan, frozen_recipe, normalized_request, request_digest, expires_at
|
||||
FROM playback_v3_attempts
|
||||
WHERE `+predicate+` AND expires_at > NOW()`, value).Scan(
|
||||
&record.PlaybackAttemptID, &record.SessionID, &record.UserID, &record.ProfileID,
|
||||
&record.RequestedMediaFileID, &record.EffectiveMediaFileID,
|
||||
&record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt,
|
||||
&record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &recipeJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, playback.ErrSessionNotFound
|
||||
@@ -179,6 +183,9 @@ func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any)
|
||||
if err := json.Unmarshal(planJSON, &record.CurrentPlan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(recipeJSON, &record.FrozenRecipe); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(requestJSON, &record.NormalizedRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -273,6 +280,10 @@ func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, bas
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recipeJSON, err := json.Marshal(record.FrozenRecipe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The base-revision predicate makes the commit a true compare-and-swap:
|
||||
// under the advisory session lock it never fails, but a skipped or broken
|
||||
// lock must surface as a conflict rather than silently last-writer-win
|
||||
@@ -280,9 +291,10 @@ func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, bas
|
||||
attemptResult, err := tx.Exec(ctx, `
|
||||
UPDATE playback_v3_attempts SET
|
||||
effective_media_file_id = $2, current_plan_id = $3,
|
||||
current_replan_request_id = $4, current_plan = $5, normalized_request = $6, expires_at = $7, updated_at = NOW()
|
||||
WHERE session_id = $1::uuid AND current_replan_request_id = $8`,
|
||||
sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.ExpiresAt, baseReplanRequestID)
|
||||
current_replan_request_id = $4, current_plan = $5, frozen_recipe = $6,
|
||||
normalized_request = $7, expires_at = $8, updated_at = NOW()
|
||||
WHERE session_id = $1::uuid AND current_replan_request_id = $9`,
|
||||
sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, recipeJSON, requestJSON, record.ExpiresAt, baseReplanRequestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -60,6 +60,17 @@ func newPlanstoreFixture(t *testing.T) *planstoreFixture {
|
||||
if !hasRevision {
|
||||
t.Skip("test database has not applied the playback v3 attempt revision migration")
|
||||
}
|
||||
var hasFrozenRecipe bool
|
||||
if err := pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'playback_v3_attempts' AND column_name = 'frozen_recipe'
|
||||
)`).Scan(&hasFrozenRecipe); err != nil {
|
||||
t.Fatalf("check frozen_recipe column: %v", err)
|
||||
}
|
||||
if !hasFrozenRecipe {
|
||||
t.Skip("test database has not applied the playback v3 frozen recipe migration")
|
||||
}
|
||||
|
||||
f := &planstoreFixture{pool: pool}
|
||||
unique := fmt.Sprintf("planstore-test-%d", time.Now().UnixNano())
|
||||
@@ -116,6 +127,10 @@ func (f *planstoreFixture) attemptRecord(sessionID, attemptID, digest string) pl
|
||||
RequestedMediaFileID: f.mediaFileID,
|
||||
EffectiveMediaFileID: f.mediaFileID,
|
||||
},
|
||||
FrozenRecipe: playback.ExecutableRecipeV3{
|
||||
Version: 1, PlanID: "plan-1", PlayMethod: playback.PlayDirect,
|
||||
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
|
||||
},
|
||||
NormalizedRequest: playback.StartRequestV3{
|
||||
ProtocolVersion: 3,
|
||||
FileID: f.mediaFileID,
|
||||
@@ -265,6 +280,9 @@ func TestPostgresPlanStore(t *testing.T) {
|
||||
if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan)) {
|
||||
t.Fatalf("%s plan JSON did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan))
|
||||
}
|
||||
if !bytes.Equal(mustJSON(t, got.FrozenRecipe), mustJSON(t, record.FrozenRecipe)) {
|
||||
t.Fatalf("%s frozen recipe did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.FrozenRecipe), mustJSON(t, record.FrozenRecipe))
|
||||
}
|
||||
if !bytes.Equal(mustJSON(t, got.NormalizedRequest), mustJSON(t, record.NormalizedRequest)) {
|
||||
t.Fatalf("%s normalized request JSON did not round-trip", name)
|
||||
}
|
||||
@@ -400,6 +418,7 @@ func TestPostgresPlanStore(t *testing.T) {
|
||||
updated.CurrentPlanID = "plan-2"
|
||||
updated.CurrentReplanRequestID = "rq-1"
|
||||
updated.CurrentPlan.PlanID = "plan-2"
|
||||
updated.FrozenRecipe.PlanID = "plan-2"
|
||||
updated.CurrentPlan.EffectiveMediaFileID = f.altFileID
|
||||
updated.CurrentPlan.DecisionReason = "transcode_fallback"
|
||||
updated.ExpiresAt = time.Now().Add(2 * time.Hour).UTC().Truncate(time.Microsecond)
|
||||
@@ -425,6 +444,9 @@ func TestPostgresPlanStore(t *testing.T) {
|
||||
if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan)) {
|
||||
t.Fatalf("plan JSON mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan))
|
||||
}
|
||||
if !bytes.Equal(mustJSON(t, got.FrozenRecipe), mustJSON(t, updated.FrozenRecipe)) {
|
||||
t.Fatalf("frozen recipe mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.FrozenRecipe), mustJSON(t, updated.FrozenRecipe))
|
||||
}
|
||||
|
||||
// The migration's sync trigger must not fight the in-transaction CAS:
|
||||
// the raw column must equal the new request ID, with no extra rewrite.
|
||||
|
||||
@@ -27,6 +27,7 @@ type AttemptRecordV3 struct {
|
||||
CurrentPlanID string
|
||||
CurrentReplanRequestID string
|
||||
CurrentPlan PlanV3
|
||||
FrozenRecipe ExecutableRecipeV3
|
||||
NormalizedRequest StartRequestV3
|
||||
// RequestDigest fingerprints the normalized start request so an attempt-ID
|
||||
// reused with different input is a detectable idempotency violation rather
|
||||
|
||||
@@ -645,7 +645,7 @@ func TestPlanPlaybackV3PassthroughRequiresExactLayoutEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T) {
|
||||
func TestPlanPlaybackV3DownloadedSubtitleCarriesStableIdentity(t *testing.T) {
|
||||
file := detailedFixtureFileV3()
|
||||
req := validStartRequestV3()
|
||||
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
|
||||
@@ -655,8 +655,8 @@ func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T)
|
||||
index := 0
|
||||
req.SubtitleTrackIndex = &index
|
||||
req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", index)
|
||||
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded"}}})
|
||||
if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID {
|
||||
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded", DownloadedSubtitleID: 71}}})
|
||||
if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID || result.DownloadedSubtitleID != 71 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
}
|
||||
@@ -1016,6 +1016,54 @@ func TestPlanPlaybackV3TimelineChangePreservesRouteIdentity(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanPlaybackV3DroppingFallbackHistoryReintroducesRejectedRoute(t *testing.T) {
|
||||
file := detailedFixtureFileV3()
|
||||
file.FilePath = "/media/movie.mp4"
|
||||
file.Container = "mp4"
|
||||
file.CodecVideo = "h264"
|
||||
file.Resolution = "1080p"
|
||||
file.Bitrate = 8_000
|
||||
file.VideoTracks[0] = models.VideoTrack{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}
|
||||
request := validStartRequestV3()
|
||||
request.ClientFeatures = append(request.ClientFeatures, FeatureDetailedDecodeV3)
|
||||
request.ClientPlaybackContext.Features = append(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
|
||||
request.Capabilities.CodecsVideo = []string{"h264"}
|
||||
request.Capabilities.CodecsVideoHardware = []string{"h264"}
|
||||
request.Capabilities.Containers = []string{"mp4"}
|
||||
request.Capabilities.MaxResolution = "1080p"
|
||||
request.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}
|
||||
input := PlannerInputV3{
|
||||
Request: request, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
|
||||
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3(),
|
||||
}
|
||||
direct := PlanPlaybackV3(input)
|
||||
if direct.Plan == nil || direct.Plan.Delivery != DeliveryOriginalHTTPV3 {
|
||||
t.Fatalf("direct plan = %#v", direct)
|
||||
}
|
||||
input.AttemptedKeys = []string{PlanAttemptKeyV3(*direct.Plan, request.OutputRouteGeneration, nil)}
|
||||
progressive := PlanPlaybackV3(input)
|
||||
if progressive.Plan == nil || progressive.Plan.Delivery != DeliveryRemuxProgressiveV3 {
|
||||
t.Fatalf("progressive fallback = %#v", progressive)
|
||||
}
|
||||
input.AttemptedKeys = append(input.AttemptedKeys, PlanAttemptKeyV3(*progressive.Plan, request.OutputRouteGeneration, nil))
|
||||
hls := PlanPlaybackV3(input)
|
||||
if hls.Plan == nil || hls.Plan.Delivery != DeliveryRemuxHLSV3 {
|
||||
t.Fatalf("HLS fallback = %#v", hls)
|
||||
}
|
||||
|
||||
seek := 321.25
|
||||
input.Request.StartPosition = &seek
|
||||
input.AttemptedKeys = nil // This is what the old seek-reanchor path did.
|
||||
replanned := PlanPlaybackV3(input)
|
||||
if replanned.Plan == nil || replanned.Plan.PlanID != direct.Plan.PlanID || replanned.Plan.PlanID == hls.Plan.PlanID {
|
||||
t.Fatalf("dropped fallback history did not reproduce identity drift: direct=%#v hls=%#v replanned=%#v", direct.Plan, hls.Plan, replanned.Plan)
|
||||
}
|
||||
if replanned.Plan.Delivery != DeliveryOriginalHTTPV3 || replanned.Plan.Engine != EngineMedia3DirectV3 ||
|
||||
replanned.Plan.Stream.Protocol != StreamHTTPProgressiveV3 || replanned.Plan.Stream.Container != "mp4" {
|
||||
t.Fatalf("reintroduced route = %#v", replanned.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func validStartRequestV3() StartRequestV3 {
|
||||
return StartRequestV3{
|
||||
ProtocolVersion: ProtocolV3,
|
||||
|
||||
@@ -7,20 +7,22 @@ import (
|
||||
)
|
||||
|
||||
type SubtitlePolicyResultV3 struct {
|
||||
Decision SubtitleDecisionV3
|
||||
Claims SubtitleClaimsV3
|
||||
RequiresBurn bool
|
||||
SelectedIndex int
|
||||
TransportIndex int
|
||||
Codec string
|
||||
Source string
|
||||
Terminal *TerminalV3
|
||||
Decision SubtitleDecisionV3
|
||||
Claims SubtitleClaimsV3
|
||||
RequiresBurn bool
|
||||
SelectedIndex int
|
||||
TransportIndex int
|
||||
Codec string
|
||||
Source string
|
||||
DownloadedSubtitleID int
|
||||
Terminal *TerminalV3
|
||||
}
|
||||
|
||||
type SubtitleInventoryEntryV3 struct {
|
||||
CombinedIndex int
|
||||
Codec string
|
||||
Source string
|
||||
CombinedIndex int
|
||||
Codec string
|
||||
Source string
|
||||
DownloadedSubtitleID int
|
||||
}
|
||||
|
||||
// ResolveSubtitlePolicyV3 decides how the selected subtitle is delivered when
|
||||
@@ -44,10 +46,11 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
if file == nil {
|
||||
return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle inventory is unavailable.")
|
||||
}
|
||||
codec, source, ok := subtitleCodecAtCombinedIndexV3(file, index, additional)
|
||||
entry, ok := subtitleEntryAtCombinedIndexV3(file, index, additional)
|
||||
if !ok {
|
||||
return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle track is unavailable.")
|
||||
}
|
||||
codec, source := entry.Codec, entry.Source
|
||||
trackID := TrackIDV3(file.ID, "subtitle", index)
|
||||
transportIndex := -1
|
||||
if source == "embedded" {
|
||||
@@ -74,6 +77,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID},
|
||||
Claims: SubtitleClaimsV3{ASSStylingPreserved: !ass || engineCaps.Subtitles.ASSStyling, Reason: "client_render_supported"},
|
||||
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
|
||||
DownloadedSubtitleID: entry.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
if request.SubtitleFidelityPreference == SubtitleFidelityCompatibleV3 {
|
||||
@@ -81,6 +85,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
Decision: SubtitleDecisionV3{Mode: SubtitleConvertV3, TrackID: trackID},
|
||||
Claims: SubtitleClaimsV3{Reason: "server_text_conversion"},
|
||||
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
|
||||
DownloadedSubtitleID: entry.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +100,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID},
|
||||
Claims: SubtitleClaimsV3{BitmapSidecar: true, Reason: "client_bitmap_render_supported"},
|
||||
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
|
||||
DownloadedSubtitleID: entry.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
if transcodeAllowed {
|
||||
@@ -105,25 +111,27 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
|
||||
Decision: SubtitleDecisionV3{Mode: SubtitleBurnInV3, TrackID: trackID},
|
||||
Claims: SubtitleClaimsV3{BitmapOverlay: burnInBitmap, Reason: "server_burn_in_required"},
|
||||
RequiresBurn: true, SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
|
||||
DownloadedSubtitleID: entry.DownloadedSubtitleID,
|
||||
}
|
||||
}
|
||||
return subtitleTerminalV3("subtitle_conversion_unsupported", fmt.Sprintf("Subtitle format %s cannot meet the selected fidelity policy.", codec))
|
||||
}
|
||||
|
||||
func subtitleCodecAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (codec, source string, ok bool) {
|
||||
func subtitleEntryAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (SubtitleInventoryEntryV3, bool) {
|
||||
if index < len(file.ExternalSubtitles) {
|
||||
return normalizeCodecV3(file.ExternalSubtitles[index].Format), "external", true
|
||||
return SubtitleInventoryEntryV3{CombinedIndex: index, Codec: normalizeCodecV3(file.ExternalSubtitles[index].Format), Source: "external"}, true
|
||||
}
|
||||
embedded := index - len(file.ExternalSubtitles)
|
||||
if embedded >= 0 && embedded < len(file.SubtitleTracks) {
|
||||
return normalizeCodecV3(file.SubtitleTracks[embedded].Codec), "embedded", true
|
||||
return SubtitleInventoryEntryV3{CombinedIndex: index, Codec: normalizeCodecV3(file.SubtitleTracks[embedded].Codec), Source: "embedded"}, true
|
||||
}
|
||||
for _, entry := range additional {
|
||||
if entry.CombinedIndex == index {
|
||||
return normalizeCodecV3(entry.Codec), entry.Source, true
|
||||
entry.Codec = normalizeCodecV3(entry.Codec)
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
return SubtitleInventoryEntryV3{}, false
|
||||
}
|
||||
|
||||
func isTextSubtitleV3(codec string) bool {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -79,6 +79,12 @@ type HTTPRoutesClient struct {
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
type WatchSyncProviderClient struct {
|
||||
client pluginv1.WatchSyncProviderClient
|
||||
deviceAuthorization pluginv1.WatchSyncDeviceAuthorizationServiceClient
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func newClient(installationID int, rpc *sdkruntime.Client, manifest *pluginv1.PluginManifest) *Client {
|
||||
capabilities := make(map[string]*pluginv1.CapabilityDescriptor, len(manifest.GetCapabilities()))
|
||||
for _, capability := range manifest.GetCapabilities() {
|
||||
@@ -208,6 +214,17 @@ func (c *Client) HTTPRoutes(capabilityID string) (*HTTPRoutesClient, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) WatchSyncProvider(capabilityID string) (*WatchSyncProviderClient, error) {
|
||||
if err := c.requireCapability("watch_sync_provider.v1", capabilityID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &WatchSyncProviderClient{
|
||||
client: c.rpc.WatchSyncProvider(),
|
||||
deviceAuthorization: c.rpc.WatchSyncDeviceAuthorization(),
|
||||
timeout: DefaultWatchSyncTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) markUnhealthy() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -385,6 +402,48 @@ func (c *HTTPRoutesClient) Handle(ctx context.Context, req *pluginv1.HandleHTTPR
|
||||
return c.client.Handle(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) ExchangeAPIKey(ctx context.Context, req *pluginv1.WatchSyncExchangeAPIKeyRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.ExchangeAPIKey(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) StartDeviceAuthorization(ctx context.Context, req *pluginv1.WatchSyncDeviceAuthorizationServiceStartRequest) (*pluginv1.WatchSyncDeviceAuthorizationServiceStartResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.deviceAuthorization.Start(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) PollDeviceAuthorization(ctx context.Context, req *pluginv1.WatchSyncDeviceAuthorizationServicePollRequest) (*pluginv1.WatchSyncDeviceAuthorizationServicePollResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.deviceAuthorization.Poll(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) RefreshCredentials(ctx context.Context, req *pluginv1.WatchSyncRefreshCredentialsRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.RefreshCredentials(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) GetAccount(ctx context.Context, req *pluginv1.WatchSyncGetAccountRequest) (*pluginv1.WatchSyncGetAccountResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.GetAccount(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) ApplyEvents(ctx context.Context, req *pluginv1.WatchSyncApplyEventsRequest) (*pluginv1.WatchSyncApplyEventsResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.ApplyEvents(callCtx, req)
|
||||
}
|
||||
|
||||
func (c *WatchSyncProviderClient) ListRemoteState(ctx context.Context, req *pluginv1.WatchSyncListRemoteStateRequest) (*pluginv1.WatchSyncListRemoteStateResponse, error) {
|
||||
callCtx, cancel := ensureDeadline(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.client.ListRemoteState(callCtx, req)
|
||||
}
|
||||
|
||||
func ensureDeadline(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return ctx, func() {}
|
||||
|
||||
@@ -23,6 +23,9 @@ const (
|
||||
DefaultEventTimeout = 10 * time.Second
|
||||
DefaultAuthTimeout = 10 * time.Second
|
||||
DefaultRouteTimeout = 10 * time.Second
|
||||
// Watch-sync providers may resolve cold external metadata before applying
|
||||
// an event. Bound that work without cutting off valid slow provider calls.
|
||||
DefaultWatchSyncTimeout = 2 * time.Minute
|
||||
// DefaultRequestRouterTimeout bounds a single request_router RPC.
|
||||
// Fulfillment hits remote arr instances, so allow generous headroom.
|
||||
DefaultRequestRouterTimeout = 60 * time.Second
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// ConfigSchemaView is the transport-safe representation of a manifest config
|
||||
// schema used by non-admin setup surfaces.
|
||||
type ConfigSchemaView struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
JSONSchema string `json:"json_schema"`
|
||||
Required bool `json:"required"`
|
||||
AdminForm *AdminFormView `json:"admin_form,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormView struct {
|
||||
Fields []AdminFormFieldView `json:"fields"`
|
||||
SubmitLabel string `json:"submit_label,omitempty"`
|
||||
Sections []AdminFormSectionView `json:"sections,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormFieldView struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Control string `json:"control"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
Secret bool `json:"secret"`
|
||||
Multiline bool `json:"multiline"`
|
||||
DefaultValue any `json:"default_value,omitempty"`
|
||||
Options []AdminFormOptionView `json:"options,omitempty"`
|
||||
Rows int32 `json:"rows,omitempty"`
|
||||
DynamicOptions bool `json:"dynamic_options,omitempty"`
|
||||
ShowWhen []AdminFormConditionView `json:"show_when,omitempty"`
|
||||
Validation *AdminFormValidationView `json:"validation,omitempty"`
|
||||
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormOptionView struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormConditionView struct {
|
||||
Field string `json:"field"`
|
||||
Equals []string `json:"equals"`
|
||||
}
|
||||
|
||||
type AdminFormValidationView struct {
|
||||
HasMin bool `json:"has_min,omitempty"`
|
||||
Min float64 `json:"min,omitempty"`
|
||||
HasMax bool `json:"has_max,omitempty"`
|
||||
Max float64 `json:"max,omitempty"`
|
||||
Pattern string `json:"pattern,omitempty"`
|
||||
MinLength int32 `json:"min_length,omitempty"`
|
||||
MaxLength int32 `json:"max_length,omitempty"`
|
||||
}
|
||||
|
||||
type AdminFormSectionView struct {
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Collapsible bool `json:"collapsible"`
|
||||
CollapsedDefault bool `json:"collapsed_default"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
ShowWhen []AdminFormConditionView `json:"show_when,omitempty"`
|
||||
}
|
||||
|
||||
func ConfigSchemaViews(schemas []*pluginv1.ConfigSchema) []ConfigSchemaView {
|
||||
views := make([]ConfigSchemaView, 0, len(schemas))
|
||||
for _, schema := range schemas {
|
||||
if schema == nil {
|
||||
continue
|
||||
}
|
||||
views = append(views, ConfigSchemaView{
|
||||
Key: schema.GetKey(),
|
||||
Title: schema.GetTitle(),
|
||||
Description: schema.GetDescription(),
|
||||
JSONSchema: schema.GetJsonSchema(),
|
||||
Required: schema.GetRequired(),
|
||||
AdminForm: AdminFormViewFromProto(schema.GetAdminForm()),
|
||||
})
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func AdminFormViewFromProto(form *pluginv1.AdminFormDescriptor) *AdminFormView {
|
||||
if form == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]AdminFormFieldView, 0, len(form.GetFields()))
|
||||
for _, field := range form.GetFields() {
|
||||
if field == nil {
|
||||
continue
|
||||
}
|
||||
options := make([]AdminFormOptionView, 0, len(field.GetOptions()))
|
||||
for _, option := range field.GetOptions() {
|
||||
if option != nil {
|
||||
options = append(options, AdminFormOptionView{Value: option.GetValue(), Label: option.GetLabel(), Description: option.GetDescription()})
|
||||
}
|
||||
}
|
||||
var defaultValue any
|
||||
if field.GetDefaultValue() != nil {
|
||||
defaultValue = field.GetDefaultValue().AsInterface()
|
||||
}
|
||||
var validation *AdminFormValidationView
|
||||
if value := field.GetValidation(); value != nil {
|
||||
validation = &AdminFormValidationView{
|
||||
HasMin: value.GetHasMin(), Min: value.GetMin(), HasMax: value.GetHasMax(), Max: value.GetMax(),
|
||||
Pattern: value.GetPattern(), MinLength: value.GetMinLength(), MaxLength: value.GetMaxLength(),
|
||||
}
|
||||
}
|
||||
fields = append(fields, AdminFormFieldView{
|
||||
Key: field.GetKey(), Label: field.GetLabel(), Description: field.GetDescription(),
|
||||
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
|
||||
Placeholder: field.GetPlaceholder(), Required: field.GetRequired(), Secret: field.GetSecret(),
|
||||
Multiline: field.GetMultiline(), DefaultValue: defaultValue, Options: options, Rows: field.GetRows(),
|
||||
DynamicOptions: field.GetDynamicOptions(), ShowWhen: adminFormConditionViews(field.GetShowWhen()),
|
||||
Validation: validation, ExclusiveGroupField: field.GetExclusiveGroupField(),
|
||||
})
|
||||
}
|
||||
sections := make([]AdminFormSectionView, 0, len(form.GetSections()))
|
||||
for _, section := range form.GetSections() {
|
||||
if section == nil {
|
||||
continue
|
||||
}
|
||||
sections = append(sections, AdminFormSectionView{
|
||||
Key: section.GetKey(), Title: section.GetTitle(), Description: section.GetDescription(),
|
||||
Collapsible: section.GetCollapsible(), CollapsedDefault: section.GetCollapsedDefault(),
|
||||
FieldKeys: append([]string(nil), section.GetFieldKeys()...), ShowWhen: adminFormConditionViews(section.GetShowWhen()),
|
||||
})
|
||||
}
|
||||
return &AdminFormView{Fields: fields, SubmitLabel: form.GetSubmitLabel(), Sections: sections}
|
||||
}
|
||||
|
||||
func adminFormConditionViews(conditions []*pluginv1.AdminFormCondition) []AdminFormConditionView {
|
||||
views := make([]AdminFormConditionView, 0, len(conditions))
|
||||
for _, condition := range conditions {
|
||||
if condition != nil {
|
||||
views = append(views, AdminFormConditionView{Field: condition.GetField(), Equals: append([]string(nil), condition.GetEquals()...)})
|
||||
}
|
||||
}
|
||||
return views
|
||||
}
|
||||
@@ -16,9 +16,16 @@ func GlobalConfigFieldSets(
|
||||
manifest *pluginv1.PluginManifest,
|
||||
configKey string,
|
||||
) (publicFields, secretFields []string) {
|
||||
return ConfigSchemaFieldSets(globalConfigSchema(manifest, configKey))
|
||||
}
|
||||
|
||||
// ConfigSchemaFieldSets returns the top-level public and secret fields for one
|
||||
// configuration schema. JSON Schema annotations and Admin form controls are
|
||||
// both authoritative; undeclared fields are intentionally absent from both
|
||||
// sets so callers can keep them on the protected path.
|
||||
func ConfigSchemaFieldSets(schema *pluginv1.ConfigSchema) (publicFields, secretFields []string) {
|
||||
declared := make(map[string]struct{})
|
||||
secrets := make(map[string]struct{})
|
||||
schema := globalConfigSchema(manifest, configKey)
|
||||
if schema != nil {
|
||||
if form := schema.GetAdminForm(); form != nil {
|
||||
for _, field := range form.GetFields() {
|
||||
|
||||
@@ -37,6 +37,7 @@ type pluginClient interface {
|
||||
EventConsumer(capabilityID string) (*pluginhost.EventConsumerClient, error)
|
||||
AuthProvider(capabilityID string) (*pluginhost.AuthProviderClient, error)
|
||||
HTTPRoutes(capabilityID string) (*pluginhost.HTTPRoutesClient, error)
|
||||
WatchSyncProvider(capabilityID string) (*pluginhost.WatchSyncProviderClient, error)
|
||||
}
|
||||
|
||||
type Host interface {
|
||||
@@ -648,6 +649,18 @@ func (s *Service) ScanSourceClientByPluginID(
|
||||
return s.ScanSourceClient(ctx, matches[0].ID, capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) WatchSyncProviderClient(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
capabilityID string,
|
||||
) (*pluginhost.WatchSyncProviderClient, error) {
|
||||
client, err := s.ensureClient(ctx, installationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.WatchSyncProvider(capabilityID)
|
||||
}
|
||||
|
||||
func (s *Service) EventConsumerClient(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
|
||||
@@ -237,6 +237,10 @@ func (f *fakePluginClient) HTTPRoutes(string) (*pluginhost.HTTPRoutesClient, err
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *fakePluginClient) WatchSyncProvider(string) (*pluginhost.WatchSyncProviderClient, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type fakeServiceInstallationStore struct {
|
||||
byID map[int]*Installation
|
||||
byPluginID map[string][]*Installation
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
// WatchSyncProviderConfig returns the installation's global configuration in
|
||||
// the transient, field-classified shape used by watch-sync RPCs. Undeclared
|
||||
// fields are treated as secret so manifest drift cannot expose credentials.
|
||||
func (s *Service) WatchSyncProviderConfig(
|
||||
ctx context.Context,
|
||||
installationID int,
|
||||
) (*pluginv1.WatchSyncProviderConfig, error) {
|
||||
manifest, err := s.manifestForInstallation(ctx, installationID, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.configs == nil {
|
||||
return &pluginv1.WatchSyncProviderConfig{}, nil
|
||||
}
|
||||
configs, err := s.configs.ListGlobalConfigs(ctx, installationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list watch sync plugin config: %w", err)
|
||||
}
|
||||
return watchSyncProviderConfig(manifest, configs)
|
||||
}
|
||||
|
||||
func watchSyncProviderConfig(
|
||||
manifest *pluginv1.PluginManifest,
|
||||
configs []*RuntimeConfig,
|
||||
) (*pluginv1.WatchSyncProviderConfig, error) {
|
||||
result := &pluginv1.WatchSyncProviderConfig{
|
||||
Values: make(map[string]string),
|
||||
SecretValues: make(map[string]string),
|
||||
}
|
||||
sort.Slice(configs, func(i, j int) bool {
|
||||
if configs[i] == nil {
|
||||
return false
|
||||
}
|
||||
if configs[j] == nil {
|
||||
return true
|
||||
}
|
||||
return configs[i].Key < configs[j].Key
|
||||
})
|
||||
for _, config := range configs {
|
||||
if config == nil {
|
||||
continue
|
||||
}
|
||||
configKey := strings.TrimSpace(config.Key)
|
||||
if configKey == "" {
|
||||
continue
|
||||
}
|
||||
publicFields, _ := GlobalConfigFieldSets(manifest, configKey)
|
||||
public := stringSet(publicFields)
|
||||
for field, raw := range config.Value {
|
||||
field = strings.TrimSpace(field)
|
||||
if field == "" {
|
||||
continue
|
||||
}
|
||||
value, err := watchSyncConfigString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode watch sync plugin config %q.%s: %w", configKey, field, err)
|
||||
}
|
||||
key := configKey + "." + field
|
||||
if _, isPublic := public[field]; isPublic {
|
||||
result.Values[key] = value
|
||||
continue
|
||||
}
|
||||
// Explicitly secret and undeclared fields both take the protected path.
|
||||
result.SecretValues[key] = value
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func stringSet(values []string) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func watchSyncConfigString(value any) (string, error) {
|
||||
if text, ok := value.(string); ok {
|
||||
return text, nil
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
)
|
||||
|
||||
func TestWatchSyncProviderConfigClassifiesManifestFields(t *testing.T) {
|
||||
manifest := &pluginv1.PluginManifest{GlobalConfigSchema: []*pluginv1.ConfigSchema{{
|
||||
Key: "provider",
|
||||
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{
|
||||
{Key: "base_url", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT},
|
||||
{Key: "client_secret", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD, Secret: true},
|
||||
}},
|
||||
}}}
|
||||
config, err := watchSyncProviderConfig(manifest, []*RuntimeConfig{nil, {
|
||||
Key: " provider ",
|
||||
Value: map[string]any{
|
||||
" base_url ": "https://floppy.example",
|
||||
"client_secret": "secret",
|
||||
"undeclared": map[string]any{"token": "also-secret"},
|
||||
" ": "ignored-empty-field",
|
||||
},
|
||||
}, {
|
||||
Key: "legacy",
|
||||
Value: map[string]any{
|
||||
"base_url": "https://legacy-floppy.example",
|
||||
},
|
||||
}, {
|
||||
Key: " ",
|
||||
Value: map[string]any{
|
||||
"base_url": "ignored-empty-key",
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.GetValues()["provider.base_url"] != "https://floppy.example" ||
|
||||
config.GetSecretValues()["provider.client_secret"] != "secret" ||
|
||||
config.GetSecretValues()["provider.undeclared"] != `{"token":"also-secret"}` ||
|
||||
config.GetSecretValues()["legacy.base_url"] != "https://legacy-floppy.example" {
|
||||
t.Fatalf("config = %#v", config)
|
||||
}
|
||||
if _, exposed := config.GetValues()["provider.undeclared"]; exposed {
|
||||
t.Fatal("undeclared plugin config was exposed as public")
|
||||
}
|
||||
if _, present := config.GetSecretValues()["."]; present {
|
||||
t.Fatal("empty plugin config key or field was emitted")
|
||||
}
|
||||
}
|
||||
+124
-8
@@ -266,10 +266,18 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
|
||||
|
||||
const (
|
||||
maxReasonableMediaDurationSeconds = 100_000
|
||||
// Corroborated metadata and packet-derived durations have stronger evidence
|
||||
// than a lone container timestamp, so they may use the same bounded ceiling
|
||||
// as long-form audio. This supports multi-day video without accepting the
|
||||
// multi-year timelines seen in malformed containers.
|
||||
maxValidatedMediaDurationSeconds = 1_000_000
|
||||
// Audio-only files (audiobooks, podcasts) legitimately exceed the video
|
||||
// ceiling, but still need a cap so malformed containers cannot persist
|
||||
// multi-year durations.
|
||||
maxReasonableAudioDurationSeconds = 1_000_000
|
||||
maxReasonableAudioDurationSeconds = maxValidatedMediaDurationSeconds
|
||||
|
||||
longVideoDurationAbsoluteToleranceSeconds = 1
|
||||
longVideoDurationRelativeTolerance = 0.001
|
||||
)
|
||||
|
||||
// A video duration is implausible when it is either far too short in absolute
|
||||
@@ -346,9 +354,90 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) {
|
||||
if duration > 0 && !durationLooksImplausible(raw, duration) {
|
||||
return truncatedDuration(duration), true
|
||||
}
|
||||
if duration, ok := corroboratedLongVideoDuration(raw, formatDuration); ok {
|
||||
return truncatedDuration(duration), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// corroboratedLongVideoDuration accepts an extended-range video duration only
|
||||
// when the container and the primary video stream independently report nearly the
|
||||
// same value. A small absolute/relative tolerance covers container rounding
|
||||
// and stream-boundary differences without trusting a lone malformed timeline.
|
||||
func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) (float64, bool) {
|
||||
if raw == nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
for _, stream := range raw.Streams {
|
||||
if !isMainVideoStream(stream) {
|
||||
continue
|
||||
}
|
||||
streamDuration := parseFloat(stream.Duration)
|
||||
formatStart := parseFloat(raw.Format.StartTime)
|
||||
streamStart := parseFloat(stream.StartTime)
|
||||
|
||||
// Some MPEG-TS/HLS timelines report duration as an absolute end
|
||||
// timestamp. Use normalized spans to reconcile raw end timestamps that
|
||||
// disagree, or when matching timestamps have a dominant start offset that
|
||||
// strongly indicates the absolute-end shape. Ordinary non-zero starts remain
|
||||
// part of an already corroborated raw duration.
|
||||
normalizedFormatDuration := durationAfterStartWithinValidatedLimit(
|
||||
formatDuration,
|
||||
formatStart,
|
||||
)
|
||||
normalizedStreamDuration := durationAfterStartWithinValidatedLimit(
|
||||
streamDuration,
|
||||
streamStart,
|
||||
)
|
||||
rawDurationsAgree := longVideoDurationsAgree(formatDuration, streamDuration)
|
||||
normalizedDurationsAgree := longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration)
|
||||
matchingAbsoluteEnds := rawDurationsAgree &&
|
||||
durationHasDominantStartOffset(formatDuration, formatStart) &&
|
||||
durationHasDominantStartOffset(streamDuration, streamStart)
|
||||
if normalizedDurationsAgree && (!rawDurationsAgree || matchingAbsoluteEnds) &&
|
||||
!durationLooksImplausible(raw, normalizedFormatDuration) {
|
||||
return normalizedFormatDuration, true
|
||||
}
|
||||
if matchingAbsoluteEnds {
|
||||
// Dominant starts identify the raw values as absolute end
|
||||
// timestamps. If their normalized spans do not corroborate, reject
|
||||
// the metadata for packet repair instead of persisting an inflated
|
||||
// raw end timestamp.
|
||||
return 0, false
|
||||
}
|
||||
|
||||
if rawDurationsAgree &&
|
||||
!durationLooksImplausible(raw, formatDuration) {
|
||||
return formatDuration, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func longVideoDurationsAgree(first, second float64) bool {
|
||||
if first <= maxReasonableMediaDurationSeconds ||
|
||||
!durationIsWithinValidatedLimit(first) ||
|
||||
!durationIsWithinValidatedLimit(second) {
|
||||
return false
|
||||
}
|
||||
tolerance := max(
|
||||
longVideoDurationAbsoluteToleranceSeconds,
|
||||
max(first, second)*longVideoDurationRelativeTolerance,
|
||||
)
|
||||
return math.Abs(first-second) <= tolerance
|
||||
}
|
||||
|
||||
// durationHasDominantStartOffset identifies the conservative absolute-end
|
||||
// shape where the start timestamp occupies at least half of the reported end.
|
||||
// Smaller starts are common media offsets and cannot disambiguate a duration
|
||||
// field from an absolute end timestamp.
|
||||
func durationHasDominantStartOffset(end, start float64) bool {
|
||||
return start > 0 && end > start && start >= end-start
|
||||
}
|
||||
|
||||
func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool {
|
||||
if raw == nil {
|
||||
return false
|
||||
@@ -368,10 +457,25 @@ func durationAfterStart(end, start float64) float64 {
|
||||
return duration
|
||||
}
|
||||
|
||||
func durationAfterStartWithinValidatedLimit(end, start float64) float64 {
|
||||
if start <= 0 || end <= start {
|
||||
return 0
|
||||
}
|
||||
duration := end - start
|
||||
if !durationIsWithinValidatedLimit(duration) {
|
||||
return 0
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
func durationIsReasonable(duration float64) bool {
|
||||
return durationIsPositiveFinite(duration) && duration <= maxReasonableMediaDurationSeconds
|
||||
}
|
||||
|
||||
func durationIsWithinValidatedLimit(duration float64) bool {
|
||||
return durationIsPositiveFinite(duration) && duration <= maxValidatedMediaDurationSeconds
|
||||
}
|
||||
|
||||
func durationIsPositiveFinite(duration float64) bool {
|
||||
return duration > 0 && !math.IsNaN(duration) && !math.IsInf(duration, 0)
|
||||
}
|
||||
@@ -458,18 +562,30 @@ func estimateVideoPacketDuration(reader io.Reader, frameRate string) int {
|
||||
maxTimestamp = max(maxTimestamp, timestamp)
|
||||
}
|
||||
|
||||
best := 0.0
|
||||
packetSpan := 0.0
|
||||
if !math.IsInf(minTimestamp, 1) && !math.IsInf(maxTimestamp, -1) {
|
||||
span := maxTimestamp - minTimestamp
|
||||
if durationIsReasonable(span) {
|
||||
best = span
|
||||
if durationIsWithinValidatedLimit(span) {
|
||||
packetSpan = span
|
||||
}
|
||||
}
|
||||
|
||||
frameDuration := 0.0
|
||||
if fps := parseFrameRate(frameRate); fps > 0 && packetCount > 0 {
|
||||
frameDuration := float64(packetCount) / fps
|
||||
if durationIsReasonable(frameDuration) && frameDuration > best {
|
||||
best = frameDuration
|
||||
}
|
||||
frameDuration = float64(packetCount) / fps
|
||||
}
|
||||
|
||||
best := packetSpan
|
||||
if packetSpan > maxReasonableMediaDurationSeconds &&
|
||||
durationIsReasonable(frameDuration) &&
|
||||
!longVideoDurationsAgree(packetSpan, frameDuration) {
|
||||
// A long PTS span is strong evidence only when a sane frame-count
|
||||
// estimate contradicts it. Malformed frame rates can produce finite but
|
||||
// unusable estimates and must not veto an otherwise valid packet span.
|
||||
best = 0
|
||||
}
|
||||
if durationIsReasonable(frameDuration) && frameDuration > best {
|
||||
best = frameDuration
|
||||
}
|
||||
if best <= 0 {
|
||||
return 0
|
||||
|
||||
@@ -96,6 +96,241 @@ func TestDurationFromProbeMetadataKeepsLongAudioDurationInSeconds(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
Duration: "182930.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
Duration: "182930.196000",
|
||||
AvgFrameRate: "24/1",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 182930 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithOrdinaryStartTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "30.000000",
|
||||
Duration: "182930.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "30.000000",
|
||||
Duration: "182930.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 182930 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithMaterialStartTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "300.000000",
|
||||
Duration: "182930.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "300.000000",
|
||||
Duration: "182930.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 182930 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataNormalizesOffsetBeforeCorroboratingLongVideo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "180000.000000",
|
||||
Duration: "182930.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "180000.000000",
|
||||
Duration: "182930.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 2930 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 2930, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataNormalizesCorroboratedLongOffsetSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350000.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "200000.000000",
|
||||
Duration: "370000.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 170000 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataNormalizesMatchingLongAbsoluteEndTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350000.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350000.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if !ok || got != 170000 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataRejectsDisagreeingAbsoluteEndSpans(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350000.275000",
|
||||
Size: "77507139196",
|
||||
},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
StartTime: "180000.000000",
|
||||
Duration: "350300.196000",
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if ok || got != 0 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataRejectsUncorroboratedLongVideoDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
formatDuration string
|
||||
streamDuration string
|
||||
}{
|
||||
{name: "missing stream duration", formatDuration: "182930.275000"},
|
||||
{name: "disagreeing durations", formatDuration: "182930.275000", streamDuration: "150000.000000"},
|
||||
{name: "beyond validated limit", formatDuration: "1000001.000000", streamDuration: "1000001.000000"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{Duration: tc.formatDuration, Size: "77507139196"},
|
||||
Streams: []ffprobeStream{{
|
||||
CodecType: "video",
|
||||
Duration: tc.streamDuration,
|
||||
}},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if ok || got != 0 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationFromProbeMetadataDoesNotUseSecondaryVideoForCorroboration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
raw := &ffprobeOutput{
|
||||
Format: ffprobeFormat{Duration: "182930.275000", Size: "77507139196"},
|
||||
Streams: []ffprobeStream{
|
||||
{CodecType: "video", Duration: "150000.000000"},
|
||||
{CodecType: "video", Duration: "182930.196000"},
|
||||
},
|
||||
}
|
||||
|
||||
got, ok := durationFromProbeMetadata(raw)
|
||||
if ok || got != 0 {
|
||||
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeFileSkipsPacketScanForCorroboratedLongVideo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
ffprobePath := filepath.Join(tempDir, "ffprobe")
|
||||
packetScanMarker := filepath.Join(tempDir, "packet-scan-called")
|
||||
script := `#!/bin/sh
|
||||
case " $* " in
|
||||
*" -show_format "*)
|
||||
printf '%s\n' '{"format":{"duration":"182930.275000","size":"77507139196"},"streams":[{"codec_type":"video","duration":"182930.196000","avg_frame_rate":"24/1"}]}'
|
||||
;;
|
||||
*)
|
||||
: > "` + packetScanMarker + `"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(ffprobePath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("writing fake ffprobe: %v", err)
|
||||
}
|
||||
|
||||
probe, err := ProbeFile(context.Background(), ffprobePath, "long.mp4")
|
||||
if err != nil {
|
||||
t.Fatalf("ProbeFile() returned error: %v", err)
|
||||
}
|
||||
if probe.Duration != 182930 {
|
||||
t.Fatalf("ProbeFile() duration = %d, want 182930", probe.Duration)
|
||||
}
|
||||
if _, err := os.Stat(packetScanMarker); !os.IsNotExist(err) {
|
||||
t.Fatalf("packet fallback ran for corroborated metadata; stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -106,6 +341,72 @@ func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationKeepsValidatedLongDuration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
packets := strings.NewReader("0.000000\n182930.196000\n")
|
||||
got := estimateVideoPacketDuration(packets, "")
|
||||
if got != 182930 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 182930", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationRejectsDurationBeyondValidatedLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
packets := strings.NewReader("0.000000\n1000001.000000\n")
|
||||
got := estimateVideoPacketDuration(packets, "")
|
||||
if got != 0 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationKeepsOrdinaryCapForFrameRateEstimate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var packets strings.Builder
|
||||
for range 900 {
|
||||
packets.WriteString("3.022000\n")
|
||||
}
|
||||
|
||||
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "1/1000")
|
||||
if got != 0 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationIgnoresUnusableFrameEstimateForLongSpan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var packets strings.Builder
|
||||
packets.WriteString("0.000000\n")
|
||||
for range 898 {
|
||||
packets.WriteString("5.000000\n")
|
||||
}
|
||||
packets.WriteString("182930.196000\n")
|
||||
|
||||
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "1/1000")
|
||||
if got != 182930 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 182930", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationRejectsOutlierSpanWhenFrameCountDisagrees(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var packets strings.Builder
|
||||
packets.WriteString("0.000000\n")
|
||||
for range 298 {
|
||||
packets.WriteString("5.000000\n")
|
||||
}
|
||||
packets.WriteString("500000.000000\n")
|
||||
|
||||
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "30/1")
|
||||
if got != 10 {
|
||||
t.Fatalf("estimateVideoPacketDuration() = %d, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimateVideoPacketDurationUsesFrameCountForCollapsedTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -102,6 +102,44 @@ func TestNeedsCriticalProbeRepair_ImplausiblyShortLargeVideoRepairs(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsCriticalProbeRepair_LongVideoConverges(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
f := &models.MediaFile{
|
||||
ProbeSource: "local",
|
||||
ProbeUpdatedAt: &now,
|
||||
FileSize: 77_507_139_196,
|
||||
Duration: 182930,
|
||||
Container: "mp4",
|
||||
CodecAudio: "aac",
|
||||
AudioTracks: []models.AudioTrack{{Language: "eng"}},
|
||||
CodecVideo: "h264",
|
||||
Resolution: "1080p",
|
||||
VideoTracks: []models.VideoTrack{{Codec: "h264", ColorRange: "unknown"}},
|
||||
Chapters: []models.MediaChapter{},
|
||||
}
|
||||
|
||||
if NeedsCriticalProbeRepair(f) {
|
||||
t.Fatal("an accepted long video must not need request-time probe repair")
|
||||
}
|
||||
|
||||
scanFile := &scanStateFile{
|
||||
ProbeSource: f.ProbeSource,
|
||||
ProbeUpdatedAt: f.ProbeUpdatedAt,
|
||||
FileSize: f.FileSize,
|
||||
Duration: f.Duration,
|
||||
Container: f.Container,
|
||||
CodecVideo: f.CodecVideo,
|
||||
CodecAudio: f.CodecAudio,
|
||||
Resolution: f.Resolution,
|
||||
HasVideoTracks: true,
|
||||
HasAudioTracks: true,
|
||||
HasChapters: true,
|
||||
}
|
||||
if needsCriticalProbeRepairScanState(scanFile) {
|
||||
t.Fatal("an accepted long video must not be reprobed on subsequent library scans")
|
||||
}
|
||||
}
|
||||
|
||||
// A short duration re-derived by the fixed parser (packet scan) is
|
||||
// authoritative: re-flagging it would reprobe genuinely short clips on every
|
||||
// playback decision forever.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
@@ -248,6 +249,10 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
|
||||
}
|
||||
rows := batch.Rows
|
||||
result := ImportListResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
|
||||
existingByRemoteKey, err := s.listItemStatesByRemoteKey(ctx, conn.ID, b.kind, rows)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
seenRemoteKeys := make(map[string]bool, len(rows))
|
||||
states := make([]ListItemState, 0, len(rows))
|
||||
// orderedIDs records matched local ids in the order the provider returned
|
||||
@@ -255,6 +260,12 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
|
||||
// sort locally.
|
||||
orderedIDs := make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if row.Removed {
|
||||
if err := s.applyRemoteListTombstone(ctx, conn, store, b, row, existingByRemoteKey, &result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
|
||||
if err != nil {
|
||||
return result, err
|
||||
@@ -296,10 +307,14 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
|
||||
if err := s.repo.UpsertListItemStates(ctx, states); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := s.reconcileMissingRemoteListItems(ctx, conn, store, b, seenRemoteKeys, &result); err != nil {
|
||||
return result, err
|
||||
if !batch.Incremental {
|
||||
if err := s.reconcileMissingRemoteListItems(ctx, conn, store, b, seenRemoteKeys, &result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
if b.applyOrder != nil && b.orderEnabled != nil && b.capOrder != nil &&
|
||||
// An incremental response is only a delta; replacing the whole local order
|
||||
// with that subset would discard every item absent from the delta.
|
||||
if !batch.Incremental && b.applyOrder != nil && b.orderEnabled != nil && b.capOrder != nil &&
|
||||
b.orderEnabled(conn) && b.capOrder(provider.Capabilities()) {
|
||||
if err := b.applyOrder(ctx, store, conn.ProfileID, orderedIDs); err != nil {
|
||||
return result, err
|
||||
@@ -315,6 +330,69 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) listItemStatesByRemoteKey(
|
||||
ctx context.Context,
|
||||
connectionID string,
|
||||
kind ListKind,
|
||||
rows []RemoteFavorite,
|
||||
) (map[string]ListItemState, error) {
|
||||
hasTombstone := false
|
||||
for _, row := range rows {
|
||||
if row.Removed {
|
||||
hasTombstone = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTombstone {
|
||||
return nil, nil
|
||||
}
|
||||
states, err := s.repo.ListListItemStates(ctx, connectionID, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byRemoteKey := make(map[string]ListItemState, len(states))
|
||||
for _, state := range states {
|
||||
if key := strings.TrimSpace(state.ProviderItemKey); key != "" {
|
||||
byRemoteKey[key] = state
|
||||
}
|
||||
}
|
||||
return byRemoteKey, nil
|
||||
}
|
||||
|
||||
func (s *Service) applyRemoteListTombstone(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
store userstore.UserStore,
|
||||
b listBinding,
|
||||
row RemoteFavorite,
|
||||
existingByRemoteKey map[string]ListItemState,
|
||||
result *ImportListResult,
|
||||
) error {
|
||||
key := strings.TrimSpace(row.ProviderItemKey)
|
||||
state, ok := existingByRemoteKey[key]
|
||||
if key == "" || !ok {
|
||||
result.Warnings = append(result.Warnings, "watch sync provider returned an unknown list removal")
|
||||
return nil
|
||||
}
|
||||
now := s.now()
|
||||
if b.removalsEnabled(conn) && state.LocalPresent {
|
||||
if err := b.localRemove(ctx, store, conn.ProfileID, state.MediaItemID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.MarkListItemLocalRemoved(ctx, conn.ID, b.kind, state.MediaItemID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Removed++
|
||||
state.LocalPresent = false
|
||||
}
|
||||
if err := s.repo.MarkListItemRemoteRemoved(ctx, conn.ID, b.kind, state.MediaItemID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
state.RemotePresent = false
|
||||
existingByRemoteKey[key] = state
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) reconcileMissingRemoteListItems(ctx context.Context, conn Connection, store userstore.UserStore, b listBinding, seenRemoteKeys map[string]bool, result *ImportListResult) error {
|
||||
states, err := s.repo.ListListItemStates(ctx, conn.ID, b.kind)
|
||||
if err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,538 @@
|
||||
package watchsync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
|
||||
"github.com/Silo-Server/silo-server/internal/historyimport"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
const (
|
||||
pluginWatchedCursorKey = "plugin.remote.watched"
|
||||
pluginProgressCursorKey = "plugin.remote.progress"
|
||||
pluginFavoritesCursorKey = "plugin.remote.favorites"
|
||||
pluginWatchlistCursorKey = "plugin.remote.watchlist"
|
||||
maxRemoteStatePages = 10_000
|
||||
maxRemoteStateItems = 100_000
|
||||
)
|
||||
|
||||
type pluginRemoteTraversal struct {
|
||||
items []*pluginv1.WatchSyncRemoteState
|
||||
nextCursor string
|
||||
completeSnapshot bool
|
||||
warnings []string
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchWatched(
|
||||
ctx context.Context,
|
||||
cfg ServerConfig,
|
||||
conn Connection,
|
||||
) ([]RemoteWatch, error) {
|
||||
batch, err := p.FetchWatchedBatch(ctx, cfg, conn)
|
||||
return batch.Rows, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchWatchedBatch(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
) (WatchedImportBatch, error) {
|
||||
traversal, err := p.listRemoteState(ctx, conn, pluginWatchedCursorKey,
|
||||
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHED)
|
||||
if err != nil {
|
||||
return WatchedImportBatch{}, err
|
||||
}
|
||||
batch := WatchedImportBatch{
|
||||
UpdatedCursors: cursorUpdate(pluginWatchedCursorKey, traversal.nextCursor),
|
||||
Warnings: traversal.warnings,
|
||||
}
|
||||
for _, state := range traversal.items {
|
||||
if state.GetWatched() == nil {
|
||||
continue
|
||||
}
|
||||
row, err := remoteWatchFromProto(p.Key(), state)
|
||||
if err != nil {
|
||||
batch.Warnings = append(batch.Warnings, err.Error())
|
||||
continue
|
||||
}
|
||||
batch.Rows = append(batch.Rows, row)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchProgress(
|
||||
ctx context.Context,
|
||||
cfg ServerConfig,
|
||||
conn Connection,
|
||||
) ([]RemoteProgress, error) {
|
||||
batch, err := p.FetchProgressBatch(ctx, cfg, conn)
|
||||
return batch.Rows, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchProgressBatch(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
) (ProgressImportBatch, error) {
|
||||
traversal, err := p.listRemoteState(ctx, conn, pluginProgressCursorKey,
|
||||
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_PROGRESS)
|
||||
if err != nil {
|
||||
return ProgressImportBatch{}, err
|
||||
}
|
||||
batch := ProgressImportBatch{
|
||||
UpdatedCursors: cursorUpdate(pluginProgressCursorKey, traversal.nextCursor),
|
||||
Warnings: traversal.warnings,
|
||||
}
|
||||
for _, state := range traversal.items {
|
||||
if state.GetProgress() == nil {
|
||||
continue
|
||||
}
|
||||
row, err := remoteProgressFromProto(p.Key(), state)
|
||||
if err != nil {
|
||||
batch.Warnings = append(batch.Warnings, err.Error())
|
||||
continue
|
||||
}
|
||||
batch.Rows = append(batch.Rows, row)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchFavorites(
|
||||
ctx context.Context,
|
||||
cfg ServerConfig,
|
||||
conn Connection,
|
||||
) ([]RemoteFavorite, error) {
|
||||
batch, err := p.FetchFavoritesBatch(ctx, cfg, conn)
|
||||
return batch.Rows, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchFavoritesBatch(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
) (FavoriteImportBatch, error) {
|
||||
return p.fetchListState(ctx, conn, pluginFavoritesCursorKey,
|
||||
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchWatchlist(
|
||||
ctx context.Context,
|
||||
cfg ServerConfig,
|
||||
conn Connection,
|
||||
) ([]RemoteFavorite, error) {
|
||||
batch, err := p.FetchWatchlistBatch(ctx, cfg, conn)
|
||||
return batch.Rows, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) FetchWatchlistBatch(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
) (FavoriteImportBatch, error) {
|
||||
return p.fetchListState(ctx, conn, pluginWatchlistCursorKey,
|
||||
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) fetchListState(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
cursorKey string,
|
||||
kind pluginv1.WatchSyncRemoteStateKind,
|
||||
) (FavoriteImportBatch, error) {
|
||||
traversal, err := p.listRemoteState(ctx, conn, cursorKey, kind)
|
||||
if err != nil {
|
||||
return FavoriteImportBatch{}, err
|
||||
}
|
||||
batch := FavoriteImportBatch{
|
||||
UpdatedCursors: cursorUpdate(cursorKey, traversal.nextCursor),
|
||||
Warnings: traversal.warnings,
|
||||
Incremental: !traversal.completeSnapshot,
|
||||
}
|
||||
for _, state := range traversal.items {
|
||||
var listed *pluginv1.WatchSyncRemoteListState
|
||||
if kind == pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE {
|
||||
listed = state.GetFavorite()
|
||||
} else {
|
||||
listed = state.GetWatchlist()
|
||||
}
|
||||
if listed == nil {
|
||||
continue
|
||||
}
|
||||
row, err := remoteFavoriteFromProto(p.Key(), state, listed)
|
||||
if err != nil {
|
||||
batch.Warnings = append(batch.Warnings, err.Error())
|
||||
continue
|
||||
}
|
||||
batch.Rows = append(batch.Rows, row)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (p *PluginProvider) listRemoteState(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
cursorKey string,
|
||||
kind pluginv1.WatchSyncRemoteStateKind,
|
||||
) (pluginRemoteTraversal, error) {
|
||||
client, err := p.resolveClient(ctx, p.installationID, p.capabilityID)
|
||||
if err != nil {
|
||||
return pluginRemoteTraversal{}, watchSyncUnavailableError()
|
||||
}
|
||||
cursor := conn.SyncCursors[cursorKey]
|
||||
pageToken := ""
|
||||
seenPageTokens := make(map[string]struct{})
|
||||
result := pluginRemoteTraversal{}
|
||||
snapshotSet := false
|
||||
for page := 0; page < maxRemoteStatePages; page++ {
|
||||
authContext, err := p.authenticatedContext(ctx, conn)
|
||||
if err != nil {
|
||||
return pluginRemoteTraversal{}, err
|
||||
}
|
||||
response, err := client.ListRemoteState(ctx, &pluginv1.WatchSyncListRemoteStateRequest{
|
||||
Context: authContext,
|
||||
Cursor: cursor,
|
||||
PageToken: pageToken,
|
||||
PageSize: int32(max(1, p.ExportBatchSize())),
|
||||
StateKinds: []pluginv1.WatchSyncRemoteStateKind{kind},
|
||||
})
|
||||
if err != nil {
|
||||
return pluginRemoteTraversal{}, watchSyncRPCError()
|
||||
}
|
||||
if response.GetUpdatedCredentials() != nil {
|
||||
conn, err = p.persistUpdatedCredentials(ctx, conn, response.GetUpdatedCredentials())
|
||||
if err != nil {
|
||||
return pluginRemoteTraversal{}, err
|
||||
}
|
||||
}
|
||||
if err := watchSyncFaultError(p.Key(), response.GetFault(), conn.AccessToken, conn.RefreshToken); err != nil {
|
||||
return pluginRemoteTraversal{}, err
|
||||
}
|
||||
if kind == pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST &&
|
||||
p.descriptor.GetProvidesWatchlistOrder() && !response.GetCompleteSnapshot() {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin returned an incremental traversal for an ordered watchlist")
|
||||
}
|
||||
if snapshotSet && result.completeSnapshot != response.GetCompleteSnapshot() {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin changed snapshot mode during pagination")
|
||||
}
|
||||
result.completeSnapshot = response.GetCompleteSnapshot()
|
||||
snapshotSet = true
|
||||
items := response.GetItems()
|
||||
if len(items) > maxRemoteStateItems-len(result.items) {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin exceeded the remote-state item limit")
|
||||
}
|
||||
result.items = append(result.items, items...)
|
||||
|
||||
nextPage := strings.TrimSpace(response.GetNextPageToken())
|
||||
if nextPage == "" {
|
||||
result.nextCursor = response.GetNextCursor()
|
||||
return result, nil
|
||||
}
|
||||
if strings.TrimSpace(response.GetNextCursor()) != "" {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin returned a durable cursor before the final page")
|
||||
}
|
||||
if _, duplicate := seenPageTokens[nextPage]; duplicate {
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin repeated a page token")
|
||||
}
|
||||
seenPageTokens[nextPage] = struct{}{}
|
||||
pageToken = nextPage
|
||||
}
|
||||
return pluginRemoteTraversal{}, errors.New("watch sync plugin exceeded the remote-state page limit")
|
||||
}
|
||||
|
||||
func (p *PluginProvider) RemoveHistory(
|
||||
ctx context.Context,
|
||||
_ ServerConfig,
|
||||
conn Connection,
|
||||
plays []LocalPlay,
|
||||
) (ExportResult, error) {
|
||||
result := ExportResult{Failed: make(map[string]string)}
|
||||
events := make([]*pluginv1.WatchSyncEvent, 0, len(plays))
|
||||
keys := make([]string, 0, len(plays))
|
||||
for _, play := range plays {
|
||||
event := watchEventFromLocalPlay(play, pluginv1.WatchSyncOrigin_WATCH_SYNC_ORIGIN_MANUAL)
|
||||
event.EventId = "unwatched:" + play.HistoryID
|
||||
event.Operation = pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_MARK_UNWATCHED
|
||||
event.ProviderItemKey = play.ProviderItemKey
|
||||
if !p.supportsMedia(event.GetMedia().GetMediaType()) {
|
||||
result.Failed[play.HistoryID] = unsupportedWatchSyncMediaMessage(event.GetMedia().GetMediaType())
|
||||
continue
|
||||
}
|
||||
events = append(events, event)
|
||||
keys = append(keys, play.HistoryID)
|
||||
}
|
||||
applied, err := p.applyPluginEvents(ctx, conn, events, keys)
|
||||
return mergeExportFailures(applied, result.Failed), err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) ExportFavorites(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
|
||||
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_FAVORITE)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) RemoveFavorites(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
|
||||
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_FAVORITE)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) ExportWatchlist(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
|
||||
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) RemoveWatchlist(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
|
||||
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_FROM_WATCHLIST)
|
||||
}
|
||||
|
||||
func (p *PluginProvider) applyListEvents(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
items []LocalFavorite,
|
||||
operation pluginv1.WatchSyncOperation,
|
||||
) (ExportResult, error) {
|
||||
result := ExportResult{Failed: make(map[string]string)}
|
||||
events := make([]*pluginv1.WatchSyncEvent, 0, len(items))
|
||||
keys := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
media := mediaFromIdentity(item.MediaItemID, item.Kind, item.Title, item.Year,
|
||||
item.IMDbID, item.TMDBID, item.TVDBID, "", 0,
|
||||
item.SeriesIMDbID, item.SeriesTMDBID, item.SeriesTVDBID, 0, 0)
|
||||
if !p.supportsMedia(media.GetMediaType()) {
|
||||
result.Failed[item.MediaItemID] = unsupportedWatchSyncMediaMessage(media.GetMediaType())
|
||||
continue
|
||||
}
|
||||
event := &pluginv1.WatchSyncEvent{
|
||||
EventId: fmt.Sprintf("%s:%s", operation.String(), item.MediaItemID),
|
||||
Operation: operation,
|
||||
Origin: pluginv1.WatchSyncOrigin_WATCH_SYNC_ORIGIN_MANUAL,
|
||||
OccurredAt: timestampOrNil(item.FavoritedAt),
|
||||
Media: media,
|
||||
ProviderItemKey: item.ProviderItemKey,
|
||||
}
|
||||
if operation == pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST {
|
||||
position := int32(len(events))
|
||||
event.ListPosition = &position
|
||||
}
|
||||
events = append(events, event)
|
||||
keys = append(keys, item.MediaItemID)
|
||||
}
|
||||
applied, err := p.applyPluginEvents(ctx, conn, events, keys)
|
||||
return mergeExportFailures(applied, result.Failed), err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) applyPluginEvents(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
events []*pluginv1.WatchSyncEvent,
|
||||
keys []string,
|
||||
) (ExportResult, error) {
|
||||
result, _, err := p.applyPluginEventsDetailed(ctx, conn, events, keys)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (p *PluginProvider) applyPluginEventsDetailed(
|
||||
ctx context.Context,
|
||||
conn Connection,
|
||||
events []*pluginv1.WatchSyncEvent,
|
||||
keys []string,
|
||||
) (ExportResult, map[string]pluginv1.WatchSyncApplyStatus, error) {
|
||||
result := ExportResult{Failed: make(map[string]string)}
|
||||
statuses := make(map[string]pluginv1.WatchSyncApplyStatus, len(events))
|
||||
if len(events) == 0 {
|
||||
return result, statuses, nil
|
||||
}
|
||||
client, err := p.resolveClient(ctx, p.installationID, p.capabilityID)
|
||||
if err != nil {
|
||||
return result, statuses, watchSyncUnavailableError()
|
||||
}
|
||||
for offset := 0; offset < len(events); offset += max(1, p.ExportBatchSize()) {
|
||||
end := min(len(events), offset+max(1, p.ExportBatchSize()))
|
||||
authContext, err := p.authenticatedContext(ctx, conn)
|
||||
if err != nil {
|
||||
return result, statuses, err
|
||||
}
|
||||
response, err := client.ApplyEvents(ctx, &pluginv1.WatchSyncApplyEventsRequest{
|
||||
Context: authContext,
|
||||
Events: events[offset:end],
|
||||
})
|
||||
if err != nil {
|
||||
return result, statuses, watchSyncRPCError()
|
||||
}
|
||||
if response.GetUpdatedCredentials() != nil {
|
||||
conn, err = p.persistUpdatedCredentials(ctx, conn, response.GetUpdatedCredentials())
|
||||
if err != nil {
|
||||
return result, statuses, err
|
||||
}
|
||||
}
|
||||
if err := watchSyncFaultError(p.Key(), response.GetFault(), conn.AccessToken, conn.RefreshToken); err != nil {
|
||||
return result, statuses, err
|
||||
}
|
||||
for index, event := range events[offset:end] {
|
||||
key := keys[offset+index]
|
||||
apply := resultForEvent(response.GetResults(), event.GetEventId())
|
||||
statuses[key] = apply.GetStatus()
|
||||
switch apply.GetStatus() {
|
||||
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_APPLIED,
|
||||
pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_NO_CHANGE:
|
||||
result.Sent = append(result.Sent, key)
|
||||
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_REJECTED:
|
||||
result.NotFound = append(result.NotFound, key)
|
||||
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_RETRY:
|
||||
fault := apply.GetFault()
|
||||
if fault.GetCode() == pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_RATE_LIMITED {
|
||||
retry := time.Duration(0)
|
||||
if fault.GetRetryAfter() != nil {
|
||||
retry = fault.GetRetryAfter().AsDuration()
|
||||
}
|
||||
return result, statuses, RateLimitedError{Provider: p.Key(), RetryAfter: retry}
|
||||
}
|
||||
result.Failed[key] = safeApplyMessage(apply, conn.AccessToken, conn.RefreshToken)
|
||||
default:
|
||||
result.Failed[key] = "watch sync plugin omitted a valid event result"
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, statuses, nil
|
||||
}
|
||||
|
||||
func mergeExportFailures(result ExportResult, failures map[string]string) ExportResult {
|
||||
if result.Failed == nil {
|
||||
result.Failed = make(map[string]string, len(failures))
|
||||
}
|
||||
for key, message := range failures {
|
||||
result.Failed[key] = message
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func remoteWatchFromProto(provider string, state *pluginv1.WatchSyncRemoteState) (RemoteWatch, error) {
|
||||
identity, err := remoteIdentityFromProto(state)
|
||||
if err != nil {
|
||||
return RemoteWatch{}, err
|
||||
}
|
||||
watched := state.GetWatched()
|
||||
if watched.GetPlayCount() < 1 {
|
||||
return RemoteWatch{}, errors.New("watch sync plugin returned watched state with no plays")
|
||||
}
|
||||
return RemoteWatch{
|
||||
Provider: provider, ProviderItemKey: state.GetProviderItemKey(),
|
||||
Kind: identity.kind, Title: identity.title, Year: identity.year,
|
||||
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
|
||||
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
|
||||
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
|
||||
SeasonNumber: identity.season, EpisodeNumber: identity.episode,
|
||||
PlayCount: int(watched.GetPlayCount()), LastWatchedAt: timePointer(watched.GetLastWatchedAt()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func remoteProgressFromProto(provider string, state *pluginv1.WatchSyncRemoteState) (RemoteProgress, error) {
|
||||
identity, err := remoteIdentityFromProto(state)
|
||||
if err != nil {
|
||||
return RemoteProgress{}, err
|
||||
}
|
||||
progress := state.GetProgress()
|
||||
if progress.GetProgressPercent() < 0 || progress.GetProgressPercent() >= 100 {
|
||||
return RemoteProgress{}, errors.New("watch sync plugin returned progress outside [0,100)")
|
||||
}
|
||||
pausedAt := timePointer(progress.GetPausedAt())
|
||||
if pausedAt == nil {
|
||||
return RemoteProgress{}, errors.New("watch sync plugin returned progress without a valid timestamp")
|
||||
}
|
||||
return RemoteProgress{
|
||||
Provider: provider, ProviderItemKey: state.GetProviderItemKey(),
|
||||
Kind: identity.kind, Title: identity.title, Year: identity.year,
|
||||
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
|
||||
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
|
||||
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
|
||||
SeasonNumber: identity.season, EpisodeNumber: identity.episode,
|
||||
ProgressPercent: progress.GetProgressPercent(), PausedAt: *pausedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func remoteFavoriteFromProto(provider string, state *pluginv1.WatchSyncRemoteState, listed *pluginv1.WatchSyncRemoteListState) (RemoteFavorite, error) {
|
||||
if state == nil || listed == nil {
|
||||
return RemoteFavorite{}, errors.New("watch sync plugin returned list state without identity")
|
||||
}
|
||||
providerItemKey := strings.TrimSpace(state.GetProviderItemKey())
|
||||
if listed.GetRemoved() {
|
||||
if providerItemKey == "" {
|
||||
return RemoteFavorite{}, errors.New("watch sync plugin returned a list tombstone without provider identity")
|
||||
}
|
||||
return RemoteFavorite{
|
||||
Provider: provider,
|
||||
ProviderItemKey: providerItemKey,
|
||||
Removed: true,
|
||||
}, nil
|
||||
}
|
||||
identity, err := remoteIdentityFromProto(state)
|
||||
if err != nil {
|
||||
return RemoteFavorite{}, err
|
||||
}
|
||||
listedAt := time.Now().UTC()
|
||||
if value := timePointer(listed.GetListedAt()); value != nil {
|
||||
listedAt = *value
|
||||
}
|
||||
return RemoteFavorite{
|
||||
Provider: provider, ProviderItemKey: providerItemKey,
|
||||
Kind: identity.kind, Title: identity.title, Year: identity.year,
|
||||
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
|
||||
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
|
||||
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
|
||||
SeasonNumber: identity.season, EpisodeNumber: identity.episode, FavoritedAt: listedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type remoteIdentity struct {
|
||||
kind, title, imdbID, tmdbID, tvdbID string
|
||||
seriesTitle, seriesIMDbID, seriesTMDBID, seriesTVDBID string
|
||||
year, seriesYear, season, episode int
|
||||
}
|
||||
|
||||
func remoteIdentityFromProto(state *pluginv1.WatchSyncRemoteState) (remoteIdentity, error) {
|
||||
if state == nil || state.GetMedia() == nil || strings.TrimSpace(state.GetProviderItemKey()) == "" {
|
||||
return remoteIdentity{}, errors.New("watch sync plugin returned remote state without identity")
|
||||
}
|
||||
media := state.GetMedia()
|
||||
var kind string
|
||||
switch media.GetMediaType() {
|
||||
case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE:
|
||||
kind = historyimport.KindMovie
|
||||
case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE:
|
||||
kind = historyimport.KindEpisode
|
||||
default:
|
||||
return remoteIdentity{}, errors.New("watch sync plugin returned unsupported remote media")
|
||||
}
|
||||
return remoteIdentity{
|
||||
kind: kind, title: media.GetTitle(), year: int(media.GetYear()),
|
||||
imdbID: media.GetExternalIds()["imdb"], tmdbID: media.GetExternalIds()["tmdb"], tvdbID: media.GetExternalIds()["tvdb"],
|
||||
seriesTitle: media.GetSeriesTitle(), seriesYear: int(media.GetSeriesYear()),
|
||||
seriesIMDbID: media.GetSeriesExternalIds()["imdb"], seriesTMDBID: media.GetSeriesExternalIds()["tmdb"], seriesTVDBID: media.GetSeriesExternalIds()["tvdb"],
|
||||
season: int(media.GetSeasonNumber()), episode: int(media.GetEpisodeNumber()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func cursorUpdate(key, value string) map[string]string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
return map[string]string{key: value}
|
||||
}
|
||||
|
||||
func timePointer(value interface {
|
||||
AsTime() time.Time
|
||||
CheckValid() error
|
||||
}) *time.Time {
|
||||
if value == nil || value.CheckValid() != nil {
|
||||
return nil
|
||||
}
|
||||
result := value.AsTime()
|
||||
return &result
|
||||
}
|
||||
|
||||
func timestampOrNil(value time.Time) *timestamppb.Timestamp {
|
||||
if value.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return timestamppb.New(value)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user