Compare commits

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

* fix(playback): address v3 recipe review feedback

* test(playback): isolate v3 fallback route fixture

* fix(database): split v3 recipe constraint validation

* fix(playback): preserve frozen seek recipe identity

---------

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

* fix(watchsync): address plugin review findings

* fix(watchsync): harden plugin provider failures

* feat(watchsync): complete plugin provider contract

* fix(watchsync): address provider review feedback

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

* fix(watchsync): build reconciliation index concurrently

* fix(watchsync): preserve empty device state updates

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

---------

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

* fix(scanner): harden long duration fallbacks

* fix(scanner): address long duration review feedback

* fix(scanner): align long duration seek semantics

* fix(scanner): align legacy long media seeking

* fix(scanner): preserve long timeline origins

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

* fix(scanner): distinguish absolute-end timelines

* fix(scanner): preserve long resume semantics

* fix(scanner): reject ambiguous absolute ends

* fix(scanner): preserve compat real-manifest resumes
2026-08-05 20:27:42 -04:00
76 changed files with 9576 additions and 1456 deletions
+104 -5
View File
@@ -19,6 +19,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -893,8 +894,10 @@ func main() {
)
}
var watchProviderService *watchsync.Service
var watchProviderRegistry *watchsync.Registry
var watchProviderRepo *watchsync.PostgresRepository
if deps.DB != nil {
watchProviderRegistry := watchsync.NewRegistry()
watchProviderRegistry = watchsync.NewRegistry()
if err := watchProviderRegistry.Register(trakt.NewProvider(nil, "")); err != nil {
log.Fatalf("register watch provider: %v", err)
}
@@ -904,10 +907,8 @@ func main() {
if err := watchProviderRegistry.Register(watchmdblist.NewProvider(nil, "")); err != nil {
log.Fatalf("register watch provider: %v", err)
}
watchProviderService = watchsync.NewService(
watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher),
watchProviderRegistry,
)
watchProviderRepo = watchsync.NewPostgresRepository(deps.DB, deps.SecretCipher)
watchProviderService = watchsync.NewService(watchProviderRepo, watchProviderRegistry)
deps.WatchProviderService = watchProviderService
}
@@ -1119,6 +1120,15 @@ func main() {
installer,
plugins.NewHostAdapter(pluginHost),
)
if watchProviderRegistry != nil {
reloadWatchProviders := func(ctx context.Context) {
if err := reloadWatchSyncPluginProviders(ctx, watchProviderRegistry, installationStore, pluginService, watchProviderRepo); err != nil {
slog.WarnContext(ctx, "failed to reload watch sync plugin providers", "component", "app", "error", err)
}
}
pluginService.AddLifecycleHook(reloadWatchProviders)
reloadWatchProviders(appCtx)
}
if deps.MarkerRegistry != nil && deps.MarkerProviderConfig != nil {
markerPluginResolver := markers.NewPluginResolverAdapter(pluginService)
pluginService.AddLifecycleHook(func(ctx context.Context) {
@@ -3088,11 +3098,100 @@ func metadataInt(value any) int {
}
}
var watchSyncPluginReloadMu sync.Mutex
type markerPluginCapabilityStore interface {
ListEnabled(ctx context.Context) ([]*plugins.Installation, error)
ListCapabilities(ctx context.Context, installationID int) ([]*plugins.Capability, error)
}
func reloadWatchSyncPluginProviders(
ctx context.Context,
registry *watchsync.Registry,
store markerPluginCapabilityStore,
service *plugins.Service,
repository watchsync.PluginCredentialRepository,
) error {
if registry == nil {
return nil
}
watchSyncPluginReloadMu.Lock()
defer watchSyncPluginReloadMu.Unlock()
var providers []watchsync.Provider
if store == nil || service == nil {
return registry.ReplacePluginProviders(providers)
}
installations, err := store.ListEnabled(ctx)
if err != nil {
return fmt.Errorf("list enabled watch sync plugin installations: %w", err)
}
sort.Slice(installations, func(i, j int) bool {
if installations[i] == nil {
return false
}
if installations[j] == nil {
return true
}
return installations[i].ID < installations[j].ID
})
for _, installation := range installations {
if installation == nil || installation.IsBuiltin() {
continue
}
capabilities, err := store.ListCapabilities(ctx, installation.ID)
if err != nil {
slog.WarnContext(ctx, "skip watch sync plugin with unreadable capabilities",
"component", "app",
"installation_id", installation.ID,
"error", err,
)
continue
}
for _, capability := range capabilities {
if capability == nil || capability.Type != sdkcapability.WatchSyncProvider {
continue
}
descriptor, err := plugins.DecodeCapability(capability)
if err != nil {
slog.WarnContext(ctx, "skip invalid watch sync plugin capability",
"component", "app",
"installation_id", installation.ID,
"capability_id", capability.ID,
"error", err,
)
continue
}
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
InstallationID: installation.ID,
ProviderKey: fmt.Sprintf("plugin:%d:%s", installation.ID, capability.ID),
CapabilityID: capability.ID,
DisplayName: descriptor.GetDisplayName(),
Descriptor: descriptor.GetWatchSyncProvider(),
ConnectionConfigSchema: descriptor.GetConfigSchema(),
ResolveClient: func(callCtx context.Context, installationID int, capabilityID string) (watchsync.WatchSyncPluginClient, error) {
return service.WatchSyncProviderClient(callCtx, installationID, capabilityID)
},
ResolveConfig: func(callCtx context.Context, installationID int) (*pluginv1.WatchSyncProviderConfig, error) {
return service.WatchSyncProviderConfig(callCtx, installationID)
},
Repository: repository,
})
if err != nil {
slog.WarnContext(ctx, "skip unsupported watch sync plugin capability",
"component", "app",
"installation_id", installation.ID,
"capability_id", capability.ID,
"error", err,
)
continue
}
providers = append(providers, provider)
}
}
return registry.ReplacePluginProviders(providers)
}
type markerPluginRuntimeConfigStore interface {
ListGlobalConfigs(ctx context.Context, installationID int) ([]*plugins.RuntimeConfig, error)
PutGlobalConfig(ctx context.Context, installationID int, key string, value map[string]any) error
+106
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -8,9 +10,12 @@ import (
"sync"
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/api"
"github.com/Silo-Server/silo-server/internal/config"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/watchsync"
)
func TestConfigureS3Clients_SetsCORSOnPublicAssetsBucket(t *testing.T) {
@@ -182,3 +187,104 @@ func TestBuildLiveSessionSync_UsesTransportPlayMethod(t *testing.T) {
})
}
}
type failingWatchSyncCapabilityStore struct{}
func (failingWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
return []*plugins.Installation{{ID: 2, Enabled: true, Kind: plugins.KindPlugin}}, nil
}
func (failingWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
return nil, errors.New("database unavailable")
}
type staticWatchSyncCapabilityStore struct {
capabilities []*plugins.Capability
}
func (s staticWatchSyncCapabilityStore) ListEnabled(context.Context) ([]*plugins.Installation, error) {
return []*plugins.Installation{{ID: 4, Enabled: true, Kind: plugins.KindPlugin}}, nil
}
func (s staticWatchSyncCapabilityStore) ListCapabilities(context.Context, int) ([]*plugins.Capability, error) {
return s.capabilities, nil
}
func TestReloadWatchSyncPluginProvidersPreservesConnectionForm(t *testing.T) {
manifest := &pluginv1.PluginManifest{Capabilities: []*pluginv1.CapabilityDescriptor{{
Type: "watch_sync_provider.v1", Id: "floppy", DisplayName: "Floppy",
ConfigSchema: []*pluginv1.ConfigSchema{{
Key: "floppy", Title: "Your Floppy server", Required: true,
JsonSchema: `{"type":"object","properties":{"base_url":{"type":"string"}},"required":["base_url"]}`,
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{{
Key: "base_url", Label: "Server URL", Required: true,
Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT,
}}},
}},
WatchSyncProvider: &pluginv1.WatchSyncProviderDescriptor{
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
},
}}}
records, err := plugins.CapabilityRecordsFromManifest(manifest)
if err != nil {
t.Fatal(err)
}
capabilities := make([]*plugins.Capability, 0, len(records))
for i := range records {
record := records[i]
capabilities = append(capabilities, &record)
}
registry := watchsync.NewRegistry()
if err := reloadWatchSyncPluginProviders(
context.Background(), registry, staticWatchSyncCapabilityStore{capabilities: capabilities}, &plugins.Service{}, nil,
); err != nil {
t.Fatal(err)
}
provider, ok := registry.Get("plugin:4:floppy")
if !ok {
t.Fatal("Floppy provider was not registered")
}
configurable, ok := provider.(interface {
ConnectionConfigSchema() []plugins.ConfigSchemaView
})
if !ok {
t.Fatal("Floppy provider does not expose connection configuration")
}
schemas := configurable.ConnectionConfigSchema()
if len(schemas) != 1 || schemas[0].AdminForm == nil || len(schemas[0].AdminForm.Fields) != 1 ||
schemas[0].AdminForm.Fields[0].Control != "TEXT" {
t.Fatalf("connection config schema = %#v", schemas)
}
}
func TestReloadWatchSyncPluginProvidersDropsStaleProvidersOnCapabilityReadFailure(t *testing.T) {
registry := watchsync.NewRegistry()
provider, err := watchsync.NewPluginProvider(watchsync.PluginProviderOptions{
InstallationID: 1,
ProviderKey: "plugin:1:tracker",
CapabilityID: "tracker",
Descriptor: &pluginv1.WatchSyncProviderDescriptor{
AuthMethods: []pluginv1.WatchSyncAuthMethod{pluginv1.WatchSyncAuthMethod_WATCH_SYNC_AUTH_METHOD_API_KEY},
ExportWatched: true,
},
ResolveClient: func(context.Context, int, string) (watchsync.WatchSyncPluginClient, error) {
return nil, errors.New("not used")
},
})
if err != nil {
t.Fatal(err)
}
if err := registry.Register(provider); err != nil {
t.Fatal(err)
}
if err := reloadWatchSyncPluginProviders(
context.Background(), registry, failingWatchSyncCapabilityStore{}, &plugins.Service{}, nil,
); err != nil {
t.Fatal(err)
}
if _, ok := registry.Get(provider.Key()); ok {
t.Fatalf("stale provider %q remained registered", provider.Key())
}
}
@@ -1,290 +0,0 @@
# 2026-07-17 — Embedded subtitle extraction: investigation and fix
Commands assume the repository root is the cwd.
This document records both the **final design** and the **investigation that
produced it**, including four superseded revisions. The dead ends are kept
deliberately: every one of them looked correct, three were disproved only by
measurement, and the last was a release-blocking bug caught in review. Anyone
tempted to "clean up" the subtitle windowing code should read Evolution first.
## Summary
Extracting an embedded subtitle track walks the interleaved container: subtitle
packets sit between video and audio across clusters, so harvesting a few KB of
text means demuxing that stretch of a multi-GB file off network storage (CephFS).
The server believes it bounds this with a 600s window. **It does not** — the flag
is inert, so every request extracts from the seek point to end-of-file. We cannot
turn the window on, because two of three clients depend on receiving whole tracks.
**Fix: cache the extract. Change nothing a client can observe.**
## Evidence
41.6h of production logs (2026-07-15 09:19 → 2026-07-17 02:56, 1.29M requests),
`/api/v1/stream/{session_id}/subtitles/{track}`:
| client | requests | p50 | p95 | max | ≥10s |
|---|---|---|---|---|---|
| browser (web player) | 220 | 72ms | **120,021ms** | 121,470ms | **45** |
| native (Android/TV) | 42 | 145ms | 9,566ms | 41,607ms | 2 |
Sidecar/external subtitles: **118 requests, all <1s** — standalone files, read
directly, no demux. That fast path already exists and is why the distribution
first looked bimodal.
Both clients are slow; they differ in *how often they pay*. The web player
re-fetches a window every ~10 minutes of playback, so each slow fetch **stalls
subtitles mid-film** — that is the 120s cluster. Native fetches once per track and
holds all cues, so it rolls the dice once: up to ~41s before subtitles appear, then
fine.
The 120s ceiling is `WriteTimeout: 120 * time.Second` (`cmd/silo/main.go:2389`)
cutting the connection mid-body. `WriteHeader(200)` was already called
(`stream.go:576`) and the logging middleware records that status
(`internal/api/middleware/request_logger.go:29`) — so **truncated subtitles are
logged `status=200`** and are invisible in error metrics.
## Root cause: the window flag is inert
`streamExtractArgs` (`subtitle_stream.go:161-168`) passes `-t` as an **input**
option, reasoning it "caps how much of the file we read". Measured against the
production binary (`ffmpeg 7.1.4-Jellyfin` at `/usr/lib/jellyfin-ffmpeg/ffmpeg`
inside the `silo` container — not on `$PATH`; there is no host ffmpeg):
| command | wall | bytes | last cue |
|---|---|---|---|
| `-ss 4000 -t 30 -i F` | 5s | 38,562 | **02:10:04** |
| `-ss 4000 -t 300 -i F` | 6s | 38,562 | **02:10:04** |
| `-ss 4000 -i F` (no `-t`) | — | 38,562 | 02:10:04 |
| `-t 600 -i F` (seek 0) | **86s** | 83,092 | **02:10:04** |
`-t 30` and `-t 300` are byte-identical to each other **and to passing no `-t` at
all**. Only `-ss` works. Cost is therefore *(duration − seek) × bitrate*, which the
logs confirm exactly: `seek=8s/130s/519s` → ≥60s; `seek=7797s/8392s/8987s` → <1s.
Bitrate correlates monotonically (39.8 Mbps → 120.6s; 5.0 Mbps → 8.0s; every 120s
outlier is a ≥28 Mbps remux).
`-to` as an **output** option does bound it correctly — `-ss 4000 … -to 4600` →
1s, 9,170 bytes, last cue 01:16:32. **We are deliberately not using it.**
## Why the window stays off
Subagent scans of the sibling repos found the accidental whole-track behaviour has
become the de-facto contract:
- **silo-apple — BREAKS.** `SidecarSubtitleFetcher.swift:48-95` issues a single
GET; `SubtitleSession.swift:244-287` caches the whole response in
`sidecarCache[urlIndex]` and never re-requests. No `position=`/`duration=`, no
window constant, no sliding window. Its comment states the "buffered path" is
deliberate.
- **silo-android — BREAKS.** `SubtitleTrackMerge.kt:56` builds the URL bare and
hands it to Media3 as a `MediaItem.SubtitleConfiguration`
(`SiloPlayerFactory.kt:413`) → `ProgressiveMediaSource` fetches once, parses every
cue. No timer, no position listener, no re-fetch.
- **web** windows correctly: sends `position=`
(`web/src/player/hooks/useSubtitleTracks.ts:79`), advances by 595
(`WINDOW_DURATION 600 − WINDOW_OVERLAP 5`, `:9`/`:16`/`:409-410`) — the exact
stride observed in production.
Enabling the window would silently kill subtitles ~10 minutes into every film on
iOS, tvOS, macOS and Android. Caching removes the need: keep whole-track delivery,
make it cheap.
## Design
`SubtitleCache` (`internal/playback/subtitle_cache.go`) already implements the
needed shape for PGS: a full-track miss streams to the client while teeing into a
temp file, atomically renamed on clean exit (`ServeSUPExtract:113`); a hit serves
from disk; a windowed request re-extracts from the small cached artifact
(`serveWindowedSUP:161`, via `InputIsExtractedSup`) or starts `WarmInBackground:188`.
Keyed by path + track ordinal + source mtime+size. Text was excluded only by the
assumption at `stream.go:557-562` that "VTT is already windowed and fast" — which
the inert `-t` makes false.
Windowing a **cached** 83 KB VTT costs **52ms** vs 16,000ms against the original
27 GB remux (`-ss 4000 -i cached.vtt … -to 4600` → 9,216 bytes, cues 01:04:40 →
01:16:32). Note `-ss` on a webvtt input lands ~2 min *before* the target — a
coverage superset, not byte-equivalence.
### The load-bearing rule: canonical artifact + effective-partial predicate
A cache entry may only ever hold a **canonical extract**: source track → requested
output format, `seek=0`, `duration=0`, original-container input, original track
mapping.
**`AllowWindow` must not be used to decide this.** It is set only in the PGS branch
(`stream.go:531`), so it is always false for text — while `streamExtractArgs:155`
applies `-ss` to any non-ASS/non-PGS source *regardless* of it. Routing on
`AllowWindow` therefore lets a seeked text extract be committed as canonical:
1. subrip request at 900s → `AllowWindow=false` → full-track path.
2. ffmpeg applies `-ss 900`; inert `-t` does not save it → output is 900s→EOF.
3. ffmpeg exits cleanly → `Commit` publishes the partial as canonical.
4. Every later viewer from 0 gets a hit and **receives no cues before 900s**.
118 of 143 production requests carry a non-zero seek, so this would poison
immediately and silently truncate subtitles for everyone until eviction. Classify
from the **effective argv** instead — ideally via the same helper that builds it:
- effective seek/duration ⇒ partial ⇒ **never fills** the canonical entry; on a hit
re-extract from the cached artifact, on a miss stream the seeked extract uncached
and start a canonical background warm.
- seek=0 & duration=0 ⇒ canonical ⇒ may fill.
(ASS→VTT happens to be safe because `IsASS` blocks `-ss` — that does **not**
generalize to subrip, which is 135 of 143 requests.)
### Steps
1. `subtitle_stream.go` — export the output-format resolver (`streamExtractOutput`
already is one; export it). Do **not** add a second mapping: the handler's
duplicate at `stream.go:493-499` is exactly what let two copies disagree.
Preserve current windowing behaviour **exactly** — this commit changes latency
only.
2. `stream.go` — delete the duplicated mapping; resolve format once. Keep
`seek`/`duration` selection keyed so the log line cannot report a window ffmpeg
will ignore.
3. `subtitle_cache.go` — generalize:
- **Key**: add the resolved output profile (out codec + muxer, not the caller's
`"vtt"` string) **and a schema version** — mtime+size cannot detect an in-place
ffmpeg upgrade or a future argv change. `.ass` and `.vtt` are both reachable
for one ASS source, so format must be in the key.
- **`removeStaleSiblings:468` groups by a prefix excluding format** — committing
`.vtt` would delete a valid `.ass` sibling. Cleanup (`:476`) and eviction
(`:517`) hardcode `.sup`, so text would never be reclaimed or counted.
- Generalize `InputIsExtractedSup` (used at `subtitle_stream.go:170-176` to force
`-f sup` + remap `0:0`) to carry the cached input's format.
- `serveWindowedSUP:171` hardcodes `application/octet-stream`; drive Content-Type
from the resolved format (`text/vtt`, `text/x-ssa`).
- Rename now that it carries text: `SUPExtractFunc` → `SubtitleExtractFunc`,
`ServeSUPExtract` → `ServeExtract`, `serveWindowedSUP` → `serveWindowed`.
- In-flight coalescing must include the output profile.
- Fix the doc comment (`:19-38`) and the `defaultSubtitleCacheMaxBytes` sizing
note, which both claim PGS-only.
4. `stream.go:563` — remove the `outFormat == "sup"` gate so text routes through the
cache. Correct the false comment at `:557-562`.
5. **Keep HTTP semantics unchanged**: copy the cached file with the current
Content-Type and `no-store`. Do **not** adopt `http.ServeContent` here — it adds
Range/`206`/`304`/`Content-Length`, and Media3 uses range-capable data sources,
so responses would differ depending on whether the cache happened to be warm.
Revisit separately with client testing.
## Risk / follow-ups
- **The inert `-t` stays.** Removing or "fixing" it changes what clients receive.
Its comment (`subtitle_stream.go:161-165`) confidently describes a bound that does
not exist and **must be corrected in place** — otherwise the next reader fixes it
and breaks both native clients. This is the most important comment in the change.
- **Cache-key completeness is correctness-critical.** A missing dimension serves
wrong bytes. mtime+size remains a residual risk (a replaced file with identical
size and preserved timestamp reuses stale content); `MediaFile.FileHash` exists if
we want content identity later. Documented, not solved.
- **First-touch is unimproved.** Native clients fetch once, so the first viewer of a
file+track still pays full price; they benefit only from another viewer's warm.
The web player's 2nd–14th windows are the win.
- **Cached-vs-cold output is a coverage superset, not byte-identical** (`-ss` on a
webvtt input lands early). Verification standard is semantic cue coverage.
- **120s WriteTimeout masking is not fixed** (`status=200` on a truncated body) —
largely inherent once headers are committed;
`LogSubtitleStreamError` (`subtitle_stream.go:273-277`) documents it as
deliberate. A cold first extract of a large remux can still exceed it. Follow-up:
the rolling deadline full downloads already use
(`internal/api/handlers/downloads.go:402`, `internal/httpstream/rolling_deadline.go`).
- **Not in scope:** the standalone proxy has separate uncached ASS/text extraction
paths (`internal/proxy/server.go:273`, `:286`); if remote/proxy subtitle URLs reach
those, they stay uncached.
- **Dropped: warm-on-playback-start.** `BeginFill` coalesces cache *writes* but does
not share extraction *output* — so a warm at session start plus the client's
immediate request (exactly what auto-enabled subtitles do) yields **two whole-file
demuxes and no latency benefit**. `WarmInBackground:188` also does `stat`/`mkdir`/
temp-file work synchronously before detaching, which could block session start on
CephFS. Revisit only if commit 1's metrics show a real gap between playback start
and subtitle selection.
- **Deferred:** (a) real windowing, once `silo-android`/`silo-apple` send
`position=`/`duration=` and re-fetch on coverage exhaustion — then `-t`→`-to`
becomes safe and this cost drops again; (b) scan-time extraction of embedded tracks
into sidecars, putting every subtitle on the already-instant external path and
retiring on-demand extraction entirely. (b) is the real endgame.
- **Non-finite `?position=`** (`+Inf` parses and passes `>= 0`, reaching ffmpeg and
failing after the 200) is a genuine bug found en route — its own commit, not this.
## Evolution (what we got wrong, and how it was caught)
Kept because each error is one a careful reader would repeat.
**R1 — "ASS→VTT skips windowing."** `streamExtractArgs:155` keys `windowable` on
`SourceCodec`, so an ASS source requested as `.vtt` never seeks → full-file demux.
Real, and the code reads exactly that way. Proposed a one-line predicate fix.
*Caught by Codex:* a **no-op**. The handler computes `seek`/`duration` from a
source-derived format *before* the `.vtt` override (`stream.go:493-520`), so ASS
yields `seek=0, duration=0` and no `-ss`/`-t` is emitted whatever the predicate
says. The proposed unit test would have passed while production stayed broken —
it hand-built opts the handler never produces.
**R2 — fix both layers.** Correct, and Codex's implementation was clean.
*Caught by the logs:* the premise was wrong. The `subtitle stream requested` line
records `track_codec`/`seek`/`duration`; correlating it against request durations
showed **all 7 ASS requests logged `seek=0 dur=0` but never exceeded 10s**, while
**every request ≥60s was subrip with `seek=NNN dur=600`** — already windowed. The
same file (71458) ranged 41ms→120,574ms. The tidy "bimodality splits by codec"
story was a subagent's inference that I propagated without testing it against logs
I already had open.
**R3 — `-t`→`-to` plus 600s→60s.** Found by running ffmpeg instead of reading it:
`-t` is inert. This is the true root cause, and it hits subrip (135 of 143
requests), not ASS. *Caught by the client scans:* both native clients fetch once and
expect whole tracks, so enabling the window breaks them. Also caught: 600→60 would
have been worse — the web player hardcodes `WINDOW_DURATION = 600` and advances by
595 without checking what it received, so a 60s server window means a **535-second
subtitle blackout** between fetches.
**R4 — cache text tracks.** *Caught by Codex:* routing on `AllowWindow` lets a
seeked extract be committed as canonical → cache poisoning (see Design). I had
generalized from ASS, the one case where `IsASS` blocks `-ss`.
**Method errors worth naming:**
- I twice reported findings from a subagent's code reading without checking them
against production data I already had.
- I greped `position=`/`duration=`/`windowed=1` on `/api/v1` request logs and
concluded no client sends them. **The api request log has no `query=` field** —
the greps were vacuous. Ground truth is `seek_seconds` in the handler log (118 of
143 non-zero). Do not grep query params off api log lines.
- The first "86s → 1s" measurement was cache-flattered; the honest cold number is
16s. Always re-measure cold, in a fresh order.
- Two rounds of code review missed the inert `-t` because we both read *intent*:
the comment says it windows, the test asserts the flag is present, and it is
present. Only execution disproved it.
## Verification
- `go build ./...`, `go vet ./...`, `gofmt -l` clean.
- `go test -race ./internal/playback/ ./internal/api/handlers/`. Note
`TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion` **already
fails on clean `origin/main`** — pre-existing, unrelated, do not chase it.
- Required tests:
- **a seeked `.vtt` miss must not commit a canonical entry**, and a subsequent
seek-0 request must still receive beginning-of-track cues (the poisoning
regression — the single most important test here);
- seek-0 fill, then a seeked request re-extracts from the cached artifact (assert
the rewritten input path/format, not the original);
- `.ass` and `.vtt` for the same file+track coexist and do not evict each other
via `removeStaleSiblings`;
- text entries participate in eviction accounting;
- a partial/failed extract never commits;
- Content-Type per format on hit and miss.
- **Behavioural equivalence is the bar**: for every (source codec, requested format,
seek), the bytes a client receives must be unchanged from `origin/main`. Only
latency may differ.
- Post-deploy: re-run the ≥1s aggregation over `docker logs silo`. Expect browser
p95 to fall away from 120s and the ≥10s count (45) to collapse toward the handful
of cold first-touches. Watch `subtitle cache warm` lines for failures and
dropped-slot rates.
## AI-use disclosure
Investigation, root-cause analysis, and this document were prepared with AI (Claude
Code) assistance. The plan was adversarially reviewed by Codex (`gpt-5.6-sol`),
which caught both the R1 no-op and the R4 cache-poisoning bug; client impact was
assessed by subagent scans of `silo-apple` and `silo-android`.
@@ -0,0 +1,317 @@
# Plugin-backed watch sync providers
## Status
Implemented design for the expanded `silo-plugin-sdk` v0.13
`watch_sync_provider.v1` contract. The server uses the immutable SDK commit
associated with the v0.13 pull request until that version is released. This
specification does not introduce a second watch synchronization pipeline.
Plugin-backed providers adapt the public plugin RPC contract to Silo's existing
`watchsync` service, connection repository, history exports, scrobble sessions,
rate-limit handling, list reconciliation, and scheduled reconciliation.
## Goals
- Allow an enabled plugin capability to appear as a profile-scoped watch
provider.
- Reuse Silo's encrypted watch-provider connections for personal credentials.
- Support API-key and device-code connection flows.
- Import and export watched state, progress, favorites, and watchlists according
to each plugin's advertised capabilities.
- Deliver explicit watched/unwatched and list operations plus live playback
start, pause, and stop events.
- Preserve the existing durable history-export reconciliation path.
- Preserve complete provider credential shapes, including token type, scopes,
expiry, and opaque secret attributes.
- Supply declared installation configuration to provider calls without mixing
it with profile credentials.
- Allow API-key providers to declare setup fields that are supplied per profile
connection rather than forcing one installation-wide endpoint.
- Keep provider plugins stateless and prevent credentials from entering generic
plugin configuration.
- Avoid behavior changes for built-in Trakt, Simkl, and MDBList providers.
## Non-goals
- Authorization-code OAuth UI and callback routes.
- A second outbox or worker implementation.
- Allowing plugins to persist or own personal provider credentials.
- Removing built-in Trakt, Simkl, or MDBList providers in the same change. They
remain available while equivalent plugins are built and connections are
migrated deliberately.
Plugins may implement any subset of the state families and operations. A
provider such as Floppy can advertise watched import/export, progress import,
and live scrobbling without claiming favorites, watchlists, or unwatch support.
Silo exposes only executable capabilities from the descriptor.
## SDK boundary
Plugins advertise `watch_sync_provider.v1` with a typed descriptor containing:
- supported authentication methods;
- import/export capabilities;
- supported media types and external-ID namespaces;
- maximum apply batch size.
The host uses `WatchSyncProvider` for the stable provider operations and a
separate `WatchSyncDeviceAuthorizationService` for device-code start and poll.
The split keeps direct Go implementations of the released v0.12 provider
interface source-compatible. Together the services support:
- device authorization `Start` and `Poll`;
- `ExchangeAPIKey`;
- `RefreshCredentials`;
- `GetAccount`;
- `ApplyEvents` for watched, unwatched, favorite, watchlist, and live scrobble
operations;
- `ListRemoteState` for watched, progress, favorite, and watchlist state.
Authorization-code descriptors remain valid SDK contracts, but the host does
not register a plugin that offers only authorization-code authentication until
the public watch-provider callback flow exists.
Every provider RPC receives the complete current credentials and relevant
installation configuration only for that call. Plugins must not persist or log
credentials. Generic `Runtime.Configure` data is not a personal credential
store. Returned credentials are an authoritative replacement, and the host
persists them before interpreting results, pages, or faults from the same RPC.
## Discovery and lifecycle
At startup and after plugin lifecycle changes, Silo:
1. lists enabled, non-builtin plugin installations;
2. selects `watch_sync_provider.v1` capabilities;
3. decodes and validates their typed descriptors;
4. constructs thin `watchsync.Provider` adapters;
5. atomically replaces the registry's previous plugin-backed providers while
preserving built-ins.
Provider keys bind persisted credentials to the installation and capability
(`plugin:<installation-id>:<capability-id>`), while RPC routing continues to use
the manifest capability ID. This prevents a subsequently installed plugin from
inheriting credentials merely by reusing another plugin's capability ID.
Provider keys must not shadow built-ins or other plugin providers. A conflicting
lifecycle reload fails without partially replacing the registry. Invalid,
unreadable, or currently unsupported descriptors are logged and omitted, and
the successfully discovered set still replaces the previous plugin providers.
This fail-closed behavior prevents a disabled or removed plugin from remaining
registered because another installation could not be decoded.
## Existing watchsync interfaces
The adapter implements the existing interfaces corresponding to the
descriptor's advertised capabilities:
- `Provider`;
- `AuthProvider`, `APIKeyAuthProvider`, and device authorization;
- watched import/export and unwatch export;
- progress import;
- favorite and watchlist import/export/removal, including ordered watchlists;
- `Scrobbler` and `OrderedScrobbler` for start, pause, and stop delivery.
`LocalPlay` and `ScrobbleEvent` values become rich desired-state plugin events.
The host supplies movie and series external IDs, season and episode numbers,
local history identity, playback identity, timestamps, and completion values.
The plugin never needs a Silo API key or a callback into catalog HTTP routes.
## Authentication and credentials
The existing watch-provider API-key connection route passes the entered token
to `ExchangeAPIKey`. A plugin capability may also declare `config_schema`
entries; the host renders those fields beside the API key, validates submitted
objects against the declared JSON schemas, classifies secret fields, and
overlays the flattened values onto `provider_config` for that exchange only.
The plugin validates or exchanges the credential and returns normalized
credentials plus provider account identity. Provider-specific connection
context needed by later calls is returned in encrypted opaque credential
attributes. Device-code providers use the existing start and poll routes.
Credential refresh remains host-initiated and serialized by the existing
watchsync service. The host encrypts an authoritative credential bundle
containing access token, refresh token, expiry, token type, scopes, and opaque
secret attributes. Legacy access/refresh columns remain readable during the
migration. Device codes are also encrypted at rest. A pending device poll may
rotate its opaque provider state, interval, or expiry; the host validates and
persists the replacement before another poll. Invalid-credential faults are
persisted using only the plugin's safe message.
Authorization-code OAuth can be added separately to the existing watch-
provider service after its client-secret storage and callback-state design are
reviewed.
## Watched export flow
### Explicit mark watched
The existing manual watch path records leaf history entries and invokes
`HandleLocalWatchEvent`. The existing history-export table is populated before
provider I/O. The plugin adapter sends each pending `LocalPlay` as an absolute
`MARK_WATCHED` desired-state event. The interactive path sends one bounded
plugin batch; additional queued events are delivered by scheduled
reconciliation.
### Playback completion
The existing playback path invokes `ScrobbleStop` with rich identity. For a
completed event and a provider that exports watched state, Silo first upserts a
pending history-export record, then dispatches the immediate stop operation.
On successful immediate delivery, the history export is marked
`satisfied_by_scrobble`. If immediate delivery fails or the process exits, the
existing scheduled history reconciliation retries the same local history. A
compatibility playback path without a local history ID retains its own durable
terminal event until the confirmed stop succeeds. The plugin operation is
convergent, so an uncertain duplicate cannot lower progress or increment a play
counter.
Start, pause, and incomplete stop calls are no-ops for providers that only
advertise watched export. Providers advertising live scrobbling receive all
three operations with playback position and duration.
If the remote stop succeeds but the local history-export transition fails, the
stop is not dispatched again. The scrobble session records a durable pending
history-reconciliation marker, and the sweeper retries only the local database
transition.
## Remote state import and lists
The scheduled sync path requests one state family at a time. Each request
contains the stable cursor for that family and an opaque page token. The host:
1. persists any credentials returned by the page;
2. discards the page and retains its cursor on a top-level fault;
3. applies valid items idempotently through existing watch-state and list
services;
4. advances the page token until the plugin reports no next page;
5. commits the family cursor only after the traversal succeeds.
Incremental list pages do not imply deletion of absent items; they use explicit
provider-key tombstones for removals. Complete snapshots may reconcile removals.
Ordered watchlists preserve the plugin's list position and require complete
snapshots because an incremental subset cannot define positions relative to
omitted items. Traversals are bounded by both page and item count before their
durable cursor advances.
Progress items without a valid provider timestamp are rejected rather than
being stamped with host time.
Imported history is tagged with the stable plugin provider key, not a generic
import source. Echo suppression therefore filters only export back to the
originating plugin; the same local event remains eligible for other connected
providers.
## Provider configuration
Manifest-declared global configuration is resolved per installation before each
provider call. Public fields are sent in `values`; secret and undeclared fields
are sent in `secret_values`. Nested manifest fields use the stable
`<config-key>.<field>` key form, such as `floppy.base_url`. Profile credentials
remain separate and encrypted in watch-provider connection storage.
Capability-level `config_schema` entries are connection setup fields, not
installation defaults. For API-key authorization, the host validates and
flattens them using the same `<config-key>.<field>` convention and lets the
connection values override a same-named global value. This supports migrations
from a legacy installation-wide endpoint while ensuring every new connection
chooses its own endpoint. The submitted objects are not retained as generic
plugin configuration; the plugin returns any context required after exchange
inside its opaque encrypted credentials.
## Apply result handling
Each event has a stable ID. The adapter requires a matching result and maps it
as follows:
- `APPLIED` and `NO_CHANGE`: export sent;
- `REJECTED`: permanent not-found/unmappable export;
- `RETRY` with a temporary fault: failed export eligible for existing retry handling;
- top-level or per-event rate limit: existing `RateLimitedError` and account
deferral, after committing any successfully applied events from the same RPC;
- missing or unknown result: failed export eligible for retry, unless the same
RPC reported a rate limit, in which case unmentioned events remain pending;
- plugin transport, process availability, and top-level temporary faults:
remain pending without consuming a per-event attempt.
A top-level invalid-credential fault records a typed safe connection error.
A dedicated reconnect-required connection state is deferred as described
above. Plugin messages persisted by Silo must be explicitly safe for operator
display and must not contain response bodies or secrets. The host redacts the
connection credentials, normalizes control characters and whitespace, and
bounds the displayed message length; transport details are replaced with
host-owned text.
## Ordering and batching
Immediate scrobbles are ordered by connection and stable series identity using
the existing ordered-scrobble queue. History reconciliation is ordered by local
watch timestamp.
Each synchronization run sends at most one plugin RPC, bounded by the
descriptor's maximum batch size. The existing service commits every per-event
result before a later run requests the next pending batch. Failures increment
attempts only for events included in that RPC. This avoids partial-success loss,
retry exhaustion within one run, and unbounded cumulative RPC time.
## Reliability boundaries
This design deliberately reuses the reliability guarantees currently accepted
for built-in providers:
- encrypted profile-scoped connections;
- durable local watch history;
- durable history-export rows;
- immediate scrobble sessions;
- scheduled reconciliation;
- bounded retry attempts and safe recorded errors;
- provider/account rate-limit deferral.
It does not claim multi-node leased delivery or infinite retries. Those should
be improved in the shared watchsync pipeline for built-in and plugin providers
together rather than introduced only for plugins.
## Security requirements
- Personal tokens never enter plugin runtime config or user settings.
- Tokens are decrypted only at the provider invocation boundary.
- RPC request values containing credentials are never logged.
- Plugin-returned errors are sanitized before persistence and API display;
transport errors are replaced by a generic message.
- Account/profile scope remains enforced by the existing connection lookup.
- Credential AAD and provider lookup include installation identity, preventing
sequential capability-ID reuse from exposing an old token to a new install.
- Disabled plugins disappear from provider discovery; existing connections are
unavailable until that same installation and capability return.
## Compatibility
The change is additive to `/api/v1`. Existing provider summaries and connection
routes continue to work. A plugin provider appears through the same provider
summary model and uses the existing API-key or device-code connection routes.
No client changes are required because no existing fields or status codes
change. SDK v0.12 plugins remain wire-compatible; descriptors only expose the
capabilities the older contract can execute.
## Validation plan
- capability enforcement in the plugin host client;
- atomic registry replacement and built-in collision tests;
- API-key and device-code authentication, full credential replacement, and
account identity conversion;
- provider configuration routing and secret/public field classification;
- rich movie and episode identity conversion;
- watched, progress, favorite, and watchlist import pagination and cursor
behavior;
- unwatch, favorite, watchlist, and ordered-list event conversion;
- provider-specific import source keys and cross-provider export behavior;
- completed playback persistence before plugin I/O;
- `satisfied_by_scrobble` transition after success;
- durable retry of a failed local post-stop history transition without another
remote stop;
- immediate failure followed by scheduled history reconciliation;
- typed rate-limit and invalid-credential handling;
- complete credential encryption/decryption and legacy fallback;
- plugin disable/re-enable lifecycle reload;
- no regression in existing watchsync, pluginhost, and plugin service tests;
- `go test`, targeted race tests, `go vet`, lint, formatting checks, and local
path verification before proposing a merge request.
+1 -1
View File
@@ -109,7 +109,7 @@ require (
)
require (
github.com/Silo-Server/silo-plugin-sdk v0.12.0
github.com/Silo-Server/silo-plugin-sdk v0.13.2
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
+4 -2
View File
@@ -4,8 +4,10 @@ github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0g
github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI=
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/Silo-Server/silo-plugin-sdk v0.12.0 h1:7MnaSIJBVdRE8FhfLXWx7Pe+Q2apJiaPMotfhb1VHoE=
github.com/Silo-Server/silo-plugin-sdk v0.12.0/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/Silo-Server/silo-plugin-sdk v0.13.1 h1:3vMaV+aPT/vu47CPk/f3nODsjXCp1OseI0QIoLnOvOM=
github.com/Silo-Server/silo-plugin-sdk v0.13.1/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/Silo-Server/silo-plugin-sdk v0.13.2 h1:w7U0mmljVPauKfzRLNKusuiYFpuoEhuCSX4Hp3s9eRw=
github.com/Silo-Server/silo-plugin-sdk v0.13.2/go.mod h1:etqmxLTwjxpFH9goAjBDfNDoqHMv2/sqUXu8yx3hNfA=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
+40 -10
View File
@@ -462,7 +462,7 @@ type changeAudioResponse struct {
PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"`
}
func (resp *changeAudioResponse) setCopyTimeline(position, origin float64) {
func (resp *changeAudioResponse) setWindowedTimeline(position, origin float64) {
playerStart := max(0, position-origin)
canSeekAnywhere := false
resp.PlayerStartSeconds = &playerStart
@@ -533,10 +533,20 @@ func canSeekAnywhere(req transcodeStartRequest, file *models.MediaFile) bool {
if file == nil || file.Duration <= 0 {
return false
}
// Copy-video HLS sessions use FFmpeg's real manifest so the player only
// seeks within the currently exposed window. Out-of-window seeks should
// restart explicitly instead of relying on segment 404s to move FFmpeg.
return !strings.EqualFold(req.TargetCodecVideo, "copy")
return !usesRealTranscodeManifest(req, file)
}
func usesRealTranscodeManifest(req transcodeStartRequest, file *models.MediaFile) bool {
durationSeconds := 0.0
if file != nil {
durationSeconds = float64(file.Duration)
}
// Copy-video, unknown-duration, and oversized HLS sessions use FFmpeg's
// real manifest so the player only seeks within the currently exposed
// window. Out-of-window seeks should restart explicitly instead of relying
// on segment 404s to move FFmpeg.
return strings.EqualFold(req.TargetCodecVideo, "copy") ||
!playback.CanGenerateSyntheticManifest(durationSeconds, req.SegmentDuration)
}
func buildTranscodeStartResponse(
@@ -2122,17 +2132,26 @@ func buildSubtitleURLs(
Label: dl.ReleaseName + " (" + dl.Provider + ")",
Source: "downloaded",
HearingImpaired: dl.HearingImpaired,
URL: subtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID),
URL: downloadedSubtitleStreamURL(sessionID, downloadedOffset+i, string(dl.Format), file.ID, dl.ID),
})
}
return urls
}
const downloadedSubtitleIDParam = "downloaded_subtitle_id"
func subtitleStreamURL(sessionID string, trackIndex int, codec string, fileID int) string {
return fmt.Sprintf("/stream/%s/subtitles/%d%s?file_id=%d", sessionID, trackIndex, subtitleURLExt(codec), fileID)
}
// downloadedSubtitleStreamURL binds a downloaded subtitle URL to its stable
// database identity while preserving the combined track index for clients.
func downloadedSubtitleStreamURL(sessionID string, trackIndex int, codec string, fileID, downloadedID int) string {
return subtitleStreamURL(sessionID, trackIndex, codec, fileID) +
"&" + downloadedSubtitleIDParam + "=" + strconv.Itoa(downloadedID)
}
func subtitleFontBundleURL(sessionID string, trackIndex int, codec string, fileID int) string {
if !playback.IsASS(codec) {
return ""
@@ -2369,8 +2388,16 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
restartStartSegment := computeStartSegment(restartSeekSeconds, restartSegmentDuration)
restartStreamOriginSeconds := 0.0
restartCopyAnchorResolved := false
legacyCopyRestart := session.PlayMethod == playback.PlayTranscode &&
strings.EqualFold(targetVideoCodec, "copy") && isLegacyTransportSession(session)
restartManifestRequest := transcodeStartRequest{
TargetCodecVideo: targetVideoCodec,
SegmentDuration: restartSegmentDuration,
}
legacyWindowedRestart := session.PlayMethod == playback.PlayTranscode &&
isLegacyTransportSession(session) && usesRealTranscodeManifest(restartManifestRequest, file)
if legacyWindowedRestart {
restartStreamOriginSeconds = restartSeekSeconds
}
legacyCopyRestart := legacyWindowedRestart && strings.EqualFold(targetVideoCodec, "copy")
if legacyCopyRestart {
restartCopyAnchorResolved = true
if req.Position > 0 {
@@ -2779,8 +2806,8 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http.
}
h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex)
}
if legacyCopyRestart {
resp.setCopyTimeline(req.Position, restartStreamOriginSeconds)
if legacyWindowedRestart {
resp.setWindowedTimeline(req.Position, restartStreamOriginSeconds)
}
h.syncSessionsNow(r.Context(), "audio_change")
@@ -3231,6 +3258,9 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re
transportSeekSeconds := alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo)
startSegmentNumber := computeStartSegment(transportSeekSeconds, req.SegmentDuration)
streamOriginSeconds := 0.0
if usesRealTranscodeManifest(req, file) {
streamOriginSeconds = transportSeekSeconds
}
if videoCopy {
streamOriginSeconds = req.SeekSeconds
if req.SeekSeconds > 0 {
@@ -4,6 +4,7 @@ import (
"testing"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
func TestSubtitleURLExt(t *testing.T) {
@@ -26,6 +27,17 @@ func TestSubtitleURLExt(t *testing.T) {
}
}
func TestBuildSubtitleURLsBindsDownloadedSubtitleIdentity(t *testing.T) {
file := &models.MediaFile{ID: 42}
urls := buildSubtitleURLs("sess-downloaded", file, []subtitles.DownloadedSubtitle{{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT}}, true)
if len(urls) != 1 {
t.Fatalf("downloaded subtitle URLs = %#v", urls)
}
if want := "/stream/sess-downloaded/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71"; urls[0].URL != want {
t.Fatalf("downloaded subtitle URL = %q, want %q", urls[0].URL, want)
}
}
func TestBuildSubtitleURLs_IncludesAllBitmapTracksForBurnInClients(t *testing.T) {
file := &models.MediaFile{
ID: 42,
+185 -1
View File
@@ -616,6 +616,31 @@ func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) {
if encodedResp.TimelineOffsetSeconds != 0 {
t.Fatalf("encoded TimelineOffsetSeconds = %v, want 0", encodedResp.TimelineOffsetSeconds)
}
longEncodedResp := buildTranscodeStartResponse(
transcodeStartRequest{
SessionID: "session-long-encoded",
SeekSeconds: 18.261,
TargetCodecVideo: "h264",
SegmentDuration: 2,
},
&models.MediaFile{Duration: 1_000_000},
nil,
"/playback/transcode/session-long-encoded/master.m3u8",
18,
)
if longEncodedResp.CanSeekAnywhere {
t.Fatal("long encoded response should require explicit restart seeks")
}
if math.Abs(longEncodedResp.PlayerStartSeconds-0.261) > 0.0001 {
t.Fatalf("long encoded PlayerStartSeconds = %v, want 0.261", longEncodedResp.PlayerStartSeconds)
}
if longEncodedResp.StreamOriginSeconds != 18 {
t.Fatalf("long encoded StreamOriginSeconds = %v, want 18", longEncodedResp.StreamOriginSeconds)
}
if longEncodedResp.TimelineOffsetSeconds != 18 {
t.Fatalf("long encoded TimelineOffsetSeconds = %v, want 18", longEncodedResp.TimelineOffsetSeconds)
}
}
func TestHandleStartPlayback_PersistsSeriesPlaybackPreferenceForEpisodes(t *testing.T) {
@@ -1614,7 +1639,7 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
CodecAudio: "aac",
Container: "mkv",
Bitrate: 8000,
Duration: 3600,
Duration: 1_000_000,
AudioTracks: []models.AudioTrack{
{Codec: "aac", Default: true},
{Codec: "ac3"},
@@ -1703,6 +1728,9 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
if remoteStartReq.SeekSeconds != 120 {
t.Fatalf("remote SeekSeconds = %v, want aligned 120", remoteStartReq.SeekSeconds)
}
if remoteStartReq.StreamOriginSeconds != 120 {
t.Fatalf("remote StreamOriginSeconds = %v, want aligned 120", remoteStartReq.StreamOriginSeconds)
}
if remoteStartReq.TargetResolution != "720p" || remoteStartReq.TargetCodecVideo != "h264" {
t.Fatalf("remote target recipe = %q/%q, want 720p/h264", remoteStartReq.TargetResolution, remoteStartReq.TargetCodecVideo)
}
@@ -1739,6 +1767,15 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t
if claims.TargetBitrateKbps != 2000 {
t.Fatalf("token TargetBitrateKbps = %d, want 2000 (recipe-complete)", claims.TargetBitrateKbps)
}
if claims.StreamOriginSeconds != 120 {
t.Fatalf("token StreamOriginSeconds = %v, want aligned 120", claims.StreamOriginSeconds)
}
if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 ||
resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 ||
resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 ||
resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere {
t.Fatalf("remote long encoded response timeline = %+v", resp)
}
if claims.SeekSeconds != 120 {
t.Fatalf("token SeekSeconds = %v, want aligned 120", claims.SeekSeconds)
}
@@ -2142,6 +2179,98 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T
}
}
func TestHandleChangeAudioTrack_LocalLongEncodedPreservesWindowedTimeline(t *testing.T) {
sessionMgr := playback.NewSessionManager(0, 0)
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: writePlaybackTestMediaFile(t, "movie-local-long-encoded.mkv"),
Resolution: "1080p",
CodecVideo: "hevc",
CodecAudio: "ac3",
Container: "mkv",
Bitrate: 8000,
Duration: 1_000_000,
AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}, {Codec: "dts"}},
}
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.JWTSecret = "test-secret"
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
startReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1}`),
).WithContext(newAuthorizedPlaybackContext())
startRR := httptest.NewRecorder()
handler.HandleStartTranscode(startRR, startReq)
if startRR.Code != http.StatusAccepted {
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
}
predecessor := handler.tm.GetTranscodeSession(session.ID)
if predecessor == nil {
t.Fatal("expected local long encoded session")
}
t.Cleanup(func() { handler.tm.CloseTranscodeSession(session.ID, "") })
changeReq := httptest.NewRequest(
http.MethodPatch,
"/api/v1/playback/"+session.ID+"/audio",
strings.NewReader(`{"audio_track_index":1,"position":121.5}`),
).WithContext(newAuthorizedPlaybackContext())
changeReq = withPlaybackRouteParam(changeReq, "session_id", session.ID)
changeRR := httptest.NewRecorder()
handler.HandleChangeAudioTrack(changeRR, changeReq)
if changeRR.Code != http.StatusOK {
t.Fatalf("change status = %d, body = %s", changeRR.Code, changeRR.Body.String())
}
successor := handler.tm.GetTranscodeSession(session.ID)
if successor == nil || successor == predecessor {
t.Fatal("audio switch did not publish a prepared successor")
}
if predecessor.IsRunning() {
t.Fatal("audio switch predecessor is still running after commit")
}
opts := successor.Opts()
if opts.TargetCodecVideo != "h264" || opts.SeekSeconds != 120 ||
opts.StreamOriginSeconds != 120 || opts.CopySeekAnchorResolved ||
opts.StartSegmentNumber != 60 || opts.AudioTrackIndex != 1 {
t.Fatalf("local long encoded restart opts = %+v", opts)
}
var resp changeAudioResponse
if err := json.NewDecoder(changeRR.Body).Decode(&resp); err != nil {
t.Fatalf("decode change response: %v", err)
}
manifestURL, err := url.Parse(resp.StreamURL)
if err != nil {
t.Fatalf("parse stream URL: %v", err)
}
claims, err := streamtoken.Verify(manifestURL.Query().Get(streamTokenParam), handler.JWTSecret)
if err != nil {
t.Fatalf("verify stream token: %v", err)
}
if claims.TargetCodec != "h264" || claims.SeekSeconds != 120 ||
claims.StreamOriginSeconds != 120 || claims.CopySeekAnchorResolved ||
claims.StartSegmentNumber != 60 || claims.AudioTrackIndex != 1 {
t.Fatalf("local long encoded reconstruction claims = %+v", claims)
}
if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 1.5 ||
resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 120 ||
resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 120 ||
resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere {
t.Fatalf("local long encoded response timeline = %+v", resp)
}
}
func TestHandleChangeAudioTrack_LocalCopyRestartUsesFreshSeekAnchor(t *testing.T) {
sessionMgr := playback.NewSessionManager(0, 0)
file := &models.MediaFile{
@@ -2395,6 +2524,61 @@ func TestHandleStartTranscode_SeekedCopyRemainsCopyVideo(t *testing.T) {
}
}
func TestHandleStartTranscode_LongEncodedUsesAlignedRealManifestOrigin(t *testing.T) {
sessionMgr := playback.NewSessionManager(0, 0)
file := &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: writePlaybackTestMediaFile(t, "movie-long-encoded.mkv"),
Resolution: "1080p",
CodecVideo: "hevc",
CodecAudio: "dts",
Container: "mkv",
Bitrate: 25000,
Duration: 1_000_000,
AudioTracks: []models.AudioTrack{{Codec: "dts", Default: true}},
}
session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayTranscode, true)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file})
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
transcodeReq := httptest.NewRequest(
http.MethodPost,
"/api/v1/playback/transcode/start",
strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":4000,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`),
).WithContext(newAuthorizedPlaybackContext())
transcodeRR := httptest.NewRecorder()
handler.HandleStartTranscode(transcodeRR, transcodeReq)
if transcodeRR.Code != http.StatusAccepted {
t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String())
}
var response transcodeStartResponse
if err := json.NewDecoder(transcodeRR.Body).Decode(&response); err != nil {
t.Fatalf("decode transcode response: %v", err)
}
if math.Abs(response.PlayerStartSeconds-0.261) > 0.0001 || response.StreamOriginSeconds != 18 ||
response.TimelineOffsetSeconds != 18 || response.CanSeekAnywhere {
t.Fatalf("long encoded response timeline = %+v", response)
}
transcodeSession := handler.tm.GetTranscodeSession(session.ID)
if transcodeSession == nil {
t.Fatal("expected local transcode session")
}
t.Cleanup(func() { _ = transcodeSession.Close() })
opts := transcodeSession.Opts()
if opts.SeekSeconds != 18 || opts.StreamOriginSeconds != 18 || opts.CopySeekAnchorResolved || opts.StartSegmentNumber != 9 {
t.Fatalf("long encoded seek recipe = seek %v origin %v copy anchor %v segment %d", opts.SeekSeconds, opts.StreamOriginSeconds, opts.CopySeekAnchorResolved, opts.StartSegmentNumber)
}
}
func TestHandleStartTranscode_CopyAnchorFailureKeepsActiveTransport(t *testing.T) {
sessionMgr := playback.NewSessionManager(0, 0)
file := &models.MediaFile{
+311 -61
View File
@@ -35,6 +35,11 @@ const (
maxPlaybackV3EventBodyBytes = 32 << 10
replanLeaseDurationV3 = 15 * time.Second
v3NodeCapabilityTTL = time.Minute
playbackNodeIntegratedV3 = "integrated"
subtitleFormatVTTV3 = "vtt"
subtitleMIMEVTTV3 = "text/vtt"
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
seekRestorationPlayerV3 = "player_position"
// Failed capability fetches are memoized briefly so an unreachable node
// costs one timeout per window instead of one per planning request.
v3NodeCapabilityErrorTTL = 15 * time.Second
@@ -44,6 +49,8 @@ const (
v3NodeCapabilityPlanTimeout = 3 * time.Second
)
var errSubtitleStoreUnavailableV3 = errors.New("subtitle store unavailable")
type v3NodeCapabilityCache struct {
transformations []playback.TransformationV3
err error
@@ -67,6 +74,19 @@ type transportErrorV3 struct {
cause error
}
func subtitleArtifactErrorV3(message string, cause error) *transportErrorV3 {
return &transportErrorV3{
reason: subtitleUnavailableReasonV3,
message: message,
retryable: errors.Is(cause, errSubtitleStoreUnavailableV3),
cause: cause,
}
}
func wrapSubtitleStoreErrorV3(err error) error {
return fmt.Errorf("%w: %w", errSubtitleStoreUnavailableV3, err)
}
type v3ReplanLock struct {
mu sync.Mutex
refs int
@@ -525,19 +545,24 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to load the initialized playback session.", cause: err}
}
result.Plan.SessionID = session.ID
frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result)
if frozenErr != nil {
abort()
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
}
transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result)
if transportErr != nil {
abort()
return playback.DecisionResponseV3{}, transportErr
}
result.Plan.Stream.URL = transport.url
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil {
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &frozenRecipe); err != nil {
transport.rollback()
abort()
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err}
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
}
response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: playback.ServerFeaturesV3(), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan}
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil {
transport.rollback()
abort()
@@ -708,9 +733,10 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 {
videoCodec = "copy"
}
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration))
sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration)
unlock := h.tm.LockSessionLifecycle(session.ID)
ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), FastStart: true, NodeType: "integrated", ExecutionMode: "integrated", FFmpegLogSink: h.FFmpegLogSink})
ts, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink})
if err != nil {
unlock()
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
@@ -765,8 +791,9 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
if result.Plan.Delivery == playback.DeliveryRemuxHLSV3 {
videoCodec = "copy"
}
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, float64(file.Duration))
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: file.CodecVideo, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: float64(file.Duration), RequireReady: true}
sourceVideoCodec, sourceDuration := sourceExecutionMetadataV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceDuration)
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceVideoCodec, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceDuration, RequireReady: true}
nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req)
if err != nil {
// A timeout can fire after the node actually started the job; the
@@ -812,6 +839,16 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
}}, nil
}
func sourceExecutionMetadataV3(file *models.MediaFile, result playback.PlannerResultV3) (string, float64) {
if result.FrozenSourceMetadata != nil {
return result.FrozenSourceMetadata.VideoCodec, result.FrozenSourceMetadata.DurationSeconds
}
if file == nil {
return "", 0
}
return file.CodecVideo, float64(file.Duration)
}
func (h *PlaybackHandler) v3SessionStreamState(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, transport preparedTransportV3) playback.SessionStreamState {
state := playback.SessionStreamState{PlayMethod: result.PlayMethod, BasePlayMethod: result.PlayMethod, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), TranscodeAudio: result.TranscodeAudio, RemuxDVMode: remuxDVModeForPlanV3(result.Plan), TranscodeNodeURL: transport.nodeURL, TranscodeTransportID: transport.transportID, TranscodeRouteSet: true, ClientIP: clientip.FromContext(ctx), ClientName: session.ClientName, ClientVersion: session.ClientVersion, ClientUserAgent: session.ClientUserAgent, StreamBitrateKbps: result.TargetBitrateKbps, TargetVideoCodec: result.TargetVideoCodec, TargetAudioCodec: result.TargetAudioCodec, TargetResolution: result.TargetResolution, SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn}
if result.Plan != nil && (result.Plan.Delivery == playback.DeliveryTranscodeHLSV3 || result.Plan.Delivery == playback.DeliveryRemuxHLSV3) {
@@ -842,16 +879,38 @@ func transportGenerationV3(sessionID, planID string) string {
return sessionID + "-" + planSuffix + "-" + uuid.NewString()[:8]
}
func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int) error {
func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionID string, file *models.MediaFile, plan *playback.PlanV3, selectedIndex int, recipe *playback.ExecutableRecipeV3) error {
if plan == nil || file == nil || selectedIndex < 0 || (plan.Subtitle.Mode != playback.SubtitleRenderV3 && plan.Subtitle.Mode != playback.SubtitleConvertV3) {
return nil
}
if recipe != nil && recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 {
if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 {
return errors.New("the frozen downloaded subtitle is unavailable")
}
downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID)
if err != nil {
return wrapSubtitleStoreErrorV3(err)
}
if downloaded == nil || downloaded.MediaFileID != file.ID {
return errors.New("the frozen downloaded subtitle is unavailable for the selected media file")
}
url := downloadedSubtitleStreamURL(sessionID, selectedIndex, string(downloaded.Format), file.ID, downloaded.ID)
format := strings.ToLower(string(downloaded.Format))
mime := subtitleMIMEV3(format)
if plan.Subtitle.Mode == playback.SubtitleConvertV3 {
format = subtitleFormatVTTV3
mime = subtitleMIMEVTTV3
url = forceSubtitleExtensionV3(url, ".vtt")
}
plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds}
return nil
}
var downloaded []subtitles.DownloadedSubtitle
if h.SubtitleRepo != nil {
var err error
downloaded, err = h.SubtitleRepo.ListDownloadedSubtitles(ctx, file.ID)
if err != nil {
return err
return wrapSubtitleStoreErrorV3(err)
}
}
for _, value := range buildSubtitleURLs(sessionID, file, downloaded, true) {
@@ -862,8 +921,8 @@ func (h *PlaybackHandler) attachSubtitleArtifactV3(ctx context.Context, sessionI
mime := subtitleMIMEV3(format)
url := value.URL
if plan.Subtitle.Mode == playback.SubtitleConvertV3 {
format = "vtt"
mime = "text/vtt"
format = subtitleFormatVTTV3
mime = subtitleMIMEVTTV3
url = forceSubtitleExtensionV3(value.URL, ".vtt")
}
plan.Subtitle.Artifact = &playback.SubtitleArtifactV3{URL: url, MIMEType: mime, Format: format, TimingOriginSeconds: plan.Timeline.StreamOriginSeconds}
@@ -883,7 +942,7 @@ func (h *PlaybackHandler) downloadedSubtitleInventoryV3(ctx context.Context, fil
base := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
result := make([]playback.SubtitleInventoryEntryV3, 0, len(downloaded))
for index, value := range downloaded {
result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded"})
result = append(result, playback.SubtitleInventoryEntryV3{CombinedIndex: base + index, Codec: string(value.Format), Source: "downloaded", DownloadedSubtitleID: value.ID})
}
return result
}
@@ -1182,7 +1241,11 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
cause: err,
}
}
if seekScopedRecovery && effectiveFile.Duration > 0 && req.PositionSeconds > float64(effectiveFile.Duration) {
seekDuration := float64(effectiveFile.Duration)
if seekReanchor && record.FrozenRecipe.ValidFor(record.CurrentPlan) {
seekDuration = record.FrozenRecipe.SourceDurationSeconds
}
if seekScopedRecovery && seekDuration > 0 && req.PositionSeconds > seekDuration {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
reason: "invalid_seek_position",
message: "The requested seek position is beyond the end of the selected media source.",
@@ -1191,9 +1254,12 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
if _, err := start.NormalizeAndValidate(); err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "invalid_replan", message: err.Error()}
}
audioIndex, err := resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
if err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
audioIndex := 0
if !seekReanchor {
audioIndex, err = resolveV3AudioIndex(effectiveFile, start.AudioTrackID, start.AudioTrackIndex)
if err != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "track_unavailable", message: err.Error()}
}
}
attemptedKeys := []string(nil)
if !intentChange && !seekReanchor {
@@ -1212,7 +1278,23 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
attemptedKeys = append(attemptedKeys, currentKey)
}
}
result := playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
var result playback.PlannerResultV3
if seekReanchor {
if err := h.validateFrozenSubtitleIdentityV3(r.Context(), effectiveFile, record.FrozenRecipe); err != nil {
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("The selected subtitle is no longer available at its frozen route.", err)
}
var frozenErr error
result, frozenErr = frozenSeekReanchorResultV3(record, req.PositionSeconds, time.Now())
if frozenErr != nil {
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
reason: "seek_reanchor_recipe_unavailable",
message: "The active playback recipe cannot be reopened; start a new playback attempt.",
retryable: true,
}
}
} else {
result = playback.PlanPlaybackV3(playback.PlannerInputV3{Request: start, RequestedFile: plannerRequestedFile, EffectiveFile: effectiveFile, AudioTrackIndex: audioIndex, Settings: h.plannerSettingsV3(r.Context()), Registry: h.transformationRegistryV3(r.Context()), HLSRegistry: h.lazyHLSPlanningRegistryV3(r.Context()), DVRPUStrippable: h.lazyDVRPUStrippableV3(r.Context(), effectiveFile), Now: time.Now(), AttemptedKeys: attemptedKeys, AdditionalSubtitles: h.downloadedSubtitleInventoryV3(r.Context(), effectiveFile)})
}
if result.Terminal != nil && result.Terminal.Reason == "no_alternate_version" && replanAllowsAlternateFileV3(operation, start.QualityPreference) {
if alternate, alternateErr := h.findAlternateFile(r.Context(), requestedFile); alternateErr == nil && alternate != nil {
alternate = h.ensurePlaybackProbe(r.Context(), alternate)
@@ -1252,17 +1334,31 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
}
}
result.Plan.SessionID = session.ID
artifactRecipe := record.FrozenRecipe
if !seekReanchor {
frozenRecipe, frozenErr := h.freezeExecutableRecipeV3(r.Context(), effectiveFile, result)
if frozenErr != nil {
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to freeze the selected subtitle identity.", frozenErr)
}
artifactRecipe = frozenRecipe
}
transport, transportErr := h.prepareTransportV3(r, session, effectiveFile, result)
if transportErr != nil {
return playback.DecisionResponseV3{}, *record, nil, transportErr
}
result.Plan.Stream.URL = transport.url
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex); err != nil {
if err := h.attachSubtitleArtifactV3(r.Context(), session.ID, effectiveFile, result.Plan, result.SubtitleTrackIndex, &artifactRecipe); err != nil {
transport.rollback()
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{reason: "subtitle_artifact_unavailable", message: "Failed to prepare the selected subtitle artifact.", cause: err}
return playback.DecisionResponseV3{}, *record, nil, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
}
if seekReanchor {
if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil {
changedFields := seekReanchorIdentityChangesV3(record, result.Plan)
slog.ErrorContext(r.Context(), "protocol v3 seek reanchor changed route identity",
"session", record.SessionID,
"playback_attempt_id", record.PlaybackAttemptID,
"changed_fields", changedFields,
)
transport.rollback()
return playback.DecisionResponseV3{}, *record, nil, &transportErrorV3{
reason: "seek_reanchor_route_changed",
@@ -1274,6 +1370,15 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
updated := *record
updated.CurrentPlanID = result.Plan.PlanID
updated.CurrentPlan = *result.Plan
// A seek reanchor replays the durable recipe verbatim (updated already
// carries it); re-freezing from live inventory could only re-introduce
// the drift this path exists to exclude. Every other replan just accepted
// a freshly planned route and must freeze its recipe — loudly, because a
// recipe with a silently missing subtitle identity would disable drift
// detection for every later seek on this attempt.
if !seekReanchor {
updated.FrozenRecipe = artifactRecipe
}
updated.NormalizedRequest = start
updated.EffectiveMediaFileID = effectiveFile.ID
updated.ExpiresAt = time.Now().Add(playback.MaxTokenTTL)
@@ -1314,6 +1419,133 @@ func (h *PlaybackHandler) executeReplanV3(r *http.Request, record *playback.Atte
return response, updated, &transport, nil
}
func frozenSeekReanchorResultV3(record *playback.AttemptRecordV3, position float64, now time.Time) (playback.PlannerResultV3, error) {
if record == nil || !record.FrozenRecipe.ValidFor(record.CurrentPlan) {
return playback.PlannerResultV3{}, errors.New("the active playback recipe is unavailable")
}
plan := record.CurrentPlan
plan.ExpiresAt = playback.NewPlanExpiryV3(now)
plan.Timeline = playback.TimelineV3{
SourceStartSeconds: position,
PlayerStartSeconds: position,
CanSeekAnywhere: true,
SeekRestoration: seekRestorationPlayerV3,
}
return record.FrozenRecipe.PlannerResult(&plan), nil
}
type subtitleIndexLocationV3 struct {
source string
offset int
}
// classifySubtitleIndexV3 maps the combined subtitle index used by
// buildSubtitleURLs to its inventory segment and segment-local offset.
func classifySubtitleIndexV3(file *models.MediaFile, index int) (subtitleIndexLocationV3, bool) {
if file == nil || index < 0 {
return subtitleIndexLocationV3{}, false
}
externalCount := len(file.ExternalSubtitles)
if index < externalCount {
return subtitleIndexLocationV3{source: playback.SubtitleSourceExternalV3, offset: index}, true
}
embeddedOffset := index - externalCount
if embeddedOffset < len(file.SubtitleTracks) {
return subtitleIndexLocationV3{source: playback.SubtitleSourceEmbeddedV3, offset: embeddedOffset}, true
}
return subtitleIndexLocationV3{
source: playback.SubtitleSourceDownloadedV3,
offset: embeddedOffset - len(file.SubtitleTracks),
}, true
}
// freezeExecutableRecipeV3 extends the pure planner freeze with the identity
// of the selected sidecar subtitle. The combined subtitle index space
// (externals, then embedded, then downloaded — see buildSubtitleURLs) is not
// stable across inventory changes, so the index alone cannot anchor a durable
// selection. A downloaded selection whose identity cannot be established is
// an error: silently omitting it would disable drift detection for exactly
// the seeks this recipe exists to protect.
func (h *PlaybackHandler) freezeExecutableRecipeV3(_ context.Context, file *models.MediaFile, result playback.PlannerResultV3) (playback.ExecutableRecipeV3, error) {
recipe := playback.FreezeExecutableRecipeV3(result)
if file != nil {
recipe.SourceVideoCodec = file.CodecVideo
recipe.SourceDurationSeconds = float64(file.Duration)
}
if file == nil || result.SubtitleTrackIndex < 0 {
return recipe, nil
}
// A downloaded row ID was selected from the planner's inventory snapshot.
// Treat it as authoritative before consulting the mutable combined-index
// segments: an external or embedded subtitle added after planning must not
// make this downloaded selection look like a different source.
if recipe.DownloadedSubtitleID > 0 {
recipe.SubtitleSource = playback.SubtitleSourceDownloadedV3
return recipe, nil
}
location, ok := classifySubtitleIndexV3(file, result.SubtitleTrackIndex)
if !ok {
return recipe, nil
}
switch location.source {
case playback.SubtitleSourceExternalV3:
recipe.SubtitleSource = playback.SubtitleSourceExternalV3
recipe.ExternalSubtitlePath = file.ExternalSubtitles[location.offset].Path
case playback.SubtitleSourceEmbeddedV3:
recipe.SubtitleSource = playback.SubtitleSourceEmbeddedV3
recipe.EmbeddedStreamIndex = file.SubtitleTracks[location.offset].Index
case playback.SubtitleSourceDownloadedV3:
if recipe.DownloadedSubtitleID <= 0 {
return playback.ExecutableRecipeV3{}, errors.New("the selected downloaded subtitle has no stable identity")
}
}
return recipe, nil
}
// validateFrozenSubtitleIdentityV3 confirms the frozen combined subtitle
// index still resolves to the identical inventory entry it was frozen
// against. It mirrors the segment layout of buildSubtitleURLs so a change in
// any earlier segment's size — which shifts every later index — is detected
// as an identity mismatch rather than silently re-resolved.
func (h *PlaybackHandler) validateFrozenSubtitleIdentityV3(ctx context.Context, file *models.MediaFile, recipe playback.ExecutableRecipeV3) error {
if recipe.SubtitleSource == "" {
return nil
}
if file == nil || recipe.SubtitleTrackIndex < 0 {
return errors.New("the frozen subtitle selection is unavailable")
}
if recipe.SubtitleSource == playback.SubtitleSourceDownloadedV3 {
if h == nil || h.SubtitleRepo == nil || recipe.DownloadedSubtitleID <= 0 {
return errors.New("the downloaded subtitle inventory is unavailable")
}
downloaded, err := h.SubtitleRepo.GetDownloadedSubtitle(ctx, recipe.DownloadedSubtitleID)
if err != nil {
return wrapSubtitleStoreErrorV3(err)
}
if downloaded == nil || downloaded.MediaFileID != file.ID {
return errors.New("the frozen downloaded subtitle identity changed")
}
return nil
}
location, ok := classifySubtitleIndexV3(file, recipe.SubtitleTrackIndex)
if !ok || location.source != recipe.SubtitleSource {
return errors.New("the frozen subtitle inventory segment changed")
}
switch recipe.SubtitleSource {
case playback.SubtitleSourceExternalV3:
if file.ExternalSubtitles[location.offset].Path != recipe.ExternalSubtitlePath {
return errors.New("the frozen external subtitle identity changed")
}
case playback.SubtitleSourceEmbeddedV3:
if file.SubtitleTracks[location.offset].Index != recipe.EmbeddedStreamIndex {
return errors.New("the frozen embedded subtitle identity changed")
}
default:
return errors.New("the frozen subtitle identity is unrecognized")
}
return nil
}
func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playback.ReplanRequestV3) error {
if record == nil {
return errors.New("the current playback attempt is unavailable")
@@ -1332,41 +1564,66 @@ func validateSeekRecoveryRequestV3(record *playback.AttemptRecordV3, req playbac
return nil
}
// seekReanchorIdentityChangesV3 returns only bounded, non-secret field names.
// It is safe for structured logs: values, URLs, headers, tokens, and subtitle
// artifact locations are deliberately excluded.
func seekReanchorIdentityChangesV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) []string {
if record == nil || candidate == nil {
return []string{"route"}
}
current := record.CurrentPlan
changed := make([]string, 0, 16)
add := func(name string, differs bool) {
if differs {
changed = append(changed, name)
}
}
add("plan_id", candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID)
add("requested_file_id", candidate.RequestedMediaFileID != record.RequestedMediaFileID)
add("effective_file_id", candidate.EffectiveMediaFileID != record.EffectiveMediaFileID)
add("delivery", candidate.Delivery != current.Delivery)
add("engine", candidate.Engine != current.Engine)
add("protocol", candidate.Stream.Protocol != current.Stream.Protocol)
add("container", candidate.Stream.Container != current.Stream.Container)
add("mime_type", candidate.Stream.MIMEType != current.Stream.MIMEType)
add("header_refresh", candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh)
add("video_codec", candidate.EffectiveRecipe.VideoCodec != current.EffectiveRecipe.VideoCodec)
add("audio_codec", candidate.EffectiveRecipe.AudioCodec != current.EffectiveRecipe.AudioCodec)
add("resolution", !optionalIntEqualV3(candidate.EffectiveRecipe.Width, current.EffectiveRecipe.Width) || !optionalIntEqualV3(candidate.EffectiveRecipe.Height, current.EffectiveRecipe.Height))
add("frame_rate", !optionalFloatEqualV3(candidate.EffectiveRecipe.FrameRate, current.EffectiveRecipe.FrameRate))
add("bitrate", !optionalIntEqualV3(candidate.EffectiveRecipe.BitrateKbps, current.EffectiveRecipe.BitrateKbps))
add("dynamic_range", candidate.EffectiveRecipe.DynamicRange != current.EffectiveRecipe.DynamicRange)
add("audio_channels", !optionalIntEqualV3(candidate.EffectiveRecipe.AudioChannels, current.EffectiveRecipe.AudioChannels) || candidate.EffectiveRecipe.AudioLayout != current.EffectiveRecipe.AudioLayout)
add("selected_audio", !sameTrackIdentityV3(candidate.SelectedTracks.Audio, current.SelectedTracks.Audio))
add("selected_subtitle", !sameTrackIdentityV3(candidate.SelectedTracks.Subtitle, current.SelectedTracks.Subtitle))
add("subtitle_mode", candidate.Subtitle.Mode != current.Subtitle.Mode || candidate.Subtitle.TrackID != current.Subtitle.TrackID)
add("subtitle_artifact_route", !sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact))
add("subtitle_fidelity", candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy)
add("transformations", !sameTransformationsV3(candidate.Transformations, current.Transformations))
add("quirks", !sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks))
add("runtime_corrections", !sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections))
add("claims", candidate.Claims != current.Claims)
return changed
}
func validateSeekReanchorPlanV3(record *playback.AttemptRecordV3, candidate *playback.PlanV3) error {
if record == nil || candidate == nil {
return errors.New("seek reanchor produced no playback route")
}
current := record.CurrentPlan
if candidate.PlanID != record.CurrentPlanID || candidate.PlanID != current.PlanID {
changedFields := seekReanchorIdentityChangesV3(record, candidate)
if len(changedFields) == 0 {
return nil
}
if containsStringExactV3(changedFields, "plan_id") {
return errors.New("seek reanchor changed the playback plan identity")
}
if candidate.RequestedMediaFileID != record.RequestedMediaFileID || candidate.EffectiveMediaFileID != record.EffectiveMediaFileID {
if containsStringExactV3(changedFields, "requested_file_id") || containsStringExactV3(changedFields, "effective_file_id") {
return errors.New("seek reanchor changed the selected media version")
}
if !sameSelectedTracksV3(candidate.SelectedTracks, current.SelectedTracks) {
if containsStringExactV3(changedFields, "selected_audio") || containsStringExactV3(changedFields, "selected_subtitle") {
return errors.New("seek reanchor changed selected tracks")
}
if candidate.Engine != current.Engine ||
candidate.Stream.MIMEType != current.Stream.MIMEType ||
candidate.Stream.HeaderRefresh != current.Stream.HeaderRefresh ||
!sameEffectiveRecipeV3(candidate.EffectiveRecipe, current.EffectiveRecipe) ||
candidate.Claims != current.Claims ||
candidate.Subtitle.Mode != current.Subtitle.Mode ||
candidate.Subtitle.TrackID != current.Subtitle.TrackID ||
!sameSubtitleArtifactRouteV3(candidate.Subtitle.Artifact, current.Subtitle.Artifact) ||
candidate.SubtitleFidelityPolicy != current.SubtitleFidelityPolicy ||
!sameTransformationsV3(candidate.Transformations, current.Transformations) ||
!sameAppliedQuirksV3(candidate.AppliedQuirks, current.AppliedQuirks) ||
!sameStringMultisetV3(candidate.RuntimeCorrections, current.RuntimeCorrections) {
return errors.New("seek reanchor changed the playback route semantics")
}
generation := record.NormalizedRequest.OutputRouteGeneration
currentKey := playback.PlanAttemptKeyV3(current, generation, nil)
candidateKey := playback.PlanAttemptKeyV3(*candidate, generation, nil)
if candidateKey != currentKey {
return errors.New("seek reanchor changed the playback route recipe")
}
return nil
return errors.New("seek reanchor changed the playback route semantics")
}
func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool {
@@ -1378,18 +1635,6 @@ func sameSubtitleArtifactRouteV3(left, right *playback.SubtitleArtifactV3) bool
return left.MIMEType == right.MIMEType && left.Format == right.Format
}
func sameEffectiveRecipeV3(left, right playback.EffectiveRecipeV3) bool {
return left.VideoCodec == right.VideoCodec &&
left.AudioCodec == right.AudioCodec &&
optionalIntEqualV3(left.Width, right.Width) &&
optionalIntEqualV3(left.Height, right.Height) &&
optionalFloatEqualV3(left.FrameRate, right.FrameRate) &&
optionalIntEqualV3(left.BitrateKbps, right.BitrateKbps) &&
left.DynamicRange == right.DynamicRange &&
optionalIntEqualV3(left.AudioChannels, right.AudioChannels) &&
left.AudioLayout == right.AudioLayout
}
func sameTransformationsV3(left, right []playback.TransformationV3) bool {
if len(left) != len(right) {
return false
@@ -1934,7 +2179,7 @@ func subtitleMIMEV3(format string) string {
case "pgs", "hdmv_pgs_subtitle":
return "application/octet-stream"
default:
return "text/vtt"
return subtitleMIMEVTTV3
}
}
@@ -2021,13 +2266,18 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
seek := alignedSeekSeconds(requested, segmentDuration, videoCodec)
startSegment := computeStartSegment(seek, segmentDuration)
plan.Timeline.SourceStartSeconds = requested
if strings.EqualFold(videoCodec, "copy") {
plan.Timeline.PlayerStartSeconds = 0
usesGrowingManifest := strings.EqualFold(videoCodec, "copy") ||
!playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
if usesGrowingManifest {
// Encoded streams seek to the preceding segment boundary. Preserve the
// requested sub-segment offset so playback still begins at the exact
// requested source position. Copy seeks are already exact, making this 0.
plan.Timeline.PlayerStartSeconds = max(0, requested-seek)
plan.Timeline.StreamOriginSeconds = seek
plan.Timeline.TimelineOffsetSeconds = seek
windowStart := seek
plan.Timeline.SeekWindowStartSeconds = &windowStart
// A copy remux is served from FFmpeg's live, still-growing playlist
// This transport is served from FFmpeg's live, still-growing playlist
// (see BuildPlaybackManifest), so the seekable extent is whatever has
// been produced so far — a value this plan cannot know and could not
// keep current if it did. Publishing the media runtime here instead
@@ -2049,7 +2299,7 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
plan.Timeline.SeekWindowStartSeconds = nil
plan.Timeline.SeekWindowEndSeconds = nil
plan.Timeline.CanSeekAnywhere = durationSeconds > 0
plan.Timeline.SeekRestoration = "player_position"
plan.Timeline.SeekRestoration = seekRestorationPlayerV3
}
return seek, startSegment
}
+633 -3
View File
@@ -3,7 +3,9 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"net/http/httptest"
"net/url"
@@ -16,6 +18,7 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
)
@@ -605,6 +608,274 @@ func TestHandleReplanPlaybackV3SeekReanchorKeepsCurrentRecipeEligible(t *testing
}
}
func TestHandleReplanPlaybackV3SeekReanchorIgnoresRefreshedProbeMetadata(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo"})
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
selectedAudio := 1
startRequest.AudioTrackIndex = &selectedAudio
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
startRR := httptest.NewRecorder()
handler.HandleStartPlayback(startRR, startReq)
if startRR.Code != http.StatusCreated {
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
}
var started playback.DecisionResponseV3
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
t.Fatal(err)
}
if started.PlaybackPlan == nil || started.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 {
t.Fatalf("start plan = %#v", started.PlaybackPlan)
}
// Simulate a refreshed probe changing a live planner input after the route
// was durably accepted. A seek is not authority to consume that drift.
file.Container = "mkv"
file.AudioTracks = file.AudioTracks[:1]
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
reanchor := playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "seek-reanchor-probe-drift-0001", FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "plan-attempt-seek-probe-0001", PlanAttemptKey: currentKey,
AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 321,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
}
body, err := json.Marshal(reanchor)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+started.SessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
req = withPlaybackRouteParam(req, "session_id", started.SessionID)
rr := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("reanchor status = %d, body = %s", rr.Code, rr.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.PlaybackPlan == nil || response.PlaybackPlan.PlanID != started.PlaybackPlan.PlanID ||
response.PlaybackPlan.Delivery != started.PlaybackPlan.Delivery ||
response.PlaybackPlan.Timeline.SourceStartSeconds != 321 {
t.Fatalf("reanchored plan = %#v, terminal = %#v", response.PlaybackPlan, response.Terminal)
}
}
func TestStartPlaybackV3FreezesDownloadedSubtitleFromPlanningSnapshot(t *testing.T) {
file := v3HandlerFixtureFile(t)
repo := newMockSubtitleRepoForHandler()
first := subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}
reordered := subtitles.DownloadedSubtitle{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}
repo.listResults = [][]subtitles.DownloadedSubtitle{{first}, {reordered, first}}
repo.subtitles[first.ID] = &first
repo.subtitles[reordered.ID] = &reordered
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
handler.SubtitleRepo = repo
request := v3HandlerStartRequest()
downloadedIndex := 0
request.SubtitleTrackIndex = &downloadedIndex
request.SubtitleTrackID = playback.TrackIDV3(file.ID, "subtitle", downloadedIndex)
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, request))).WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("start status = %d, body = %s", rr.Code, rr.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.PlaybackPlan == nil || response.PlaybackPlan.Subtitle.Artifact == nil ||
!strings.Contains(response.PlaybackPlan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") {
t.Fatalf("playback plan = %#v, want downloaded subtitle 71", response.PlaybackPlan)
}
if repo.listCalls != 1 {
t.Fatalf("downloaded inventory listed %d times, want one planning snapshot", repo.listCalls)
}
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID)
if err != nil {
t.Fatal(err)
}
if record.FrozenRecipe.DownloadedSubtitleID != 71 {
t.Fatalf("frozen downloaded subtitle = %d, want 71", record.FrozenRecipe.DownloadedSubtitleID)
}
}
func TestHandleReplanPlaybackV3SeekReanchorPreservesFallbackRecipe(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: "audio_to_aac", RecipeVersion: "1", Available: true},
{Name: "video_to_h264", RecipeVersion: "1", Available: true},
{Name: "server_dv7_to_hdr10", RecipeVersion: "1", Available: true},
}))
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true}
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3HLSV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true}
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
startRR := httptest.NewRecorder()
handler.HandleStartPlayback(startRR, startReq)
if startRR.Code != http.StatusCreated {
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
}
var active playback.DecisionResponseV3
if err := json.Unmarshal(startRR.Body.Bytes(), &active); err != nil {
t.Fatal(err)
}
if active.PlaybackPlan == nil || active.PlaybackPlan.Delivery != playback.DeliveryOriginalHTTPV3 {
t.Fatalf("initial plan = %#v", active.PlaybackPlan)
}
attempted := []string{}
wantFallbacks := []playback.DeliveryV3{playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3}
for index, classification := range []string{"playback_error", "decoder_error"} {
currentKey := playback.PlanAttemptKeyV3(*active.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
attempted = append(attempted, currentKey)
response := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: fmt.Sprintf("fallback-replan-%04d", index+1),
FailedPlanID: active.PlaybackPlan.PlanID,
PlanAttemptID: fmt.Sprintf("fallback-plan-attempt-%04d", index+1),
PlanAttemptKey: currentKey, AttemptedPlanKeys: append([]string(nil), attempted...),
AttemptCount: index + 1, QualityPreference: startRequest.QualityPreference,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: active.PlaybackPlan.SelectedTracks,
Failure: playback.FailureV3{Classification: classification},
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if response.PlaybackPlan == nil {
t.Fatalf("fallback %d = %#v", index, response)
}
if response.PlaybackPlan.Delivery != wantFallbacks[index] {
t.Fatalf("fallback %d delivery = %q, want %q; response=%#v", index, response.PlaybackPlan.Delivery, wantFallbacks[index], response)
}
active = response
}
if active.PlaybackPlan.Delivery != playback.DeliveryRemuxHLSV3 {
t.Fatalf("fallback route = %#v", active.PlaybackPlan)
}
frozen := *active.PlaybackPlan
currentKey := playback.PlanAttemptKeyV3(frozen, startRequest.OutputRouteGeneration, nil)
reanchored := postPlaybackReplanV3(t, handler, active.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "fallback-seek-reanchor-0001", FailedPlanID: frozen.PlanID,
PlanAttemptID: "fallback-seek-plan-0001", PlanAttemptKey: currentKey,
AttemptCount: 3, QualityPreference: startRequest.QualityPreference, PositionSeconds: 819.185,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: frozen.SelectedTracks,
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if reanchored.PlaybackPlan == nil || reanchored.PlaybackPlan.PlanID != frozen.PlanID ||
reanchored.PlaybackPlan.Delivery != frozen.Delivery || reanchored.PlaybackPlan.Engine != frozen.Engine ||
reanchored.PlaybackPlan.Timeline.SourceStartSeconds != 819.185 {
t.Fatalf("reanchored fallback = %#v, terminal = %#v", reanchored.PlaybackPlan, reanchored.Terminal)
}
}
// A pre-migration attempt row carries frozen_recipe = '{}', which decodes to
// an invalid zero recipe. A seek reanchor against it must fail with the
// dedicated retryable reason — telling the client to mint a fresh playback
// attempt — while leaving the durable attempt fully usable for subsequent
// failure replans.
func TestHandleReplanPlaybackV3SeekReanchorWithoutFrozenRecipeFailsRetryably(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"playback.protocol_v3_enabled": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
startRequest.ClientPlaybackContext.Engines[string(playback.EngineMedia3ProgressiveRemuxV3)] = playback.EngineCapabilityV3{Enabled: true, SupportedOnDevice: true, Subtitles: playback.EngineSubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}
startReq := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
startRR := httptest.NewRecorder()
handler.HandleStartPlayback(startRR, startReq)
if startRR.Code != http.StatusCreated {
t.Fatalf("start status = %d, body = %s", startRR.Code, startRR.Body.String())
}
var started playback.DecisionResponseV3
if err := json.Unmarshal(startRR.Body.Bytes(), &started); err != nil {
t.Fatal(err)
}
if started.PlaybackPlan == nil {
t.Fatalf("start response = %s", startRR.Body.String())
}
// Simulate the row predating the frozen_recipe migration: the JSONB
// default '{}' unmarshals to the zero recipe.
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
record.FrozenRecipe = playback.ExecutableRecipeV3{}
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
currentKey := playback.PlanAttemptKeyV3(*started.PlaybackPlan, startRequest.OutputRouteGeneration, nil)
seekResponse := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationSeekReanchorV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "legacy-seek-reanchor-0001", FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "legacy-seek-plan-0001", PlanAttemptKey: currentKey,
AttemptCount: 1, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if seekResponse.Terminal == nil || seekResponse.Terminal.Reason != "seek_reanchor_recipe_unavailable" || !seekResponse.Terminal.Retryable {
t.Fatalf("legacy seek reanchor = %#v, terminal = %#v", seekResponse.PlaybackPlan, seekResponse.Terminal)
}
// The failed seek must not have consumed the durable attempt: the current
// plan is unchanged and an ordinary failure replan still succeeds.
after, err := handler.PlanStoreV3.GetAttempt(context.Background(), started.SessionID)
if err != nil {
t.Fatal(err)
}
if after.CurrentPlanID != started.PlaybackPlan.PlanID {
t.Fatalf("failed legacy seek moved the current plan: %q -> %q", started.PlaybackPlan.PlanID, after.CurrentPlanID)
}
recovery := postPlaybackReplanV3(t, handler, started.SessionID, playback.ReplanRequestV3{
ProtocolVersion: playback.ProtocolV3, Operation: playback.ReplanOperationFailureRecoveryV3,
PlaybackAttemptID: startRequest.PlaybackAttemptID,
ReplanRequestID: "legacy-recovery-0001", FailedPlanID: started.PlaybackPlan.PlanID,
PlanAttemptID: "legacy-recovery-plan-0001", PlanAttemptKey: currentKey,
AttemptedPlanKeys: []string{currentKey},
AttemptCount: 2, QualityPreference: startRequest.QualityPreference, PositionSeconds: 456,
OutputRouteGeneration: startRequest.OutputRouteGeneration,
SelectedTracks: started.PlaybackPlan.SelectedTracks,
Failure: playback.FailureV3{Classification: "playback_error"},
Capabilities: startRequest.Capabilities,
ClientPlaybackContext: startRequest.ClientPlaybackContext,
})
if recovery.PlaybackPlan == nil {
t.Fatalf("failure replan after legacy seek = %#v, terminal = %#v", recovery.PlaybackPlan, recovery.Terminal)
}
}
func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *testing.T) {
// This test has never passed. It fails at 854d07cf, the commit that
// introduced it, so it describes behavior that was specified and not
@@ -828,6 +1099,7 @@ func TestHandleReplanPlaybackV3SeekUsesEffectiveEditionWhenRequestedEditionIsGon
record.CurrentPlan,
)
record.CurrentPlanID = record.CurrentPlan.PlanID
record.FrozenRecipe.PlanID = record.CurrentPlan.PlanID
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
currentKey := playback.PlanAttemptKeyV3(record.CurrentPlan, record.NormalizedRequest.OutputRouteGeneration, nil)
@@ -1061,6 +1333,283 @@ func TestValidateSeekReanchorPlanV3RejectsRouteDrift(t *testing.T) {
}
}
func TestSeekReanchorIdentityChangesV3ReportsOnlyChangedFieldNames(t *testing.T) {
current := playback.PlanV3{
PlanID: "plan:current", Delivery: playback.DeliveryOriginalHTTPV3,
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4"},
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac"},
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
}
record := &playback.AttemptRecordV3{
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
CurrentPlanID: current.PlanID, CurrentPlan: current,
}
candidate := current
candidate.PlanID = "plan:candidate"
candidate.Delivery = playback.DeliveryRemuxHLSV3
candidate.Stream.Container = "hls"
candidate.EffectiveRecipe.AudioCodec = "ac3"
got := strings.Join(seekReanchorIdentityChangesV3(record, &candidate), ",")
if want := "plan_id,delivery,container,audio_codec"; got != want {
t.Fatalf("changed fields = %q, want %q", got, want)
}
}
func TestFrozenSeekReanchorResultV3PreservesRouteMatrix(t *testing.T) {
audioIndex := 0
basePlan := func(name string) playback.PlanV3 {
return playback.PlanV3{
ProtocolVersion: playback.ProtocolV3, PlanID: "plan:" + name,
Delivery: playback.DeliveryOriginalHTTPV3, Engine: playback.EngineMedia3DirectV3,
Stream: playback.StreamV3{Protocol: playback.StreamHTTPProgressiveV3, Container: "mp4", MIMEType: "video/mp4", HeaderRefresh: playback.HeaderRefreshSessionV3},
SelectedTracks: playback.SelectedTracksV3{Audio: &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "audio", audioIndex), Index: &audioIndex}},
EffectiveRecipe: playback.EffectiveRecipeV3{VideoCodec: "h264", AudioCodec: "aac", DynamicRange: "sdr"},
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleOffV3},
Transformations: []playback.TransformationV3{}, AppliedQuirks: []playback.AppliedQuirkV3{}, RuntimeCorrections: []string{},
RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
}
}
tests := []struct {
name string
mutate func(*playback.PlanV3, *playback.PlannerResultV3)
}{
{name: "direct"},
{name: "progressive remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
plan.Delivery = playback.DeliveryRemuxProgressiveV3
plan.Engine = playback.EngineMedia3ProgressiveRemuxV3
result.PlayMethod = playback.PlayRemux
}},
{name: "HLS remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
plan.Delivery = playback.DeliveryRemuxHLSV3
plan.Engine = playback.EngineMedia3HLSV3
plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3}
result.PlayMethod = playback.PlayRemux
result.TargetVideoCodec = "copy"
result.TargetAudioCodec = "copy"
}},
{name: "audio converting remux", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
plan.Delivery = playback.DeliveryRemuxProgressiveV3
plan.Engine = playback.EngineMedia3ProgressiveRemuxV3
plan.Transformations = []playback.TransformationV3{{Name: "audio_to_aac", Executor: "server", RecipeVersion: "1"}}
result.PlayMethod = playback.PlayRemux
result.TranscodeAudio = true
result.TargetAudioCodec = "aac"
result.TargetAudioChannels = 2
}},
{name: "downloaded subtitle", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
subtitleIndex := 7
plan.SelectedTracks.Subtitle = &playback.TrackIdentityV3{ID: playback.TrackIDV3(42, "subtitle", subtitleIndex), Index: &subtitleIndex}
plan.Subtitle = playback.SubtitleDecisionV3{Mode: playback.SubtitleConvertV3, TrackID: plan.SelectedTracks.Subtitle.ID, Artifact: &playback.SubtitleArtifactV3{MIMEType: "text/vtt", Format: "vtt"}}
result.SubtitleTrackIndex = subtitleIndex
result.SubtitleTransportTrackIndex = subtitleIndex
result.SubtitleCodec = "srt"
result.DownloadedSubtitleID = 71
}},
{name: "Dolby Vision transformation", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) {
plan.EffectiveRecipe.DynamicRange = "hdr10"
plan.Transformations = []playback.TransformationV3{{Name: "server_dv7_to_hdr10", Executor: "server", RecipeVersion: "1"}}
}},
{name: "pooled node only transformation", mutate: func(plan *playback.PlanV3, result *playback.PlannerResultV3) {
plan.Delivery = playback.DeliveryTranscodeHLSV3
plan.Engine = playback.EngineMedia3HLSV3
plan.Stream = playback.StreamV3{Protocol: playback.StreamHLSV3, Container: "hls", MIMEType: "application/vnd.apple.mpegurl", HeaderRefresh: playback.HeaderRefreshSessionV3}
plan.Transformations = []playback.TransformationV3{{Name: "video_to_h264", Executor: "server", RecipeVersion: "1"}}
result.PlayMethod = playback.PlayTranscode
result.TargetVideoCodec = "h264"
result.TargetAudioCodec = "aac"
result.TargetResolution = "1080p"
}},
{name: "device quirks and runtime corrections", mutate: func(plan *playback.PlanV3, _ *playback.PlannerResultV3) {
plan.AppliedQuirks = []playback.AppliedQuirkV3{{ID: "apple-hls-audio", RegistryRevision: "3", Action: "force-aac"}}
plan.RuntimeCorrections = []string{"pcm_fallback"}
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plan := basePlan(strings.ReplaceAll(test.name, " ", "-"))
operational := playback.PlannerResultV3{Plan: &plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1}
if test.mutate != nil {
test.mutate(&plan, &operational)
}
operational.Plan = &plan
record := &playback.AttemptRecordV3{
PlaybackAttemptID: "attempt-matrix-0001", RequestedMediaFileID: 42, EffectiveMediaFileID: 42,
CurrentPlanID: plan.PlanID, CurrentPlan: plan, FrozenRecipe: playback.FreezeExecutableRecipeV3(operational),
}
result, err := frozenSeekReanchorResultV3(record, 321.25, time.Unix(1_786_000_000, 0))
if err != nil || result.Plan == nil {
t.Fatalf("frozen reanchor: result=%#v err=%v", result, err)
}
if err := validateSeekReanchorPlanV3(record, result.Plan); err != nil {
t.Fatalf("route semantics changed: %v, plan=%#v", err, result.Plan)
}
if result.Plan.Timeline.SourceStartSeconds != 321.25 || result.PlayMethod != operational.PlayMethod ||
result.TranscodeAudio != operational.TranscodeAudio || result.TargetVideoCodec != operational.TargetVideoCodec ||
result.TargetAudioCodec != operational.TargetAudioCodec || result.TargetAudioChannels != operational.TargetAudioChannels ||
result.TargetResolution != operational.TargetResolution || result.SubtitleTrackIndex != operational.SubtitleTrackIndex ||
result.SubtitleTransportTrackIndex != operational.SubtitleTransportTrackIndex || result.SubtitleBurnIn != operational.SubtitleBurnIn ||
result.SubtitleCodec != operational.SubtitleCodec || result.DownloadedSubtitleID != operational.DownloadedSubtitleID {
t.Fatalf("operational recipe changed: got=%#v want=%#v", result, operational)
}
})
}
}
func TestFrozenDownloadedSubtitleV3AcceptsInventoryReordering(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.ExternalSubtitles = []models.ExternalSubtitle{{Format: "srt"}}
file.SubtitleTracks = []models.SubtitleTrack{{Index: 0, Codec: "ass"}}
repo := newMockSubtitleRepoForHandler()
repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.SubtitleRepo = repo
downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
plan := &playback.PlanV3{PlanID: "plan:downloaded", SelectedTracks: playback.SelectedTracksV3{Subtitle: &playback.TrackIdentityV3{ID: playback.TrackIDV3(file.ID, "subtitle", downloadedIndex), Index: &downloadedIndex}}}
result := playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex, DownloadedSubtitleID: 71}
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result)
if err != nil {
t.Fatalf("freeze: %v", err)
}
if recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 {
t.Fatalf("frozen subtitle identity = %q/%d, want downloaded/71", recipe.SubtitleSource, recipe.DownloadedSubtitleID)
}
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil {
t.Fatalf("stable inventory rejected: %v", err)
}
repo.list = []subtitles.DownloadedSubtitle{{ID: 72, MediaFileID: file.ID, Format: subtitles.FormatSRT}, {ID: 71, MediaFileID: file.ID, Format: subtitles.FormatSRT}}
repo.listErr = errors.New("mutable inventory must not be consulted")
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); err != nil {
t.Fatalf("reordered downloaded subtitle inventory rejected stable identity: %v", err)
}
repo.getErr = errors.New("database unavailable")
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, recipe); !errors.Is(err, errSubtitleStoreUnavailableV3) {
t.Fatalf("validation error = %v, want wrapped subtitle-store failure", err)
}
}
func TestAttachSubtitleArtifactV3UsesFrozenDownloadedIdentityWithoutOrdinalLookup(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.ExternalSubtitles = nil
file.SubtitleTracks = nil
repo := newMockSubtitleRepoForHandler()
repo.subtitles[71] = &subtitles.DownloadedSubtitle{
ID: 71, MediaFileID: file.ID, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt",
}
// A second ordinal lookup would either fail or select a different row.
// Artifact attachment must use GetDownloadedSubtitle(71) exclusively.
repo.listErr = errors.New("mutable inventory must not be consulted")
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.SubtitleRepo = repo
selectedIndex := 0
plan := &playback.PlanV3{
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3},
Timeline: playback.TimelineV3{StreamOriginSeconds: 321},
}
recipe := playback.ExecutableRecipeV3{
SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71,
SubtitleTrackIndex: selectedIndex, SubtitleCodec: "vtt",
}
if err := handler.attachSubtitleArtifactV3(context.Background(), "session-frozen-subtitle", file, plan, selectedIndex, &recipe); err != nil {
t.Fatalf("attach frozen downloaded subtitle: %v", err)
}
if plan.Subtitle.Artifact == nil || !strings.Contains(plan.Subtitle.Artifact.URL, "downloaded_subtitle_id=71") {
t.Fatalf("artifact = %#v, want frozen downloaded subtitle 71", plan.Subtitle.Artifact)
}
}
func TestSubtitleArtifactStoreFailuresAreRetryable(t *testing.T) {
storeErr := errors.New("database unavailable")
wantRetryable := subtitleArtifactErrorV3("subtitle lookup failed", wrapSubtitleStoreErrorV3(storeErr))
if !wantRetryable.retryable || !errors.Is(wantRetryable.cause, errSubtitleStoreUnavailableV3) {
t.Fatalf("store failure = %#v, want retryable subtitle error", wantRetryable)
}
wantPermanent := subtitleArtifactErrorV3("subtitle identity changed", errors.New("identity changed"))
if wantPermanent.retryable {
t.Fatalf("identity failure = %#v, want non-retryable subtitle error", wantPermanent)
}
file := v3HandlerFixtureFile(t)
file.ExternalSubtitles = nil
file.SubtitleTracks = nil
repo := newMockSubtitleRepoForHandler()
repo.getErr = storeErr
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.SubtitleRepo = repo
plan := &playback.PlanV3{
Subtitle: playback.SubtitleDecisionV3{Mode: playback.SubtitleRenderV3},
}
recipe := playback.ExecutableRecipeV3{
SubtitleSource: playback.SubtitleSourceDownloadedV3, DownloadedSubtitleID: 71,
SubtitleTrackIndex: 0, SubtitleCodec: "vtt",
}
err := handler.attachSubtitleArtifactV3(context.Background(), "session-store-error", file, plan, 0, &recipe)
if !errors.Is(err, errSubtitleStoreUnavailableV3) {
t.Fatalf("attach error = %v, want wrapped subtitle-store failure", err)
}
}
func TestFreezeExecutableRecipeV3FailsLoudlyWhenDownloadedIdentityUnavailable(t *testing.T) {
file := v3HandlerFixtureFile(t)
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
downloadedIndex := len(file.ExternalSubtitles) + len(file.SubtitleTracks)
result := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:downloaded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: downloadedIndex, SubtitleTransportTrackIndex: downloadedIndex}
if _, err := handler.freezeExecutableRecipeV3(context.Background(), file, result); err == nil {
t.Fatal("downloaded subtitle without a planning-snapshot identity was frozen")
}
result.DownloadedSubtitleID = 71
file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/added-after-planning.srt", Format: "srt"}}
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, result)
if err != nil || recipe.SubtitleSource != playback.SubtitleSourceDownloadedV3 || recipe.DownloadedSubtitleID != 71 {
t.Fatalf("freeze planning snapshot identity: recipe=%#v err=%v", recipe, err)
}
}
func TestFrozenSubtitleIdentityV3RejectsExternalAndEmbeddedInventoryDrift(t *testing.T) {
file := v3HandlerFixtureFile(t)
file.ExternalSubtitles = []models.ExternalSubtitle{{Path: "/subs/en.srt", Format: "srt"}, {Path: "/subs/de.srt", Format: "srt"}}
file.SubtitleTracks = []models.SubtitleTrack{{Index: 3, Codec: "ass"}}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
externalIndex := 1 // /subs/de.srt
externalResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:external"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: externalIndex, SubtitleTransportTrackIndex: externalIndex}
externalRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, externalResult)
if err != nil {
t.Fatalf("freeze external: %v", err)
}
if externalRecipe.SubtitleSource != playback.SubtitleSourceExternalV3 || externalRecipe.ExternalSubtitlePath != "/subs/de.srt" {
t.Fatalf("frozen external identity = %q/%q", externalRecipe.SubtitleSource, externalRecipe.ExternalSubtitlePath)
}
embeddedIndex := 2 // combined index of SubtitleTracks[0]
embeddedResult := playback.PlannerResultV3{Plan: &playback.PlanV3{PlanID: "plan:embedded"}, PlayMethod: playback.PlayDirect, SubtitleTrackIndex: embeddedIndex, SubtitleTransportTrackIndex: 3}
embeddedRecipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, embeddedResult)
if err != nil {
t.Fatalf("freeze embedded: %v", err)
}
if embeddedRecipe.SubtitleSource != playback.SubtitleSourceEmbeddedV3 || embeddedRecipe.EmbeddedStreamIndex != 3 {
t.Fatalf("frozen embedded identity = %q/%d", embeddedRecipe.SubtitleSource, embeddedRecipe.EmbeddedStreamIndex)
}
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err != nil {
t.Fatalf("stable external inventory rejected: %v", err)
}
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err != nil {
t.Fatalf("stable embedded inventory rejected: %v", err)
}
// Deleting the first external subtitle shifts every later combined index.
// The frozen external selection now points past the external segment and
// the frozen embedded selection resolves one entry early; both must be
// rejected rather than silently re-resolved to a different artifact.
file.ExternalSubtitles = file.ExternalSubtitles[1:]
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, externalRecipe); err == nil {
t.Fatal("external subtitle deletion was accepted for the frozen external selection")
}
if err := handler.validateFrozenSubtitleIdentityV3(context.Background(), file, embeddedRecipe); err == nil {
t.Fatal("external subtitle deletion was accepted for the frozen embedded selection")
}
}
func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
@@ -1174,6 +1723,54 @@ func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) {
}
}
func TestPrepareTransportV3UsesFrozenSourceMetadataAfterProbeDrift(t *testing.T) {
var startRequest transcodenode.TranscodeStartRequest
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities":
writeJSON(w, http.StatusOK, playback.HWAccelInfo{})
case r.Method == http.MethodPost && r.URL.Path == "/transcode/start":
if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil {
t.Errorf("decode remote start: %v", err)
}
writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"})
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer remote.Close()
file := v3HandlerFixtureFile(t)
file.CodecVideo = "hevc"
file.Duration = 7_201
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}}
plan := &playback.PlanV3{PlanID: "plan:frozen-source", Delivery: playback.DeliveryRemuxHLSV3}
initial := playback.PlannerResultV3{
Plan: plan, PlayMethod: playback.PlayRemux, TargetVideoCodec: "copy", TargetAudioCodec: "copy",
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
}
recipe, err := handler.freezeExecutableRecipeV3(context.Background(), file, initial)
if err != nil {
t.Fatalf("freeze executable recipe: %v", err)
}
file.CodecVideo = "mpeg2video"
file.Duration = 99
result := recipe.PlannerResult(plan)
request := httptest.NewRequest(http.MethodPost, "/", nil)
transport, transportErr := handler.prepareTransportV3(request, &playback.Session{ID: "session-frozen-source", UserID: 7, ProfileID: "profile-1"}, file, result)
if transportErr != nil {
t.Fatalf("prepare remote transport: %v", transportErr)
}
defer transport.rollback()
if startRequest.SourceVideoCodec != "hevc" || startRequest.TotalDuration != 7_201 {
t.Fatalf("remote start consumed refreshed probe metadata: %#v", startRequest)
}
}
func TestHandleStartPlaybackUnknownProtocolUsesLegacyBranch(t *testing.T) {
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
@@ -1232,10 +1829,23 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
encodePlan.Timeline.SeekRestoration != "player_position" {
t.Fatalf("encode timeline=%#v seek=%v segment=%d", encodePlan.Timeline, encodeSeek, encodeSegment)
}
longEncodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
longEncodeSeek, longEncodeSegment := configureHLSTimelineV3(longEncodePlan, "h264", 2, 1_000_000)
if longEncodeSeek != 16 || longEncodeSegment != 8 || longEncodePlan.Timeline.StreamOriginSeconds != 16 || longEncodePlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(longEncodePlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || longEncodePlan.Timeline.CanSeekAnywhere ||
longEncodePlan.Timeline.SeekWindowStartSeconds == nil || *longEncodePlan.Timeline.SeekWindowStartSeconds != 16 ||
longEncodePlan.Timeline.SeekWindowEndSeconds != nil ||
longEncodePlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("long encode timeline=%#v seek=%v segment=%d", longEncodePlan.Timeline, longEncodeSeek, longEncodeSegment)
}
unknownDurationPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
if unknownDurationPlan.Timeline.CanSeekAnywhere {
t.Fatalf("unknown-duration timeline = %#v", unknownDurationPlan.Timeline)
unknownDurationSeek, unknownDurationSegment := configureHLSTimelineV3(unknownDurationPlan, "h264", 2, 0)
if unknownDurationSeek != 16 || unknownDurationSegment != 8 || unknownDurationPlan.Timeline.StreamOriginSeconds != 16 || unknownDurationPlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(unknownDurationPlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || unknownDurationPlan.Timeline.CanSeekAnywhere ||
unknownDurationPlan.Timeline.SeekWindowStartSeconds == nil || *unknownDurationPlan.Timeline.SeekWindowStartSeconds != 16 ||
unknownDurationPlan.Timeline.SeekWindowEndSeconds != nil ||
unknownDurationPlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("unknown-duration timeline=%#v seek=%v segment=%d", unknownDurationPlan.Timeline, unknownDurationSeek, unknownDurationSegment)
}
}
@@ -1360,3 +1970,23 @@ func marshalV3StartRequest(t *testing.T, request playback.StartRequestV3) string
}
return string(body)
}
func postPlaybackReplanV3(t *testing.T, handler *PlaybackHandler, sessionID string, request playback.ReplanRequestV3) playback.DecisionResponseV3 {
t.Helper()
body, err := json.Marshal(request)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/"+sessionID+"/replan", strings.NewReader(string(body))).WithContext(newAuthorizedPlaybackContext())
req = withPlaybackRouteParam(req, "session_id", sessionID)
rr := httptest.NewRecorder()
handler.HandleReplanPlaybackV3(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("replan status = %d, body = %s", rr.Code, rr.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response
}
+6 -171
View File
@@ -201,69 +201,10 @@ type pluginPresentationJSON struct {
LicenseSPDX string `json:"license_spdx"`
}
type pluginConfigSchemaJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
JSONSchema string `json:"json_schema"`
Required bool `json:"required"`
AdminForm *pluginAdminFormJSON `json:"admin_form,omitempty"`
}
type pluginAdminFormJSON struct {
Fields []pluginAdminFormFieldJSON `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Sections []pluginAdminFormSectionJSON `json:"sections,omitempty"`
}
type pluginAdminFormFieldJSON struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Multiline bool `json:"multiline"`
DefaultValue any `json:"default_value,omitempty"`
Options []pluginAdminFormOptionJSON `json:"options,omitempty"`
Rows int32 `json:"rows,omitempty"`
DynamicOptions bool `json:"dynamic_options,omitempty"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
Validation *pluginAdminFormValidationJSON `json:"validation,omitempty"`
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
}
type pluginAdminFormOptionJSON struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
type pluginAdminFormConditionJSON struct {
Field string `json:"field"`
Equals []string `json:"equals"`
}
type pluginAdminFormValidationJSON struct {
HasMin bool `json:"has_min,omitempty"`
Min float64 `json:"min,omitempty"`
HasMax bool `json:"has_max,omitempty"`
Max float64 `json:"max,omitempty"`
Pattern string `json:"pattern,omitempty"`
MinLength int32 `json:"min_length,omitempty"`
MaxLength int32 `json:"max_length,omitempty"`
}
type pluginAdminFormSectionJSON struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []pluginAdminFormConditionJSON `json:"show_when,omitempty"`
}
type pluginConfigSchemaJSON = plugins.ConfigSchemaView
type pluginAdminFormJSON = plugins.AdminFormView
type pluginAdminFormFieldJSON = plugins.AdminFormFieldView
type pluginAdminFormSectionJSON = plugins.AdminFormSectionView
type pluginCapabilityJSON struct {
Type string `json:"type"`
@@ -1567,117 +1508,11 @@ func toUserPluginSettingsSummary(
}
func configSchemasToJSON(schemas []*pluginv1.ConfigSchema) []pluginConfigSchemaJSON {
response := make([]pluginConfigSchemaJSON, 0, len(schemas))
for _, schema := range schemas {
if schema == nil {
continue
}
response = append(response, pluginConfigSchemaJSON{
Key: schema.GetKey(),
Title: schema.GetTitle(),
Description: schema.GetDescription(),
JSONSchema: schema.GetJsonSchema(),
Required: schema.GetRequired(),
AdminForm: adminFormToJSON(schema.GetAdminForm()),
})
}
return response
return plugins.ConfigSchemaViews(schemas)
}
func adminFormToJSON(form *pluginv1.AdminFormDescriptor) *pluginAdminFormJSON {
if form == nil {
return nil
}
fields := make([]pluginAdminFormFieldJSON, 0, len(form.GetFields()))
for _, field := range form.GetFields() {
if field == nil {
continue
}
options := make([]pluginAdminFormOptionJSON, 0, len(field.GetOptions()))
for _, option := range field.GetOptions() {
if option == nil {
continue
}
options = append(options, pluginAdminFormOptionJSON{
Value: option.GetValue(),
Label: option.GetLabel(),
Description: option.GetDescription(),
})
}
var defaultValue any
if field.GetDefaultValue() != nil {
defaultValue = field.GetDefaultValue().AsInterface()
}
var validation *pluginAdminFormValidationJSON
if v := field.GetValidation(); v != nil {
validation = &pluginAdminFormValidationJSON{
HasMin: v.GetHasMin(),
Min: v.GetMin(),
HasMax: v.GetHasMax(),
Max: v.GetMax(),
Pattern: v.GetPattern(),
MinLength: v.GetMinLength(),
MaxLength: v.GetMaxLength(),
}
}
fields = append(fields, pluginAdminFormFieldJSON{
Key: field.GetKey(),
Label: field.GetLabel(),
Description: field.GetDescription(),
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
Placeholder: field.GetPlaceholder(),
Required: field.GetRequired(),
Secret: field.GetSecret(),
Multiline: field.GetMultiline(),
DefaultValue: defaultValue,
Options: options,
Rows: field.GetRows(),
DynamicOptions: field.GetDynamicOptions(),
ShowWhen: adminFormConditionsToJSON(field.GetShowWhen()),
Validation: validation,
ExclusiveGroupField: field.GetExclusiveGroupField(),
})
}
sections := make([]pluginAdminFormSectionJSON, 0, len(form.GetSections()))
for _, section := range form.GetSections() {
if section == nil {
continue
}
sections = append(sections, pluginAdminFormSectionJSON{
Key: section.GetKey(),
Title: section.GetTitle(),
Description: section.GetDescription(),
Collapsible: section.GetCollapsible(),
CollapsedDefault: section.GetCollapsedDefault(),
FieldKeys: append([]string(nil), section.GetFieldKeys()...),
ShowWhen: adminFormConditionsToJSON(section.GetShowWhen()),
})
}
return &pluginAdminFormJSON{
Fields: fields,
SubmitLabel: form.GetSubmitLabel(),
Sections: sections,
}
}
func adminFormConditionsToJSON(conditions []*pluginv1.AdminFormCondition) []pluginAdminFormConditionJSON {
if len(conditions) == 0 {
return nil
}
out := make([]pluginAdminFormConditionJSON, 0, len(conditions))
for _, condition := range conditions {
if condition == nil {
continue
}
out = append(out, pluginAdminFormConditionJSON{
Field: condition.GetField(),
Equals: append([]string(nil), condition.GetEquals()...),
})
}
if len(out) == 0 {
return nil
}
return out
return plugins.AdminFormViewFromProto(form)
}
func capabilitiesToJSON(descriptors []*pluginv1.CapabilityDescriptor) []pluginCapabilityJSON {
+104 -40
View File
@@ -219,6 +219,37 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
return
}
// New subtitle artifact URLs bind downloaded subtitles by their stable row
// identity. The path ordinal remains for compatibility and display, but it
// must not be re-resolved against a mutable inventory after a seek reanchor.
if rawID := strings.TrimSpace(r.URL.Query().Get(downloadedSubtitleIDParam)); rawID != "" {
downloadedID, parseErr := strconv.Atoi(rawID)
if parseErr != nil || downloadedID <= 0 {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid downloaded subtitle identity")
return
}
if h.SubtitleRepo == nil || h.S3Client == nil {
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
return
}
downloaded, lookupErr := h.SubtitleRepo.GetDownloadedSubtitle(r.Context(), downloadedID)
if lookupErr != nil {
slog.ErrorContext(r.Context(), "get downloaded subtitle failed", "component", "api",
"file_id", file.ID,
"downloaded_subtitle_id", downloadedID,
"error", lookupErr,
)
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load downloaded subtitle")
return
}
if downloaded == nil || downloaded.MediaFileID != file.ID {
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
return
}
h.serveDownloadedSubtitle(w, r, *downloaded, requestedFormat)
return
}
externalCount := len(file.ExternalSubtitles)
if trackIndex < externalCount {
sub := file.ExternalSubtitles[trackIndex]
@@ -287,32 +318,7 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
downloadedIndex := embeddedIndex - len(file.SubtitleTracks)
if downloadedIndex >= 0 && downloadedIndex < len(downloaded) {
dl := downloaded[downloadedIndex]
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, dl.S3Key)
if err != nil {
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
return
}
// Serve ASS/SSA downloaded subtitles as raw data.
if playback.IsASS(string(dl.Format)) && requestedFormat != "vtt" {
playback.ServeSubtitle(w, data, "ass")
return
}
// If the subtitle is already VTT, serve directly.
if dl.Format == subtitles.FormatVTT {
playback.ServeSubtitle(w, data, "vtt")
return
}
// Convert to VTT using the playback conversion pipeline.
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(dl.Format), h.ffmpegPath())
if err != nil {
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
return
}
playback.ServeSubtitle(w, vttData, "vtt")
h.serveDownloadedSubtitle(w, r, downloaded[downloadedIndex], requestedFormat)
return
}
}
@@ -320,6 +326,34 @@ func (h *StreamHandler) HandleSubtitle(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "Subtitle track not found")
}
func (h *StreamHandler) serveDownloadedSubtitle(w http.ResponseWriter, r *http.Request, subtitle subtitles.DownloadedSubtitle, requestedFormat string) {
data, err := h.S3Client.GetObject(r.Context(), h.S3Bucket, subtitle.S3Key)
if err != nil {
writeError(w, http.StatusBadGateway, "s3_error", "Failed to load subtitle from storage")
return
}
// Serve ASS/SSA downloaded subtitles as raw data.
if playback.IsASS(string(subtitle.Format)) && requestedFormat != "vtt" {
playback.ServeSubtitle(w, data, "ass")
return
}
// If the subtitle is already VTT, serve directly.
if subtitle.Format == subtitles.FormatVTT {
playback.ServeSubtitle(w, data, "vtt")
return
}
// Convert other text formats to VTT using the playback conversion pipeline.
vttData, err := playback.ConvertToVTTWithFFmpeg(r.Context(), data, string(subtitle.Format), h.ffmpegPath())
if err != nil {
writeError(w, http.StatusInternalServerError, "convert_error", "Failed to convert subtitle")
return
}
playback.ServeSubtitle(w, vttData, "vtt")
}
// subtitleSourceFileID pins a subtitle URL to the file whose track list was
// used to create it. A quality/seek restart may change session.MediaFileID to
// an alternate version; interpreting the old combined track index against the
@@ -490,7 +524,13 @@ func (h *StreamHandler) handleTransportStartFailure(ctx context.Context, session
// pipeline, it works the same for direct play, remux, and transcode.
func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Request, file *models.MediaFile, embeddedIndex int, session *playback.Session, requestedFormat ...string) {
track := file.SubtitleTracks[embeddedIndex]
_, outFormat := playback.StreamExtractOutput(track.Codec, requestedFormat...)
outFormat := "vtt"
switch {
case playback.IsASS(track.Codec):
outFormat = "ass"
case playback.IsPGS(track.Codec):
outFormat = "sup"
}
// ASS is fetched exactly once and consumed whole by its client-side
// renderer (JASSUB), so it must never be windowed. PGS defaults to
@@ -505,11 +545,11 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
// misleading nonzero seek here.
var seek, duration float64
var allowWindow bool
switch {
case outFormat == "webvtt" && !playback.IsASS(track.Codec):
switch outFormat {
case "vtt":
seek = subtitleSeekPosition(r, session)
duration = subtitleWindowDuration(r)
case outFormat == "sup":
case "sup":
allowWindow, seek, duration = playback.PGSWindowRequest(r.URL.Query())
}
slog.InfoContext(r.Context(), "subtitle stream requested", "component", "api",
@@ -543,16 +583,38 @@ func (h *StreamHandler) streamEmbeddedSubtitle(w http.ResponseWriter, r *http.Re
return
}
opts.TargetFormat = "vtt"
outFormat = "vtt"
}
w.Header().Set("Access-Control-Allow-Origin", "*")
// Embedded extracts are served from or teed into the subtitle cache.
// Requests whose effective ffmpeg argv includes seek/duration are never
// canonical: they stream uncached and warm a seek-0/duration-0 artifact,
// or re-extract from that artifact when it is already present.
err := h.SubtitleCache.ServeExtract(w, r, opts, playback.StreamExtractSubtitle)
playback.LogSubtitleStreamError(r.Context(), err, file.ID, embeddedIndex)
// Full-track PGS extracts are expensive (whole-file demux) and byte-
// identical across requests, so they are served from / teed into the
// subtitle cache; windowed PGS requests extract their slice from the
// cached full track when present (warming it in the background when
// not). All other formats stream uncached: VTT is already windowed
// and fast, ASS is small.
if outFormat == "sup" {
err := h.SubtitleCache.ServeSUPExtract(w, r, opts, playback.StreamExtractSubtitle)
playback.LogSubtitleStreamError(r.Context(), err, file.ID, embeddedIndex)
return
}
switch outFormat {
case "ass":
w.Header().Set("Content-Type", "text/x-ssa; charset=utf-8")
default:
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
opts.Writer = w
if err := playback.StreamExtractSubtitle(r.Context(), opts); err != nil {
// Headers already committed — best we can do is log and let
// the client see a truncated response.
playback.LogSubtitleStreamError(r.Context(), err, file.ID, embeddedIndex)
}
}
// subtitleSeekPosition picks the best-known starting position for a
@@ -571,10 +633,12 @@ func subtitleSeekPosition(r *http.Request, session *playback.Session) float64 {
return 0
}
// subtitleWindowDuration picks the compatibility duration argument. The
// client overrides via ?duration=; absent that we pass 10 minutes. ffmpeg
// currently ignores this input-side -t for subtitle extracts and runs to EOF;
// changing it would alter the whole-track response native clients depend on.
// subtitleWindowDuration picks the bounded extract length. The client
// overrides via ?duration=; absent that we use a 10-minute window,
// which is long enough that a single fetch covers many minutes of
// uninterrupted playback but short enough that the ffmpeg process
// finishes (and frees its input handle) well before the next window
// is requested.
func subtitleWindowDuration(r *http.Request) float64 {
const defaultDuration = 600.0
const maxDuration = 3600.0
+49
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
@@ -14,6 +15,7 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/subtitles"
)
type hookedSessionManager struct {
@@ -246,6 +248,53 @@ func TestHandleSubtitle_ListDownloadedSubtitlesErrorReturns500(t *testing.T) {
}
}
func TestHandleSubtitleUsesBoundDownloadedIdentityAfterInventoryReorder(t *testing.T) {
file := &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: "/tmp/movie.mkv", Duration: 3600}
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
if err != nil {
t.Fatalf("StartSession: %v", err)
}
repo := newMockSubtitleRepoForHandler()
repo.subtitles[71] = &subtitles.DownloadedSubtitle{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"}
// The mutable ordinal now points at a different subtitle. An ID-bound URL
// must still fetch 71 without consulting this reordered list.
repo.list = []subtitles.DownloadedSubtitle{
{ID: 72, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "other-72.vtt"},
{ID: 71, MediaFileID: 42, Format: subtitles.FormatVTT, S3Key: "selected-71.vtt"},
}
handler := NewStreamHandler(baseMgr, testPlaybackFileResolver{file: file})
handler.SubtitleRepo = repo
handler.S3Client = subtitleContentS3Client{objects: map[string][]byte{
"selected-71.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nselected-71\n"),
"other-72.vtt": []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nother-72\n"),
}}
handler.S3Bucket = "test-bucket"
req := httptest.NewRequest(http.MethodGet, "/api/v1/stream/"+session.ID+"/subtitles/0.vtt?file_id=42&downloaded_subtitle_id=71", nil)
req = req.WithContext(newAuthorizedPlaybackContext())
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("session_id", session.ID)
routeCtx.URLParams.Add("track", "0.vtt")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler.HandleSubtitle(rr, req)
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "selected-71") || strings.Contains(rr.Body.String(), "other-72") {
t.Fatalf("status = %d, body = %q", rr.Code, rr.Body.String())
}
}
type subtitleContentS3Client struct {
objects map[string][]byte
}
func (subtitleContentS3Client) PutObject(context.Context, string, string, []byte) error { return nil }
func (c subtitleContentS3Client) GetObject(_ context.Context, _, key string) ([]byte, error) {
return append([]byte(nil), c.objects[key]...), nil
}
func (subtitleContentS3Client) DeleteObject(context.Context, string, string) error { return nil }
func TestHandleSubtitle_NilMediaFileReturns404(t *testing.T) {
baseMgr := playback.NewSessionManager(0, 0)
session, err := baseMgr.StartSession(1, "profile-1", 42, playback.PlayDirect, false)
+16 -2
View File
@@ -239,9 +239,15 @@ type handlerMockSubtitleRepo struct {
subtitles map[int]*subtitles.DownloadedSubtitle
nextID int
byKey map[string]*subtitles.DownloadedSubtitle
// getErr, when set, is returned by GetDownloadedSubtitle to simulate a
// backing-store failure.
getErr error
// listErr, when set, is returned by ListDownloadedSubtitles to simulate a
// backing-store failure.
listErr error
listErr error
list []subtitles.DownloadedSubtitle
listResults [][]subtitles.DownloadedSubtitle
listCalls int
}
func newMockSubtitleRepoForHandler() *handlerMockSubtitleRepo {
@@ -260,6 +266,9 @@ func (m *handlerMockSubtitleRepo) InsertDownloadedSubtitle(_ context.Context, su
}
func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
if m.getErr != nil {
return nil, m.getErr
}
if sub, ok := m.subtitles[id]; ok {
copy := *sub
return &copy, nil
@@ -268,10 +277,15 @@ func (m *handlerMockSubtitleRepo) GetDownloadedSubtitle(_ context.Context, id in
}
func (m *handlerMockSubtitleRepo) ListDownloadedSubtitles(context.Context, int) ([]subtitles.DownloadedSubtitle, error) {
m.listCalls++
if m.listErr != nil {
return nil, m.listErr
}
return nil, nil
if len(m.listResults) > 0 {
index := min(m.listCalls-1, len(m.listResults)-1)
return append([]subtitles.DownloadedSubtitle(nil), m.listResults[index]...), nil
}
return append([]subtitles.DownloadedSubtitle(nil), m.list...), nil
}
func (m *handlerMockSubtitleRepo) DeleteDownloadedSubtitle(_ context.Context, id int) (*subtitles.DownloadedSubtitle, error) {
+22 -4
View File
@@ -6,6 +6,7 @@ import (
"errors"
"net/http"
"strconv"
"time"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/watchsync"
@@ -15,7 +16,7 @@ type WatchProviderService interface {
ListProviders() []watchsync.ProviderSummary
StartDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string) (watchsync.DeviceAuthSession, error)
PollDeviceAuth(ctx context.Context, userID int, profileID string, providerKey string, sessionID string) (watchsync.Connection, error)
ConnectAPIKey(ctx context.Context, userID int, profileID string, providerKey string, apiKey string) (watchsync.Connection, error)
ConnectAPIKeyWithConfig(ctx context.Context, userID int, profileID string, providerKey string, apiKey string, connectionConfig watchsync.ConnectionConfigValues) (watchsync.Connection, error)
GetConnectionStatus(ctx context.Context, userID int, profileID string, provider string) (watchsync.ConnectionStatus, error)
UpdateConnection(ctx context.Context, userID int, profileID string, provider string, update watchsync.ConnectionUpdate) (watchsync.ConnectionStatus, error)
DeleteConnection(ctx context.Context, userID int, profileID string, provider string) error
@@ -27,6 +28,15 @@ type WatchProviderHandler struct {
service WatchProviderService
}
type watchProviderDeviceAuthResponse struct {
ID string `json:"id"`
Provider string `json:"provider"`
UserCode string `json:"user_code"`
VerificationURL string `json:"verification_url"`
IntervalSeconds int `json:"interval_seconds"`
ExpiresAt time.Time `json:"expires_at"`
}
func NewWatchProviderHandler(service WatchProviderService) *WatchProviderHandler {
return &WatchProviderHandler{service: service}
}
@@ -97,7 +107,14 @@ func (h *WatchProviderHandler) HandleStartDeviceAuth(w http.ResponseWriter, r *h
writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error())
return
}
writeJSON(w, http.StatusOK, session)
writeJSON(w, http.StatusOK, watchProviderDeviceAuthResponse{
ID: session.ID,
Provider: session.Provider,
UserCode: session.UserCode,
VerificationURL: session.VerificationURL,
IntervalSeconds: session.IntervalSeconds,
ExpiresAt: session.ExpiresAt,
})
}
func (h *WatchProviderHandler) HandlePollDeviceAuth(w http.ResponseWriter, r *http.Request) {
@@ -130,13 +147,14 @@ func (h *WatchProviderHandler) HandleConnectAPIKey(w http.ResponseWriter, r *htt
return
}
var req struct {
APIKey string `json:"api_key"`
APIKey string `json:"api_key"`
ConnectionConfig watchsync.ConnectionConfigValues `json:"connection_config"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
if _, err := h.service.ConnectAPIKey(r.Context(), userID, profileID, provider, req.APIKey); err != nil {
if _, err := h.service.ConnectAPIKeyWithConfig(r.Context(), userID, profileID, provider, req.APIKey, req.ConnectionConfig); err != nil {
writeError(w, http.StatusBadRequest, "watch_provider_error", err.Error())
return
}
+53 -2
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
@@ -23,6 +25,25 @@ type stubWatchProviderService struct {
runs []watchsync.SyncRun
}
type capturingWatchProviderService struct {
stubWatchProviderService
apiKey string
config watchsync.ConnectionConfigValues
}
func (s *capturingWatchProviderService) ConnectAPIKeyWithConfig(
_ context.Context,
_ int,
_ string,
_ string,
apiKey string,
config watchsync.ConnectionConfigValues,
) (watchsync.Connection, error) {
s.apiKey = apiKey
s.config = config
return watchsync.Connection{}, nil
}
func (s stubWatchProviderService) ListProviders() []watchsync.ProviderSummary {
return s.providers
}
@@ -32,7 +53,7 @@ func (s stubWatchProviderService) StartDeviceAuth(context.Context, int, string,
func (s stubWatchProviderService) PollDeviceAuth(context.Context, int, string, string, string) (watchsync.Connection, error) {
return watchsync.Connection{}, nil
}
func (s stubWatchProviderService) ConnectAPIKey(context.Context, int, string, string, string) (watchsync.Connection, error) {
func (s stubWatchProviderService) ConnectAPIKeyWithConfig(context.Context, int, string, string, string, watchsync.ConnectionConfigValues) (watchsync.Connection, error) {
return watchsync.Connection{}, nil
}
func (s stubWatchProviderService) GetConnectionStatus(context.Context, int, string, string) (watchsync.ConnectionStatus, error) {
@@ -89,7 +110,7 @@ func TestWatchProviderHandlerListsProviders(t *testing.T) {
if len(resp.Providers) != 1 {
t.Fatalf("providers length = %d, want 1", len(resp.Providers))
}
if resp.Providers[0] != service.providers[0] {
if !reflect.DeepEqual(resp.Providers[0], service.providers[0]) {
t.Fatalf("provider = %#v, want %#v", resp.Providers[0], service.providers[0])
}
}
@@ -159,6 +180,11 @@ func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T)
t.Fatalf("response contains Go-style key %q: %#v", key, resp)
}
}
for _, key := range []string{"device_code", "user_id", "profile_id", "completed_at"} {
if _, ok := resp[key]; ok {
t.Fatalf("response exposes host-private field %q: %#v", key, resp)
}
}
if resp["id"] != "auth-1" {
t.Fatalf("id = %#v, want auth-1", resp["id"])
}
@@ -170,6 +196,31 @@ func TestWatchProviderHandlerStartsDeviceAuthWithFrontendJSONShape(t *testing.T)
}
}
func TestWatchProviderHandlerPassesConnectionConfigWithAPIKey(t *testing.T) {
service := &capturingWatchProviderService{}
handler := NewWatchProviderHandler(service)
req := httptest.NewRequest(http.MethodPost, "/watch-providers/plugin:4:floppy/auth/api-key", strings.NewReader(`{
"api_key":"token",
"connection_config":{"floppy":{"base_url":"https://floppy.example.com"}}
}`))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("provider", "plugin:4:floppy")
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx)
ctx = middleware.SetClaims(ctx, &auth.Claims{UserID: 7})
ctx = middleware.SetProfileID(ctx, "profile-1")
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
handler.HandleConnectAPIKey(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if service.apiKey != "token" || service.config["floppy"]["base_url"] != "https://floppy.example.com" {
t.Fatalf("api key = %q, config = %#v", service.apiKey, service.config)
}
}
func TestWatchProviderHandlerManualSyncReturnsRun(t *testing.T) {
startedAt := time.Date(2026, 5, 4, 16, 0, 0, 0, time.UTC)
handler := NewWatchProviderHandler(stubWatchProviderService{
+1 -1
View File
@@ -388,7 +388,7 @@ func (h *PlaybackHandler) buildProxyRedirectURL(
}
return redirectURL, nil
case string(playback.PlayTranscode):
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8", nil
return proxyNode.URL + "/stream/transcode/" + token + "/master.m3u8?" + playback.SourceTimelineQueryParam + "=1", nil
default:
return "", fmt.Errorf("unsupported proxy method %q", method)
}
+42 -15
View File
@@ -246,7 +246,8 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
writeError(w, http.StatusInternalServerError, "ServerError", "Failed to bind transcode node")
return
}
if err := h.startRemoteTranscode(r.Context(), playSession.ID, playSession.UpstreamSessionID, *source, file, playSession.InitialSeekSeconds, tcNode.URL); err != nil {
initialSeekSeconds, _ := compatInitialTranscodePosition(*source, h.compatSegmentDuration(), playSession.InitialSeekSeconds)
if err := h.startRemoteTranscode(r.Context(), playSession.ID, playSession.UpstreamSessionID, *source, file, initialSeekSeconds, tcNode.URL); err != nil {
failRemoteStart()
if errors.Is(err, errTranscode4KDisallowed) {
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
@@ -279,7 +280,7 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
}
// Ensure the transcode process is running.
_, err = h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
manifest, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
if err != nil {
if errors.Is(err, errTranscode4KDisallowed) {
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
@@ -299,7 +300,9 @@ func (h *PlaybackHandler) HandleMasterManifest(w http.ResponseWriter, r *http.Re
segDuration := h.compatSegmentDuration()
manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
if manifest == nil {
manifest = generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
}
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.WriteHeader(http.StatusOK)
@@ -329,7 +332,7 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
}
// Ensure the transcode process is running.
_, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
manifest, err := h.ensureTranscodeManifest(r.Context(), session, playSession.ID, *source)
if err != nil {
if errors.Is(err, errTranscode4KDisallowed) {
writeError(w, http.StatusForbidden, "Forbidden", "4K video transcoding is disabled on this server")
@@ -349,7 +352,9 @@ func (h *PlaybackHandler) HandleHLSManifest(w http.ResponseWriter, r *http.Reque
segDuration := h.compatSegmentDuration()
manifest := generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
if manifest == nil {
manifest = generateFullManifest(source.Version.Duration, segDuration, source.TranscodeAudio, playSession.InitialSeekSeconds)
}
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewriteManifest(manifest, playSession.RouteItemID, playSession.ID, source.ID))
@@ -1564,10 +1569,11 @@ func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSes
return nil, err
}
// When duration is known, Jellycompat serves its own synthetic VOD manifest.
// We only need ffmpeg running; waiting for startup segments here adds
// unnecessary latency before the player can request the actual target segment.
if source.Version.Duration > 0 {
// When the duration fits the shared segment-count bound, Jellycompat serves
// its own synthetic VOD manifest. Longer media waits for FFmpeg's bounded
// real playlist so one request cannot allocate hundreds of thousands of
// segment entries.
if shouldGenerateCompatFullManifest(source, h.compatSegmentDuration()) {
return nil, nil
}
@@ -1579,7 +1585,7 @@ func (h *PlaybackHandler) ensureTranscodeManifest(ctx context.Context, compatSes
for {
manifest, err := transcodeSession.GetManifest()
if err == nil {
return manifest, nil
return playback.AlignRealManifestToSourceTimeline(manifest, transcodeSession.Opts(), "")
}
if !errors.Is(err, playback.ErrManifestNotReady) {
return nil, err
@@ -1628,11 +1634,11 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
initialSeekSeconds := 0.0
startSegmentNumber := 0
if playSession, ok := h.playbackStore.Get(playSessionID); ok {
initialSeekSeconds = playSession.InitialSeekSeconds
segDuration := h.compatSegmentDuration()
if initialSeekSeconds > 0 && segDuration > 0 {
startSegmentNumber = int(initialSeekSeconds / float64(segDuration))
}
initialSeekSeconds, startSegmentNumber = compatInitialTranscodePosition(
source,
h.compatSegmentDuration(),
playSession.InitialSeekSeconds,
)
}
opts := playback.TranscodeOpts{
@@ -1646,6 +1652,7 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
FFmpegPath: h.FFmpegPath,
HWAccel: h.HWAccel,
AudioTrackIndex: compatAudioTrackIndexOrDefault(source),
TotalDuration: float64(source.Version.Duration),
FastStart: true,
}
if source.TranscodeAudio {
@@ -1689,6 +1696,26 @@ func (h *PlaybackHandler) ensureTranscodeSession(ctx context.Context, playSessio
return transcodeSession, nil
}
func shouldGenerateCompatFullManifest(source PlaybackMediaSource, segmentDuration int) bool {
return playback.CanGenerateSyntheticManifest(float64(source.Version.Duration), segmentDuration)
}
// compatInitialTranscodePosition keeps FFmpeg close to the requested resume
// position. Bounded synthetic manifests list the omitted source segments;
// seeked real manifests receive an EXT-X-GAP timeline anchor before serving.
func compatInitialTranscodePosition(source PlaybackMediaSource, segmentDuration int, requested float64) (float64, int) {
if requested <= 0 {
return 0, 0
}
if duration := float64(source.Version.Duration); duration > 0 && requested > duration {
requested = duration
}
if segmentDuration <= 0 {
segmentDuration = compatSegmentDuration
}
return requested, int(requested / float64(segmentDuration))
}
// audioSelectionChanged reports whether an incoming AudioStreamIndex differs
// from what the play session already records for the target media source.
// Used to short-circuit progress reports that merely echo the current
+48
View File
@@ -10,7 +10,9 @@ import (
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
)
@@ -81,6 +83,52 @@ func TestGenerateFullManifest_HLSVersionForResumeStartTag(t *testing.T) {
}
}
func TestShouldGenerateCompatFullManifestBoundsSegmentCount(t *testing.T) {
short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}}
if !shouldGenerateCompatFullManifest(short, 2) {
t.Fatal("historical 50,000-segment compatibility manifest should remain supported")
}
long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}}
if shouldGenerateCompatFullManifest(long, 2) {
t.Fatal("long compatibility playback should use FFmpeg's bounded real manifest")
}
}
func TestCompatInitialTranscodePositionKeepsResumeNearRequestedSegment(t *testing.T) {
short := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 100_000}}
seek, segment := compatInitialTranscodePosition(short, 2, 17.3)
if seek != 17.3 || segment != 8 {
t.Fatalf("bounded manifest position = (%v, %d), want (17.3, 8)", seek, segment)
}
long := PlaybackMediaSource{Version: catalog.FileVersion{Duration: 1_000_000}}
seek, segment = compatInitialTranscodePosition(long, 2, 17.3)
if seek != 17.3 || segment != 8 {
t.Fatalf("real manifest position = (%v, %d), want (17.3, 8)", seek, segment)
}
}
func TestBuildProxyRedirectURLRequestsSourceAlignedCompatManifest(t *testing.T) {
h := &PlaybackHandler{JWTSecret: "test-secret"}
redirectURL, err := h.buildProxyRedirectURL(
"play-1",
"upstream-1",
string(playback.PlayTranscode),
&models.MediaFile{FilePath: "/media/movie.mkv"},
PlaybackMediaSource{},
"http://transcode-1",
0,
&nodepool.Node{URL: "http://proxy-1"},
)
if err != nil {
t.Fatalf("buildProxyRedirectURL: %v", err)
}
if !strings.HasSuffix(redirectURL, "/master.m3u8?"+playback.SourceTimelineQueryParam+"=1") {
t.Fatalf("redirect URL = %q, want source-timeline opt-in", redirectURL)
}
}
func TestRewriteManifest_PreservesPlaybackAndMediaSourceIDs(t *testing.T) {
manifest := strings.Join([]string{
"#EXTM3U",
+110
View File
@@ -0,0 +1,110 @@
package playback
// ExecutableRecipeV3 is the frozen operational half of a protocol-v3 plan.
// PlanV3 describes the client-visible route identity; these fields are the
// additional inputs needed to open another transport for that same route.
// Keeping them with the durable attempt prevents seek reanchoring from
// reverse-engineering execution details from presentation fields or mutable
// planner inputs.
type ExecutableRecipeV3 struct {
Version int `json:"version"`
PlanID string `json:"plan_id"`
PlayMethod PlayMethod `json:"play_method"`
TranscodeAudio bool `json:"transcode_audio"`
TargetVideoCodec string `json:"target_video_codec,omitempty"`
TargetAudioCodec string `json:"target_audio_codec,omitempty"`
TargetAudioChannels int `json:"target_audio_channels,omitempty"`
TargetResolution string `json:"target_resolution,omitempty"`
TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"`
SourceVideoCodec string `json:"source_video_codec,omitempty"`
SourceDurationSeconds float64 `json:"source_duration_seconds,omitempty"`
SubtitleTrackIndex int `json:"subtitle_track_index"`
SubtitleTransportTrackIndex int `json:"subtitle_transport_track_index"`
SubtitleBurnIn bool `json:"subtitle_burn_in"`
SubtitleCodec string `json:"subtitle_codec,omitempty"`
// SubtitleSource pins which sidecar inventory segment SubtitleTrackIndex
// pointed into when the plan was accepted, and the identity fields below
// pin the exact entry. The combined index space (externals, then embedded,
// then downloaded) shifts when any segment grows or shrinks; a seek
// reanchor must detect that drift and fail rather than silently resolve
// the frozen index to a different artifact.
SubtitleSource string `json:"subtitle_source,omitempty"`
ExternalSubtitlePath string `json:"external_subtitle_path,omitempty"`
EmbeddedStreamIndex int `json:"embedded_stream_index,omitempty"`
DownloadedSubtitleID int `json:"downloaded_subtitle_id,omitempty"`
}
const executableRecipeVersionV3 = 1
const (
SubtitleSourceExternalV3 = "external"
SubtitleSourceEmbeddedV3 = "embedded"
SubtitleSourceDownloadedV3 = "downloaded"
)
func FreezeExecutableRecipeV3(result PlannerResultV3) ExecutableRecipeV3 {
planID := ""
if result.Plan != nil {
planID = result.Plan.PlanID
}
sourceMetadata := SourceExecutionMetadataV3{}
if result.FrozenSourceMetadata != nil {
sourceMetadata = *result.FrozenSourceMetadata
}
return ExecutableRecipeV3{
Version: executableRecipeVersionV3,
PlanID: planID,
PlayMethod: result.PlayMethod,
TranscodeAudio: result.TranscodeAudio,
TargetVideoCodec: result.TargetVideoCodec,
TargetAudioCodec: result.TargetAudioCodec,
TargetAudioChannels: result.TargetAudioChannels,
TargetResolution: result.TargetResolution,
TargetBitrateKbps: result.TargetBitrateKbps,
SourceVideoCodec: sourceMetadata.VideoCodec,
SourceDurationSeconds: sourceMetadata.DurationSeconds,
SubtitleTrackIndex: result.SubtitleTrackIndex,
SubtitleTransportTrackIndex: result.SubtitleTransportTrackIndex,
SubtitleBurnIn: result.SubtitleBurnIn,
SubtitleCodec: result.SubtitleCodec,
DownloadedSubtitleID: result.DownloadedSubtitleID,
}
}
func (r ExecutableRecipeV3) Valid() bool {
if r.Version != executableRecipeVersionV3 || r.PlanID == "" {
return false
}
switch r.PlayMethod {
case PlayDirect, PlayRemux, PlayTranscode:
return true
default:
return false
}
}
func (r ExecutableRecipeV3) ValidFor(plan PlanV3) bool {
return r.Valid() && r.PlanID == plan.PlanID
}
func (r ExecutableRecipeV3) PlannerResult(plan *PlanV3) PlannerResultV3 {
return PlannerResultV3{
Plan: plan,
PlayMethod: r.PlayMethod,
TranscodeAudio: r.TranscodeAudio,
TargetVideoCodec: r.TargetVideoCodec,
TargetAudioCodec: r.TargetAudioCodec,
TargetAudioChannels: r.TargetAudioChannels,
TargetResolution: r.TargetResolution,
TargetBitrateKbps: r.TargetBitrateKbps,
FrozenSourceMetadata: &SourceExecutionMetadataV3{
VideoCodec: r.SourceVideoCodec,
DurationSeconds: r.SourceDurationSeconds,
},
SubtitleTrackIndex: r.SubtitleTrackIndex,
SubtitleTransportTrackIndex: r.SubtitleTransportTrackIndex,
SubtitleBurnIn: r.SubtitleBurnIn,
SubtitleCodec: r.SubtitleCodec,
DownloadedSubtitleID: r.DownloadedSubtitleID,
}
}
@@ -0,0 +1,75 @@
package playback
import (
"encoding/json"
"testing"
)
func TestExecutableRecipeV3RoundTripPreservesOperationalFields(t *testing.T) {
plan := &PlanV3{PlanID: "plan:frozen", Delivery: DeliveryRemuxHLSV3}
want := PlannerResultV3{
Plan: plan, PlayMethod: PlayRemux, TranscodeAudio: true,
TargetVideoCodec: "copy", TargetAudioCodec: "aac", TargetAudioChannels: 6,
TargetResolution: "1080p", TargetBitrateKbps: 18_000,
FrozenSourceMetadata: &SourceExecutionMetadataV3{VideoCodec: "hevc", DurationSeconds: 7_201},
SubtitleTrackIndex: 4, SubtitleTransportTrackIndex: 2,
SubtitleBurnIn: true, SubtitleCodec: "hdmv_pgs_subtitle", DownloadedSubtitleID: 71,
}
recipe := FreezeExecutableRecipeV3(want)
if !recipe.Valid() {
t.Fatalf("frozen recipe is invalid: %#v", recipe)
}
if !recipe.ValidFor(*plan) {
t.Fatalf("frozen recipe does not match its plan: %#v", recipe)
}
changedPlan := *plan
changedPlan.PlanID = "plan:newer"
if recipe.ValidFor(changedPlan) {
t.Fatal("stale frozen recipe matched a newer plan")
}
got := recipe.PlannerResult(plan)
if got.Plan != plan || got.PlayMethod != want.PlayMethod || got.TranscodeAudio != want.TranscodeAudio ||
got.TargetVideoCodec != want.TargetVideoCodec || got.TargetAudioCodec != want.TargetAudioCodec ||
got.TargetAudioChannels != want.TargetAudioChannels || got.TargetResolution != want.TargetResolution ||
got.TargetBitrateKbps != want.TargetBitrateKbps || got.SubtitleTrackIndex != want.SubtitleTrackIndex ||
got.SubtitleTransportTrackIndex != want.SubtitleTransportTrackIndex || got.SubtitleBurnIn != want.SubtitleBurnIn ||
got.SubtitleCodec != want.SubtitleCodec || got.DownloadedSubtitleID != want.DownloadedSubtitleID || got.FrozenSourceMetadata == nil ||
got.FrozenSourceMetadata.VideoCodec != want.FrozenSourceMetadata.VideoCodec || got.FrozenSourceMetadata.DurationSeconds != want.FrozenSourceMetadata.DurationSeconds {
t.Fatalf("thawed result = %#v, want %#v", got, want)
}
}
func TestExecutableRecipeV3SurvivesJSONRoundTrip(t *testing.T) {
plan := &PlanV3{PlanID: "plan:frozen"}
recipe := FreezeExecutableRecipeV3(PlannerResultV3{
Plan: plan, PlayMethod: PlayRemux,
FrozenSourceMetadata: &SourceExecutionMetadataV3{VideoCodec: "hevc", DurationSeconds: 7_201},
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: 0,
})
recipe.SubtitleSource = SubtitleSourceDownloadedV3
recipe.DownloadedSubtitleID = 71
encoded, err := json.Marshal(recipe)
if err != nil {
t.Fatalf("marshal recipe: %v", err)
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(encoded, &fields); err != nil {
t.Fatalf("unmarshal recipe fields: %v", err)
}
for _, field := range []string{"subtitle_track_index", "subtitle_transport_track_index"} {
if _, ok := fields[field]; !ok {
t.Fatalf("encoded recipe omitted meaningful zero-value field %q: %s", field, encoded)
}
}
var decoded ExecutableRecipeV3
if err := json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("unmarshal recipe: %v", err)
}
if decoded != recipe {
t.Fatalf("decoded recipe = %#v, want %#v", decoded, recipe)
}
if !decoded.ValidFor(*plan) {
t.Fatalf("decoded recipe no longer matches its plan: %#v", decoded)
}
}
+21 -6
View File
@@ -49,6 +49,13 @@ type PlannerInputV3 struct {
AdditionalSubtitles []SubtitleInventoryEntryV3
}
// SourceExecutionMetadataV3 is the immutable source probe snapshot used to
// reopen a frozen playback recipe without consuming later catalog drift.
type SourceExecutionMetadataV3 struct {
VideoCodec string
DurationSeconds float64
}
// dvRPUStrippable resolves the per-source strip verdict, defaulting to true
// when no probe is wired in.
func (input PlannerInputV3) dvRPUStrippable() bool {
@@ -84,6 +91,14 @@ type PlannerResultV3 struct {
SubtitleTransportTrackIndex int
SubtitleBurnIn bool
SubtitleCodec string
// DownloadedSubtitleID comes from the same inventory snapshot used for
// planning. Freezing must not re-list a mutable ordinal inventory after the
// route has already been accepted.
DownloadedSubtitleID int
// FrozenSourceMetadata is set only when a durable executable recipe is
// thawed for a seek reanchor. Transport construction must then use this
// captured source snapshot instead of a freshly probed media row.
FrozenSourceMetadata *SourceExecutionMetadataV3
}
func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
@@ -254,7 +269,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
})
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
}
}
if clientHDR10Eligible {
@@ -275,7 +290,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
})
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
}
}
}
@@ -289,7 +304,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
applyCopiedVideoQuirksV3(&plan, source, input.Request, high10Quirk)
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayDirect, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
}
}
@@ -350,7 +365,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
plan.Claims.Subtitles = remuxSubtitle.Claims
finalizePlanIdentityV3(&plan, input.Request.PlaybackAttemptID)
if engineAvailableV3(input.Request, EngineMedia3ProgressiveRemuxV3) && !planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: transcodeAudio, TargetAudioCodec: plan.EffectiveRecipe.AudioCodec, SubtitleTrackIndex: remuxSubtitle.SelectedIndex, SubtitleTransportTrackIndex: remuxSubtitle.TransportIndex, SubtitleCodec: remuxSubtitle.Codec, DownloadedSubtitleID: remuxSubtitle.DownloadedSubtitleID}
}
}
if engineAvailableV3(input.Request, EngineMedia3HLSV3) && hlsRemuxSubtitleOK {
@@ -414,7 +429,7 @@ func PlanPlaybackV3(input PlannerInputV3) PlannerResultV3 {
if hlsTranscodeAudio {
targetAudio = "aac"
}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayRemux, TranscodeAudio: hlsTranscodeAudio, TargetVideoCodec: "copy", TargetAudioCodec: targetAudio, TargetAudioChannels: hlsAudioChannels, TargetResolution: resolutionLabelV3(source.Height), TargetBitrateKbps: source.BitrateKbps, SubtitleTrackIndex: hlsSubtitle.SelectedIndex, SubtitleTransportTrackIndex: hlsSubtitle.TransportIndex, SubtitleCodec: hlsSubtitle.Codec, DownloadedSubtitleID: hlsSubtitle.DownloadedSubtitleID}
}
}
}
@@ -490,7 +505,7 @@ func planVideoTranscodeV3(input PlannerInputV3, base PlanV3, source SourceDescri
if planAttemptedV3(plan, input.Request.OutputRouteGeneration, input.AttemptedKeys) {
return terminalPlannerResultV3("adaptation_exhausted", "All compatible playback recipes have already failed for this output route.", false)
}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec}
return PlannerResultV3{Plan: &plan, PlayMethod: PlayTranscode, TranscodeAudio: true, TargetVideoCodec: "h264", TargetAudioCodec: "aac", TargetAudioChannels: targetAudioChannels, TargetResolution: quality.Label, TargetBitrateKbps: quality.BitrateKbps, SubtitleTrackIndex: subtitle.SelectedIndex, SubtitleTransportTrackIndex: subtitle.TransportIndex, SubtitleBurnIn: subtitle.RequiresBurn, SubtitleCodec: subtitle.Codec, DownloadedSubtitleID: subtitle.DownloadedSubtitleID}
}
func canStripDolbyVisionToHDR10V3(source SourceDescriptorV3, request StartRequestV3, registry *TransformationRegistryV3) bool {
+21 -9
View File
@@ -79,6 +79,10 @@ func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecor
if err != nil {
return err
}
recipeJSON, err := json.Marshal(record.FrozenRecipe)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
@@ -98,12 +102,12 @@ func (s *Postgres) SaveAttempt(ctx context.Context, record playback.AttemptRecor
INSERT INTO playback_v3_attempts (
playback_attempt_id, session_id, user_id, profile_id,
requested_media_file_id, effective_media_file_id,
current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at
) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
current_plan_id, current_replan_request_id, current_plan, frozen_recipe, normalized_request, request_digest, expires_at
) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
ON CONFLICT DO NOTHING`,
record.PlaybackAttemptID, record.SessionID, record.UserID, record.ProfileID,
record.RequestedMediaFileID, record.EffectiveMediaFileID,
record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.RequestDigest, record.ExpiresAt)
record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, recipeJSON, requestJSON, record.RequestDigest, record.ExpiresAt)
if err != nil {
return err
}
@@ -159,16 +163,16 @@ func (s *Postgres) getAttemptIdentity(ctx context.Context, predicate string, val
func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any) (*playback.AttemptRecordV3, error) {
var record playback.AttemptRecordV3
var planJSON, requestJSON []byte
var planJSON, recipeJSON, requestJSON []byte
err := s.db.QueryRow(ctx, `
SELECT playback_attempt_id, session_id::text, user_id, profile_id,
requested_media_file_id, effective_media_file_id,
current_plan_id, current_replan_request_id, current_plan, normalized_request, request_digest, expires_at
current_plan_id, current_replan_request_id, current_plan, frozen_recipe, normalized_request, request_digest, expires_at
FROM playback_v3_attempts
WHERE `+predicate+` AND expires_at > NOW()`, value).Scan(
&record.PlaybackAttemptID, &record.SessionID, &record.UserID, &record.ProfileID,
&record.RequestedMediaFileID, &record.EffectiveMediaFileID,
&record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt,
&record.CurrentPlanID, &record.CurrentReplanRequestID, &planJSON, &recipeJSON, &requestJSON, &record.RequestDigest, &record.ExpiresAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return nil, playback.ErrSessionNotFound
@@ -179,6 +183,9 @@ func (s *Postgres) getAttempt(ctx context.Context, predicate string, value any)
if err := json.Unmarshal(planJSON, &record.CurrentPlan); err != nil {
return nil, err
}
if err := json.Unmarshal(recipeJSON, &record.FrozenRecipe); err != nil {
return nil, err
}
if err := json.Unmarshal(requestJSON, &record.NormalizedRequest); err != nil {
return nil, err
}
@@ -273,6 +280,10 @@ func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, bas
if err != nil {
return err
}
recipeJSON, err := json.Marshal(record.FrozenRecipe)
if err != nil {
return err
}
// The base-revision predicate makes the commit a true compare-and-swap:
// under the advisory session lock it never fails, but a skipped or broken
// lock must surface as a conflict rather than silently last-writer-win
@@ -280,9 +291,10 @@ func (s *Postgres) CompleteReplan(ctx context.Context, sessionID, requestID, bas
attemptResult, err := tx.Exec(ctx, `
UPDATE playback_v3_attempts SET
effective_media_file_id = $2, current_plan_id = $3,
current_replan_request_id = $4, current_plan = $5, normalized_request = $6, expires_at = $7, updated_at = NOW()
WHERE session_id = $1::uuid AND current_replan_request_id = $8`,
sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, requestJSON, record.ExpiresAt, baseReplanRequestID)
current_replan_request_id = $4, current_plan = $5, frozen_recipe = $6,
normalized_request = $7, expires_at = $8, updated_at = NOW()
WHERE session_id = $1::uuid AND current_replan_request_id = $9`,
sessionID, record.EffectiveMediaFileID, record.CurrentPlanID, record.CurrentReplanRequestID, planJSON, recipeJSON, requestJSON, record.ExpiresAt, baseReplanRequestID)
if err != nil {
return err
}
@@ -60,6 +60,17 @@ func newPlanstoreFixture(t *testing.T) *planstoreFixture {
if !hasRevision {
t.Skip("test database has not applied the playback v3 attempt revision migration")
}
var hasFrozenRecipe bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'playback_v3_attempts' AND column_name = 'frozen_recipe'
)`).Scan(&hasFrozenRecipe); err != nil {
t.Fatalf("check frozen_recipe column: %v", err)
}
if !hasFrozenRecipe {
t.Skip("test database has not applied the playback v3 frozen recipe migration")
}
f := &planstoreFixture{pool: pool}
unique := fmt.Sprintf("planstore-test-%d", time.Now().UnixNano())
@@ -116,6 +127,10 @@ func (f *planstoreFixture) attemptRecord(sessionID, attemptID, digest string) pl
RequestedMediaFileID: f.mediaFileID,
EffectiveMediaFileID: f.mediaFileID,
},
FrozenRecipe: playback.ExecutableRecipeV3{
Version: 1, PlanID: "plan-1", PlayMethod: playback.PlayDirect,
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
},
NormalizedRequest: playback.StartRequestV3{
ProtocolVersion: 3,
FileID: f.mediaFileID,
@@ -265,6 +280,9 @@ func TestPostgresPlanStore(t *testing.T) {
if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan)) {
t.Fatalf("%s plan JSON did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.CurrentPlan), mustJSON(t, record.CurrentPlan))
}
if !bytes.Equal(mustJSON(t, got.FrozenRecipe), mustJSON(t, record.FrozenRecipe)) {
t.Fatalf("%s frozen recipe did not round-trip:\n got %s\nwant %s", name, mustJSON(t, got.FrozenRecipe), mustJSON(t, record.FrozenRecipe))
}
if !bytes.Equal(mustJSON(t, got.NormalizedRequest), mustJSON(t, record.NormalizedRequest)) {
t.Fatalf("%s normalized request JSON did not round-trip", name)
}
@@ -400,6 +418,7 @@ func TestPostgresPlanStore(t *testing.T) {
updated.CurrentPlanID = "plan-2"
updated.CurrentReplanRequestID = "rq-1"
updated.CurrentPlan.PlanID = "plan-2"
updated.FrozenRecipe.PlanID = "plan-2"
updated.CurrentPlan.EffectiveMediaFileID = f.altFileID
updated.CurrentPlan.DecisionReason = "transcode_fallback"
updated.ExpiresAt = time.Now().Add(2 * time.Hour).UTC().Truncate(time.Microsecond)
@@ -425,6 +444,9 @@ func TestPostgresPlanStore(t *testing.T) {
if !bytes.Equal(mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan)) {
t.Fatalf("plan JSON mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.CurrentPlan), mustJSON(t, updated.CurrentPlan))
}
if !bytes.Equal(mustJSON(t, got.FrozenRecipe), mustJSON(t, updated.FrozenRecipe)) {
t.Fatalf("frozen recipe mismatch after replan:\n got %s\nwant %s", mustJSON(t, got.FrozenRecipe), mustJSON(t, updated.FrozenRecipe))
}
// The migration's sync trigger must not fight the in-transaction CAS:
// the raw column must equal the new request ID, with no extra rewrite.
+1
View File
@@ -27,6 +27,7 @@ type AttemptRecordV3 struct {
CurrentPlanID string
CurrentReplanRequestID string
CurrentPlan PlanV3
FrozenRecipe ExecutableRecipeV3
NormalizedRequest StartRequestV3
// RequestDigest fingerprints the normalized start request so an attempt-ID
// reused with different input is a detectable idempotency violation rather
+51 -3
View File
@@ -645,7 +645,7 @@ func TestPlanPlaybackV3PassthroughRequiresExactLayoutEntry(t *testing.T) {
}
}
func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T) {
func TestPlanPlaybackV3DownloadedSubtitleCarriesStableIdentity(t *testing.T) {
file := detailedFixtureFileV3()
req := validStartRequestV3()
req.ClientFeatures = append(req.ClientFeatures, FeatureDetailedDecodeV3)
@@ -655,8 +655,8 @@ func TestPlanPlaybackV3DownloadedSubtitleUsesFrozenCombinedOrdinal(t *testing.T)
index := 0
req.SubtitleTrackIndex = &index
req.SubtitleTrackID = TrackIDV3(file.ID, "subtitle", index)
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded"}}})
if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID {
result := PlanPlaybackV3(PlannerInputV3{Request: req, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0, Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, AdditionalSubtitles: []SubtitleInventoryEntryV3{{CombinedIndex: 0, Codec: "srt", Source: "downloaded", DownloadedSubtitleID: 71}}})
if result.Plan == nil || result.Plan.Subtitle.Mode != SubtitleRenderV3 || result.Plan.SelectedTracks.Subtitle == nil || result.Plan.SelectedTracks.Subtitle.ID != req.SubtitleTrackID || result.DownloadedSubtitleID != 71 {
t.Fatalf("result = %#v", result)
}
}
@@ -1016,6 +1016,54 @@ func TestPlanPlaybackV3TimelineChangePreservesRouteIdentity(t *testing.T) {
}
}
func TestPlanPlaybackV3DroppingFallbackHistoryReintroducesRejectedRoute(t *testing.T) {
file := detailedFixtureFileV3()
file.FilePath = "/media/movie.mp4"
file.Container = "mp4"
file.CodecVideo = "h264"
file.Resolution = "1080p"
file.Bitrate = 8_000
file.VideoTracks[0] = models.VideoTrack{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}
request := validStartRequestV3()
request.ClientFeatures = append(request.ClientFeatures, FeatureDetailedDecodeV3)
request.ClientPlaybackContext.Features = append(request.ClientPlaybackContext.Features, FeatureDetailedDecodeV3)
request.Capabilities.CodecsVideo = []string{"h264"}
request.Capabilities.CodecsVideoHardware = []string{"h264"}
request.Capabilities.Containers = []string{"mp4"}
request.Capabilities.MaxResolution = "1080p"
request.Capabilities.VideoDecode = []VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}
input := PlannerInputV3{
Request: request, RequestedFile: file, EffectiveFile: file, AudioTrackIndex: 0,
Settings: PlannerSettingsV3{TranscodeEnabled: true, Allow4KTranscode: true}, Registry: testTransformationRegistryV3(),
}
direct := PlanPlaybackV3(input)
if direct.Plan == nil || direct.Plan.Delivery != DeliveryOriginalHTTPV3 {
t.Fatalf("direct plan = %#v", direct)
}
input.AttemptedKeys = []string{PlanAttemptKeyV3(*direct.Plan, request.OutputRouteGeneration, nil)}
progressive := PlanPlaybackV3(input)
if progressive.Plan == nil || progressive.Plan.Delivery != DeliveryRemuxProgressiveV3 {
t.Fatalf("progressive fallback = %#v", progressive)
}
input.AttemptedKeys = append(input.AttemptedKeys, PlanAttemptKeyV3(*progressive.Plan, request.OutputRouteGeneration, nil))
hls := PlanPlaybackV3(input)
if hls.Plan == nil || hls.Plan.Delivery != DeliveryRemuxHLSV3 {
t.Fatalf("HLS fallback = %#v", hls)
}
seek := 321.25
input.Request.StartPosition = &seek
input.AttemptedKeys = nil // This is what the old seek-reanchor path did.
replanned := PlanPlaybackV3(input)
if replanned.Plan == nil || replanned.Plan.PlanID != direct.Plan.PlanID || replanned.Plan.PlanID == hls.Plan.PlanID {
t.Fatalf("dropped fallback history did not reproduce identity drift: direct=%#v hls=%#v replanned=%#v", direct.Plan, hls.Plan, replanned.Plan)
}
if replanned.Plan.Delivery != DeliveryOriginalHTTPV3 || replanned.Plan.Engine != EngineMedia3DirectV3 ||
replanned.Plan.Stream.Protocol != StreamHTTPProgressiveV3 || replanned.Plan.Stream.Container != "mp4" {
t.Fatalf("reintroduced route = %#v", replanned.Plan)
}
}
func validStartRequestV3() StartRequestV3 {
return StartRequestV3{
ProtocolVersion: ProtocolV3,
+90 -165
View File
@@ -16,25 +16,26 @@ import (
"time"
)
// SubtitleCache stores canonical full-track subtitle extracts on disk so
// SubtitleCache stores full-track PGS (.sup) subtitle extracts on disk so
// repeat selections of the same embedded track don't re-run a whole-file
// ffmpeg demux (minutes for a large remux). Only extracts whose effective
// ffmpeg argv has no seek or duration are cached; partial requests may use a
// canonical cached artifact as their input but are never published themselves.
// ffmpeg demux (minutes for a large remux). Only complete, unwindowed .sup
// extracts are cached — VTT extracts are already windowed and fast, and ASS
// extracts are small; neither pays the full-demux cost PGS does.
//
// Entries are keyed by schema version, source file path, subtitle stream
// ordinal, resolved output codec+muxer, and source mtime+size, all encoded in
// the cache filename. Invalidation is therefore implicit when any dimension
// changes. Entry recency for LRU is tracked by bumping the cache file's mtime
// on every hit (portable, unlike atime on noatime/relatime mounts).
// Entries are keyed by the source file path, subtitle stream ordinal, and the
// source's mtime+size, all encoded in the cache filename. Invalidation is
// therefore implicit: when the source changes, the lookup key changes and the
// old entry becomes garbage that eviction reclaims. Entry recency for LRU is
// tracked by bumping the cache file's mtime on every hit (portable, unlike
// atime which is often disabled via noatime/relatime mounts).
//
// Concurrency: the first requester of an uncached track streams the extract
// progressively to its client while teeing bytes into a temp file that is
// atomically renamed into the cache on clean ffmpeg exit (and discarded on
// any error, so a partial entry is never served). Concurrent requesters for
// the same track and output profile while a fill is in flight simply run their
// own un-teed extract — no worse than today's behavior, and it avoids making a
// viewer's first-byte latency depend on another client's connection.
// the same track while a fill is in flight simply run their own un-teed
// extract — no worse than today's behavior, and it avoids making a viewer's
// first-byte latency depend on another client's connection.
type SubtitleCache struct {
// transcodeDir returns the current transcode directory; the cache lives
// in a subtitle-cache subdirectory beneath it, created lazily. An empty
@@ -55,12 +56,8 @@ type SubtitleCache struct {
const (
subtitleCacheDirName = "subtitle-cache"
// subtitleCacheSchemaVersion invalidates artifacts when extraction argv or
// artifact semantics change in a way source mtime+size cannot detect.
subtitleCacheSchemaVersion = 1
// defaultSubtitleCacheMaxBytes caps all cached subtitle formats at 2 GiB.
// Bitmap tracks dominate sizing at roughly 15-80 MB apiece; text tracks
// are much smaller.
// defaultSubtitleCacheMaxBytes caps the cache at 2 GiB — PGS tracks run
// 15-80 MB, so this holds a few dozen tracks.
// TODO: expose as a config knob following the download.artifact_max_bytes
// pattern (internal/config/config.go DownloadConfig.ArtifactMaxBytes).
defaultSubtitleCacheMaxBytes = 2 << 30
@@ -77,27 +74,12 @@ const (
subtitleCacheWarmTimeout = 30 * time.Minute
)
// ffmpeg muxer names for the subtitle formats an extract can produce (see
// StreamExtractOutput), plus the on-disk extension and the source codec the
// PGS-only entry points key on. Named because the extraction plan, the cache
// key, and the response Content-Type all have to agree on the same spellings.
const (
subtitleFormatWebVTT = "webvtt"
subtitleFormatASS = "ass"
subtitleFormatSUP = "sup"
// subtitleExtVTT is the file extension for a webvtt artifact; the ffmpeg
// muxer name and the conventional extension differ only for this format.
subtitleExtVTT = "vtt"
subtitleCodecPGS = "hdmv_pgs_subtitle"
subtitleTypeOctetStream = "application/octet-stream"
)
// SubtitleExtractFunc runs one ffmpeg subtitle extract described by opts, writing
// SUPExtractFunc runs one ffmpeg subtitle extract described by opts, writing
// output to opts.Writer. Production callers pass StreamExtractSubtitle;
// tests substitute fakes. The cache invokes it with the caller's options
// rewritten as needed (tee writer for fills, cached-artifact input for partial
// rewritten as needed (tee writer for fills, cached-.sup input for windowed
// serves, cleared window for background warms).
type SubtitleExtractFunc func(ctx context.Context, opts StreamExtractOpts) error
type SUPExtractFunc func(ctx context.Context, opts StreamExtractOpts) error
// NewSubtitleCache builds a cache rooted under the transcode directory
// returned by transcodeDir at call time (so runtime config changes are
@@ -111,48 +93,46 @@ func NewSubtitleCache(transcodeDir func() string) *SubtitleCache {
}
}
// ServeExtract serves one embedded subtitle extract (opts.Writer is ignored;
// the cache supplies it). Canonical requests may fill the cache on a miss.
// Requests whose effective ffmpeg argv applies seek or duration are partial:
// they never fill, re-extract from a cached canonical artifact on a hit, and
// trigger a canonical background warm on a miss.
//
// PGS cache hits retain the existing http.ServeContent behavior. Text hits
// deliberately use a plain copy with no-store, matching cold-path HTTP
// semantics without adding Range, validators, or Content-Length based on cache
// warmth. A nil receiver disables caching and just streams.
// ServeSUPExtract serves the .sup extract for one source+track described by
// opts (opts.Writer is ignored; the cache supplies it). Full-track requests
// (no AllowWindow): a cache hit is served with http.ServeContent (Range
// support, Content-Length, Last-Modified from the source file's mtime,
// revalidatable instead of no-store); a miss invokes extract with a writer
// that streams to the client while teeing bytes into a temp file, atomically
// published as the cache entry on clean extract exit and discarded on any
// error (ffmpeg failure or client disconnect) — a partial entry is never
// served. Windowed requests (opts.AllowWindow): the output covers only a
// slice of the track, so it is never cached; but when the full-track entry
// already exists, the windowed extract runs against the small cached .sup
// instead of re-demuxing the original file, and when it doesn't, a detached
// background warm is kicked off so subsequent windows get that fast path. A
// nil receiver disables caching and just streams.
//
// The caller sets any extra response headers (e.g. CORS) before calling.
// The returned error is the extract error; cache hits return nil.
func (c *SubtitleCache) ServeExtract(w http.ResponseWriter, r *http.Request, opts StreamExtractOpts, extract SubtitleExtractFunc) error {
plan := streamExtractPlanFor(opts)
if plan.partial() {
return c.serveWindowed(w, r, opts, plan, extract)
func (c *SubtitleCache) ServeSUPExtract(w http.ResponseWriter, r *http.Request, opts StreamExtractOpts, extract SUPExtractFunc) error {
if opts.AllowWindow {
return c.serveWindowedSUP(w, r, opts, extract)
}
if cached, modTime, ok := c.lookup(opts); ok {
if cached, modTime, ok := c.Lookup(opts.InputPath, opts.TrackIndex); ok {
defer func() { _ = cached.Close() }()
slog.DebugContext(r.Context(), "subtitle stream served from cache",
"input", opts.InputPath, "track", opts.TrackIndex)
w.Header().Set("Content-Type", subtitleContentType(plan.outFormat))
if plan.outFormat == subtitleFormatSUP {
w.Header().Set("Cache-Control", "private, no-cache")
http.ServeContent(w, r, "", modTime, cached)
return nil
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return copyAndFlush(w, cached)
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "private, no-cache")
http.ServeContent(w, r, "", modTime, cached)
return nil
}
w.Header().Set("Content-Type", subtitleContentType(plan.outFormat))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
// BeginFill returns nil when another fill for this track is already in
// flight (or the cache dir is unusable); this request then streams its
// own uncached extract.
fill := c.beginFill(opts)
fill := c.BeginFill(opts.InputPath, opts.TrackIndex)
var writer io.Writer = w
if fill != nil {
writer = fill.Tee(w)
@@ -171,24 +151,24 @@ func (c *SubtitleCache) ServeExtract(w http.ResponseWriter, r *http.Request, opt
return err
}
// serveWindowed streams a partial request. Its effective argv is noncanonical,
// so its output is never cached itself, but the cache still speeds it up: with
// a committed full-track entry the extract's input is
// rewritten to the cached artifact (small enough that the -ss scan is fast
// serveWindowedSUP streams a windowed slice of the track. The output is a
// position-dependent slice so it is never cached itself, but the cache still
// speeds it up: with a committed full-track entry the extract's input is
// rewritten to the cached .sup (15-80 MB, so the -ss scan is near-instant
// versus re-demuxing a multi-GB source); without one, a background warm is
// started so later windows — the client re-fetches on every seek — hit the
// fast path.
func (c *SubtitleCache) serveWindowed(w http.ResponseWriter, r *http.Request, opts StreamExtractOpts, plan streamExtractPlan, extract SubtitleExtractFunc) error {
if cachedPath, _, ok := c.cachedEntryPath(opts); ok {
func (c *SubtitleCache) serveWindowedSUP(w http.ResponseWriter, r *http.Request, opts StreamExtractOpts, extract SUPExtractFunc) error {
if cachedPath, _, ok := c.cachedEntryPath(opts.InputPath, opts.TrackIndex); ok {
slog.DebugContext(r.Context(), "windowed subtitle extract using cached full track",
"input", opts.InputPath, "track", opts.TrackIndex, "cache_entry", cachedPath)
opts.InputPath = cachedPath
opts.ExtractedInputFormat = plan.outFormat
opts.InputIsExtractedSup = true
} else {
c.WarmInBackground(opts, extract)
}
w.Header().Set("Content-Type", subtitleContentType(plan.outFormat))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
@@ -197,15 +177,15 @@ func (c *SubtitleCache) serveWindowed(w http.ResponseWriter, r *http.Request, op
}
// WarmInBackground starts a detached full-track extract that fills the cache
// entry for opts' source+track+output profile, so future partial requests can
// extract from the small cached artifact instead of the original file. The
// warm runs on a background context with a generous timeout — it must survive
// the request that triggered it. BeginFill's in-flight coalescing guarantees at
// most one fill per output profile (a concurrent client-driven fill wins and the warm is
// entry for opts' source+track, so future windowed requests can extract from
// the small cached .sup instead of the original file. The warm runs on a
// background context with a generous timeout — it must survive the request
// that triggered it. BeginFill's in-flight coalescing guarantees at most one
// fill per track (a concurrent client-driven fill wins and the warm is
// skipped), and warmSem bounds warms server-wide: beyond the budget the warm
// is dropped, not queued — the next windowed miss re-attempts it. A nil
// receiver is a no-op.
func (c *SubtitleCache) WarmInBackground(opts StreamExtractOpts, extract SubtitleExtractFunc) {
func (c *SubtitleCache) WarmInBackground(opts StreamExtractOpts, extract SUPExtractFunc) {
if c == nil || extract == nil {
return
}
@@ -216,19 +196,20 @@ func (c *SubtitleCache) WarmInBackground(opts StreamExtractOpts, extract Subtitl
"input", opts.InputPath, "track", opts.TrackIndex)
return
}
// Full-track options: the warm ignores the triggering request's window
// and writes only to the cache temp file (no response writer).
opts.SeekSeconds = 0
opts.DurationSeconds = 0
opts.AllowWindow = false
opts.ExtractedInputFormat = ""
fill := c.beginFill(opts)
fill := c.BeginFill(opts.InputPath, opts.TrackIndex)
if fill == nil {
// Another fill (client-driven or a previous warm) is already in
// flight, or the cache is unusable — either way, nothing to do.
<-c.warmSem
return
}
// Full-track options: the warm ignores the triggering request's window
// and writes only to the cache temp file (no response writer).
opts.SeekSeconds = 0
opts.DurationSeconds = 0
opts.AllowWindow = false
opts.InputIsExtractedSup = false
opts.Writer = fill.Tee(io.Discard)
go func() {
@@ -269,65 +250,27 @@ func (c *SubtitleCache) dir() string {
return filepath.Join(base, subtitleCacheDirName)
}
type subtitleCacheProfile struct {
codec string
format string
}
func subtitleProfile(opts StreamExtractOpts) subtitleCacheProfile {
plan := streamExtractPlanFor(opts)
return subtitleCacheProfile{codec: plan.outCodec, format: plan.outFormat}
}
// subtitleCacheKeyPrefix identifies a source file + track ordinal + resolved
// output profile regardless of source version. Including the profile keeps,
// for example, ASS and WebVTT artifacts for the same source track independent.
func subtitleCacheKeyPrefix(inputPath string, trackIndex int, profile subtitleCacheProfile) string {
// subtitleCacheKeyPrefix identifies a source file + track ordinal regardless
// of source version; the full key appends mtime+size so a changed source
// yields a different filename.
func subtitleCacheKeyPrefix(inputPath string, trackIndex int) string {
sum := sha256.Sum256([]byte(inputPath))
return fmt.Sprintf("v%d-%x-s%d-%s-%s-", subtitleCacheSchemaVersion, sum[:12], trackIndex, profile.codec, profile.format)
return fmt.Sprintf("%x-s%d-", sum[:12], trackIndex)
}
func subtitleCacheKey(inputPath string, trackIndex int, profile subtitleCacheProfile, mtime time.Time, size int64) string {
return fmt.Sprintf("%s%d-%d.%s", subtitleCacheKeyPrefix(inputPath, trackIndex, profile), mtime.UnixNano(), size, subtitleCacheExtension(profile.format))
func subtitleCacheKey(inputPath string, trackIndex int, mtime time.Time, size int64) string {
return fmt.Sprintf("%s%d-%d.sup", subtitleCacheKeyPrefix(inputPath, trackIndex), mtime.UnixNano(), size)
}
func subtitleCacheExtension(format string) string {
if format == subtitleFormatWebVTT {
return subtitleExtVTT
}
return format
}
func subtitleContentType(format string) string {
switch format {
case subtitleFormatASS:
return "text/x-ssa; charset=utf-8"
case subtitleFormatWebVTT:
return "text/vtt; charset=utf-8"
default:
return subtitleTypeOctetStream
}
}
// Lookup opens the cached canonical PGS extract for the source and track.
// It is retained as a focused cache primitive; format-aware serving uses
// lookup with the caller's resolved extraction options.
//
// Lookup opens the cached full-track .sup extract for the given source file
// and subtitle stream ordinal. The source is stat'ed on every lookup: an
// mtime or size mismatch means the entry (if any) is stale and reads as a
// miss. On a hit the returned modTime is the *source* file's mtime — stable
// across hits, suitable for Last-Modified — while the cache file's own mtime
// is bumped to record recency for LRU eviction. The caller owns closing the
// returned file.
func (c *SubtitleCache) Lookup(inputPath string, trackIndex int) (f *os.File, modTime time.Time, ok bool) {
return c.lookup(StreamExtractOpts{
InputPath: inputPath,
TrackIndex: trackIndex,
SourceCodec: subtitleCodecPGS,
})
}
func (c *SubtitleCache) lookup(opts StreamExtractOpts) (f *os.File, modTime time.Time, ok bool) {
path, modTime, ok := c.cachedEntryPath(opts)
path, modTime, ok := c.cachedEntryPath(inputPath, trackIndex)
if !ok {
return nil, time.Time{}, false
}
@@ -344,16 +287,16 @@ func (c *SubtitleCache) lookup(opts StreamExtractOpts) (f *os.File, modTime time
// miss) and bumps the entry's mtime to record recency for LRU eviction.
// Callers that hand the path to an external reader (ffmpeg) rather than
// opening it themselves use this instead of Lookup.
func (c *SubtitleCache) cachedEntryPath(opts StreamExtractOpts) (path string, srcModTime time.Time, ok bool) {
func (c *SubtitleCache) cachedEntryPath(inputPath string, trackIndex int) (path string, srcModTime time.Time, ok bool) {
dir := c.dir()
if dir == "" {
return "", time.Time{}, false
}
src, err := os.Stat(opts.InputPath)
src, err := os.Stat(inputPath)
if err != nil {
return "", time.Time{}, false
}
path = filepath.Join(dir, subtitleCacheKey(opts.InputPath, opts.TrackIndex, subtitleProfile(opts), src.ModTime(), src.Size()))
path = filepath.Join(dir, subtitleCacheKey(inputPath, trackIndex, src.ModTime(), src.Size()))
if _, err := os.Stat(path); err != nil {
return "", time.Time{}, false
}
@@ -375,7 +318,6 @@ type SubtitleCacheFill struct {
key string
inputPath string
trackIndex int
profile subtitleCacheProfile
srcMtime time.Time
srcSize int64
tmp *os.File
@@ -384,25 +326,17 @@ type SubtitleCacheFill struct {
failed bool
}
// BeginFill reserves the in-flight slot for the given PGS track and creates the
// BeginFill reserves the in-flight slot for the given track and creates the
// temp file the tee will write into. Returns nil — meaning "stream without
// caching" — when caching is disabled, the source can't be stat'ed, the
// cache directory can't be created, or another fill for the same track is
// already in flight.
func (c *SubtitleCache) BeginFill(inputPath string, trackIndex int) *SubtitleCacheFill {
return c.beginFill(StreamExtractOpts{
InputPath: inputPath,
TrackIndex: trackIndex,
SourceCodec: subtitleCodecPGS,
})
}
func (c *SubtitleCache) beginFill(opts StreamExtractOpts) *SubtitleCacheFill {
dir := c.dir()
if dir == "" {
return nil
}
src, err := os.Stat(opts.InputPath)
src, err := os.Stat(inputPath)
if err != nil {
return nil
}
@@ -410,8 +344,7 @@ func (c *SubtitleCache) beginFill(opts StreamExtractOpts) *SubtitleCacheFill {
slog.Warn("subtitle cache dir create failed", "dir", dir, "error", err)
return nil
}
profile := subtitleProfile(opts)
key := subtitleCacheKey(opts.InputPath, opts.TrackIndex, profile, src.ModTime(), src.Size())
key := subtitleCacheKey(inputPath, trackIndex, src.ModTime(), src.Size())
c.mu.Lock()
if _, busy := c.inflight[key]; busy {
@@ -430,9 +363,8 @@ func (c *SubtitleCache) beginFill(opts StreamExtractOpts) *SubtitleCacheFill {
return &SubtitleCacheFill{
c: c,
key: key,
inputPath: opts.InputPath,
trackIndex: opts.TrackIndex,
profile: profile,
inputPath: inputPath,
trackIndex: trackIndex,
srcMtime: src.ModTime(),
srcSize: src.Size(),
tmp: tmp,
@@ -510,7 +442,7 @@ func (f *SubtitleCacheFill) Commit() error {
return fmt.Errorf("publish subtitle cache entry: %w", err)
}
f.c.removeStaleSiblings(dir, f.inputPath, f.trackIndex, f.profile, f.key)
f.c.removeStaleSiblings(dir, f.inputPath, f.trackIndex, f.key)
f.c.evict(dir)
return nil
}
@@ -530,18 +462,18 @@ func (f *SubtitleCacheFill) closeAndRemoveTmp() {
}
}
// removeStaleSiblings deletes committed entries for the same
// source+track+output profile with a different mtime/size suffix. Other output
// profiles remain valid siblings and must coexist.
func (c *SubtitleCache) removeStaleSiblings(dir, inputPath string, trackIndex int, profile subtitleCacheProfile, keepKey string) {
prefix := subtitleCacheKeyPrefix(inputPath, trackIndex, profile)
// removeStaleSiblings deletes committed entries for the same source+track
// with a different mtime/size suffix — the source was replaced, so those can
// never be served again.
func (c *SubtitleCache) removeStaleSiblings(dir, inputPath string, trackIndex int, keepKey string) {
prefix := subtitleCacheKeyPrefix(inputPath, trackIndex)
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
name := e.Name()
if name == keepKey || !strings.HasPrefix(name, prefix) || strings.Contains(name, ".part-") {
if name == keepKey || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".sup") {
continue
}
if err := os.Remove(filepath.Join(dir, name)); err != nil && !os.IsNotExist(err) {
@@ -582,7 +514,7 @@ func (c *SubtitleCache) evict(dir string) {
}
continue
}
if !isCommittedSubtitleCacheEntry(e.Name()) {
if !strings.HasSuffix(e.Name(), ".sup") {
continue
}
ents = append(ents, cacheEnt{path: path, size: info.Size(), mtime: info.ModTime()})
@@ -606,10 +538,3 @@ func (c *SubtitleCache) evict(dir string) {
total -= e.size
}
}
func isCommittedSubtitleCacheEntry(name string) bool {
if strings.Contains(name, ".part-") {
return false
}
return strings.HasSuffix(name, ".sup") || strings.HasSuffix(name, ".ass") || strings.HasSuffix(name, ".vtt")
}
+26 -216
View File
@@ -31,14 +31,9 @@ func newTestCache(t *testing.T) (*SubtitleCache, string) {
// the real BeginFill → Tee → Commit path.
func fillEntry(t *testing.T, c *SubtitleCache, source string, track int, payload string) {
t.Helper()
fillOpts(t, c, supExtractOpts(source, track), payload)
}
func fillOpts(t *testing.T, c *SubtitleCache, opts StreamExtractOpts, payload string) {
t.Helper()
fill := c.beginFill(opts)
fill := c.BeginFill(source, track)
if fill == nil {
t.Fatalf("beginFill returned nil for track %d", opts.TrackIndex)
t.Fatalf("BeginFill returned nil for track %d", track)
}
if _, err := fill.Tee(io.Discard).Write([]byte(payload)); err != nil {
t.Fatalf("tee write: %v", err)
@@ -48,17 +43,8 @@ func fillOpts(t *testing.T, c *SubtitleCache, opts StreamExtractOpts, payload st
}
}
func vttExtractOpts(source string, track int) StreamExtractOpts {
return StreamExtractOpts{
InputPath: source,
TrackIndex: track,
SourceCodec: "subrip",
TargetFormat: "vtt",
}
}
// supExtractOpts builds the base extract options a handler would pass to
// ServeExtract for a PGS track.
// ServeSUPExtract for a PGS track.
func supExtractOpts(source string, track int) StreamExtractOpts {
return StreamExtractOpts{
InputPath: source,
@@ -333,7 +319,7 @@ func TestSubtitleCacheCoalescingConcurrent(t *testing.T) {
fills[0].Discard()
}
func TestServeExtractCacheFlow(t *testing.T) {
func TestServeSUPExtractCacheFlow(t *testing.T) {
c, source := newTestCache(t)
extractCalls := 0
@@ -346,7 +332,7 @@ func TestServeExtractCacheFlow(t *testing.T) {
// First request: miss → streamed 200 with no-store, entry committed.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sub.sup", nil)
if err := c.ServeExtract(rec, req, supExtractOpts(source, 0), extract); err != nil {
if err := c.ServeSUPExtract(rec, req, supExtractOpts(source, 0), extract); err != nil {
t.Fatal(err)
}
if extractCalls != 1 {
@@ -361,7 +347,7 @@ func TestServeExtractCacheFlow(t *testing.T) {
// Second request: hit → served from cache, no extract, revalidatable.
rec = httptest.NewRecorder()
if err := c.ServeExtract(rec, req, supExtractOpts(source, 0), extract); err != nil {
if err := c.ServeSUPExtract(rec, req, supExtractOpts(source, 0), extract); err != nil {
t.Fatal(err)
}
if extractCalls != 1 {
@@ -384,7 +370,7 @@ func TestServeExtractCacheFlow(t *testing.T) {
rec = httptest.NewRecorder()
rangeReq := httptest.NewRequest(http.MethodGet, "/sub.sup", nil)
rangeReq.Header.Set("Range", "bytes=4-10")
if err := c.ServeExtract(rec, rangeReq, supExtractOpts(source, 0), extract); err != nil {
if err := c.ServeSUPExtract(rec, rangeReq, supExtractOpts(source, 0), extract); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusPartialContent || rec.Body.String() != "PAYLOAD" {
@@ -392,185 +378,11 @@ func TestServeExtractCacheFlow(t *testing.T) {
}
}
// A seeked text request has effective -ss/-t argv even though AllowWindow is
// false. Its successful output is only seek->EOF and must never become the
// canonical cache artifact used by a later viewer starting at zero.
func TestServeExtractSeekedVTTMissNeverPoisonsCanonicalEntry(t *testing.T) {
c, source := newTestCache(t)
// Disable detached warms so the test observes only the request-driven
// cache behavior and can prove the partial response itself was not stored.
c.warmSem = make(chan struct{})
partial := vttExtractOpts(source, 0)
partial.SeekSeconds = 900
partial.DurationSeconds = 600
extract := func(_ context.Context, opts StreamExtractOpts) error {
payload := "BEGINNING CUE\nLATE CUE"
if streamExtractPlanFor(opts).partial() {
payload = "LATE CUE"
}
_, err := opts.Writer.Write([]byte(payload))
return err
}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sub.vtt?position=900", nil)
if err := c.ServeExtract(rec, req, partial, extract); err != nil {
t.Fatal(err)
}
if rec.Body.String() != "LATE CUE" {
t.Fatalf("seeked body = %q", rec.Body.String())
}
if _, _, ok := c.lookup(vttExtractOpts(source, 0)); ok {
t.Fatal("seeked VTT extract must not commit a canonical entry")
}
rec = httptest.NewRecorder()
if err := c.ServeExtract(rec, httptest.NewRequest(http.MethodGet, "/sub.vtt", nil),
vttExtractOpts(source, 0), extract); err != nil {
t.Fatal(err)
}
if rec.Body.String() != "BEGINNING CUE\nLATE CUE" {
t.Fatalf("seek-0 body lost beginning-of-track cues: %q", rec.Body.String())
}
}
func TestServeExtractSeekedVTTUsesCachedArtifactAndFormat(t *testing.T) {
c, source := newTestCache(t)
canonical := vttExtractOpts(source, 0)
fillOpts(t, c, canonical, "WEBVTT\n\nFULL TRACK")
entry, _, ok := c.cachedEntryPath(canonical)
if !ok {
t.Fatal("canonical VTT entry missing")
}
partial := canonical
partial.SeekSeconds = 1200
partial.DurationSeconds = 600
var got StreamExtractOpts
rec := httptest.NewRecorder()
err := c.ServeExtract(rec, httptest.NewRequest(http.MethodGet, "/sub.vtt?position=1200", nil), partial,
func(_ context.Context, opts StreamExtractOpts) error {
got = opts
_, err := opts.Writer.Write([]byte("WINDOW"))
return err
})
if err != nil {
t.Fatal(err)
}
if got.InputPath != entry {
t.Fatalf("partial input = %q, want cached artifact %q", got.InputPath, entry)
}
if got.ExtractedInputFormat != "webvtt" {
t.Fatalf("cached input format = %q, want webvtt", got.ExtractedInputFormat)
}
args := strings.Join(streamExtractArgs(got), " ")
if !strings.Contains(args, "-f webvtt -i "+entry) || !strings.Contains(args, "-map 0:s:0") {
t.Fatalf("cached VTT argv does not force format and sole stream: %s", args)
}
}
func TestSubtitleCacheOutputProfilesCoexist(t *testing.T) {
c, source := newTestCache(t)
ass := StreamExtractOpts{InputPath: source, TrackIndex: 0, SourceCodec: "ass"}
vtt := ass
vtt.TargetFormat = "vtt"
fillOpts(t, c, ass, "ASS TRACK")
fillOpts(t, c, vtt, "WEBVTT TRACK")
assFile, _, assOK := c.lookup(ass)
if !assOK {
t.Fatal("ASS entry was removed when VTT sibling committed")
}
if got := readAllAndClose(t, assFile); got != "ASS TRACK" {
t.Fatalf("ASS entry = %q", got)
}
vttFile, _, vttOK := c.lookup(vtt)
if !vttOK {
t.Fatal("VTT entry missing")
}
if got := readAllAndClose(t, vttFile); got != "WEBVTT TRACK" {
t.Fatalf("VTT entry = %q", got)
}
// In-flight coalescing is profile-specific too.
assFill := c.beginFill(ass)
vttFill := c.beginFill(vtt)
if assFill == nil || vttFill == nil {
t.Fatal("ASS and VTT fills must not coalesce with each other")
}
assFill.Discard()
vttFill.Discard()
}
func TestSubtitleCacheEvictionCountsTextEntries(t *testing.T) {
c, source := newTestCache(t)
c.maxBytes = 5
vtt := vttExtractOpts(source, 0)
fillOpts(t, c, vtt, "123456")
if _, _, ok := c.lookup(vtt); ok {
t.Fatal("VTT entry over the cache budget must be evicted")
}
}
func TestServeExtractContentTypeOnMissAndHit(t *testing.T) {
tests := []struct {
name string
opts func(string) StreamExtractOpts
contentType string
path string
}{
{"sup", func(source string) StreamExtractOpts { return supExtractOpts(source, 0) }, "application/octet-stream", "/sub.sup"},
{"ass", func(source string) StreamExtractOpts {
return StreamExtractOpts{InputPath: source, SourceCodec: "ass"}
}, "text/x-ssa; charset=utf-8", "/sub.ass"},
{"vtt", func(source string) StreamExtractOpts { return vttExtractOpts(source, 0) }, "text/vtt; charset=utf-8", "/sub.vtt"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c, source := newTestCache(t)
opts := tc.opts(source)
extractCalls := 0
extract := func(_ context.Context, opts StreamExtractOpts) error {
extractCalls++
_, err := opts.Writer.Write([]byte("FULL TRACK"))
return err
}
for request := 0; request < 2; request++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
if request == 1 && tc.name != "sup" {
req.Header.Set("Range", "bytes=5-9")
}
if err := c.ServeExtract(rec, req, opts, extract); err != nil {
t.Fatal(err)
}
if got := rec.Header().Get("Content-Type"); got != tc.contentType {
t.Fatalf("request %d Content-Type = %q, want %q", request, got, tc.contentType)
}
if tc.name != "sup" {
if rec.Code != http.StatusOK || rec.Body.String() != "FULL TRACK" {
t.Fatalf("text cache warmth changed response: code=%d body=%q", rec.Code, rec.Body.String())
}
if rec.Header().Get("Cache-Control") != "no-store" || rec.Header().Get("Content-Length") != "" || rec.Header().Get("Last-Modified") != "" {
t.Fatalf("text response added cache-dependent HTTP headers: %v", rec.Header())
}
}
}
if extractCalls != 1 {
t.Fatalf("extract calls = %d, want one miss then one hit", extractCalls)
}
})
}
}
// A windowed request against a cached track must run its extract with the
// cached .sup as input (small file → near-instant window) instead of
// re-demuxing the original media, must never publish its sliced output as a
// cache entry, and must bump the entry's LRU recency.
func TestServeExtractWindowedUsesCachedTrack(t *testing.T) {
func TestServeSUPExtractWindowedUsesCachedTrack(t *testing.T) {
c, source := newTestCache(t)
fillEntry(t, c, source, 0, "FULL TRACK")
@@ -585,7 +397,7 @@ func TestServeExtractWindowedUsesCachedTrack(t *testing.T) {
extractCalls := 0
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sub.sup?windowed=1&position=1200&duration=3600", nil)
err := c.ServeExtract(rec, req, windowedSupOpts(source, 0, 1200, 3600), func(_ context.Context, opts StreamExtractOpts) error {
err := c.ServeSUPExtract(rec, req, windowedSupOpts(source, 0, 1200, 3600), func(_ context.Context, opts StreamExtractOpts) error {
extractCalls++
got = opts
_, err := opts.Writer.Write([]byte("WINDOW SLICE"))
@@ -606,8 +418,8 @@ func TestServeExtractWindowedUsesCachedTrack(t *testing.T) {
if got.InputPath != entry {
t.Fatalf("windowed extract input = %q, want cached entry %q", got.InputPath, entry)
}
if got.ExtractedInputFormat != "sup" {
t.Fatalf("windowed extract cached input format = %q, want sup", got.ExtractedInputFormat)
if !got.InputIsExtractedSup {
t.Fatal("windowed extract from cache must set InputIsExtractedSup")
}
if got.SeekSeconds != 1200 || got.DurationSeconds != 3600 || !got.AllowWindow {
t.Fatalf("window parameters not preserved: %+v", got)
@@ -633,7 +445,7 @@ func TestServeExtractWindowedUsesCachedTrack(t *testing.T) {
// A windowed miss must trigger exactly one detached background warm no
// matter how many windowed requests arrive while it runs, and once the warm
// commits, the next windowed request extracts from the cached track.
func TestServeExtractWindowedMissWarmsOnce(t *testing.T) {
func TestServeSUPExtractWindowedMissWarmsOnce(t *testing.T) {
c, source := newTestCache(t)
var (
@@ -661,11 +473,11 @@ func TestServeExtractWindowedMissWarmsOnce(t *testing.T) {
// N windowed misses: each still streams its own windowed slice from the
// original file; only the first starts a warm (BeginFill coalescing keeps
// the rest out — deterministic because the in-flight slot is reserved
// synchronously before ServeExtract returns).
// synchronously before ServeSUPExtract returns).
for i := 0; i < 4; i++ {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sub.sup?windowed=1&position=100&duration=3600", nil)
if err := c.ServeExtract(rec, req, windowedSupOpts(source, 0, 100, 3600), extract); err != nil {
if err := c.ServeSUPExtract(rec, req, windowedSupOpts(source, 0, 100, 3600), extract); err != nil {
t.Fatal(err)
}
if rec.Body.String() != "WINDOW SLICE" {
@@ -680,14 +492,14 @@ func TestServeExtractWindowedMissWarmsOnce(t *testing.T) {
t.Fatalf("warm extracts = %d, want exactly 1", len(warmOpts))
}
warm := warmOpts[0]
if warm.InputPath != source || warm.SeekSeconds != 0 || warm.DurationSeconds != 0 || warm.AllowWindow || warm.ExtractedInputFormat != "" {
if warm.InputPath != source || warm.SeekSeconds != 0 || warm.DurationSeconds != 0 || warm.AllowWindow || warm.InputIsExtractedSup {
t.Fatalf("warm must be a full-track extract of the original file: %+v", warm)
}
if len(windowOpts) != 4 {
t.Fatalf("windowed extracts = %d, want 4", len(windowOpts))
}
for _, wo := range windowOpts {
if wo.InputPath != source || wo.ExtractedInputFormat != "" {
if wo.InputPath != source || wo.InputIsExtractedSup {
t.Fatalf("pre-warm windowed extract must read the original file: %+v", wo)
}
}
@@ -696,13 +508,13 @@ func TestServeExtractWindowedMissWarmsOnce(t *testing.T) {
// Warm committed → the next windowed request reads the cached track.
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sub.sup?windowed=1&position=200&duration=3600", nil)
if err := c.ServeExtract(rec, req, windowedSupOpts(source, 0, 200, 3600), extract); err != nil {
if err := c.ServeSUPExtract(rec, req, windowedSupOpts(source, 0, 200, 3600), extract); err != nil {
t.Fatal(err)
}
mu.Lock()
last := windowOpts[len(windowOpts)-1]
mu.Unlock()
if last.InputPath != entryPath(t, c, source, 0) || last.ExtractedInputFormat != "sup" {
if last.InputPath != entryPath(t, c, source, 0) || !last.InputIsExtractedSup {
t.Fatalf("post-warm windowed extract must read the cached track: %+v", last)
}
}
@@ -781,21 +593,20 @@ func TestWarmInBackgroundSkipsInFlightFill(t *testing.T) {
clientFill.Discard()
}
func TestServeExtractDiscardsOnExtractError(t *testing.T) {
func TestServeSUPExtractDiscardsOnExtractError(t *testing.T) {
c, source := newTestCache(t)
opts := vttExtractOpts(source, 0)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sub.vtt", nil)
req := httptest.NewRequest(http.MethodGet, "/sub.sup", nil)
wantErr := errors.New("ffmpeg exploded")
err := c.ServeExtract(rec, req, opts, func(_ context.Context, opts StreamExtractOpts) error {
err := c.ServeSUPExtract(rec, req, supExtractOpts(source, 0), func(_ context.Context, opts StreamExtractOpts) error {
_, _ = opts.Writer.Write([]byte("PARTIAL"))
return wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v", err)
}
if _, _, ok := c.lookup(opts); ok {
if _, _, ok := c.Lookup(source, 0); ok {
t.Fatal("partial extract must not be cached")
}
if n := countCacheFiles(t, c); n != 0 {
@@ -803,7 +614,7 @@ func TestServeExtractDiscardsOnExtractError(t *testing.T) {
}
}
func TestServeExtractNilCacheStreams(t *testing.T) {
func TestServeSUPExtractNilCacheStreams(t *testing.T) {
var c *SubtitleCache
extract := func(_ context.Context, opts StreamExtractOpts) error {
_, err := opts.Writer.Write([]byte("UNCACHED"))
@@ -812,7 +623,7 @@ func TestServeExtractNilCacheStreams(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/sub.sup", nil)
if err := c.ServeExtract(rec, req, supExtractOpts("/nonexistent.mkv", 0), extract); err != nil {
if err := c.ServeSUPExtract(rec, req, supExtractOpts("/nonexistent.mkv", 0), extract); err != nil {
t.Fatal(err)
}
if rec.Body.String() != "UNCACHED" {
@@ -822,7 +633,7 @@ func TestServeExtractNilCacheStreams(t *testing.T) {
// Windowed requests on a nil cache stream too (no lookup, no warm).
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/sub.sup?windowed=1&position=10", nil)
if err := c.ServeExtract(rec, req, windowedSupOpts("/nonexistent.mkv", 0, 10, 3600), extract); err != nil {
if err := c.ServeSUPExtract(rec, req, windowedSupOpts("/nonexistent.mkv", 0, 10, 3600), extract); err != nil {
t.Fatal(err)
}
if rec.Body.String() != "UNCACHED" {
@@ -837,8 +648,7 @@ func entryPath(t *testing.T, c *SubtitleCache, source string, track int) string
if err != nil {
t.Fatal(err)
}
return filepath.Join(c.dir(), subtitleCacheKey(source, track,
subtitleProfile(supExtractOpts(source, track)), src.ModTime(), src.Size()))
return filepath.Join(c.dir(), subtitleCacheKey(source, track, src.ModTime(), src.Size()))
}
// countCacheEntries counts committed .sup entries in the cache dir.
+25 -17
View File
@@ -7,20 +7,22 @@ import (
)
type SubtitlePolicyResultV3 struct {
Decision SubtitleDecisionV3
Claims SubtitleClaimsV3
RequiresBurn bool
SelectedIndex int
TransportIndex int
Codec string
Source string
Terminal *TerminalV3
Decision SubtitleDecisionV3
Claims SubtitleClaimsV3
RequiresBurn bool
SelectedIndex int
TransportIndex int
Codec string
Source string
DownloadedSubtitleID int
Terminal *TerminalV3
}
type SubtitleInventoryEntryV3 struct {
CombinedIndex int
Codec string
Source string
CombinedIndex int
Codec string
Source string
DownloadedSubtitleID int
}
// ResolveSubtitlePolicyV3 decides how the selected subtitle is delivered when
@@ -44,10 +46,11 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
if file == nil {
return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle inventory is unavailable.")
}
codec, source, ok := subtitleCodecAtCombinedIndexV3(file, index, additional)
entry, ok := subtitleEntryAtCombinedIndexV3(file, index, additional)
if !ok {
return subtitleTerminalV3("subtitle_track_unavailable", "The selected subtitle track is unavailable.")
}
codec, source := entry.Codec, entry.Source
trackID := TrackIDV3(file.ID, "subtitle", index)
transportIndex := -1
if source == "embedded" {
@@ -74,6 +77,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID},
Claims: SubtitleClaimsV3{ASSStylingPreserved: !ass || engineCaps.Subtitles.ASSStyling, Reason: "client_render_supported"},
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
DownloadedSubtitleID: entry.DownloadedSubtitleID,
}
}
if request.SubtitleFidelityPreference == SubtitleFidelityCompatibleV3 {
@@ -81,6 +85,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
Decision: SubtitleDecisionV3{Mode: SubtitleConvertV3, TrackID: trackID},
Claims: SubtitleClaimsV3{Reason: "server_text_conversion"},
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
DownloadedSubtitleID: entry.DownloadedSubtitleID,
}
}
}
@@ -95,6 +100,7 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
Decision: SubtitleDecisionV3{Mode: SubtitleRenderV3, TrackID: trackID},
Claims: SubtitleClaimsV3{BitmapSidecar: true, Reason: "client_bitmap_render_supported"},
SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
DownloadedSubtitleID: entry.DownloadedSubtitleID,
}
}
if transcodeAllowed {
@@ -105,25 +111,27 @@ func ResolveSubtitlePolicyV3(file *models.MediaFile, request StartRequestV3, tra
Decision: SubtitleDecisionV3{Mode: SubtitleBurnInV3, TrackID: trackID},
Claims: SubtitleClaimsV3{BitmapOverlay: burnInBitmap, Reason: "server_burn_in_required"},
RequiresBurn: true, SelectedIndex: index, TransportIndex: transportIndex, Codec: codec, Source: source,
DownloadedSubtitleID: entry.DownloadedSubtitleID,
}
}
return subtitleTerminalV3("subtitle_conversion_unsupported", fmt.Sprintf("Subtitle format %s cannot meet the selected fidelity policy.", codec))
}
func subtitleCodecAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (codec, source string, ok bool) {
func subtitleEntryAtCombinedIndexV3(file *models.MediaFile, index int, additional []SubtitleInventoryEntryV3) (SubtitleInventoryEntryV3, bool) {
if index < len(file.ExternalSubtitles) {
return normalizeCodecV3(file.ExternalSubtitles[index].Format), "external", true
return SubtitleInventoryEntryV3{CombinedIndex: index, Codec: normalizeCodecV3(file.ExternalSubtitles[index].Format), Source: "external"}, true
}
embedded := index - len(file.ExternalSubtitles)
if embedded >= 0 && embedded < len(file.SubtitleTracks) {
return normalizeCodecV3(file.SubtitleTracks[embedded].Codec), "embedded", true
return SubtitleInventoryEntryV3{CombinedIndex: index, Codec: normalizeCodecV3(file.SubtitleTracks[embedded].Codec), Source: "embedded"}, true
}
for _, entry := range additional {
if entry.CombinedIndex == index {
return normalizeCodecV3(entry.Codec), entry.Source, true
entry.Codec = normalizeCodecV3(entry.Codec)
return entry, true
}
}
return "", "", false
return SubtitleInventoryEntryV3{}, false
}
func isTextSubtitleV3(codec string) bool {
+38 -54
View File
@@ -35,10 +35,11 @@ type StreamExtractOpts struct {
// cues the client will never display. Ignored for ASS because ASS
// output needs the script header that only appears at offset 0.
SeekSeconds float64
// DurationSeconds preserves the existing input-side ffmpeg `-t` argument.
// It does not actually bound subtitle extraction: ffmpeg ignores it here
// and runs from the effective seek point to EOF. Zero omits the argument.
// See streamExtractArgs for why changing this would break native clients.
// DurationSeconds bounds the extract to a window of this length
// (passed as ffmpeg's `-t`). Zero means "until end of file". A
// bounded window lets the client consume one fetch to completion
// while keeping memory and in-flight state finite; the client
// requests subsequent windows as playback approaches the tail.
DurationSeconds float64
// AllowWindow lets SeekSeconds/DurationSeconds apply to PGS extracts.
// By default PGS is never windowed because clients fetch the .sup
@@ -48,12 +49,18 @@ type StreamExtractOpts struct {
// [Script Info] header exists only at stream offset 0, so a seeked
// extract would be structurally broken.
AllowWindow bool
// ExtractedInputFormat marks InputPath as a cached full-track subtitle
// artifact rather than the original media container. The input demuxer is
// forced to this value and mapping is forced to `0:s:0`, because a cached
// artifact holds exactly one stream and TrackIndex names the ordinal in the
// original container. Seeking with -copyts preserves absolute source PTS.
ExtractedInputFormat string
// InputIsExtractedSup marks InputPath as a cached full-track .sup
// elementary stream (a previous full extract, produced with -copyts so
// its timestamps are absolute source PTS) rather than the original
// media container. The input format is forced with `-f sup` — the
// headerless stream is probeable via its "PG" magic, but an explicit
// format is robust against probe-size edge cases — and the stream
// mapping is forced to `0:s:0`: a .sup holds exactly one stream, so
// TrackIndex (which names the ordinal in the *original* container) no
// longer applies. Seeking such an input with -copyts re-emits the same
// absolute timestamps, so windowed output is byte-compatible with a
// window cut from the original file.
InputIsExtractedSup bool
// FFmpegPath overrides the ffmpeg binary lookup.
FFmpegPath string
// Writer receives ffmpeg's stdout bytes as they arrive. When it
@@ -129,7 +136,7 @@ func StreamExtractSubtitle(ctx context.Context, opts StreamExtractOpts) error {
// streamExtractArgs builds the ffmpeg argument list for a streaming
// subtitle extract.
func streamExtractArgs(opts StreamExtractOpts) []string {
plan := streamExtractPlanFor(opts)
outCodec, outFormat := streamExtractOutput(opts.SourceCodec, opts.TargetFormat)
args := []string{
"-hide_banner", "-nostats", "-loglevel", "error",
@@ -143,32 +150,34 @@ func streamExtractArgs(opts StreamExtractOpts) []string {
// from offset 0 — windowing would silently drop every cue outside
// the window. Clients that manage their own sliding window opt in
// via AllowWindow; -copyts below keeps the windowed output on
// absolute source timestamps so cues stay in sync. The same predicate
// controls whether the compatibility -t argument is emitted below.
if plan.seekApplied {
// absolute source timestamps so cues stay in sync. The same logic
// governs the -t duration cap below.
windowable := !IsASS(opts.SourceCodec) && (!IsPGS(opts.SourceCodec) || opts.AllowWindow)
seekApplied := opts.SeekSeconds > 0 && windowable
if seekApplied {
args = append(args, "-ss", strconv.FormatFloat(opts.SeekSeconds, 'f', 3, 64))
}
// This input-side -t is intentionally retained for byte compatibility,
// even though ffmpeg silently ignores it for these subtitle extracts and
// runs from the seek point to EOF. Moving it after -i or replacing it with
// -to would actually bound the output and break native clients that fetch
// once and depend on receiving the rest of the track.
if plan.durationApplied {
// Duration limit must be an *input* option (placed before -i) so it
// caps how much of the file we read. Placed as an output option, -t
// combined with -copyts stops output when PTS reaches the given
// value — which with a non-zero seek is already in the past, so
// ffmpeg would emit only the WEBVTT header and zero cues.
if opts.DurationSeconds > 0 && windowable {
args = append(args, "-t", strconv.FormatFloat(opts.DurationSeconds, 'f', 3, 64))
}
// A cached artifact has exactly one stream: force its demuxer and remap to
// the sole stream ordinal.
// A cached .sup input has no container magic worth probing and exactly
// one stream: force the demuxer and remap to the sole stream ordinal.
trackIndex := opts.TrackIndex
if opts.ExtractedInputFormat != "" {
args = append(args, "-f", opts.ExtractedInputFormat)
if opts.InputIsExtractedSup {
args = append(args, "-f", "sup")
trackIndex = 0
}
args = append(args,
"-i", opts.InputPath,
"-map", fmt.Sprintf("0:s:%d", trackIndex),
"-c:s", plan.outCodec,
"-c:s", outCodec,
)
// When we seek the input, preserve the absolute source timestamps
@@ -176,41 +185,16 @@ func streamExtractArgs(opts StreamExtractOpts) []string {
// which makes every cue play `opts.SeekSeconds` earlier than it
// should — the symptom is subtitles that look "out of sync" with
// the video the player is showing at the same media time.
if plan.seekApplied {
if seekApplied {
args = append(args, "-copyts", "-avoid_negative_ts", "disabled")
}
return append(args,
"-f", plan.outFormat,
"-f", outFormat,
"pipe:1",
)
}
type streamExtractPlan struct {
outCodec string
outFormat string
seekApplied bool
durationApplied bool
}
// streamExtractPlanFor is the single source of truth for both ffmpeg argv and
// cache canonicality. Any effective seek or duration makes an extract partial,
// regardless of the handler flag that caused the argument to be applied.
func streamExtractPlanFor(opts StreamExtractOpts) streamExtractPlan {
outCodec, outFormat := StreamExtractOutput(opts.SourceCodec, opts.TargetFormat)
windowable := !IsASS(opts.SourceCodec) && (!IsPGS(opts.SourceCodec) || opts.AllowWindow)
return streamExtractPlan{
outCodec: outCodec,
outFormat: outFormat,
seekApplied: opts.SeekSeconds > 0 && windowable,
durationApplied: opts.DurationSeconds > 0 && windowable,
}
}
func (p streamExtractPlan) partial() bool {
return p.seekApplied || p.durationApplied
}
// PGSWindowRequest reports whether a subtitle request explicitly opts in
// to windowed PGS extraction (?windowed=1) and, if so, the seek position
// and window duration to use. Only explicit query params count — there is
@@ -263,12 +247,12 @@ func copyAndFlush(dst io.Writer, src io.Reader) error {
}
}
// StreamExtractOutput picks the ffmpeg output codec and muxer format for
// streamExtractOutput picks the ffmpeg output codec and muxer format for
// a given source codec. ASS/SSA is copied so styling survives; PGS is
// copied into a .sup elementary stream for client-side bitmap rendering
// (libpgs); everything else is transmuxed to WebVTT for direct `<track>`
// consumption.
func StreamExtractOutput(codec string, targetFormat ...string) (outCodec, outFormat string) {
func streamExtractOutput(codec string, targetFormat ...string) (outCodec, outFormat string) {
// A forced WebVTT target only applies to text sources: bitmap codecs
// carry no text for ffmpeg's webvtt encoder, so honoring the override
// would build a command that always fails mid-response. Fall through to
+14 -15
View File
@@ -42,9 +42,9 @@ func TestStreamExtractOutput(t *testing.T) {
{"mov_text", "webvtt", "webvtt"},
}
for _, tc := range cases {
outCodec, outFormat := StreamExtractOutput(tc.codec)
outCodec, outFormat := streamExtractOutput(tc.codec)
if outCodec != tc.wantCodec || outFormat != tc.wantFormat {
t.Errorf("StreamExtractOutput(%q) = (%q, %q), want (%q, %q)",
t.Errorf("streamExtractOutput(%q) = (%q, %q), want (%q, %q)",
tc.codec, outCodec, outFormat, tc.wantCodec, tc.wantFormat)
}
}
@@ -64,7 +64,7 @@ func TestStreamExtractArgs_TextCodecIsWindowed(t *testing.T) {
t.Fatalf("text extract should seek the input: %s", joined)
}
if !strings.Contains(joined, "-t 600.000") {
t.Fatalf("text extract should preserve the duration argument: %s", joined)
t.Fatalf("text extract should cap the read duration: %s", joined)
}
if !strings.Contains(joined, "-copyts") {
t.Fatalf("seeked extract must preserve source timestamps: %s", joined)
@@ -99,8 +99,7 @@ func TestStreamExtractArgs_WholeTrackCodecsIgnoreWindow(t *testing.T) {
}
}
// A client that opts in via AllowWindow gets a seeked PGS extract with the
// existing duration argument and
// A client that opts in via AllowWindow gets a seeked, duration-capped PGS
// extract with -copyts preserving absolute source timestamps — the -ss must
// be an input option (before -i) so ffmpeg uses the container index.
func TestStreamExtractArgs_WindowedPGS(t *testing.T) {
@@ -123,7 +122,7 @@ func TestStreamExtractArgs_WindowedPGS(t *testing.T) {
t.Fatalf("-ss must be an input option (before -i): %s", joined)
}
if !strings.Contains(joined, "-t 3600.000") {
t.Fatalf("windowed PGS extract should preserve the duration argument: %s", joined)
t.Fatalf("windowed PGS extract should cap the read duration: %s", joined)
}
if !strings.Contains(joined, "-copyts") {
t.Fatalf("windowed PGS extract must preserve source timestamps: %s", joined)
@@ -140,13 +139,13 @@ func TestStreamExtractArgs_WindowedPGS(t *testing.T) {
// the cached stream's absolute timestamps survive into the output.
func TestStreamExtractArgs_ExtractedSupInput(t *testing.T) {
args := streamExtractArgs(StreamExtractOpts{
InputPath: "/transcode/subtitle-cache/abc-s3-1-2.sup",
TrackIndex: 3,
SourceCodec: "hdmv_pgs_subtitle",
SeekSeconds: 1200,
DurationSeconds: 3600,
AllowWindow: true,
ExtractedInputFormat: "sup",
InputPath: "/transcode/subtitle-cache/abc-s3-1-2.sup",
TrackIndex: 3,
SourceCodec: "hdmv_pgs_subtitle",
SeekSeconds: 1200,
DurationSeconds: 3600,
AllowWindow: true,
InputIsExtractedSup: true,
})
joined := strings.Join(args, " ")
@@ -246,9 +245,9 @@ func TestStreamExtractOutput_TargetFormatVTTGatedToTextSources(t *testing.T) {
{"hdmv_pgs_subtitle", "copy", "sup"},
}
for _, tc := range cases {
outCodec, outFormat := StreamExtractOutput(tc.codec, "vtt")
outCodec, outFormat := streamExtractOutput(tc.codec, "vtt")
if outCodec != tc.wantCodec || outFormat != tc.wantFormat {
t.Errorf("StreamExtractOutput(%q, \"vtt\") = (%q, %q), want (%q, %q)",
t.Errorf("streamExtractOutput(%q, \"vtt\") = (%q, %q), want (%q, %q)",
tc.codec, outCodec, outFormat, tc.wantCodec, tc.wantFormat)
}
}
+163 -5
View File
@@ -152,6 +152,11 @@ const defaultSegmentDuration = 2
// embedded length matches what the node actually produces.
const DefaultSegmentDuration = defaultSegmentDuration
// maxSyntheticManifestSegments preserves the historical worst-case playlist
// size (100,000 seconds at two-second segments). Longer media uses FFmpeg's
// real sliding playlist instead of allocating a complete synthetic manifest.
const maxSyntheticManifestSegments = 50_000
const maxPersistedFFmpegLines = 2000
const maxPersistedFFmpegBytes = 256 * 1024
const maxPersistedFFmpegChars = 2000
@@ -383,7 +388,10 @@ func buildFFmpegArgs(opts TranscodeOpts) []string {
"-max_delay", "5000000",
"-f", "hls",
"-hls_time", fmt.Sprintf("%d", opts.SegmentDuration),
"-hls_list_size", "0",
// Bound real playlists as well as synthetic ones. Segment files remain on
// disk because delete_segments is not enabled, while the manifest itself
// cannot grow without limit during multi-day sessions.
"-hls_list_size", strconv.Itoa(maxSyntheticManifestSegments),
"-hls_segment_type", segmentType,
// Write segments to temp files first so the player never fetches a
// partially-written segment during a quality switch.
@@ -1023,12 +1031,14 @@ func (s *TranscodeSession) WaitForManifest(timeout time.Duration) ([]byte, error
// Copy-video sessions always expose FFmpeg's real manifest so the playlist
// timing matches the variable-length fragments FFmpeg actually writes and the
// seekable window reflects what FFmpeg has produced so far. Encoded transcodes
// still use the synthetic full VOD manifest when duration is known because
// forced keyframes make that timeline stable and seek-anywhere friendly.
// use the synthetic full VOD manifest only while its segment count is bounded;
// longer media uses FFmpeg's real sliding playlist.
func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([]byte, error) {
opts := s.Opts()
if strings.EqualFold(opts.TargetCodecVideo, "copy") || opts.TotalDuration <= 0 {
// Copy-video or unknown-duration sessions must use FFmpeg's real manifest.
if strings.EqualFold(opts.TargetCodecVideo, "copy") ||
!CanGenerateSyntheticManifest(opts.TotalDuration, opts.SegmentDuration) {
// Copy-video, unknown-duration, or oversized sessions must use FFmpeg's
// real manifest.
manifest, err := s.WaitForManifest(30 * time.Second)
if err != nil {
return nil, err
@@ -1039,6 +1049,154 @@ func (s *TranscodeSession) BuildPlaybackManifest(segPrefix, rawQuery string) ([]
return s.GenerateFullManifest(segPrefix, rawQuery), nil
}
// SourceTimelineQueryParam opts a real transcode manifest into source-time
// alignment for compatibility clients that apply their resume position to the
// HLS timeline themselves.
const SourceTimelineQueryParam = "source_timeline"
// BuildSourceAlignedPlaybackManifest builds the normal playback manifest and,
// when it is a seeked real playlist, prepends a virtual unavailable span so the
// first produced segment retains its source-time position. Synthetic manifests
// already cover the full source timeline and need no adjustment.
func (s *TranscodeSession) BuildSourceAlignedPlaybackManifest(segPrefix, rawQuery string) ([]byte, error) {
manifest, err := s.BuildPlaybackManifest(segPrefix, rawQuery)
if err != nil {
return nil, err
}
opts := s.Opts()
usesRealManifest := strings.EqualFold(opts.TargetCodecVideo, "copy") ||
!CanGenerateSyntheticManifest(opts.TotalDuration, opts.SegmentDuration)
if opts.SeekSeconds <= 0 || !usesRealManifest {
return manifest, nil
}
gapURI := segPrefix + "source_timeline_gap" + hlsSegmentExtension(opts)
if rawQuery != "" {
gapURI += "?" + rawQuery
}
return AlignRealManifestToSourceTimeline(manifest, opts, gapURI)
}
// AlignRealManifestToSourceTimeline prepends bounded EXT-X-GAP segments to a
// seeked FFmpeg playlist. The gaps contribute the omitted source time while
// allowing clients to seek to the original source position.
func AlignRealManifestToSourceTimeline(manifest []byte, opts TranscodeOpts, gapURI string) ([]byte, error) {
if opts.SeekSeconds <= 0 {
return manifest, nil
}
timeline, err := parseManifestTimeline(manifest)
if err != nil {
return nil, err
}
if len(timeline.entries) == 0 {
return nil, fmt.Errorf("manifest contains no media segments")
}
segmentDuration := opts.SegmentDuration
if segmentDuration <= 0 {
segmentDuration = defaultSegmentDuration
}
firstSegment := timeline.entries[0].number
advancedSegments := max(0, firstSegment-opts.StartSegmentNumber)
gapDuration := opts.SeekSeconds + float64(advancedSegments*segmentDuration)
if gapDuration <= 0 {
return manifest, nil
}
if gapURI == "" {
gapURI = "source_timeline_gap" + hlsSegmentExtension(opts)
}
lines := bytes.Split(manifest, []byte("\n"))
targetDuration := segmentDuration
for _, line := range lines {
trimmed := bytes.TrimSpace(line)
if !bytes.HasPrefix(trimmed, []byte("#EXT-X-TARGETDURATION:")) {
continue
}
value := strings.TrimSpace(strings.TrimPrefix(string(trimmed), "#EXT-X-TARGETDURATION:"))
parsed, parseErr := strconv.Atoi(value)
if parseErr != nil || parsed <= 0 {
return nil, fmt.Errorf("parse manifest target duration %q", value)
}
targetDuration = parsed
break
}
// Keep the real segment's media sequence aligned with its FFmpeg segment
// number when possible. Very large seeks remain bounded by the same limit as
// generated VOD manifests; their gap durations grow instead of their count.
gapCount := max(1, firstSegment)
if gapCount > maxSyntheticManifestSegments {
gapCount = maxSyntheticManifestSegments
}
gapSegmentDuration := gapDuration / float64(gapCount)
requiredTargetDuration := int(math.Ceil(gapSegmentDuration))
if requiredTargetDuration > targetDuration {
targetDuration = requiredTargetDuration
}
mediaSequence := max(0, firstSegment-gapCount)
result := make([][]byte, 0, len(lines)+gapCount*3)
insertedGap := false
foundSequence := false
foundVersion := false
for _, line := range lines {
trimmed := bytes.TrimSpace(line)
switch {
case bytes.HasPrefix(trimmed, []byte("#EXT-X-VERSION:")):
foundVersion = true
value := strings.TrimSpace(strings.TrimPrefix(string(trimmed), "#EXT-X-VERSION:"))
version, parseErr := strconv.Atoi(value)
if parseErr != nil {
return nil, fmt.Errorf("parse manifest version %q: %w", value, parseErr)
}
if version < 8 {
line = []byte("#EXT-X-VERSION:8")
}
case bytes.HasPrefix(trimmed, []byte("#EXT-X-TARGETDURATION:")):
line = []byte(fmt.Sprintf("#EXT-X-TARGETDURATION:%d", targetDuration))
case bytes.HasPrefix(trimmed, []byte("#EXT-X-MEDIA-SEQUENCE:")):
foundSequence = true
line = []byte(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d", mediaSequence))
case bytes.HasPrefix(trimmed, []byte("#EXTINF:")) && !insertedGap:
if !foundVersion {
result = append(result, []byte("#EXT-X-VERSION:8"))
foundVersion = true
}
if !foundSequence {
result = append(result, []byte(fmt.Sprintf("#EXT-X-MEDIA-SEQUENCE:%d", mediaSequence)))
foundSequence = true
}
for range gapCount {
result = append(result,
[]byte("#EXT-X-GAP"),
[]byte(fmt.Sprintf("#EXTINF:%.6f,", gapSegmentDuration)),
[]byte(gapURI),
)
}
insertedGap = true
}
result = append(result, line)
}
if !insertedGap {
return nil, fmt.Errorf("manifest contains no segment duration")
}
return bytes.Join(result, []byte("\n")), nil
}
// CanGenerateSyntheticManifest reports whether a complete VOD playlist fits
// within the shared segment-count bound. Callers outside playback use the same
// decision so native and compatibility manifests cannot drift.
func CanGenerateSyntheticManifest(totalDuration float64, segmentDuration int) bool {
if totalDuration <= 0 || math.IsNaN(totalDuration) || math.IsInf(totalDuration, 0) {
return false
}
if segmentDuration <= 0 {
segmentDuration = defaultSegmentDuration
}
return totalDuration <= float64(segmentDuration)*maxSyntheticManifestSegments
}
func firstNonEmptyManifestLine(manifest []byte) []byte {
for line := range bytes.SplitSeq(manifest, []byte("\n")) {
trimmed := bytes.TrimSpace(line)
+18
View File
@@ -65,6 +65,24 @@ func TestBuildFFmpegArgs_CPUPreservesSuperfastFastStart(t *testing.T) {
}
}
func TestBuildFFmpegArgsBoundsHLSManifestSize(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/long.mkv",
OutputDir: "/tmp/out",
SessionID: "session-long",
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
TotalDuration: 1_000_000,
})
joined := strings.Join(args, " ")
want := "-hls_list_size 50000"
if !strings.Contains(joined, want) {
t.Fatalf("FFmpeg args missing %q: %s", want, joined)
}
}
func TestBuildFFmpegArgs_CopyVideoFromStartUsesZeroBasedTimestamps(t *testing.T) {
args := buildFFmpegArgs(TranscodeOpts{
InputPath: "/media/movie.mkv",
@@ -160,6 +160,116 @@ func TestBuildPlaybackManifest_EncodedTranscodeUsesSyntheticVODManifest(t *testi
}
}
func TestBuildPlaybackManifest_LongEncodedTranscodeUsesRealManifest(t *testing.T) {
tempDir := t.TempDir()
manifest := strings.Join([]string{
"#EXTM3U",
"#EXT-X-VERSION:3",
"#EXT-X-TARGETDURATION:2",
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXTINF:2.000000,",
"seg_00000.ts",
"#EXTINF:2.000000,",
"seg_00001.ts",
"",
}, "\n")
if err := os.WriteFile(filepath.Join(tempDir, "stream.m3u8"), []byte(manifest), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
session := &TranscodeSession{
outputDir: tempDir,
opts: TranscodeOpts{
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
TotalDuration: 1_000_000,
},
}
got, err := session.BuildPlaybackManifest("segment/", "token=test")
if err != nil {
t.Fatalf("BuildPlaybackManifest: %v", err)
}
text := string(got)
if strings.Contains(text, "#EXT-X-PLAYLIST-TYPE:VOD") ||
strings.Contains(text, "seg_499999.ts") {
t.Fatalf("long encoded manifest should not synthesize every segment:\n%s", text)
}
for _, want := range []string{
"#EXT-X-MEDIA-SEQUENCE:0",
"segment/seg_00000.ts?token=test",
"segment/seg_00001.ts?token=test",
} {
if !strings.Contains(text, want) {
t.Fatalf("manifest missing %q:\n%s", want, text)
}
}
}
func TestBuildSourceAlignedPlaybackManifestAnchorsSeekedRealPlaylist(t *testing.T) {
tempDir := t.TempDir()
manifest := strings.Join([]string{
"#EXTM3U",
"#EXT-X-VERSION:3",
"#EXT-X-TARGETDURATION:2",
"#EXT-X-MEDIA-SEQUENCE:8",
"#EXTINF:2.000000,",
"seg_00008.ts",
"#EXTINF:2.000000,",
"seg_00009.ts",
"",
}, "\n")
if err := os.WriteFile(filepath.Join(tempDir, "stream.m3u8"), []byte(manifest), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
session := &TranscodeSession{
outputDir: tempDir,
opts: TranscodeOpts{
TargetCodecVideo: "h264",
TargetCodecAudio: "aac",
SegmentDuration: 2,
TotalDuration: 1_000_000,
SeekSeconds: 17.3,
StartSegmentNumber: 8,
},
}
got, err := session.BuildSourceAlignedPlaybackManifest("segment/", "source_timeline=1")
if err != nil {
t.Fatalf("BuildSourceAlignedPlaybackManifest: %v", err)
}
text := string(got)
for _, want := range []string{
"#EXT-X-VERSION:8",
"#EXT-X-TARGETDURATION:3",
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXT-X-GAP\n#EXTINF:2.162500,\nsegment/source_timeline_gap.ts?source_timeline=1",
"segment/seg_00008.ts?source_timeline=1",
} {
if !strings.Contains(text, want) {
t.Fatalf("source-aligned manifest missing %q:\n%s", want, text)
}
}
if gap := strings.Index(text, "source_timeline_gap.ts"); gap < 0 || gap > strings.Index(text, "seg_00008.ts") {
t.Fatalf("timeline gap must precede the first real segment:\n%s", text)
}
if count := strings.Count(text, "#EXT-X-GAP"); count != 8 {
t.Fatalf("timeline gap count = %d, want 8:\n%s", count, text)
}
}
func TestCanGenerateSyntheticManifestBoundsSegmentCount(t *testing.T) {
if !CanGenerateSyntheticManifest(100_000, 2) {
t.Fatal("historical 50,000-segment manifest should remain supported")
}
if CanGenerateSyntheticManifest(100_001, 2) {
t.Fatal("manifest above 50,000 segments should use the real playlist")
}
}
func TestBuildPlaybackManifest_UnknownDurationRejectsBrokenManifest(t *testing.T) {
tempDir := t.TempDir()
manifest := strings.Join([]string{
+59
View File
@@ -79,6 +79,12 @@ type HTTPRoutesClient struct {
timeout time.Duration
}
type WatchSyncProviderClient struct {
client pluginv1.WatchSyncProviderClient
deviceAuthorization pluginv1.WatchSyncDeviceAuthorizationServiceClient
timeout time.Duration
}
func newClient(installationID int, rpc *sdkruntime.Client, manifest *pluginv1.PluginManifest) *Client {
capabilities := make(map[string]*pluginv1.CapabilityDescriptor, len(manifest.GetCapabilities()))
for _, capability := range manifest.GetCapabilities() {
@@ -208,6 +214,17 @@ func (c *Client) HTTPRoutes(capabilityID string) (*HTTPRoutesClient, error) {
}, nil
}
func (c *Client) WatchSyncProvider(capabilityID string) (*WatchSyncProviderClient, error) {
if err := c.requireCapability("watch_sync_provider.v1", capabilityID); err != nil {
return nil, err
}
return &WatchSyncProviderClient{
client: c.rpc.WatchSyncProvider(),
deviceAuthorization: c.rpc.WatchSyncDeviceAuthorization(),
timeout: DefaultWatchSyncTimeout,
}, nil
}
func (c *Client) markUnhealthy() {
c.mu.Lock()
defer c.mu.Unlock()
@@ -385,6 +402,48 @@ func (c *HTTPRoutesClient) Handle(ctx context.Context, req *pluginv1.HandleHTTPR
return c.client.Handle(callCtx, req)
}
func (c *WatchSyncProviderClient) ExchangeAPIKey(ctx context.Context, req *pluginv1.WatchSyncExchangeAPIKeyRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.ExchangeAPIKey(callCtx, req)
}
func (c *WatchSyncProviderClient) StartDeviceAuthorization(ctx context.Context, req *pluginv1.WatchSyncDeviceAuthorizationServiceStartRequest) (*pluginv1.WatchSyncDeviceAuthorizationServiceStartResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.deviceAuthorization.Start(callCtx, req)
}
func (c *WatchSyncProviderClient) PollDeviceAuthorization(ctx context.Context, req *pluginv1.WatchSyncDeviceAuthorizationServicePollRequest) (*pluginv1.WatchSyncDeviceAuthorizationServicePollResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.deviceAuthorization.Poll(callCtx, req)
}
func (c *WatchSyncProviderClient) RefreshCredentials(ctx context.Context, req *pluginv1.WatchSyncRefreshCredentialsRequest) (*pluginv1.WatchSyncCredentialResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.RefreshCredentials(callCtx, req)
}
func (c *WatchSyncProviderClient) GetAccount(ctx context.Context, req *pluginv1.WatchSyncGetAccountRequest) (*pluginv1.WatchSyncGetAccountResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.GetAccount(callCtx, req)
}
func (c *WatchSyncProviderClient) ApplyEvents(ctx context.Context, req *pluginv1.WatchSyncApplyEventsRequest) (*pluginv1.WatchSyncApplyEventsResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.ApplyEvents(callCtx, req)
}
func (c *WatchSyncProviderClient) ListRemoteState(ctx context.Context, req *pluginv1.WatchSyncListRemoteStateRequest) (*pluginv1.WatchSyncListRemoteStateResponse, error) {
callCtx, cancel := ensureDeadline(ctx, c.timeout)
defer cancel()
return c.client.ListRemoteState(callCtx, req)
}
func ensureDeadline(ctx context.Context, timeout time.Duration) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return ctx, func() {}
+3
View File
@@ -23,6 +23,9 @@ const (
DefaultEventTimeout = 10 * time.Second
DefaultAuthTimeout = 10 * time.Second
DefaultRouteTimeout = 10 * time.Second
// Watch-sync providers may resolve cold external metadata before applying
// an event. Bound that work without cutting off valid slow provider calls.
DefaultWatchSyncTimeout = 2 * time.Minute
// DefaultRequestRouterTimeout bounds a single request_router RPC.
// Fulfillment hits remote arr instances, so allow generous headroom.
DefaultRequestRouterTimeout = 60 * time.Second
+150
View File
@@ -0,0 +1,150 @@
package plugins
import (
"strings"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
// ConfigSchemaView is the transport-safe representation of a manifest config
// schema used by non-admin setup surfaces.
type ConfigSchemaView struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
JSONSchema string `json:"json_schema"`
Required bool `json:"required"`
AdminForm *AdminFormView `json:"admin_form,omitempty"`
}
type AdminFormView struct {
Fields []AdminFormFieldView `json:"fields"`
SubmitLabel string `json:"submit_label,omitempty"`
Sections []AdminFormSectionView `json:"sections,omitempty"`
}
type AdminFormFieldView struct {
Key string `json:"key"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
Control string `json:"control"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Secret bool `json:"secret"`
Multiline bool `json:"multiline"`
DefaultValue any `json:"default_value,omitempty"`
Options []AdminFormOptionView `json:"options,omitempty"`
Rows int32 `json:"rows,omitempty"`
DynamicOptions bool `json:"dynamic_options,omitempty"`
ShowWhen []AdminFormConditionView `json:"show_when,omitempty"`
Validation *AdminFormValidationView `json:"validation,omitempty"`
ExclusiveGroupField string `json:"exclusive_group_field,omitempty"`
}
type AdminFormOptionView struct {
Value string `json:"value"`
Label string `json:"label"`
Description string `json:"description,omitempty"`
}
type AdminFormConditionView struct {
Field string `json:"field"`
Equals []string `json:"equals"`
}
type AdminFormValidationView struct {
HasMin bool `json:"has_min,omitempty"`
Min float64 `json:"min,omitempty"`
HasMax bool `json:"has_max,omitempty"`
Max float64 `json:"max,omitempty"`
Pattern string `json:"pattern,omitempty"`
MinLength int32 `json:"min_length,omitempty"`
MaxLength int32 `json:"max_length,omitempty"`
}
type AdminFormSectionView struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Collapsible bool `json:"collapsible"`
CollapsedDefault bool `json:"collapsed_default"`
FieldKeys []string `json:"field_keys"`
ShowWhen []AdminFormConditionView `json:"show_when,omitempty"`
}
func ConfigSchemaViews(schemas []*pluginv1.ConfigSchema) []ConfigSchemaView {
views := make([]ConfigSchemaView, 0, len(schemas))
for _, schema := range schemas {
if schema == nil {
continue
}
views = append(views, ConfigSchemaView{
Key: schema.GetKey(),
Title: schema.GetTitle(),
Description: schema.GetDescription(),
JSONSchema: schema.GetJsonSchema(),
Required: schema.GetRequired(),
AdminForm: AdminFormViewFromProto(schema.GetAdminForm()),
})
}
return views
}
func AdminFormViewFromProto(form *pluginv1.AdminFormDescriptor) *AdminFormView {
if form == nil {
return nil
}
fields := make([]AdminFormFieldView, 0, len(form.GetFields()))
for _, field := range form.GetFields() {
if field == nil {
continue
}
options := make([]AdminFormOptionView, 0, len(field.GetOptions()))
for _, option := range field.GetOptions() {
if option != nil {
options = append(options, AdminFormOptionView{Value: option.GetValue(), Label: option.GetLabel(), Description: option.GetDescription()})
}
}
var defaultValue any
if field.GetDefaultValue() != nil {
defaultValue = field.GetDefaultValue().AsInterface()
}
var validation *AdminFormValidationView
if value := field.GetValidation(); value != nil {
validation = &AdminFormValidationView{
HasMin: value.GetHasMin(), Min: value.GetMin(), HasMax: value.GetHasMax(), Max: value.GetMax(),
Pattern: value.GetPattern(), MinLength: value.GetMinLength(), MaxLength: value.GetMaxLength(),
}
}
fields = append(fields, AdminFormFieldView{
Key: field.GetKey(), Label: field.GetLabel(), Description: field.GetDescription(),
Control: strings.TrimPrefix(field.GetControl().String(), "ADMIN_FORM_CONTROL_"),
Placeholder: field.GetPlaceholder(), Required: field.GetRequired(), Secret: field.GetSecret(),
Multiline: field.GetMultiline(), DefaultValue: defaultValue, Options: options, Rows: field.GetRows(),
DynamicOptions: field.GetDynamicOptions(), ShowWhen: adminFormConditionViews(field.GetShowWhen()),
Validation: validation, ExclusiveGroupField: field.GetExclusiveGroupField(),
})
}
sections := make([]AdminFormSectionView, 0, len(form.GetSections()))
for _, section := range form.GetSections() {
if section == nil {
continue
}
sections = append(sections, AdminFormSectionView{
Key: section.GetKey(), Title: section.GetTitle(), Description: section.GetDescription(),
Collapsible: section.GetCollapsible(), CollapsedDefault: section.GetCollapsedDefault(),
FieldKeys: append([]string(nil), section.GetFieldKeys()...), ShowWhen: adminFormConditionViews(section.GetShowWhen()),
})
}
return &AdminFormView{Fields: fields, SubmitLabel: form.GetSubmitLabel(), Sections: sections}
}
func adminFormConditionViews(conditions []*pluginv1.AdminFormCondition) []AdminFormConditionView {
views := make([]AdminFormConditionView, 0, len(conditions))
for _, condition := range conditions {
if condition != nil {
views = append(views, AdminFormConditionView{Field: condition.GetField(), Equals: append([]string(nil), condition.GetEquals()...)})
}
}
return views
}
+8 -1
View File
@@ -16,9 +16,16 @@ func GlobalConfigFieldSets(
manifest *pluginv1.PluginManifest,
configKey string,
) (publicFields, secretFields []string) {
return ConfigSchemaFieldSets(globalConfigSchema(manifest, configKey))
}
// ConfigSchemaFieldSets returns the top-level public and secret fields for one
// configuration schema. JSON Schema annotations and Admin form controls are
// both authoritative; undeclared fields are intentionally absent from both
// sets so callers can keep them on the protected path.
func ConfigSchemaFieldSets(schema *pluginv1.ConfigSchema) (publicFields, secretFields []string) {
declared := make(map[string]struct{})
secrets := make(map[string]struct{})
schema := globalConfigSchema(manifest, configKey)
if schema != nil {
if form := schema.GetAdminForm(); form != nil {
for _, field := range form.GetFields() {
+13
View File
@@ -37,6 +37,7 @@ type pluginClient interface {
EventConsumer(capabilityID string) (*pluginhost.EventConsumerClient, error)
AuthProvider(capabilityID string) (*pluginhost.AuthProviderClient, error)
HTTPRoutes(capabilityID string) (*pluginhost.HTTPRoutesClient, error)
WatchSyncProvider(capabilityID string) (*pluginhost.WatchSyncProviderClient, error)
}
type Host interface {
@@ -648,6 +649,18 @@ func (s *Service) ScanSourceClientByPluginID(
return s.ScanSourceClient(ctx, matches[0].ID, capabilityID)
}
func (s *Service) WatchSyncProviderClient(
ctx context.Context,
installationID int,
capabilityID string,
) (*pluginhost.WatchSyncProviderClient, error) {
client, err := s.ensureClient(ctx, installationID)
if err != nil {
return nil, err
}
return client.WatchSyncProvider(capabilityID)
}
func (s *Service) EventConsumerClient(
ctx context.Context,
installationID int,
@@ -237,6 +237,10 @@ func (f *fakePluginClient) HTTPRoutes(string) (*pluginhost.HTTPRoutesClient, err
return nil, nil
}
func (f *fakePluginClient) WatchSyncProvider(string) (*pluginhost.WatchSyncProviderClient, error) {
return nil, nil
}
type fakeServiceInstallationStore struct {
byID map[int]*Installation
byPluginID map[string][]*Installation
+101
View File
@@ -0,0 +1,101 @@
package plugins
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
// WatchSyncProviderConfig returns the installation's global configuration in
// the transient, field-classified shape used by watch-sync RPCs. Undeclared
// fields are treated as secret so manifest drift cannot expose credentials.
func (s *Service) WatchSyncProviderConfig(
ctx context.Context,
installationID int,
) (*pluginv1.WatchSyncProviderConfig, error) {
manifest, err := s.manifestForInstallation(ctx, installationID, false)
if err != nil {
return nil, err
}
if s.configs == nil {
return &pluginv1.WatchSyncProviderConfig{}, nil
}
configs, err := s.configs.ListGlobalConfigs(ctx, installationID)
if err != nil {
return nil, fmt.Errorf("list watch sync plugin config: %w", err)
}
return watchSyncProviderConfig(manifest, configs)
}
func watchSyncProviderConfig(
manifest *pluginv1.PluginManifest,
configs []*RuntimeConfig,
) (*pluginv1.WatchSyncProviderConfig, error) {
result := &pluginv1.WatchSyncProviderConfig{
Values: make(map[string]string),
SecretValues: make(map[string]string),
}
sort.Slice(configs, func(i, j int) bool {
if configs[i] == nil {
return false
}
if configs[j] == nil {
return true
}
return configs[i].Key < configs[j].Key
})
for _, config := range configs {
if config == nil {
continue
}
configKey := strings.TrimSpace(config.Key)
if configKey == "" {
continue
}
publicFields, _ := GlobalConfigFieldSets(manifest, configKey)
public := stringSet(publicFields)
for field, raw := range config.Value {
field = strings.TrimSpace(field)
if field == "" {
continue
}
value, err := watchSyncConfigString(raw)
if err != nil {
return nil, fmt.Errorf("encode watch sync plugin config %q.%s: %w", configKey, field, err)
}
key := configKey + "." + field
if _, isPublic := public[field]; isPublic {
result.Values[key] = value
continue
}
// Explicitly secret and undeclared fields both take the protected path.
result.SecretValues[key] = value
}
}
return result, nil
}
func stringSet(values []string) map[string]struct{} {
result := make(map[string]struct{}, len(values))
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
result[value] = struct{}{}
}
}
return result
}
func watchSyncConfigString(value any) (string, error) {
if text, ok := value.(string); ok {
return text, nil
}
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
return string(encoded), nil
}
@@ -0,0 +1,51 @@
package plugins
import (
"testing"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
)
func TestWatchSyncProviderConfigClassifiesManifestFields(t *testing.T) {
manifest := &pluginv1.PluginManifest{GlobalConfigSchema: []*pluginv1.ConfigSchema{{
Key: "provider",
AdminForm: &pluginv1.AdminFormDescriptor{Fields: []*pluginv1.AdminFormField{
{Key: "base_url", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_TEXT},
{Key: "client_secret", Control: pluginv1.AdminFormControl_ADMIN_FORM_CONTROL_PASSWORD, Secret: true},
}},
}}}
config, err := watchSyncProviderConfig(manifest, []*RuntimeConfig{nil, {
Key: " provider ",
Value: map[string]any{
" base_url ": "https://floppy.example",
"client_secret": "secret",
"undeclared": map[string]any{"token": "also-secret"},
" ": "ignored-empty-field",
},
}, {
Key: "legacy",
Value: map[string]any{
"base_url": "https://legacy-floppy.example",
},
}, {
Key: " ",
Value: map[string]any{
"base_url": "ignored-empty-key",
},
}})
if err != nil {
t.Fatal(err)
}
if config.GetValues()["provider.base_url"] != "https://floppy.example" ||
config.GetSecretValues()["provider.client_secret"] != "secret" ||
config.GetSecretValues()["provider.undeclared"] != `{"token":"also-secret"}` ||
config.GetSecretValues()["legacy.base_url"] != "https://legacy-floppy.example" {
t.Fatalf("config = %#v", config)
}
if _, exposed := config.GetValues()["provider.undeclared"]; exposed {
t.Fatal("undeclared plugin config was exposed as public")
}
if _, present := config.GetSecretValues()["."]; present {
t.Fatal("empty plugin config key or field was emitted")
}
}
+1 -1
View File
@@ -252,7 +252,7 @@ func (s *Server) handleSubtitle(w http.ResponseWriter, r *http.Request) {
// track when one exists (warming it in the background when not).
if requestedFormat == "sup" {
allowWindow, seek, duration := playback.PGSWindowRequest(r.URL.Query())
err := s.subCache.ServeExtract(w, r, playback.StreamExtractOpts{
err := s.subCache.ServeSUPExtract(w, r, playback.StreamExtractOpts{
InputPath: claims.MediaPath,
TrackIndex: trackIndex,
SourceCodec: "hdmv_pgs_subtitle", // .sup URLs are only generated for PGS tracks
+124 -8
View File
@@ -266,10 +266,18 @@ func convertProbeData(raw *ffprobeOutput) *ProbeData {
const (
maxReasonableMediaDurationSeconds = 100_000
// Corroborated metadata and packet-derived durations have stronger evidence
// than a lone container timestamp, so they may use the same bounded ceiling
// as long-form audio. This supports multi-day video without accepting the
// multi-year timelines seen in malformed containers.
maxValidatedMediaDurationSeconds = 1_000_000
// Audio-only files (audiobooks, podcasts) legitimately exceed the video
// ceiling, but still need a cap so malformed containers cannot persist
// multi-year durations.
maxReasonableAudioDurationSeconds = 1_000_000
maxReasonableAudioDurationSeconds = maxValidatedMediaDurationSeconds
longVideoDurationAbsoluteToleranceSeconds = 1
longVideoDurationRelativeTolerance = 0.001
)
// A video duration is implausible when it is either far too short in absolute
@@ -346,9 +354,90 @@ func durationFromProbeMetadata(raw *ffprobeOutput) (int, bool) {
if duration > 0 && !durationLooksImplausible(raw, duration) {
return truncatedDuration(duration), true
}
if duration, ok := corroboratedLongVideoDuration(raw, formatDuration); ok {
return truncatedDuration(duration), true
}
return 0, false
}
// corroboratedLongVideoDuration accepts an extended-range video duration only
// when the container and the primary video stream independently report nearly the
// same value. A small absolute/relative tolerance covers container rounding
// and stream-boundary differences without trusting a lone malformed timeline.
func corroboratedLongVideoDuration(raw *ffprobeOutput, formatDuration float64) (float64, bool) {
if raw == nil {
return 0, false
}
for _, stream := range raw.Streams {
if !isMainVideoStream(stream) {
continue
}
streamDuration := parseFloat(stream.Duration)
formatStart := parseFloat(raw.Format.StartTime)
streamStart := parseFloat(stream.StartTime)
// Some MPEG-TS/HLS timelines report duration as an absolute end
// timestamp. Use normalized spans to reconcile raw end timestamps that
// disagree, or when matching timestamps have a dominant start offset that
// strongly indicates the absolute-end shape. Ordinary non-zero starts remain
// part of an already corroborated raw duration.
normalizedFormatDuration := durationAfterStartWithinValidatedLimit(
formatDuration,
formatStart,
)
normalizedStreamDuration := durationAfterStartWithinValidatedLimit(
streamDuration,
streamStart,
)
rawDurationsAgree := longVideoDurationsAgree(formatDuration, streamDuration)
normalizedDurationsAgree := longVideoDurationsAgree(normalizedFormatDuration, normalizedStreamDuration)
matchingAbsoluteEnds := rawDurationsAgree &&
durationHasDominantStartOffset(formatDuration, formatStart) &&
durationHasDominantStartOffset(streamDuration, streamStart)
if normalizedDurationsAgree && (!rawDurationsAgree || matchingAbsoluteEnds) &&
!durationLooksImplausible(raw, normalizedFormatDuration) {
return normalizedFormatDuration, true
}
if matchingAbsoluteEnds {
// Dominant starts identify the raw values as absolute end
// timestamps. If their normalized spans do not corroborate, reject
// the metadata for packet repair instead of persisting an inflated
// raw end timestamp.
return 0, false
}
if rawDurationsAgree &&
!durationLooksImplausible(raw, formatDuration) {
return formatDuration, true
}
return 0, false
}
return 0, false
}
func longVideoDurationsAgree(first, second float64) bool {
if first <= maxReasonableMediaDurationSeconds ||
!durationIsWithinValidatedLimit(first) ||
!durationIsWithinValidatedLimit(second) {
return false
}
tolerance := max(
longVideoDurationAbsoluteToleranceSeconds,
max(first, second)*longVideoDurationRelativeTolerance,
)
return math.Abs(first-second) <= tolerance
}
// durationHasDominantStartOffset identifies the conservative absolute-end
// shape where the start timestamp occupies at least half of the reported end.
// Smaller starts are common media offsets and cannot disambiguate a duration
// field from an absolute end timestamp.
func durationHasDominantStartOffset(end, start float64) bool {
return start > 0 && end > start && start >= end-start
}
func durationLooksImplausible(raw *ffprobeOutput, duration float64) bool {
if raw == nil {
return false
@@ -368,10 +457,25 @@ func durationAfterStart(end, start float64) float64 {
return duration
}
func durationAfterStartWithinValidatedLimit(end, start float64) float64 {
if start <= 0 || end <= start {
return 0
}
duration := end - start
if !durationIsWithinValidatedLimit(duration) {
return 0
}
return duration
}
func durationIsReasonable(duration float64) bool {
return durationIsPositiveFinite(duration) && duration <= maxReasonableMediaDurationSeconds
}
func durationIsWithinValidatedLimit(duration float64) bool {
return durationIsPositiveFinite(duration) && duration <= maxValidatedMediaDurationSeconds
}
func durationIsPositiveFinite(duration float64) bool {
return duration > 0 && !math.IsNaN(duration) && !math.IsInf(duration, 0)
}
@@ -458,18 +562,30 @@ func estimateVideoPacketDuration(reader io.Reader, frameRate string) int {
maxTimestamp = max(maxTimestamp, timestamp)
}
best := 0.0
packetSpan := 0.0
if !math.IsInf(minTimestamp, 1) && !math.IsInf(maxTimestamp, -1) {
span := maxTimestamp - minTimestamp
if durationIsReasonable(span) {
best = span
if durationIsWithinValidatedLimit(span) {
packetSpan = span
}
}
frameDuration := 0.0
if fps := parseFrameRate(frameRate); fps > 0 && packetCount > 0 {
frameDuration := float64(packetCount) / fps
if durationIsReasonable(frameDuration) && frameDuration > best {
best = frameDuration
}
frameDuration = float64(packetCount) / fps
}
best := packetSpan
if packetSpan > maxReasonableMediaDurationSeconds &&
durationIsReasonable(frameDuration) &&
!longVideoDurationsAgree(packetSpan, frameDuration) {
// A long PTS span is strong evidence only when a sane frame-count
// estimate contradicts it. Malformed frame rates can produce finite but
// unusable estimates and must not veto an otherwise valid packet span.
best = 0
}
if durationIsReasonable(frameDuration) && frameDuration > best {
best = frameDuration
}
if best <= 0 {
return 0
+301
View File
@@ -96,6 +96,241 @@ func TestDurationFromProbeMetadataKeepsLongAudioDurationInSeconds(t *testing.T)
}
}
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoDuration(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
Duration: "182930.275000",
Size: "77507139196",
},
Streams: []ffprobeStream{{
CodecType: "video",
Duration: "182930.196000",
AvgFrameRate: "24/1",
}},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 182930 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
}
}
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithOrdinaryStartTime(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
StartTime: "30.000000",
Duration: "182930.275000",
Size: "77507139196",
},
Streams: []ffprobeStream{{
CodecType: "video",
StartTime: "30.000000",
Duration: "182930.196000",
}},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 182930 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
}
}
func TestDurationFromProbeMetadataKeepsCorroboratedLongVideoWithMaterialStartTime(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
StartTime: "300.000000",
Duration: "182930.275000",
Size: "77507139196",
},
Streams: []ffprobeStream{{
CodecType: "video",
StartTime: "300.000000",
Duration: "182930.196000",
}},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 182930 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 182930, true", got, ok)
}
}
func TestDurationFromProbeMetadataNormalizesOffsetBeforeCorroboratingLongVideo(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
StartTime: "180000.000000",
Duration: "182930.275000",
Size: "77507139196",
},
Streams: []ffprobeStream{{
CodecType: "video",
StartTime: "180000.000000",
Duration: "182930.196000",
}},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 2930 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 2930, true", got, ok)
}
}
func TestDurationFromProbeMetadataNormalizesCorroboratedLongOffsetSpan(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
StartTime: "180000.000000",
Duration: "350000.275000",
Size: "77507139196",
},
Streams: []ffprobeStream{{
CodecType: "video",
StartTime: "200000.000000",
Duration: "370000.196000",
}},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 170000 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok)
}
}
func TestDurationFromProbeMetadataNormalizesMatchingLongAbsoluteEndTimestamps(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
StartTime: "180000.000000",
Duration: "350000.275000",
Size: "77507139196",
},
Streams: []ffprobeStream{{
CodecType: "video",
StartTime: "180000.000000",
Duration: "350000.196000",
}},
}
got, ok := durationFromProbeMetadata(raw)
if !ok || got != 170000 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 170000, true", got, ok)
}
}
func TestDurationFromProbeMetadataRejectsDisagreeingAbsoluteEndSpans(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{
StartTime: "180000.000000",
Duration: "350000.275000",
Size: "77507139196",
},
Streams: []ffprobeStream{{
CodecType: "video",
StartTime: "180000.000000",
Duration: "350300.196000",
}},
}
got, ok := durationFromProbeMetadata(raw)
if ok || got != 0 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
}
func TestDurationFromProbeMetadataRejectsUncorroboratedLongVideoDuration(t *testing.T) {
t.Parallel()
tests := []struct {
name string
formatDuration string
streamDuration string
}{
{name: "missing stream duration", formatDuration: "182930.275000"},
{name: "disagreeing durations", formatDuration: "182930.275000", streamDuration: "150000.000000"},
{name: "beyond validated limit", formatDuration: "1000001.000000", streamDuration: "1000001.000000"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{Duration: tc.formatDuration, Size: "77507139196"},
Streams: []ffprobeStream{{
CodecType: "video",
Duration: tc.streamDuration,
}},
}
got, ok := durationFromProbeMetadata(raw)
if ok || got != 0 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
})
}
}
func TestDurationFromProbeMetadataDoesNotUseSecondaryVideoForCorroboration(t *testing.T) {
t.Parallel()
raw := &ffprobeOutput{
Format: ffprobeFormat{Duration: "182930.275000", Size: "77507139196"},
Streams: []ffprobeStream{
{CodecType: "video", Duration: "150000.000000"},
{CodecType: "video", Duration: "182930.196000"},
},
}
got, ok := durationFromProbeMetadata(raw)
if ok || got != 0 {
t.Fatalf("durationFromProbeMetadata() = %d, %v; want 0, false", got, ok)
}
}
func TestProbeFileSkipsPacketScanForCorroboratedLongVideo(t *testing.T) {
t.Parallel()
tempDir := t.TempDir()
ffprobePath := filepath.Join(tempDir, "ffprobe")
packetScanMarker := filepath.Join(tempDir, "packet-scan-called")
script := `#!/bin/sh
case " $* " in
*" -show_format "*)
printf '%s\n' '{"format":{"duration":"182930.275000","size":"77507139196"},"streams":[{"codec_type":"video","duration":"182930.196000","avg_frame_rate":"24/1"}]}'
;;
*)
: > "` + packetScanMarker + `"
exit 1
;;
esac
`
if err := os.WriteFile(ffprobePath, []byte(script), 0o755); err != nil {
t.Fatalf("writing fake ffprobe: %v", err)
}
probe, err := ProbeFile(context.Background(), ffprobePath, "long.mp4")
if err != nil {
t.Fatalf("ProbeFile() returned error: %v", err)
}
if probe.Duration != 182930 {
t.Fatalf("ProbeFile() duration = %d, want 182930", probe.Duration)
}
if _, err := os.Stat(packetScanMarker); !os.IsNotExist(err) {
t.Fatalf("packet fallback ran for corroborated metadata; stat error = %v", err)
}
}
func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) {
t.Parallel()
@@ -106,6 +341,72 @@ func TestEstimateVideoPacketDurationUsesPacketSpan(t *testing.T) {
}
}
func TestEstimateVideoPacketDurationKeepsValidatedLongDuration(t *testing.T) {
t.Parallel()
packets := strings.NewReader("0.000000\n182930.196000\n")
got := estimateVideoPacketDuration(packets, "")
if got != 182930 {
t.Fatalf("estimateVideoPacketDuration() = %d, want 182930", got)
}
}
func TestEstimateVideoPacketDurationRejectsDurationBeyondValidatedLimit(t *testing.T) {
t.Parallel()
packets := strings.NewReader("0.000000\n1000001.000000\n")
got := estimateVideoPacketDuration(packets, "")
if got != 0 {
t.Fatalf("estimateVideoPacketDuration() = %d, want 0", got)
}
}
func TestEstimateVideoPacketDurationKeepsOrdinaryCapForFrameRateEstimate(t *testing.T) {
t.Parallel()
var packets strings.Builder
for range 900 {
packets.WriteString("3.022000\n")
}
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "1/1000")
if got != 0 {
t.Fatalf("estimateVideoPacketDuration() = %d, want 0", got)
}
}
func TestEstimateVideoPacketDurationIgnoresUnusableFrameEstimateForLongSpan(t *testing.T) {
t.Parallel()
var packets strings.Builder
packets.WriteString("0.000000\n")
for range 898 {
packets.WriteString("5.000000\n")
}
packets.WriteString("182930.196000\n")
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "1/1000")
if got != 182930 {
t.Fatalf("estimateVideoPacketDuration() = %d, want 182930", got)
}
}
func TestEstimateVideoPacketDurationRejectsOutlierSpanWhenFrameCountDisagrees(t *testing.T) {
t.Parallel()
var packets strings.Builder
packets.WriteString("0.000000\n")
for range 298 {
packets.WriteString("5.000000\n")
}
packets.WriteString("500000.000000\n")
got := estimateVideoPacketDuration(strings.NewReader(packets.String()), "30/1")
if got != 10 {
t.Fatalf("estimateVideoPacketDuration() = %d, want 10", got)
}
}
func TestEstimateVideoPacketDurationUsesFrameCountForCollapsedTimestamps(t *testing.T) {
t.Parallel()
@@ -102,6 +102,44 @@ func TestNeedsCriticalProbeRepair_ImplausiblyShortLargeVideoRepairs(t *testing.T
}
}
func TestNeedsCriticalProbeRepair_LongVideoConverges(t *testing.T) {
now := time.Now().UTC()
f := &models.MediaFile{
ProbeSource: "local",
ProbeUpdatedAt: &now,
FileSize: 77_507_139_196,
Duration: 182930,
Container: "mp4",
CodecAudio: "aac",
AudioTracks: []models.AudioTrack{{Language: "eng"}},
CodecVideo: "h264",
Resolution: "1080p",
VideoTracks: []models.VideoTrack{{Codec: "h264", ColorRange: "unknown"}},
Chapters: []models.MediaChapter{},
}
if NeedsCriticalProbeRepair(f) {
t.Fatal("an accepted long video must not need request-time probe repair")
}
scanFile := &scanStateFile{
ProbeSource: f.ProbeSource,
ProbeUpdatedAt: f.ProbeUpdatedAt,
FileSize: f.FileSize,
Duration: f.Duration,
Container: f.Container,
CodecVideo: f.CodecVideo,
CodecAudio: f.CodecAudio,
Resolution: f.Resolution,
HasVideoTracks: true,
HasAudioTracks: true,
HasChapters: true,
}
if needsCriticalProbeRepairScanState(scanFile) {
t.Fatal("an accepted long video must not be reprobed on subsequent library scans")
}
}
// A short duration re-derived by the fixed parser (packet scan) is
// authoritative: re-flagging it would reprobe genuinely short clips on every
// playback decision forever.
+7 -1
View File
@@ -846,7 +846,13 @@ func (s *Server) handleManifest(w http.ResponseWriter, r *http.Request) {
s.touchSession(sessionID)
}
manifest, err := session.BuildPlaybackManifest("segment/", r.URL.RawQuery)
var manifest []byte
var err error
if r.URL.Query().Get(playback.SourceTimelineQueryParam) == "1" {
manifest, err = session.BuildSourceAlignedPlaybackManifest("segment/", r.URL.RawQuery)
} else {
manifest, err = session.BuildPlaybackManifest("segment/", r.URL.RawQuery)
}
if err != nil {
slog.ErrorContext(r.Context(), "get manifest", "component", "transcodenode", "error", err, "session", sessionID, "playback_session_id", sessionID)
http.Error(w, "manifest not ready", http.StatusServiceUnavailable)
+81 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/Silo-Server/silo-server/internal/historyimport"
@@ -248,6 +249,10 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
}
rows := batch.Rows
result := ImportListResult{Found: len(rows), Warnings: append([]string{}, batch.Warnings...)}
existingByRemoteKey, err := s.listItemStatesByRemoteKey(ctx, conn.ID, b.kind, rows)
if err != nil {
return result, err
}
seenRemoteKeys := make(map[string]bool, len(rows))
states := make([]ListItemState, 0, len(rows))
// orderedIDs records matched local ids in the order the provider returned
@@ -255,6 +260,12 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
// sort locally.
orderedIDs := make([]string, 0, len(rows))
for _, row := range rows {
if row.Removed {
if err := s.applyRemoteListTombstone(ctx, conn, store, b, row, existingByRemoteKey, &result); err != nil {
return result, err
}
continue
}
match, reason, err := s.matcher.Match(ctx, row.HistoryRecord())
if err != nil {
return result, err
@@ -296,10 +307,14 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
if err := s.repo.UpsertListItemStates(ctx, states); err != nil {
return result, err
}
if err := s.reconcileMissingRemoteListItems(ctx, conn, store, b, seenRemoteKeys, &result); err != nil {
return result, err
if !batch.Incremental {
if err := s.reconcileMissingRemoteListItems(ctx, conn, store, b, seenRemoteKeys, &result); err != nil {
return result, err
}
}
if b.applyOrder != nil && b.orderEnabled != nil && b.capOrder != nil &&
// An incremental response is only a delta; replacing the whole local order
// with that subset would discard every item absent from the delta.
if !batch.Incremental && b.applyOrder != nil && b.orderEnabled != nil && b.capOrder != nil &&
b.orderEnabled(conn) && b.capOrder(provider.Capabilities()) {
if err := b.applyOrder(ctx, store, conn.ProfileID, orderedIDs); err != nil {
return result, err
@@ -315,6 +330,69 @@ func (s *Service) importList(ctx context.Context, conn Connection, cfg ServerCon
return result, nil
}
func (s *Service) listItemStatesByRemoteKey(
ctx context.Context,
connectionID string,
kind ListKind,
rows []RemoteFavorite,
) (map[string]ListItemState, error) {
hasTombstone := false
for _, row := range rows {
if row.Removed {
hasTombstone = true
break
}
}
if !hasTombstone {
return nil, nil
}
states, err := s.repo.ListListItemStates(ctx, connectionID, kind)
if err != nil {
return nil, err
}
byRemoteKey := make(map[string]ListItemState, len(states))
for _, state := range states {
if key := strings.TrimSpace(state.ProviderItemKey); key != "" {
byRemoteKey[key] = state
}
}
return byRemoteKey, nil
}
func (s *Service) applyRemoteListTombstone(
ctx context.Context,
conn Connection,
store userstore.UserStore,
b listBinding,
row RemoteFavorite,
existingByRemoteKey map[string]ListItemState,
result *ImportListResult,
) error {
key := strings.TrimSpace(row.ProviderItemKey)
state, ok := existingByRemoteKey[key]
if key == "" || !ok {
result.Warnings = append(result.Warnings, "watch sync provider returned an unknown list removal")
return nil
}
now := s.now()
if b.removalsEnabled(conn) && state.LocalPresent {
if err := b.localRemove(ctx, store, conn.ProfileID, state.MediaItemID); err != nil {
return err
}
if err := s.repo.MarkListItemLocalRemoved(ctx, conn.ID, b.kind, state.MediaItemID, now); err != nil {
return err
}
result.Removed++
state.LocalPresent = false
}
if err := s.repo.MarkListItemRemoteRemoved(ctx, conn.ID, b.kind, state.MediaItemID, now); err != nil {
return err
}
state.RemotePresent = false
existingByRemoteKey[key] = state
return nil
}
func (s *Service) reconcileMissingRemoteListItems(ctx context.Context, conn Connection, store userstore.UserStore, b listBinding, seenRemoteKeys map[string]bool, result *ImportListResult) error {
states, err := s.repo.ListListItemStates(ctx, conn.ID, b.kind)
if err != nil {
File diff suppressed because it is too large Load Diff
+538
View File
@@ -0,0 +1,538 @@
package watchsync
import (
"context"
"errors"
"fmt"
"strings"
"time"
pluginv1 "github.com/Silo-Server/silo-plugin-sdk/pkg/pluginproto/silo/plugin/v1"
"github.com/Silo-Server/silo-server/internal/historyimport"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
pluginWatchedCursorKey = "plugin.remote.watched"
pluginProgressCursorKey = "plugin.remote.progress"
pluginFavoritesCursorKey = "plugin.remote.favorites"
pluginWatchlistCursorKey = "plugin.remote.watchlist"
maxRemoteStatePages = 10_000
maxRemoteStateItems = 100_000
)
type pluginRemoteTraversal struct {
items []*pluginv1.WatchSyncRemoteState
nextCursor string
completeSnapshot bool
warnings []string
}
func (p *PluginProvider) FetchWatched(
ctx context.Context,
cfg ServerConfig,
conn Connection,
) ([]RemoteWatch, error) {
batch, err := p.FetchWatchedBatch(ctx, cfg, conn)
return batch.Rows, err
}
func (p *PluginProvider) FetchWatchedBatch(
ctx context.Context,
_ ServerConfig,
conn Connection,
) (WatchedImportBatch, error) {
traversal, err := p.listRemoteState(ctx, conn, pluginWatchedCursorKey,
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHED)
if err != nil {
return WatchedImportBatch{}, err
}
batch := WatchedImportBatch{
UpdatedCursors: cursorUpdate(pluginWatchedCursorKey, traversal.nextCursor),
Warnings: traversal.warnings,
}
for _, state := range traversal.items {
if state.GetWatched() == nil {
continue
}
row, err := remoteWatchFromProto(p.Key(), state)
if err != nil {
batch.Warnings = append(batch.Warnings, err.Error())
continue
}
batch.Rows = append(batch.Rows, row)
}
return batch, nil
}
func (p *PluginProvider) FetchProgress(
ctx context.Context,
cfg ServerConfig,
conn Connection,
) ([]RemoteProgress, error) {
batch, err := p.FetchProgressBatch(ctx, cfg, conn)
return batch.Rows, err
}
func (p *PluginProvider) FetchProgressBatch(
ctx context.Context,
_ ServerConfig,
conn Connection,
) (ProgressImportBatch, error) {
traversal, err := p.listRemoteState(ctx, conn, pluginProgressCursorKey,
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_PROGRESS)
if err != nil {
return ProgressImportBatch{}, err
}
batch := ProgressImportBatch{
UpdatedCursors: cursorUpdate(pluginProgressCursorKey, traversal.nextCursor),
Warnings: traversal.warnings,
}
for _, state := range traversal.items {
if state.GetProgress() == nil {
continue
}
row, err := remoteProgressFromProto(p.Key(), state)
if err != nil {
batch.Warnings = append(batch.Warnings, err.Error())
continue
}
batch.Rows = append(batch.Rows, row)
}
return batch, nil
}
func (p *PluginProvider) FetchFavorites(
ctx context.Context,
cfg ServerConfig,
conn Connection,
) ([]RemoteFavorite, error) {
batch, err := p.FetchFavoritesBatch(ctx, cfg, conn)
return batch.Rows, err
}
func (p *PluginProvider) FetchFavoritesBatch(
ctx context.Context,
_ ServerConfig,
conn Connection,
) (FavoriteImportBatch, error) {
return p.fetchListState(ctx, conn, pluginFavoritesCursorKey,
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE)
}
func (p *PluginProvider) FetchWatchlist(
ctx context.Context,
cfg ServerConfig,
conn Connection,
) ([]RemoteFavorite, error) {
batch, err := p.FetchWatchlistBatch(ctx, cfg, conn)
return batch.Rows, err
}
func (p *PluginProvider) FetchWatchlistBatch(
ctx context.Context,
_ ServerConfig,
conn Connection,
) (FavoriteImportBatch, error) {
return p.fetchListState(ctx, conn, pluginWatchlistCursorKey,
pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST)
}
func (p *PluginProvider) fetchListState(
ctx context.Context,
conn Connection,
cursorKey string,
kind pluginv1.WatchSyncRemoteStateKind,
) (FavoriteImportBatch, error) {
traversal, err := p.listRemoteState(ctx, conn, cursorKey, kind)
if err != nil {
return FavoriteImportBatch{}, err
}
batch := FavoriteImportBatch{
UpdatedCursors: cursorUpdate(cursorKey, traversal.nextCursor),
Warnings: traversal.warnings,
Incremental: !traversal.completeSnapshot,
}
for _, state := range traversal.items {
var listed *pluginv1.WatchSyncRemoteListState
if kind == pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_FAVORITE {
listed = state.GetFavorite()
} else {
listed = state.GetWatchlist()
}
if listed == nil {
continue
}
row, err := remoteFavoriteFromProto(p.Key(), state, listed)
if err != nil {
batch.Warnings = append(batch.Warnings, err.Error())
continue
}
batch.Rows = append(batch.Rows, row)
}
return batch, nil
}
func (p *PluginProvider) listRemoteState(
ctx context.Context,
conn Connection,
cursorKey string,
kind pluginv1.WatchSyncRemoteStateKind,
) (pluginRemoteTraversal, error) {
client, err := p.resolveClient(ctx, p.installationID, p.capabilityID)
if err != nil {
return pluginRemoteTraversal{}, watchSyncUnavailableError()
}
cursor := conn.SyncCursors[cursorKey]
pageToken := ""
seenPageTokens := make(map[string]struct{})
result := pluginRemoteTraversal{}
snapshotSet := false
for page := 0; page < maxRemoteStatePages; page++ {
authContext, err := p.authenticatedContext(ctx, conn)
if err != nil {
return pluginRemoteTraversal{}, err
}
response, err := client.ListRemoteState(ctx, &pluginv1.WatchSyncListRemoteStateRequest{
Context: authContext,
Cursor: cursor,
PageToken: pageToken,
PageSize: int32(max(1, p.ExportBatchSize())),
StateKinds: []pluginv1.WatchSyncRemoteStateKind{kind},
})
if err != nil {
return pluginRemoteTraversal{}, watchSyncRPCError()
}
if response.GetUpdatedCredentials() != nil {
conn, err = p.persistUpdatedCredentials(ctx, conn, response.GetUpdatedCredentials())
if err != nil {
return pluginRemoteTraversal{}, err
}
}
if err := watchSyncFaultError(p.Key(), response.GetFault(), conn.AccessToken, conn.RefreshToken); err != nil {
return pluginRemoteTraversal{}, err
}
if kind == pluginv1.WatchSyncRemoteStateKind_WATCH_SYNC_REMOTE_STATE_KIND_WATCHLIST &&
p.descriptor.GetProvidesWatchlistOrder() && !response.GetCompleteSnapshot() {
return pluginRemoteTraversal{}, errors.New("watch sync plugin returned an incremental traversal for an ordered watchlist")
}
if snapshotSet && result.completeSnapshot != response.GetCompleteSnapshot() {
return pluginRemoteTraversal{}, errors.New("watch sync plugin changed snapshot mode during pagination")
}
result.completeSnapshot = response.GetCompleteSnapshot()
snapshotSet = true
items := response.GetItems()
if len(items) > maxRemoteStateItems-len(result.items) {
return pluginRemoteTraversal{}, errors.New("watch sync plugin exceeded the remote-state item limit")
}
result.items = append(result.items, items...)
nextPage := strings.TrimSpace(response.GetNextPageToken())
if nextPage == "" {
result.nextCursor = response.GetNextCursor()
return result, nil
}
if strings.TrimSpace(response.GetNextCursor()) != "" {
return pluginRemoteTraversal{}, errors.New("watch sync plugin returned a durable cursor before the final page")
}
if _, duplicate := seenPageTokens[nextPage]; duplicate {
return pluginRemoteTraversal{}, errors.New("watch sync plugin repeated a page token")
}
seenPageTokens[nextPage] = struct{}{}
pageToken = nextPage
}
return pluginRemoteTraversal{}, errors.New("watch sync plugin exceeded the remote-state page limit")
}
func (p *PluginProvider) RemoveHistory(
ctx context.Context,
_ ServerConfig,
conn Connection,
plays []LocalPlay,
) (ExportResult, error) {
result := ExportResult{Failed: make(map[string]string)}
events := make([]*pluginv1.WatchSyncEvent, 0, len(plays))
keys := make([]string, 0, len(plays))
for _, play := range plays {
event := watchEventFromLocalPlay(play, pluginv1.WatchSyncOrigin_WATCH_SYNC_ORIGIN_MANUAL)
event.EventId = "unwatched:" + play.HistoryID
event.Operation = pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_MARK_UNWATCHED
event.ProviderItemKey = play.ProviderItemKey
if !p.supportsMedia(event.GetMedia().GetMediaType()) {
result.Failed[play.HistoryID] = unsupportedWatchSyncMediaMessage(event.GetMedia().GetMediaType())
continue
}
events = append(events, event)
keys = append(keys, play.HistoryID)
}
applied, err := p.applyPluginEvents(ctx, conn, events, keys)
return mergeExportFailures(applied, result.Failed), err
}
func (p *PluginProvider) ExportFavorites(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_FAVORITE)
}
func (p *PluginProvider) RemoveFavorites(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_FAVORITE)
}
func (p *PluginProvider) ExportWatchlist(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST)
}
func (p *PluginProvider) RemoveWatchlist(ctx context.Context, _ ServerConfig, conn Connection, items []LocalFavorite) (ExportResult, error) {
return p.applyListEvents(ctx, conn, items, pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_REMOVE_FROM_WATCHLIST)
}
func (p *PluginProvider) applyListEvents(
ctx context.Context,
conn Connection,
items []LocalFavorite,
operation pluginv1.WatchSyncOperation,
) (ExportResult, error) {
result := ExportResult{Failed: make(map[string]string)}
events := make([]*pluginv1.WatchSyncEvent, 0, len(items))
keys := make([]string, 0, len(items))
for _, item := range items {
media := mediaFromIdentity(item.MediaItemID, item.Kind, item.Title, item.Year,
item.IMDbID, item.TMDBID, item.TVDBID, "", 0,
item.SeriesIMDbID, item.SeriesTMDBID, item.SeriesTVDBID, 0, 0)
if !p.supportsMedia(media.GetMediaType()) {
result.Failed[item.MediaItemID] = unsupportedWatchSyncMediaMessage(media.GetMediaType())
continue
}
event := &pluginv1.WatchSyncEvent{
EventId: fmt.Sprintf("%s:%s", operation.String(), item.MediaItemID),
Operation: operation,
Origin: pluginv1.WatchSyncOrigin_WATCH_SYNC_ORIGIN_MANUAL,
OccurredAt: timestampOrNil(item.FavoritedAt),
Media: media,
ProviderItemKey: item.ProviderItemKey,
}
if operation == pluginv1.WatchSyncOperation_WATCH_SYNC_OPERATION_ADD_TO_WATCHLIST {
position := int32(len(events))
event.ListPosition = &position
}
events = append(events, event)
keys = append(keys, item.MediaItemID)
}
applied, err := p.applyPluginEvents(ctx, conn, events, keys)
return mergeExportFailures(applied, result.Failed), err
}
func (p *PluginProvider) applyPluginEvents(
ctx context.Context,
conn Connection,
events []*pluginv1.WatchSyncEvent,
keys []string,
) (ExportResult, error) {
result, _, err := p.applyPluginEventsDetailed(ctx, conn, events, keys)
return result, err
}
func (p *PluginProvider) applyPluginEventsDetailed(
ctx context.Context,
conn Connection,
events []*pluginv1.WatchSyncEvent,
keys []string,
) (ExportResult, map[string]pluginv1.WatchSyncApplyStatus, error) {
result := ExportResult{Failed: make(map[string]string)}
statuses := make(map[string]pluginv1.WatchSyncApplyStatus, len(events))
if len(events) == 0 {
return result, statuses, nil
}
client, err := p.resolveClient(ctx, p.installationID, p.capabilityID)
if err != nil {
return result, statuses, watchSyncUnavailableError()
}
for offset := 0; offset < len(events); offset += max(1, p.ExportBatchSize()) {
end := min(len(events), offset+max(1, p.ExportBatchSize()))
authContext, err := p.authenticatedContext(ctx, conn)
if err != nil {
return result, statuses, err
}
response, err := client.ApplyEvents(ctx, &pluginv1.WatchSyncApplyEventsRequest{
Context: authContext,
Events: events[offset:end],
})
if err != nil {
return result, statuses, watchSyncRPCError()
}
if response.GetUpdatedCredentials() != nil {
conn, err = p.persistUpdatedCredentials(ctx, conn, response.GetUpdatedCredentials())
if err != nil {
return result, statuses, err
}
}
if err := watchSyncFaultError(p.Key(), response.GetFault(), conn.AccessToken, conn.RefreshToken); err != nil {
return result, statuses, err
}
for index, event := range events[offset:end] {
key := keys[offset+index]
apply := resultForEvent(response.GetResults(), event.GetEventId())
statuses[key] = apply.GetStatus()
switch apply.GetStatus() {
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_APPLIED,
pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_NO_CHANGE:
result.Sent = append(result.Sent, key)
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_REJECTED:
result.NotFound = append(result.NotFound, key)
case pluginv1.WatchSyncApplyStatus_WATCH_SYNC_APPLY_STATUS_RETRY:
fault := apply.GetFault()
if fault.GetCode() == pluginv1.WatchSyncFaultCode_WATCH_SYNC_FAULT_CODE_RATE_LIMITED {
retry := time.Duration(0)
if fault.GetRetryAfter() != nil {
retry = fault.GetRetryAfter().AsDuration()
}
return result, statuses, RateLimitedError{Provider: p.Key(), RetryAfter: retry}
}
result.Failed[key] = safeApplyMessage(apply, conn.AccessToken, conn.RefreshToken)
default:
result.Failed[key] = "watch sync plugin omitted a valid event result"
}
}
}
return result, statuses, nil
}
func mergeExportFailures(result ExportResult, failures map[string]string) ExportResult {
if result.Failed == nil {
result.Failed = make(map[string]string, len(failures))
}
for key, message := range failures {
result.Failed[key] = message
}
return result
}
func remoteWatchFromProto(provider string, state *pluginv1.WatchSyncRemoteState) (RemoteWatch, error) {
identity, err := remoteIdentityFromProto(state)
if err != nil {
return RemoteWatch{}, err
}
watched := state.GetWatched()
if watched.GetPlayCount() < 1 {
return RemoteWatch{}, errors.New("watch sync plugin returned watched state with no plays")
}
return RemoteWatch{
Provider: provider, ProviderItemKey: state.GetProviderItemKey(),
Kind: identity.kind, Title: identity.title, Year: identity.year,
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
SeasonNumber: identity.season, EpisodeNumber: identity.episode,
PlayCount: int(watched.GetPlayCount()), LastWatchedAt: timePointer(watched.GetLastWatchedAt()),
}, nil
}
func remoteProgressFromProto(provider string, state *pluginv1.WatchSyncRemoteState) (RemoteProgress, error) {
identity, err := remoteIdentityFromProto(state)
if err != nil {
return RemoteProgress{}, err
}
progress := state.GetProgress()
if progress.GetProgressPercent() < 0 || progress.GetProgressPercent() >= 100 {
return RemoteProgress{}, errors.New("watch sync plugin returned progress outside [0,100)")
}
pausedAt := timePointer(progress.GetPausedAt())
if pausedAt == nil {
return RemoteProgress{}, errors.New("watch sync plugin returned progress without a valid timestamp")
}
return RemoteProgress{
Provider: provider, ProviderItemKey: state.GetProviderItemKey(),
Kind: identity.kind, Title: identity.title, Year: identity.year,
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
SeasonNumber: identity.season, EpisodeNumber: identity.episode,
ProgressPercent: progress.GetProgressPercent(), PausedAt: *pausedAt,
}, nil
}
func remoteFavoriteFromProto(provider string, state *pluginv1.WatchSyncRemoteState, listed *pluginv1.WatchSyncRemoteListState) (RemoteFavorite, error) {
if state == nil || listed == nil {
return RemoteFavorite{}, errors.New("watch sync plugin returned list state without identity")
}
providerItemKey := strings.TrimSpace(state.GetProviderItemKey())
if listed.GetRemoved() {
if providerItemKey == "" {
return RemoteFavorite{}, errors.New("watch sync plugin returned a list tombstone without provider identity")
}
return RemoteFavorite{
Provider: provider,
ProviderItemKey: providerItemKey,
Removed: true,
}, nil
}
identity, err := remoteIdentityFromProto(state)
if err != nil {
return RemoteFavorite{}, err
}
listedAt := time.Now().UTC()
if value := timePointer(listed.GetListedAt()); value != nil {
listedAt = *value
}
return RemoteFavorite{
Provider: provider, ProviderItemKey: providerItemKey,
Kind: identity.kind, Title: identity.title, Year: identity.year,
IMDbID: identity.imdbID, TMDBID: identity.tmdbID, TVDBID: identity.tvdbID,
SeriesTitle: identity.seriesTitle, SeriesYear: identity.seriesYear,
SeriesIMDbID: identity.seriesIMDbID, SeriesTMDBID: identity.seriesTMDBID, SeriesTVDBID: identity.seriesTVDBID,
SeasonNumber: identity.season, EpisodeNumber: identity.episode, FavoritedAt: listedAt,
}, nil
}
type remoteIdentity struct {
kind, title, imdbID, tmdbID, tvdbID string
seriesTitle, seriesIMDbID, seriesTMDBID, seriesTVDBID string
year, seriesYear, season, episode int
}
func remoteIdentityFromProto(state *pluginv1.WatchSyncRemoteState) (remoteIdentity, error) {
if state == nil || state.GetMedia() == nil || strings.TrimSpace(state.GetProviderItemKey()) == "" {
return remoteIdentity{}, errors.New("watch sync plugin returned remote state without identity")
}
media := state.GetMedia()
var kind string
switch media.GetMediaType() {
case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_MOVIE:
kind = historyimport.KindMovie
case pluginv1.WatchSyncMediaType_WATCH_SYNC_MEDIA_TYPE_EPISODE:
kind = historyimport.KindEpisode
default:
return remoteIdentity{}, errors.New("watch sync plugin returned unsupported remote media")
}
return remoteIdentity{
kind: kind, title: media.GetTitle(), year: int(media.GetYear()),
imdbID: media.GetExternalIds()["imdb"], tmdbID: media.GetExternalIds()["tmdb"], tvdbID: media.GetExternalIds()["tvdb"],
seriesTitle: media.GetSeriesTitle(), seriesYear: int(media.GetSeriesYear()),
seriesIMDbID: media.GetSeriesExternalIds()["imdb"], seriesTMDBID: media.GetSeriesExternalIds()["tmdb"], seriesTVDBID: media.GetSeriesExternalIds()["tvdb"],
season: int(media.GetSeasonNumber()), episode: int(media.GetEpisodeNumber()),
}, nil
}
func cursorUpdate(key, value string) map[string]string {
if strings.TrimSpace(value) == "" {
return nil
}
return map[string]string{key: value}
}
func timePointer(value interface {
AsTime() time.Time
CheckValid() error
}) *time.Time {
if value == nil || value.CheckValid() != nil {
return nil
}
result := value.AsTime()
return &result
}
func timestampOrNil(value time.Time) *timestamppb.Timestamp {
if value.IsZero() {
return nil
}
return timestamppb.New(value)
}
File diff suppressed because it is too large Load Diff
+42 -2
View File
@@ -6,6 +6,12 @@ import (
"sync"
)
const providerSourcePlugin = "plugin"
type sourcedProvider interface {
ProviderSource() string
}
type Registry struct {
mu sync.RWMutex
providers map[string]Provider
@@ -35,6 +41,36 @@ func (r *Registry) Register(provider Provider) error {
return nil
}
// ReplacePluginProviders atomically replaces providers discovered from enabled
// plugin installations while preserving built-in providers. A plugin may not
// shadow a built-in key, and duplicate plugin keys reject the entire reload.
func (r *Registry) ReplacePluginProviders(providers []Provider) error {
if r == nil {
return fmt.Errorf("watchsync registry is nil")
}
r.mu.Lock()
defer r.mu.Unlock()
next := make(map[string]Provider, len(r.providers)+len(providers))
for key, provider := range r.providers {
if sourced, ok := provider.(sourcedProvider); ok && sourced.ProviderSource() == providerSourcePlugin {
continue
}
next[key] = provider
}
for _, provider := range providers {
if provider == nil || provider.Key() == "" {
return fmt.Errorf("watchsync plugin provider and key are required")
}
if _, exists := next[provider.Key()]; exists {
return fmt.Errorf("watchsync plugin provider key %q conflicts with another provider", provider.Key())
}
next[provider.Key()] = provider
}
r.providers = next
return nil
}
func (r *Registry) Get(key string) (Provider, bool) {
if r == nil {
return nil, false
@@ -55,11 +91,15 @@ func (r *Registry) List() []ProviderSummary {
summaries := make([]ProviderSummary, 0, len(r.providers))
for key, provider := range r.providers {
summaries = append(summaries, ProviderSummary{
summary := ProviderSummary{
Key: key,
DisplayName: provider.DisplayName(),
Capabilities: provider.Capabilities(),
})
}
if configurable, ok := provider.(connectionConfigProvider); ok {
summary.ConnectionConfigSchema = configurable.ConnectionConfigSchema()
}
summaries = append(summaries, summary)
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].Key < summaries[j].Key
+36
View File
@@ -24,6 +24,42 @@ func TestRegistryRejectsDuplicateProvider(t *testing.T) {
}
}
type stubPluginProvider struct{ stubProvider }
func (stubPluginProvider) ProviderSource() string { return providerSourcePlugin }
func TestRegistryReplacePluginProviders(t *testing.T) {
registry := NewRegistry()
if err := registry.Register(stubProvider{key: "trakt"}); err != nil {
t.Fatal(err)
}
if err := registry.ReplacePluginProviders([]Provider{stubPluginProvider{stubProvider{key: testPluginCapabilityID}}}); err != nil {
t.Fatal(err)
}
if _, ok := registry.Get("anilist"); !ok {
t.Fatal("plugin provider was not registered")
}
if err := registry.ReplacePluginProviders([]Provider{stubPluginProvider{stubProvider{key: "simkl-plugin"}}}); err != nil {
t.Fatal(err)
}
if _, ok := registry.Get("anilist"); ok {
t.Fatal("stale plugin provider was not removed")
}
if _, ok := registry.Get("trakt"); !ok {
t.Fatal("built-in provider was removed")
}
}
func TestRegistryReplacePluginProvidersRejectsBuiltinCollision(t *testing.T) {
registry := NewRegistry()
if err := registry.Register(stubProvider{key: "trakt"}); err != nil {
t.Fatal(err)
}
if err := registry.ReplacePluginProviders([]Provider{stubPluginProvider{stubProvider{key: "trakt"}}}); err == nil {
t.Fatal("expected built-in collision to fail")
}
}
func TestRegistryListWithCapabilities(t *testing.T) {
registry := NewRegistry()
+244 -9
View File
@@ -34,7 +34,9 @@ type Repository interface {
ListListEventConnections(ctx context.Context, userID int, profileID string, list ListKind) ([]Connection, error)
UpsertHistoryExports(ctx context.Context, exports []HistoryExport) error
ListPendingHistoryExports(ctx context.Context, connectionID string, limit int) ([]HistoryExport, error)
ListPendingHistoryExportsByHistoryIDs(ctx context.Context, connectionID string, historyIDs []string, limit int) ([]HistoryExport, error)
MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error
MarkHistoryExportSatisfiedByScrobble(ctx context.Context, connectionID string, historyID string) error
UpsertListItemStates(ctx context.Context, states []ListItemState) error
ListListItemStates(ctx context.Context, connectionID string, kind ListKind) ([]ListItemState, error)
ListPendingListItemExports(ctx context.Context, connectionID string, kind ListKind, limit int) ([]ListItemState, error)
@@ -50,6 +52,8 @@ type Repository interface {
FailConfirmedScrobbleStop(ctx context.Context, playbackSessionID string, connectionID string, progress float64, historyID string, claimVersion time.Time, lastError string) error
UpdateScrobbleSession(ctx context.Context, playbackSessionID string, connectionID string, action string, progress float64, historyID string, lastError string, stopSentAt *time.Time) error
ListOpenScrobbleSessions(ctx context.Context) ([]ScrobbleSession, error)
ListPendingScrobbleReconciliations(ctx context.Context) ([]ScrobbleSession, error)
MarkScrobbleHistoryReconciled(ctx context.Context, playbackSessionID, connectionID string, reconciledAt time.Time) error
}
type confirmedStopPreparation uint8
@@ -67,7 +71,7 @@ var errConfirmedStopClaimLost = errors.New("confirmed scrobble stop claim lost")
// it across every read query keeps the column set and scan order in lockstep.
const connectionColumns = `
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
access_token, refresh_token, token_expires_at, plugin_credentials, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
import_watchlist_enabled, export_watchlist_enabled, sync_watchlist_removals_enabled,
@@ -115,6 +119,10 @@ func TokenAAD(column, provider string, userID int, profileID string) string {
return secret.RowAAD("watch_provider_connections", column, provider+":"+strconv.Itoa(userID)+":"+profileID)
}
func authStateAAD(provider string, userID int, profileID string) string {
return secret.RowAAD("watch_provider_auth_sessions", "device_code", provider+":"+strconv.Itoa(userID)+":"+profileID)
}
func (r *PostgresRepository) GetServerSetting(ctx context.Context, key string) (string, error) {
var value string
err := r.pool.QueryRow(ctx, `SELECT value FROM server_settings WHERE key = $1`, key).Scan(&value)
@@ -138,6 +146,10 @@ func (r *PostgresRepository) UpsertAuthSession(
ctx context.Context,
session DeviceAuthSession,
) (DeviceAuthSession, error) {
deviceCode, err := r.cipher.Encrypt(session.DeviceCode, authStateAAD(session.Provider, session.UserID, session.ProfileID))
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("encrypt watch provider authorization state: %w", err)
}
row := r.pool.QueryRow(ctx, `
INSERT INTO watch_provider_auth_sessions (
id, provider, user_id, profile_id, device_code, user_code,
@@ -166,14 +178,14 @@ func (r *PostgresRepository) UpsertAuthSession(
session.Provider,
session.UserID,
session.ProfileID,
session.DeviceCode,
deviceCode,
session.UserCode,
session.VerificationURL,
session.IntervalSeconds,
session.ExpiresAt,
session.CompletedAt,
)
saved, err := scanDeviceAuthSession(row)
saved, err := r.scanDeviceAuthSession(row)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("upsert watch provider auth session: %w", err)
}
@@ -188,7 +200,7 @@ func (r *PostgresRepository) GetAuthSession(ctx context.Context, id string) (Dev
FROM watch_provider_auth_sessions
WHERE id = $1::uuid
`, id)
session, err := scanDeviceAuthSession(row)
session, err := r.scanDeviceAuthSession(row)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("get watch provider auth session %q: %w", id, err)
}
@@ -204,10 +216,14 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
if err != nil {
return Connection{}, fmt.Errorf("encrypt watch refresh token: %w", err)
}
pluginCredentials, err := r.pluginCredentialsForConnection(conn)
if err != nil {
return Connection{}, err
}
row := r.pool.QueryRow(ctx, `
INSERT INTO watch_provider_connections (
id, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at, import_watched_enabled,
access_token, refresh_token, token_expires_at, plugin_credentials, import_watched_enabled,
import_progress_enabled, export_watched_enabled, export_unwatched_enabled,
import_favorites_enabled, export_favorites_enabled, sync_favorite_removals_enabled,
import_watchlist_enabled, export_watchlist_enabled, sync_watchlist_removals_enabled,
@@ -217,8 +233,8 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
)
VALUES (
COALESCE(NULLIF($1, '')::uuid, gen_random_uuid()),
$2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30::jsonb
$2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14,
$15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31::jsonb
)
ON CONFLICT (provider, user_id, profile_id) DO UPDATE SET
provider_account_id = EXCLUDED.provider_account_id,
@@ -226,6 +242,7 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
access_token = EXCLUDED.access_token,
refresh_token = EXCLUDED.refresh_token,
token_expires_at = EXCLUDED.token_expires_at,
plugin_credentials = EXCLUDED.plugin_credentials,
import_watched_enabled = EXCLUDED.import_watched_enabled,
import_progress_enabled = EXCLUDED.import_progress_enabled,
export_watched_enabled = EXCLUDED.export_watched_enabled,
@@ -259,6 +276,7 @@ func (r *PostgresRepository) UpsertConnection(ctx context.Context, conn Connecti
accessToken,
refreshToken,
conn.TokenExpiresAt,
pluginCredentials,
conn.ImportWatchedEnabled,
conn.ImportProgressEnabled,
conn.ExportWatchedEnabled,
@@ -688,7 +706,9 @@ func (r *PostgresRepository) UpsertHistoryExports(ctx context.Context, exports [
ON CONFLICT (connection_id, history_id) DO UPDATE SET
provider_item_key = EXCLUDED.provider_item_key,
status = CASE
WHEN watch_provider_history_exports.status IN ('sent', 'satisfied_by_scrobble') THEN watch_provider_history_exports.status
WHEN watch_provider_history_exports.status IN ('sent', 'satisfied_by_scrobble', 'not_found')
OR watch_provider_history_exports.attempt_count >= 5
THEN watch_provider_history_exports.status
ELSE EXCLUDED.status
END,
updated_at = now()
@@ -746,6 +766,60 @@ func (r *PostgresRepository) ListPendingHistoryExports(ctx context.Context, conn
return exports, nil
}
func (r *PostgresRepository) ListPendingHistoryExportsByHistoryIDs(
ctx context.Context,
connectionID string,
historyIDs []string,
limit int,
) ([]HistoryExport, error) {
if len(historyIDs) == 0 {
return nil, nil
}
if limit <= 0 || limit > len(historyIDs) {
limit = len(historyIDs)
}
rows, err := r.pool.Query(ctx, `
SELECT id::text, connection_id::text, history_id, media_item_id, watched_at,
provider_item_key, status, attempt_count, last_attempt_at, last_error, created_at, updated_at
FROM watch_provider_history_exports
WHERE connection_id = $1::uuid
AND history_id = ANY($2::text[])
AND status IN ('pending', 'failed')
AND attempt_count < 5
ORDER BY watched_at ASC
LIMIT $3
`, connectionID, historyIDs, limit)
if err != nil {
return nil, fmt.Errorf("list pending history exports by history ids: %w", err)
}
defer rows.Close()
var exports []HistoryExport
for rows.Next() {
var export HistoryExport
if err := rows.Scan(
&export.ID,
&export.ConnectionID,
&export.HistoryID,
&export.MediaItemID,
&export.WatchedAt,
&export.ProviderItemKey,
&export.Status,
&export.AttemptCount,
&export.LastAttemptAt,
&export.LastError,
&export.CreatedAt,
&export.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan pending history export by history ids: %w", err)
}
exports = append(exports, export)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending history exports by history ids: %w", err)
}
return exports, nil
}
func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id string, status string, lastError string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_history_exports
@@ -755,6 +829,7 @@ func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id str
last_error = $3,
updated_at = now()
WHERE id = $1::uuid
AND status NOT IN ('sent', 'satisfied_by_scrobble', 'not_found')
`, id, status, lastError)
if err != nil {
return fmt.Errorf("mark history export status: %w", err)
@@ -762,6 +837,22 @@ func (r *PostgresRepository) MarkHistoryExportStatus(ctx context.Context, id str
return nil
}
func (r *PostgresRepository) MarkHistoryExportSatisfiedByScrobble(ctx context.Context, connectionID string, historyID string) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_history_exports
SET status = 'satisfied_by_scrobble',
last_attempt_at = now(),
last_error = '',
updated_at = now()
WHERE connection_id = $1::uuid AND history_id = $2
AND status NOT IN ('sent', 'not_found')
`, connectionID, historyID)
if err != nil {
return fmt.Errorf("mark history export satisfied by scrobble: %w", err)
}
return nil
}
func (r *PostgresRepository) UpsertListItemStates(ctx context.Context, states []ListItemState) error {
for _, state := range states {
kind := state.ListKind
@@ -1152,7 +1243,62 @@ func (r *PostgresRepository) ListOpenScrobbleSessions(ctx context.Context) ([]Sc
return sessions, nil
}
func scanDeviceAuthSession(row pgx.Row) (DeviceAuthSession, error) {
func (r *PostgresRepository) ListPendingScrobbleReconciliations(ctx context.Context) ([]ScrobbleSession, error) {
rows, err := r.pool.Query(ctx, `
SELECT playback_session_id, connection_id::text, media_item_id, provider_item_key, kind,
imdb_id, tmdb_id, tvdb_id, series_imdb_id, series_tmdb_id, series_tvdb_id,
season_number, episode_number, history_id, started_at, last_progress,
duration_seconds, completed, last_action, stop_sent_at, history_reconciled_at, last_error
FROM watch_provider_scrobble_sessions
WHERE stop_sent_at IS NOT NULL AND completed = true AND history_id <> ''
AND history_reconciled_at IS NULL
ORDER BY stop_sent_at ASC
LIMIT 100
`)
if err != nil {
return nil, fmt.Errorf("list pending scrobble reconciliations: %w", err)
}
defer rows.Close()
var sessions []ScrobbleSession
for rows.Next() {
var session ScrobbleSession
if err := rows.Scan(
&session.PlaybackSessionID, &session.ConnectionID, &session.MediaItemID,
&session.ProviderItemKey, &session.Kind, &session.IMDbID, &session.TMDBID,
&session.TVDBID, &session.SeriesIMDbID, &session.SeriesTMDBID,
&session.SeriesTVDBID, &session.SeasonNumber, &session.EpisodeNumber,
&session.HistoryID, &session.StartedAt, &session.LastProgress,
&session.DurationSeconds, &session.Completed, &session.LastAction,
&session.StopSentAt, &session.HistoryReconciledAt, &session.LastError,
); err != nil {
return nil, fmt.Errorf("scan pending scrobble reconciliation: %w", err)
}
sessions = append(sessions, session)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate pending scrobble reconciliations: %w", err)
}
return sessions, nil
}
func (r *PostgresRepository) MarkScrobbleHistoryReconciled(
ctx context.Context,
playbackSessionID string,
connectionID string,
reconciledAt time.Time,
) error {
_, err := r.pool.Exec(ctx, `
UPDATE watch_provider_scrobble_sessions
SET history_reconciled_at = $3, last_error = '', updated_at = now()
WHERE playback_session_id = $1 AND connection_id = $2::uuid
`, playbackSessionID, connectionID, reconciledAt)
if err != nil {
return fmt.Errorf("mark scrobble history reconciled: %w", err)
}
return nil
}
func (r *PostgresRepository) scanDeviceAuthSession(row pgx.Row) (DeviceAuthSession, error) {
var session DeviceAuthSession
err := row.Scan(
&session.ID,
@@ -1169,6 +1315,14 @@ func scanDeviceAuthSession(row pgx.Row) (DeviceAuthSession, error) {
if err != nil {
return DeviceAuthSession{}, err
}
decrypted, err := r.cipher.DecryptIfEncrypted(
session.DeviceCode,
authStateAAD(session.Provider, session.UserID, session.ProfileID),
)
if err != nil {
return DeviceAuthSession{}, fmt.Errorf("decrypt watch provider authorization state: %w", err)
}
session.DeviceCode = decrypted
return session, nil
}
@@ -1211,6 +1365,7 @@ func scanSyncRun(row pgx.Row) (SyncRun, error) {
func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
var conn Connection
var rawSyncCursors []byte
var rawPluginCredentials string
err := row.Scan(
&conn.ID,
&conn.Provider,
@@ -1221,6 +1376,7 @@ func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
&conn.AccessToken,
&conn.RefreshToken,
&conn.TokenExpiresAt,
&rawPluginCredentials,
&conn.ImportWatchedEnabled,
&conn.ImportProgressEnabled,
&conn.ExportWatchedEnabled,
@@ -1256,10 +1412,89 @@ func (r *PostgresRepository) scanConnection(row pgx.Row) (Connection, error) {
if conn.RefreshToken, err = r.cipher.DecryptIfEncrypted(conn.RefreshToken, TokenAAD("refresh_token", conn.Provider, conn.UserID, conn.ProfileID)); err != nil {
return Connection{}, fmt.Errorf("decrypt watch refresh token: %w", err)
}
if err := r.decodePluginCredentials(&conn, rawPluginCredentials); err != nil {
return Connection{}, err
}
conn.SyncCursors = decodeSyncCursors(rawSyncCursors)
return conn, nil
}
type storedPluginCredentials struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
TokenExpiresAt *time.Time `json:"expires_at,omitempty"`
TokenType string `json:"token_type,omitempty"`
Scopes []string `json:"scopes,omitempty"`
SecretAttributes map[string]string `json:"secret_attributes,omitempty"`
}
func (r *PostgresRepository) pluginCredentialsForConnection(conn Connection) (string, error) {
if !strings.HasPrefix(conn.Provider, providerSourcePlugin+":") {
// Built-in providers have legacy token writers that update the dedicated
// token columns directly. Keeping a second authoritative bundle for them
// would let that bundle become stale and overwrite freshly rotated tokens.
return "", nil
}
return r.encodePluginCredentials(conn)
}
func (r *PostgresRepository) encodePluginCredentials(conn Connection) (string, error) {
payload, err := json.Marshal(storedPluginCredentials{
AccessToken: conn.AccessToken,
RefreshToken: conn.RefreshToken,
TokenExpiresAt: conn.TokenExpiresAt,
TokenType: conn.TokenType,
Scopes: conn.Scopes,
SecretAttributes: conn.SecretAttributes,
})
if err != nil {
return "", fmt.Errorf("encode watch provider credentials: %w", err)
}
encoded, err := r.cipher.Encrypt(
string(payload),
TokenAAD("plugin_credentials", conn.Provider, conn.UserID, conn.ProfileID),
)
if err != nil {
return "", fmt.Errorf("encrypt watch provider credentials: %w", err)
}
return encoded, nil
}
func (r *PostgresRepository) decodePluginCredentials(conn *Connection, encoded string) error {
if conn == nil || strings.TrimSpace(encoded) == "" {
return nil
}
plaintext, err := r.cipher.DecryptIfEncrypted(
encoded,
TokenAAD("plugin_credentials", conn.Provider, conn.UserID, conn.ProfileID),
)
if err != nil {
return fmt.Errorf("decrypt watch provider credentials: %w", err)
}
var credentials storedPluginCredentials
if err := json.Unmarshal([]byte(plaintext), &credentials); err != nil {
return fmt.Errorf("decode watch provider credentials: %w", err)
}
conn.AccessToken = credentials.AccessToken
conn.RefreshToken = credentials.RefreshToken
conn.TokenExpiresAt = credentials.TokenExpiresAt
conn.TokenType = credentials.TokenType
conn.Scopes = append([]string(nil), credentials.Scopes...)
conn.SecretAttributes = cloneStringMap(credentials.SecretAttributes)
return nil
}
func cloneStringMap(values map[string]string) map[string]string {
if values == nil {
return nil
}
result := make(map[string]string, len(values))
for key, value := range values {
result[key] = value
}
return result
}
type listItemStateRows interface {
Next() bool
Scan(dest ...any) error
+83
View File
@@ -1,8 +1,12 @@
package watchsync
import (
"reflect"
"strings"
"testing"
"time"
"github.com/Silo-Server/silo-server/internal/secret"
)
func TestMediaDurationQueryUsesActiveMediaFilesPredicate(t *testing.T) {
@@ -13,3 +17,82 @@ func TestMediaDurationQueryUsesActiveMediaFilesPredicate(t *testing.T) {
t.Fatalf("media duration query references removed media_files.missing column:\n%s", mediaDurationQuery)
}
}
func TestPluginCredentialBundleRoundTrip(t *testing.T) {
cipher, err := secret.New([]byte("01234567890123456789012345678901"))
if err != nil {
t.Fatal(err)
}
repository := NewPostgresRepository(nil, cipher)
expiresAt := time.Now().UTC().Truncate(time.Second)
input := Connection{
Provider: "plugin:4:tracker", UserID: 7, ProfileID: "profile",
AccessToken: testAccessToken, RefreshToken: testRefreshToken, TokenExpiresAt: &expiresAt,
TokenType: testDPoPTokenType, Scopes: []string{testHistoryScope, "watchlist"},
SecretAttributes: map[string]string{"instance": testOneValue},
}
encoded, err := repository.encodePluginCredentials(input)
if err != nil {
t.Fatal(err)
}
if strings.Contains(encoded, input.AccessToken) || !strings.HasPrefix(encoded, "enc:v1:") {
t.Fatalf("credential bundle was not encrypted: %q", encoded)
}
output := Connection{Provider: input.Provider, UserID: input.UserID, ProfileID: input.ProfileID}
if err := repository.decodePluginCredentials(&output, encoded); err != nil {
t.Fatal(err)
}
if output.AccessToken != input.AccessToken || output.RefreshToken != input.RefreshToken ||
output.TokenType != input.TokenType || !output.TokenExpiresAt.Equal(expiresAt) ||
!reflect.DeepEqual(output.Scopes, input.Scopes) || !reflect.DeepEqual(output.SecretAttributes, input.SecretAttributes) {
t.Fatalf("decoded credentials = %#v", output)
}
}
func TestPluginCredentialBundleUsesConnectionIdentityAsAAD(t *testing.T) {
cipher, err := secret.New([]byte("01234567890123456789012345678901"))
if err != nil {
t.Fatal(err)
}
repository := NewPostgresRepository(nil, cipher)
input := Connection{
Provider: "plugin:4:tracker", UserID: 7, ProfileID: "profile-a",
AccessToken: testAccessToken,
}
encoded, err := repository.encodePluginCredentials(input)
if err != nil {
t.Fatal(err)
}
wrongIdentity := Connection{Provider: input.Provider, UserID: input.UserID, ProfileID: "profile-b"}
if err := repository.decodePluginCredentials(&wrongIdentity, encoded); err == nil {
t.Fatal("decodePluginCredentials with different profile identity succeeded")
}
}
func TestPluginCredentialBundleIsOnlyWrittenForPluginProviders(t *testing.T) {
cipher, err := secret.New([]byte("01234567890123456789012345678901"))
if err != nil {
t.Fatal(err)
}
repository := NewPostgresRepository(nil, cipher)
for _, provider := range []string{"trakt", "simkl", "mdblist"} {
encoded, err := repository.pluginCredentialsForConnection(Connection{
Provider: provider, UserID: 7, ProfileID: "profile", AccessToken: testAccessToken,
})
if err != nil {
t.Fatalf("pluginCredentialsForConnection(%q): %v", provider, err)
}
if encoded != "" {
t.Fatalf("pluginCredentialsForConnection(%q) = %q, want empty", provider, encoded)
}
}
encoded, err := repository.pluginCredentialsForConnection(Connection{
Provider: "plugin:4:tracker", UserID: 7, ProfileID: "profile", AccessToken: testAccessToken,
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(encoded, "enc:v1:") {
t.Fatalf("plugin credential bundle = %q, want encrypted value", encoded)
}
}
+324 -71
View File
@@ -49,11 +49,13 @@ type watchStateImporter interface {
const (
manualSyncCooldown = time.Hour
manualSyncTimeout = 10 * time.Minute
// Built-in providers bind requests to this context and use HTTP client
// timeouts of at most 20 seconds. Keep dispatch below the reclaim lease;
// the per-session queue remains occupied until the worker itself exits.
confirmedStopDispatchTimeout = 25 * time.Second
confirmedStopLease = time.Minute
// A completed stop may require a cold metadata lookup in a provider plugin.
// Match the plugin-host watch-sync deadline so the durable confirmation path
// does not cancel valid provider work before the RPC can finish. The reclaim
// lease must remain longer than that entire dispatch and finalization window
// so a second request cannot take over while the first is still valid.
confirmedStopDispatchTimeout = 2 * time.Minute
confirmedStopLease = confirmedStopDispatchTimeout + 30*time.Second
)
var errConfirmedStopInProgress = errors.New("watch provider stop confirmation already in progress")
@@ -102,6 +104,9 @@ func (s *Service) GetConnectionStatus(ctx context.Context, userID int, profileID
}
authMethod := authMethodOf(provider)
credentialsConfigured := authMethod == AuthMethodAPIKey
if _, pluginConfig := provider.(interface{ usesHostPluginConfig() }); pluginConfig {
credentialsConfigured = true
}
if !credentialsConfigured {
cfg, _ := s.serverConfig(ctx, providerKey)
credentialsConfigured = cfg.Configured()
@@ -131,6 +136,9 @@ func (s *Service) GetConnectionStatus(ctx context.Context, userID int, profileID
SyncWatchlistOrderEnabled: true,
ScrobbleEnabled: true,
}
if configurable, ok := provider.(connectionConfigProvider); ok {
status.ConnectionConfigSchema = configurable.ConnectionConfigSchema()
}
if connected {
status.ProviderUsername = conn.ProviderUsername
status.ImportWatchedEnabled = conn.ImportWatchedEnabled
@@ -374,7 +382,13 @@ func (s *Service) processLocalWatchEvent(ctx context.Context, event LocalWatchEv
continue
}
if err := s.exportLocalPlays(ctx, conn, cfg, exporter, event.Plays); err != nil {
s.recordLocalWatchEventError(ctx, conn, err)
if limited, ok := AsRateLimited(err); ok {
if deferErr := s.deferRateLimitedConnection(ctx, conn, limited); deferErr != nil {
s.recordLocalWatchEventError(ctx, conn, errors.Join(err, deferErr))
}
} else {
s.recordLocalWatchEventError(ctx, conn, err)
}
}
case LocalWatchEventMarkedUnwatched:
if !provider.Capabilities().ExportUnwatched {
@@ -490,10 +504,26 @@ func (s *Service) PollDeviceAuth(
}
tokens, err := authProvider.PollDeviceAuth(ctx, cfg, session)
if err != nil {
var pending deviceAuthorizationPendingError
if errors.As(err, &pending) {
updated := pending.session
// Keep host-owned scope and identity authoritative even if a provider
// accidentally copied or zeroed those fields in its pending state.
updated.ID = session.ID
updated.Provider = session.Provider
updated.UserID = session.UserID
updated.ProfileID = session.ProfileID
updated.UserCode = session.UserCode
updated.VerificationURL = session.VerificationURL
updated.CompletedAt = session.CompletedAt
if _, persistErr := s.repo.UpsertAuthSession(ctx, updated); persistErr != nil {
return Connection{}, errors.Join(err, fmt.Errorf("persist pending auth session: %w", persistErr))
}
}
return Connection{}, err
}
account, err := authProvider.LookupAccount(ctx, cfg, Connection{AccessToken: tokens.AccessToken})
account, err := authProvider.LookupAccount(ctx, cfg, connectionWithTokens(Connection{}, tokens))
if err != nil {
return Connection{}, err
}
@@ -517,6 +547,17 @@ func (s *Service) ConnectAPIKey(
profileID string,
providerKey string,
apiKey string,
) (Connection, error) {
return s.ConnectAPIKeyWithConfig(ctx, userID, profileID, providerKey, apiKey, nil)
}
func (s *Service) ConnectAPIKeyWithConfig(
ctx context.Context,
userID int,
profileID string,
providerKey string,
apiKey string,
connectionConfig ConnectionConfigValues,
) (Connection, error) {
if userID <= 0 {
return Connection{}, fmt.Errorf("user id is required")
@@ -537,11 +578,24 @@ func (s *Service) ConnectAPIKey(
return Connection{}, fmt.Errorf("provider %q does not support api-key auth", providerKey)
}
tokens, account, err := authProvider.ConnectWithAPIKey(ctx, apiKey)
var tokens TokenSet
var account ProviderAccount
var err error
if configured, ok := provider.(configuredAPIKeyAuthProvider); ok {
tokens, account, err = configured.ConnectWithAPIKeyConfig(ctx, apiKey, connectionConfig)
} else {
if len(connectionConfig) > 0 {
return Connection{}, fmt.Errorf("provider %q does not accept connection configuration", providerKey)
}
tokens, account, err = authProvider.ConnectWithAPIKey(ctx, apiKey)
}
if err != nil {
return Connection{}, err
}
if strings.TrimSpace(tokens.AccessToken) == "" {
if sourced, ok := provider.(sourcedProvider); ok && sourced.ProviderSource() == providerSourcePlugin {
return Connection{}, errors.New("watch sync plugin returned no access token")
}
tokens.AccessToken = apiKey
}
return s.persistConnection(ctx, providerKey, userID, profileID, tokens, account)
@@ -578,11 +632,10 @@ func (s *Service) persistConnection(
conn.Provider = providerKey
conn.UserID = userID
conn.ProfileID = profileID
conn.AccessToken = tokens.AccessToken
conn.RefreshToken = tokens.RefreshToken
conn.TokenExpiresAt = tokens.TokenExpiresAt
conn = connectionWithTokens(conn, tokens)
conn.ProviderAccountID = account.ID
conn.ProviderUsername = account.Username
conn.LastError = ""
return s.repo.UpsertConnection(ctx, conn)
}
@@ -843,9 +896,15 @@ func (s *Service) deferRateLimitedConnection(ctx context.Context, conn Connectio
return err
}
retryAfter := rle.RetryAfter
if retryAfter <= 0 {
switch {
case retryAfter <= 0:
retryAfter = time.Hour
case retryAfter < time.Second:
retryAfter = time.Second
case retryAfter > 24*time.Hour:
retryAfter = 24 * time.Hour
}
rle.RetryAfter = retryAfter
until := s.now().Add(retryAfter)
lastError := fmt.Sprintf("%s; sync deferred until %s", rle.Error(), until.Format(time.RFC3339))
deferred := 1
@@ -1246,20 +1305,47 @@ func (s *Service) refreshConnectionIfNeeded(ctx context.Context, provider Provid
return Connection{}, fmt.Errorf("provider %q does not support token refresh", conn.Provider)
}
tokens, err := authProvider.RefreshToken(ctx, cfg, conn)
_, authoritative := provider.(authoritativeRefreshProvider)
if authoritative && strings.TrimSpace(tokens.AccessToken) != "" {
conn = connectionWithTokens(conn, tokens)
} else if err == nil {
if tokens.AccessToken != "" {
conn.AccessToken = tokens.AccessToken
}
if tokens.RefreshToken != "" {
conn.RefreshToken = tokens.RefreshToken
}
if tokens.TokenExpiresAt != nil {
conn.TokenExpiresAt = tokens.TokenExpiresAt
}
}
if err != nil && isWatchSyncInvalidCredentialError(err) {
conn.LastError = err.Error()
} else if err == nil {
conn.LastError = ""
}
credentialsReturned := authoritative && strings.TrimSpace(tokens.AccessToken) != ""
if err == nil || credentialsReturned || isWatchSyncInvalidCredentialError(err) {
persisted, persistErr := s.repo.UpsertConnection(ctx, conn)
if persistErr != nil {
return Connection{}, fmt.Errorf("persist refreshed %s connection: %w", conn.Provider, persistErr)
}
conn = persisted
}
if err != nil {
return Connection{}, fmt.Errorf("refresh %s token: %w", conn.Provider, err)
}
if tokens.AccessToken != "" {
conn.AccessToken = tokens.AccessToken
}
if tokens.RefreshToken != "" {
conn.RefreshToken = tokens.RefreshToken
}
if tokens.TokenExpiresAt != nil {
conn.TokenExpiresAt = tokens.TokenExpiresAt
}
conn.LastError = ""
return s.repo.UpsertConnection(ctx, conn)
return conn, nil
}
func connectionWithTokens(conn Connection, tokens TokenSet) Connection {
conn.AccessToken = tokens.AccessToken
conn.RefreshToken = tokens.RefreshToken
conn.TokenExpiresAt = tokens.TokenExpiresAt
conn.TokenType = tokens.TokenType
conn.Scopes = append([]string(nil), tokens.Scopes...)
conn.SecretAttributes = cloneStringMap(tokens.SecretAttributes)
return conn
}
type ExportWatchedResult struct {
@@ -1309,9 +1395,10 @@ func (s *Service) ExportWatched(
exports := reconcileHistoryExports(conn.ID, local, remote)
for _, export := range exports {
if export.Status == "remote_present" {
switch export.Status {
case historyExportStatusRemotePresent:
result.RemotePresent++
} else if export.Status == "pending" {
case historyExportStatusPending:
result.Queued++
}
}
@@ -1337,7 +1424,7 @@ func (s *Service) ExportWatched(
for _, export := range pending {
play, ok := localByHistoryID[export.HistoryID]
if !ok {
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "local history entry not found"); err != nil {
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusNotFound, "local history entry not found"); err != nil {
return result, err
}
progressed = true
@@ -1349,26 +1436,36 @@ func (s *Service) ExportWatched(
if len(pendingPlays) == 0 {
continue
}
pendingPlays, singleBatch := limitWatchedExportBatch(exporter, pendingPlays)
exportResult, err := exporter.ExportHistory(ctx, cfg, conn, pendingPlays)
if err != nil {
// Rate-limited plays are not failures: leave them pending so the
// next run (after the deferral) retries without churning state.
if _, limited := AsRateLimited(err); limited {
return result, err
}
for _, export := range pending {
_ = s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", err.Error())
}
result.Failed += len(pending)
return result, err
_, limited := AsRateLimited(err)
retryable := isRetryableProviderError(err)
if err != nil && isWatchSyncInvalidCredentialError(err) {
return result, errors.Join(err, s.persistConnectionError(ctx, conn, err.Error()))
}
if err != nil && !limited && !retryable {
var markErr error
for _, play := range pendingPlays {
export := exportByHistoryID[play.HistoryID]
if export.ID != "" {
if statusErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusFailed, err.Error()); statusErr != nil {
markErr = errors.Join(markErr, statusErr)
}
}
}
result.Failed += len(pendingPlays)
return result, errors.Join(err, markErr)
}
// Commit per-event outcomes even when the provider also returned a
// connection-wide rate limit so successfully applied events are not
// retried after the deferral.
for _, historyID := range exportResult.Sent {
export := exportByHistoryID[historyID]
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "sent", ""); err != nil {
return result, err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusSent, ""); markErr != nil {
return result, markErr
}
result.Sent++
progressed = true
@@ -1378,8 +1475,8 @@ func (s *Service) ExportWatched(
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "provider item not found"); err != nil {
return result, err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusNotFound, "provider item not found"); markErr != nil {
return result, markErr
}
progressed = true
}
@@ -1388,13 +1485,17 @@ func (s *Service) ExportWatched(
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", message); err != nil {
return result, err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusFailed, message); markErr != nil {
return result, markErr
}
result.Failed++
progressed = true
}
if !progressed {
if limited || retryable {
// Leave unmentioned events pending for a later retry.
return result, err
}
if !progressed || singleBatch {
break
}
}
@@ -1426,7 +1527,7 @@ func (s *Service) exportLocalPlays(
MediaItemID: play.MediaItemID,
WatchedAt: play.WatchedAt,
ProviderItemKey: play.ProviderItemKey,
Status: "pending",
Status: historyExportStatusPending,
})
}
if len(exports) == 0 {
@@ -1435,7 +1536,19 @@ func (s *Service) exportLocalPlays(
if err := s.repo.UpsertHistoryExports(ctx, exports); err != nil {
return err
}
pending, err := s.repo.ListPendingHistoryExports(ctx, conn.ID, 100)
historyIDs := make([]string, 0, len(exports))
for _, export := range exports {
historyIDs = append(historyIDs, export.HistoryID)
}
// A live watch event must not wait behind a connection's historical
// backlog. Scheduled sync still drains that backlog oldest-first, while
// this path selects only the events the user just created.
pending, err := s.repo.ListPendingHistoryExportsByHistoryIDs(
ctx,
conn.ID,
historyIDs,
watchedExportBatchSize(exporter, len(historyIDs)),
)
if err != nil {
return err
}
@@ -1456,24 +1569,32 @@ func (s *Service) exportLocalPlays(
if len(pendingPlays) == 0 {
return nil
}
pendingPlays, _ = limitWatchedExportBatch(exporter, pendingPlays)
exportResult, err := exporter.ExportHistory(ctx, cfg, conn, pendingPlays)
if err != nil {
// Leave rate-limited plays pending; the next scheduled sync retries.
if _, limited := AsRateLimited(err); limited {
return err
_, limited := AsRateLimited(err)
retryable := isRetryableProviderError(err)
if err != nil && isWatchSyncInvalidCredentialError(err) {
return errors.Join(err, s.persistConnectionError(ctx, conn, err.Error()))
}
if err != nil && !limited && !retryable {
var markErr error
for _, play := range pendingPlays {
export := exportByHistoryID[play.HistoryID]
if export.ID != "" {
if statusErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusFailed, err.Error()); statusErr != nil {
markErr = errors.Join(markErr, statusErr)
}
}
}
for _, export := range exportByHistoryID {
_ = s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", err.Error())
}
return err
return errors.Join(err, markErr)
}
for _, historyID := range exportResult.Sent {
export := exportByHistoryID[historyID]
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "sent", ""); err != nil {
return err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusSent, ""); markErr != nil {
return markErr
}
}
for _, historyID := range exportResult.NotFound {
@@ -1481,8 +1602,8 @@ func (s *Service) exportLocalPlays(
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "not_found", "provider item not found"); err != nil {
return err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusNotFound, "provider item not found"); markErr != nil {
return markErr
}
}
for historyID, message := range exportResult.Failed {
@@ -1490,10 +1611,13 @@ func (s *Service) exportLocalPlays(
if export.ID == "" {
continue
}
if err := s.repo.MarkHistoryExportStatus(ctx, export.ID, "failed", message); err != nil {
return err
if markErr := s.repo.MarkHistoryExportStatus(ctx, export.ID, historyExportStatusFailed, message); markErr != nil {
return markErr
}
}
if limited || retryable {
return err
}
now := s.now()
conn.LastOutboundSyncAt = &now
conn.LastError = ""
@@ -1503,6 +1627,39 @@ func (s *Service) exportLocalPlays(
return nil
}
func (s *Service) persistConnectionError(ctx context.Context, conn Connection, message string) error {
if conn.ID != "" {
fresh, err := s.reloadConnection(ctx, conn)
if err != nil {
return err
}
conn = fresh
}
conn.LastError = message
_, err := s.repo.UpsertConnection(ctx, conn)
return err
}
func limitWatchedExportBatch(exporter WatchedExporter, plays []LocalPlay) ([]LocalPlay, bool) {
_, ok := exporter.(singleBatchWatchedExporter)
if !ok {
return plays, false
}
limit := watchedExportBatchSize(exporter, len(plays))
if len(plays) > limit {
plays = plays[:limit]
}
return plays, true
}
func watchedExportBatchSize(exporter WatchedExporter, fallback int) int {
bounded, ok := exporter.(singleBatchWatchedExporter)
if !ok {
return max(1, fallback)
}
return max(1, bounded.ExportBatchSize())
}
func reconcileHistoryExports(connectionID string, local []LocalPlay, remote []RemotePlay) []HistoryExport {
remoteExact := make(map[string]struct{}, len(remote))
for _, play := range remote {
@@ -1510,9 +1667,9 @@ func reconcileHistoryExports(connectionID string, local []LocalPlay, remote []Re
}
exports := make([]HistoryExport, 0, len(local))
for _, play := range local {
status := "pending"
status := historyExportStatusPending
if _, ok := remoteExact[remotePlayKey(play.ProviderItemKey, play.WatchedAt)]; ok {
status = "remote_present"
status = historyExportStatusRemotePresent
}
exports = append(exports, HistoryExport{
ConnectionID: connectionID,
@@ -1683,6 +1840,8 @@ func parseInt(value string) int {
return parsed
}
const scrobbleActionStop = "stop"
func (s *Service) ScrobbleStart(ctx context.Context, event ScrobbleEvent) error {
return s.scrobble(ctx, event, "start", false)
}
@@ -1692,7 +1851,7 @@ func (s *Service) ScrobblePause(ctx context.Context, event ScrobbleEvent) error
}
func (s *Service) ScrobbleStop(ctx context.Context, event ScrobbleEvent) error {
return s.scrobble(ctx, event, "stop", false)
return s.scrobble(ctx, event, scrobbleActionStop, false)
}
// ScrobbleStopConfirmed waits for each provider dispatch and reports provider
@@ -1753,6 +1912,19 @@ func (s *Service) scrobble(ctx context.Context, event ScrobbleEvent, action stri
return err
}
}
// Persist completed playback before provider I/O. Immediate scrobbling
// remains the low-latency path; the normal history-export reconciliation
// retries this desired state after crashes, plugin downtime, or uncertain
// upstream outcomes.
if action == scrobbleActionStop && event.Completed && provider.Capabilities().ExportWatched {
if err := s.persistCompletedScrobbleExport(ctx, conn, event); err != nil {
if confirm {
dispatchErrors = append(dispatchErrors, err)
continue
}
return err
}
}
if confirm {
confirmedTargets = append(confirmedTargets, confirmedScrobbleTarget{
provider: provider, scrobbler: scrobbler, connection: conn,
@@ -1795,6 +1967,38 @@ func (s *Service) scrobble(ctx context.Context, event ScrobbleEvent, action stri
return errors.Join(dispatchErrors...)
}
func (s *Service) persistCompletedScrobbleExport(ctx context.Context, conn Connection, event ScrobbleEvent) error {
if event.HistoryID == "" {
return nil
}
providerItemKey := event.ProviderItemKey
if providerItemKey == "" {
providerItemKey = providerItemKeyForLocalPlay(LocalPlay{
MediaItemID: event.MediaItemID,
Kind: event.Kind,
IMDbID: event.IMDbID,
TMDBID: event.TMDBID,
TVDBID: event.TVDBID,
SeriesIMDbID: event.SeriesIMDbID,
SeriesTMDBID: event.SeriesTMDBID,
SeriesTVDBID: event.SeriesTVDBID,
SeasonNumber: event.SeasonNumber,
EpisodeNumber: event.EpisodeNumber,
})
}
if providerItemKey == "" {
return nil
}
return s.repo.UpsertHistoryExports(ctx, []HistoryExport{{
ConnectionID: conn.ID,
HistoryID: event.HistoryID,
MediaItemID: event.MediaItemID,
WatchedAt: event.OccurredAt,
ProviderItemKey: providerItemKey,
Status: historyExportStatusPending,
}})
}
func (s *Service) dispatchScrobbleAsync(scrobbler Scrobbler, cfg ServerConfig, conn Connection, event ScrobbleEvent, action string) {
s.enqueueOrderedScrobble(scrobbleDispatchKey(scrobbler, conn, event), func() {
_ = s.dispatchScrobble(context.Background(), scrobbler, cfg, conn, event, action, nil)
@@ -1879,12 +2083,21 @@ func (s *Service) dispatchScrobble(ctx context.Context, scrobbler Scrobbler, cfg
switch action {
case "pause":
err = scrobbler.Pause(ctx, cfg, conn, event)
case "stop":
case scrobbleActionStop:
err = scrobbler.Stop(ctx, cfg, conn, event)
default:
err = scrobbler.Start(ctx, cfg, conn, event)
}
if err != nil {
if limited, ok := AsRateLimited(err); ok {
if deferErr := s.deferRateLimitedConnection(ctx, conn, limited); deferErr != nil {
err = errors.Join(err, deferErr)
}
}
var persistErr error
if isWatchSyncInvalidCredentialError(err) {
persistErr = s.persistConnectionError(ctx, conn, err.Error())
}
if confirmedClaim != nil {
_ = s.repo.FailConfirmedScrobbleStop(
ctx, event.PlaybackSessionID, conn.ID,
@@ -1893,27 +2106,46 @@ func (s *Service) dispatchScrobble(ctx context.Context, scrobbler Scrobbler, cfg
} else {
_ = s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, err.Error(), nil)
}
return err
return errors.Join(err, persistErr)
}
if action == "stop" {
if action == scrobbleActionStop {
stopSentAt := s.now()
if confirmedClaim != nil {
return s.repo.CompleteConfirmedScrobbleStop(
if err := s.repo.CompleteConfirmedScrobbleStop(
ctx, event.PlaybackSessionID, conn.ID,
event.PositionSeconds, event.HistoryID, *confirmedClaim, stopSentAt,
)
}
if err := s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, "", &stopSentAt); err != nil {
); err != nil {
return err
}
} else if err := s.repo.UpdateScrobbleSession(ctx, event.PlaybackSessionID, conn.ID, action, event.PositionSeconds, event.HistoryID, "", &stopSentAt); err != nil {
return err
}
if event.Completed && event.HistoryID != "" {
return s.reconcileScrobbleHistory(ctx, ScrobbleSession{
PlaybackSessionID: event.PlaybackSessionID,
ConnectionID: conn.ID,
HistoryID: event.HistoryID,
})
}
}
return nil
}
func (s *Service) SweepOpenScrobbles(ctx context.Context) error {
var reconciliationErr error
pending, err := s.repo.ListPendingScrobbleReconciliations(ctx)
if err != nil {
reconciliationErr = err
} else {
for _, session := range pending {
if err := s.reconcileScrobbleHistory(ctx, session); err != nil {
reconciliationErr = errors.Join(reconciliationErr, err)
}
}
}
sessions, err := s.repo.ListOpenScrobbleSessions(ctx)
if err != nil {
return err
return errors.Join(reconciliationErr, err)
}
for _, session := range sessions {
conn, ok, err := s.repo.GetConnectionByID(ctx, session.ConnectionID)
@@ -1943,6 +2175,19 @@ func (s *Service) SweepOpenScrobbles(ctx context.Context) error {
}
_ = s.dispatchScrobble(ctx, scrobbler, cfg, conn, scrobbleEventFromSession(session, conn, s.now()), "stop", nil)
}
return reconciliationErr
}
func (s *Service) reconcileScrobbleHistory(ctx context.Context, session ScrobbleSession) error {
if session.PlaybackSessionID == "" || session.ConnectionID == "" || session.HistoryID == "" {
return errors.New("scrobble history reconciliation identity is incomplete")
}
if err := s.repo.MarkHistoryExportSatisfiedByScrobble(ctx, session.ConnectionID, session.HistoryID); err != nil {
return fmt.Errorf("mark history export satisfied by scrobble: %w", err)
}
if err := s.repo.MarkScrobbleHistoryReconciled(ctx, session.PlaybackSessionID, session.ConnectionID, s.now()); err != nil {
return err
}
return nil
}
@@ -1971,6 +2216,9 @@ func scrobbleEventFromSession(session ScrobbleSession, conn Connection, occurred
}
func authMethodOf(provider Provider) string {
if provider, ok := provider.(interface{ AuthMethod() string }); ok {
return provider.AuthMethod()
}
if _, ok := provider.(APIKeyAuthProvider); ok {
return AuthMethodAPIKey
}
@@ -1978,6 +2226,11 @@ func authMethodOf(provider Provider) string {
}
func (s *Service) serverConfig(ctx context.Context, providerKey string) (ServerConfig, error) {
if provider, ok := s.registry.Get(providerKey); ok {
if _, pluginConfig := provider.(interface{ usesHostPluginConfig() }); pluginConfig {
return ServerConfig{}, nil
}
}
if provider, ok := s.registry.Get(providerKey); ok && authMethodOf(provider) == AuthMethodAPIKey {
// API-key providers carry their credential on the connection itself
// and don't consult server settings. Return a zero config so sync
File diff suppressed because it is too large Load Diff
+141 -67
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/Silo-Server/silo-server/internal/historyimport"
"github.com/Silo-Server/silo-server/internal/plugins"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -58,6 +59,13 @@ type AuthProvider interface {
LookupAccount(ctx context.Context, cfg ServerConfig, conn Connection) (ProviderAccount, error)
}
// authoritativeRefreshProvider marks providers whose refreshed credentials are
// a complete replacement rather than a patch. The unexported method keeps this
// contract internal to watchsync until all providers can share it.
type authoritativeRefreshProvider interface {
authoritativeRefreshProvider()
}
// APIKeyAuthProvider is implemented by providers that authenticate via a
// user-supplied API key rather than an OAuth device flow. The key itself is
// stored in Connection.AccessToken; LookupAccount and RefreshToken (no-op)
@@ -67,6 +75,18 @@ type APIKeyAuthProvider interface {
ConnectWithAPIKey(ctx context.Context, apiKey string) (TokenSet, ProviderAccount, error)
}
// ConnectionConfigValues contains manifest-declared, per-connection setup
// values keyed first by config-schema key and then by field key.
type ConnectionConfigValues map[string]map[string]any
type configuredAPIKeyAuthProvider interface {
ConnectWithAPIKeyConfig(ctx context.Context, apiKey string, config ConnectionConfigValues) (TokenSet, ProviderAccount, error)
}
type connectionConfigProvider interface {
ConnectionConfigSchema() []plugins.ConfigSchemaView
}
type WatchedImporter interface {
FetchWatched(ctx context.Context, cfg ServerConfig, conn Connection) ([]RemoteWatch, error)
}
@@ -100,6 +120,12 @@ type WatchedExporter interface {
ExportHistory(ctx context.Context, cfg ServerConfig, conn Connection, plays []LocalPlay) (ExportResult, error)
}
// singleBatchWatchedExporter bounds one synchronization run to one provider
// batch. This is used for plugin RPCs with independent per-call deadlines.
type singleBatchWatchedExporter interface {
ExportBatchSize() int
}
type UnwatchedExporter interface {
RemoveHistory(ctx context.Context, cfg ServerConfig, conn Connection, plays []LocalPlay) (ExportResult, error)
}
@@ -112,6 +138,9 @@ type FavoriteImportBatch struct {
Rows []RemoteFavorite
UpdatedCursors map[string]string
Warnings []string
// Incremental means absent remote items are not deletions. The zero value is
// an authoritative snapshot, preserving existing built-in provider behavior.
Incremental bool
}
type FavoriteBatchImporter interface {
@@ -175,6 +204,9 @@ type Connection struct {
AccessToken string
RefreshToken string
TokenExpiresAt *time.Time
TokenType string
Scopes []string
SecretAttributes map[string]string
ImportWatchedEnabled bool
ImportProgressEnabled bool
ExportWatchedEnabled bool
@@ -278,6 +310,19 @@ func AsRateLimited(err error) (RateLimitedError, bool) {
return rle, ok
}
type retryableProviderError struct {
message string
}
func (e retryableProviderError) Error() string {
return e.message
}
func isRetryableProviderError(err error) bool {
var retryable retryableProviderError
return errors.As(err, &retryable)
}
type DeviceAuthSession struct {
ID string `json:"id"`
Provider string `json:"provider"`
@@ -291,10 +336,24 @@ type DeviceAuthSession struct {
CompletedAt *time.Time `json:"completed_at,omitempty"`
}
// deviceAuthorizationPendingError carries an authoritative replacement for a
// device challenge that must be persisted before the next poll. It remains an
// error so the existing HTTP contract continues to report an incomplete flow.
type deviceAuthorizationPendingError struct {
session DeviceAuthSession
}
func (deviceAuthorizationPendingError) Error() string {
return "watch sync plugin device authorization is pending"
}
type TokenSet struct {
AccessToken string
RefreshToken string
TokenExpiresAt *time.Time
AccessToken string
RefreshToken string
TokenExpiresAt *time.Time
TokenType string
Scopes []string
SecretAttributes map[string]string
}
type ProviderAccount struct {
@@ -345,20 +404,23 @@ type RemoteProgress struct {
type RemoteFavorite struct {
Provider string
ProviderItemKey string
Kind string
Title string
Year int
IMDbID string
TMDBID string
TVDBID string
SeriesTitle string
SeriesYear int
SeriesIMDbID string
SeriesTMDBID string
SeriesTVDBID string
SeasonNumber int
EpisodeNumber int
FavoritedAt time.Time
// Removed is an explicit provider tombstone used by incremental list syncs.
// Tombstones are resolved through ProviderItemKey and do not require media.
Removed bool
Kind string
Title string
Year int
IMDbID string
TMDBID string
TVDBID string
SeriesTitle string
SeriesYear int
SeriesIMDbID string
SeriesTMDBID string
SeriesTVDBID string
SeasonNumber int
EpisodeNumber int
FavoritedAt time.Time
}
type RemotePlay struct {
@@ -449,6 +511,15 @@ type LocalListEvent struct {
Items []LocalFavorite
}
const (
historyExportStatusPending = "pending"
historyExportStatusFailed = "failed"
historyExportStatusSent = "sent"
historyExportStatusNotFound = "not_found"
historyExportStatusRemotePresent = "remote_present"
historyExportStatusSatisfiedByScrobble = "satisfied_by_scrobble"
)
type HistoryExport struct {
ID string
ConnectionID string
@@ -490,27 +561,28 @@ type ListItemState struct {
}
type ScrobbleSession struct {
PlaybackSessionID string
ConnectionID string
MediaItemID string
ProviderItemKey string
Kind string
IMDbID string
TMDBID string
TVDBID string
SeriesIMDbID string
SeriesTMDBID string
SeriesTVDBID string
SeasonNumber int
EpisodeNumber int
HistoryID string
StartedAt time.Time
LastProgress float64
DurationSeconds float64
Completed bool
LastAction string
StopSentAt *time.Time
LastError string
PlaybackSessionID string
ConnectionID string
MediaItemID string
ProviderItemKey string
Kind string
IMDbID string
TMDBID string
TVDBID string
SeriesIMDbID string
SeriesTMDBID string
SeriesTVDBID string
SeasonNumber int
EpisodeNumber int
HistoryID string
StartedAt time.Time
LastProgress float64
DurationSeconds float64
Completed bool
LastAction string
StopSentAt *time.Time
HistoryReconciledAt *time.Time
LastError string
}
type ExportResult struct {
@@ -601,38 +673,40 @@ func (f RemoteFavorite) HistoryRecord() historyimport.Record {
}
type ProviderSummary struct {
Key string `json:"key"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
Key string `json:"key"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
ConnectionConfigSchema []plugins.ConfigSchemaView `json:"connection_config_schema,omitempty"`
}
type ConnectionStatus struct {
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
AuthMethod string `json:"auth_method"`
Connected bool `json:"connected"`
ProviderUsername string `json:"provider_username,omitempty"`
ImportWatchedEnabled bool `json:"import_watched_enabled"`
ImportProgressEnabled bool `json:"import_progress_enabled"`
ExportWatchedEnabled bool `json:"export_watched_enabled"`
ExportUnwatchedEnabled bool `json:"export_unwatched_enabled"`
ImportFavoritesEnabled bool `json:"import_favorites_enabled"`
ExportFavoritesEnabled bool `json:"export_favorites_enabled"`
SyncFavoriteRemovalsEnabled bool `json:"sync_favorite_removals_enabled"`
ImportWatchlistEnabled bool `json:"import_watchlist_enabled"`
ExportWatchlistEnabled bool `json:"export_watchlist_enabled"`
SyncWatchlistRemovalsEnabled bool `json:"sync_watchlist_removals_enabled"`
SyncWatchlistOrderEnabled bool `json:"sync_watchlist_order_enabled"`
ScrobbleEnabled bool `json:"scrobble_enabled"`
CredentialsConfigured bool `json:"credentials_configured"`
LastInboundSyncAt *time.Time `json:"last_inbound_sync_at,omitempty"`
LastProgressSyncAt *time.Time `json:"last_progress_sync_at,omitempty"`
LastOutboundSyncAt *time.Time `json:"last_outbound_sync_at,omitempty"`
LastFavoritesSyncAt *time.Time `json:"last_favorites_sync_at,omitempty"`
LastWatchlistSyncAt *time.Time `json:"last_watchlist_sync_at,omitempty"`
LastScrobbleErrorAt *time.Time `json:"last_scrobble_error_at,omitempty"`
LastError string `json:"last_error,omitempty"`
Provider string `json:"provider"`
DisplayName string `json:"display_name"`
Capabilities Capabilities `json:"capabilities"`
AuthMethod string `json:"auth_method"`
Connected bool `json:"connected"`
ProviderUsername string `json:"provider_username,omitempty"`
ImportWatchedEnabled bool `json:"import_watched_enabled"`
ImportProgressEnabled bool `json:"import_progress_enabled"`
ExportWatchedEnabled bool `json:"export_watched_enabled"`
ExportUnwatchedEnabled bool `json:"export_unwatched_enabled"`
ImportFavoritesEnabled bool `json:"import_favorites_enabled"`
ExportFavoritesEnabled bool `json:"export_favorites_enabled"`
SyncFavoriteRemovalsEnabled bool `json:"sync_favorite_removals_enabled"`
ImportWatchlistEnabled bool `json:"import_watchlist_enabled"`
ExportWatchlistEnabled bool `json:"export_watchlist_enabled"`
SyncWatchlistRemovalsEnabled bool `json:"sync_watchlist_removals_enabled"`
SyncWatchlistOrderEnabled bool `json:"sync_watchlist_order_enabled"`
ScrobbleEnabled bool `json:"scrobble_enabled"`
CredentialsConfigured bool `json:"credentials_configured"`
ConnectionConfigSchema []plugins.ConfigSchemaView `json:"connection_config_schema,omitempty"`
LastInboundSyncAt *time.Time `json:"last_inbound_sync_at,omitempty"`
LastProgressSyncAt *time.Time `json:"last_progress_sync_at,omitempty"`
LastOutboundSyncAt *time.Time `json:"last_outbound_sync_at,omitempty"`
LastFavoritesSyncAt *time.Time `json:"last_favorites_sync_at,omitempty"`
LastWatchlistSyncAt *time.Time `json:"last_watchlist_sync_at,omitempty"`
LastScrobbleErrorAt *time.Time `json:"last_scrobble_error_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type ConnectionUpdate struct {
@@ -0,0 +1,16 @@
-- +goose Up
ALTER TABLE watch_provider_connections
ADD COLUMN plugin_credentials text NOT NULL DEFAULT '';
ALTER TABLE watch_provider_scrobble_sessions
ADD COLUMN history_reconciled_at timestamptz;
-- +goose Down
-- Irreversible for plugin connections: TokenType, Scopes, and
-- SecretAttributes exist only in plugin_credentials, so rolling back may
-- require those providers to reconnect.
ALTER TABLE watch_provider_connections
DROP COLUMN IF EXISTS plugin_credentials;
ALTER TABLE watch_provider_scrobble_sessions
DROP COLUMN IF EXISTS history_reconciled_at;
@@ -0,0 +1,32 @@
-- +goose NO TRANSACTION
-- +goose Up
-- A failed concurrent build can leave an invalid index that causes
-- IF NOT EXISTS to skip every retry. Remove only that unusable artifact.
-- +goose StatementBegin
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_index i ON i.indexrelid = c.oid
WHERE n.nspname = 'public'
AND c.relname = 'watch_provider_scrobble_reconcile_pending_idx'
AND NOT i.indisvalid
) THEN
DROP INDEX public.watch_provider_scrobble_reconcile_pending_idx;
END IF;
END;
$$;
-- +goose StatementEnd
CREATE INDEX CONCURRENTLY IF NOT EXISTS watch_provider_scrobble_reconcile_pending_idx
ON public.watch_provider_scrobble_sessions (stop_sent_at)
WHERE stop_sent_at IS NOT NULL
AND completed = true
AND history_id <> ''
AND history_reconciled_at IS NULL;
-- +goose Down
DROP INDEX CONCURRENTLY IF EXISTS public.watch_provider_scrobble_reconcile_pending_idx;
@@ -0,0 +1,12 @@
-- +goose Up
ALTER TABLE playback_v3_attempts
ADD COLUMN frozen_recipe JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE playback_v3_attempts
ADD CONSTRAINT playback_v3_attempts_frozen_recipe_object
CHECK (jsonb_typeof(frozen_recipe) = 'object') NOT VALID;
-- +goose Down
ALTER TABLE playback_v3_attempts
DROP CONSTRAINT IF EXISTS playback_v3_attempts_frozen_recipe_object,
DROP COLUMN IF EXISTS frozen_recipe;
@@ -0,0 +1,8 @@
-- +goose Up
ALTER TABLE playback_v3_attempts
VALIDATE CONSTRAINT playback_v3_attempts_frozen_recipe_object;
-- +goose Down
-- PostgreSQL cannot mark a validated CHECK constraint NOT VALID again. The
-- preceding migration's Down step drops the constraint with its column.
SELECT 1;
@@ -36,6 +36,30 @@ const schema: PluginConfigSchema = {
};
describe("PluginConfigForm secrets", () => {
it("derives a form when a plugin only supplies JSON Schema", () => {
render(
<PluginConfigForm
schema={{
key: "server",
title: "Server",
json_schema: JSON.stringify({
type: "object",
properties: {
base_url: { type: "string", title: "Base URL" },
api_key: { type: "string", format: "password" },
},
required: ["base_url", "api_key"],
}),
required: true,
}}
onSave={vi.fn()}
/>,
);
expect(screen.getByLabelText("Base URL")).toBeInTheDocument();
expect(screen.getByLabelText("Api Key")).toHaveAttribute("type", "password");
});
it("shows redacted saved state and only clears through an explicit action", async () => {
const onSave = vi.fn();
render(
@@ -10,11 +10,14 @@ import { ConnectionCheckAction } from "@/components/admin/ConnectionCheckAction"
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { adminFormForConfigSchema, humanizeConfigKey } from "./configSchemaAdminForm";
import { SchemaForm } from "./SchemaForm";
import { buildSchemaValues } from "./schemaFormUtils";
type PluginConfigValue = Record<string, unknown>;
const EMPTY_FIELDS: PluginAdminFormField[] = [];
type Props = {
schema: PluginConfigSchema;
value?: PluginConfigValue;
@@ -29,82 +32,7 @@ type Props = {
isTesting?: boolean;
};
type SupportedField = PluginAdminFormField & {
inferredType?: "string" | "number" | "integer" | "boolean";
};
type ParsedObjectSchema = {
supported: boolean;
fields: SupportedField[];
};
function humanizeKey(value: string) {
return value
.split("_")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function parseJSONSchema(schema: PluginConfigSchema): ParsedObjectSchema {
try {
const parsed = JSON.parse(schema.json_schema) as {
type?: string;
required?: string[];
properties?: Record<
string,
{
type?: string;
title?: string;
description?: string;
writeOnly?: boolean;
format?: string;
}
>;
};
if (parsed.type !== "object" || !parsed.properties) {
return { supported: false, fields: [] };
}
const fields = Object.entries(parsed.properties).map(([key, property]) => {
const propertyType = property.type;
if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) {
return null;
}
const isSensitive = property.writeOnly === true || property.format === "password";
const control =
propertyType === "boolean"
? "SWITCH"
: propertyType === "number" || propertyType === "integer"
? "NUMBER"
: isSensitive
? "PASSWORD"
: "TEXT";
return {
key,
label: property.title || humanizeKey(key),
description: property.description,
control,
placeholder: "",
required: parsed.required?.includes(key) ?? false,
secret: isSensitive,
multiline: false,
options: [],
rows: 0,
inferredType: propertyType as "string" | "number" | "integer" | "boolean",
} satisfies SupportedField;
});
if (fields.some((field) => field == null)) {
return { supported: false, fields: [] };
}
return { supported: true, fields: fields.filter(Boolean) as SupportedField[] };
} catch {
return { supported: false, fields: [] };
}
}
function defaultValueForField(field: SupportedField): string | boolean {
function defaultValueForField(field: PluginAdminFormField): string | boolean {
if (field.default_value !== undefined) {
if (typeof field.default_value === "boolean") {
return field.default_value;
@@ -122,7 +50,10 @@ function defaultValueForField(field: SupportedField): string | boolean {
return "";
}
function valueForField(field: SupportedField, configValue?: PluginConfigValue): string | boolean {
function valueForField(
field: PluginAdminFormField,
configValue?: PluginConfigValue,
): string | boolean {
const raw = configValue?.[field.key];
if (typeof raw === "boolean") {
return raw;
@@ -145,19 +76,12 @@ export function PluginConfigForm({
isSaving = false,
isTesting = false,
}: Props) {
const parsedFallback = useMemo(() => parseJSONSchema(schema), [schema]);
const fields = useMemo<SupportedField[]>(() => {
if (schema.admin_form?.fields?.length) {
return schema.admin_form.fields;
}
return parsedFallback.fields;
}, [parsedFallback.fields, schema.admin_form?.fields]);
const supported =
fields.length > 0 && (schema.admin_form?.fields?.length ? true : parsedFallback.supported);
const inferredDescriptor = useMemo(() => adminFormForConfigSchema(schema), [schema]);
const fields = inferredDescriptor?.fields ?? EMPTY_FIELDS;
const supported = inferredDescriptor != null;
const descriptor = useMemo<PluginAdminForm>(() => {
const base = schema.admin_form ?? { fields };
const base = inferredDescriptor ?? { fields };
const configured = new Set(configuredSecrets);
return {
...base,
@@ -167,7 +91,7 @@ export function PluginConfigForm({
: field,
),
};
}, [configuredSecrets, fields, schema.admin_form]);
}, [configuredSecrets, fields, inferredDescriptor]);
const [values, setValues] = useState<PluginConfigValue>(() =>
Object.fromEntries(fields.map((field) => [field.key, valueForField(field, value)])),
@@ -248,7 +172,7 @@ export function PluginConfigForm({
return (
<div key={key} className="flex items-center justify-between gap-3 text-xs">
<span className={clearing ? "text-destructive" : "text-muted-foreground"}>
{field?.label || humanizeKey(key)}: {clearing ? "will be cleared" : "saved"}
{field?.label || humanizeConfigKey(key)}: {clearing ? "will be cleared" : "saved"}
{required ? " (required)" : ""}
</span>
{!required ? (
@@ -98,6 +98,32 @@ describe("SchemaForm", () => {
"true",
);
});
it("uses a controlling field default when rendering a conditional field", () => {
const d: PluginAdminForm = {
fields: [
{
key: "advanced_enabled",
label: "Advanced",
control: "SWITCH",
required: false,
secret: false,
multiline: false,
default_value: true,
},
{
key: "endpoint",
label: "Endpoint",
control: "TEXT",
required: false,
secret: false,
multiline: false,
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
},
],
};
render(<SchemaForm descriptor={d} values={{}} onChange={vi.fn()} />);
expect(screen.getByText("Endpoint")).toBeTruthy();
});
it("reports validity through onValidityChange (#14)", () => {
const onValidityChange = vi.fn();
const d: PluginAdminForm = {
@@ -193,6 +219,43 @@ describe("SchemaForm collapsible sections", () => {
fireEvent.click(screen.getByText("Show"));
expect(screen.getByText("Verbose")).toBeTruthy();
});
it("uses a controlling field default when rendering a conditional section", () => {
const d: PluginAdminForm = {
fields: [
{
key: "advanced_enabled",
label: "Advanced",
control: "SWITCH",
required: false,
secret: false,
multiline: false,
default_value: true,
},
{
key: "endpoint",
label: "Endpoint",
control: "TEXT",
required: false,
secret: false,
multiline: false,
},
],
sections: [
{
key: "advanced",
title: "Advanced options",
collapsible: false,
collapsed_default: false,
field_keys: ["endpoint"],
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
},
],
};
render(<SchemaForm descriptor={d} values={{}} onChange={vi.fn()} />);
expect(screen.getByText("Advanced options")).toBeTruthy();
expect(screen.getByText("Endpoint")).toBeTruthy();
});
});
it("marks a show_when-gated field as nested when it is revealed", () => {
@@ -97,11 +97,13 @@ function ChipsSkeleton() {
function SchemaFormSection({
section,
values,
fields,
forceOpen,
renderFields,
}: {
section: PluginAdminFormSection;
values: Record<string, unknown>;
fields: PluginAdminFormField[];
forceOpen: boolean;
renderFields: (keys: string[]) => React.ReactNode;
}) {
@@ -109,7 +111,7 @@ function SchemaFormSection({
// (the section has unresolved errors) always wins so setup can't be hidden.
const [userOpen, setUserOpen] = useState<boolean | null>(null);
if (!evaluateShowWhen(section.show_when, values)) {
if (!evaluateShowWhen(section.show_when, values, fields)) {
return null;
}
@@ -331,7 +333,9 @@ export function SchemaForm({
// bordered, divided container so toggles read as a cohesive group instead of
// a column of separate boxes. Honors show_when on each field.
function renderFieldList(fields: PluginAdminFormField[]): React.ReactNode {
const visible = fields.filter((field) => evaluateShowWhen(field.show_when, values));
const visible = fields.filter((field) =>
evaluateShowWhen(field.show_when, values, descriptor.fields),
);
const nodes: React.ReactNode[] = [];
let run: PluginAdminFormField[] = [];
// Key switch groups by their position in the list, not by their first
@@ -389,6 +393,7 @@ export function SchemaForm({
key={section.key}
section={section}
values={values}
fields={descriptor.fields}
forceOpen={section.field_keys.some((key) => mergedErrors[key] != null)}
renderFields={(keys) => renderFieldList(resolveKeys(keys))}
/>
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import type { PluginConfigSchema } from "@/api/types";
import { adminFormForConfigSchema } from "./configSchemaAdminForm";
function schema(overrides: Partial<PluginConfigSchema> = {}): PluginConfigSchema {
return {
key: "connection",
title: "Connection",
json_schema: JSON.stringify({
type: "object",
properties: {},
additionalProperties: false,
}),
required: true,
...overrides,
};
}
describe("adminFormForConfigSchema", () => {
it("preserves primitive JSON Schema defaults on inferred fields", () => {
const form = adminFormForConfigSchema(
schema({
json_schema: JSON.stringify({
type: "object",
properties: {
base_url: { type: "string", default: "https://floppy.example.com" },
port: { type: "integer", default: 8080 },
verify_tls: { type: "boolean", default: true },
},
}),
}),
);
expect(form?.fields.map(({ key, default_value }) => ({ key, default_value }))).toEqual([
{ key: "base_url", default_value: "https://floppy.example.com" },
{ key: "port", default_value: 8080 },
{ key: "verify_tls", default_value: true },
]);
});
it("adds scalar schema properties omitted by a partial admin form", () => {
const form = adminFormForConfigSchema(
schema({
json_schema: JSON.stringify({
type: "object",
properties: {
base_url: { type: "string" },
username: { type: "string" },
},
required: ["base_url", "username"],
}),
admin_form: {
fields: [
{
key: "base_url",
label: "Custom URL",
control: "TEXT",
required: true,
secret: false,
multiline: false,
},
],
},
}),
);
expect(form?.fields.map(({ key, label, required }) => ({ key, label, required }))).toEqual([
{ key: "base_url", label: "Custom URL", required: true },
{ key: "username", label: "Username", required: true },
]);
});
it("preserves an explicit form when its schema cannot be inferred", () => {
const explicit = {
fields: [
{
key: "api_key",
label: "API Key",
control: "PASSWORD" as const,
required: true,
secret: true,
multiline: false,
},
],
};
expect(adminFormForConfigSchema(schema({ json_schema: "", admin_form: explicit }))).toBe(
explicit,
);
});
it("applies JSON Schema sensitivity to matching explicit fields", () => {
const form = adminFormForConfigSchema(
schema({
json_schema: JSON.stringify({
type: "object",
properties: {
api_key: { type: "string", writeOnly: true },
password: { type: "string", format: "password" },
advanced: { type: "object", writeOnly: true },
},
}),
admin_form: {
fields: [
{
key: "api_key",
label: "API Key",
control: "TEXT",
required: false,
secret: false,
multiline: false,
},
{
key: "password",
label: "Password",
control: "TEXT",
required: false,
secret: false,
multiline: false,
},
{
key: "advanced",
label: "Advanced",
control: "TEXTAREA",
required: false,
secret: false,
multiline: true,
},
],
},
}),
);
expect(form?.fields.map(({ key, secret }) => ({ key, secret }))).toEqual([
{ key: "api_key", secret: true },
{ key: "password", secret: true },
{ key: "advanced", secret: true },
]);
});
});
@@ -0,0 +1,98 @@
import type { PluginAdminForm, PluginAdminFormField, PluginConfigSchema } from "@/api/types";
export function humanizeConfigKey(value: string) {
return value
.split("_")
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
export function adminFormForConfigSchema(schema: PluginConfigSchema): PluginAdminForm | null {
const explicitFields = schema.admin_form?.fields ?? [];
try {
const parsed = JSON.parse(schema.json_schema) as {
type?: string;
required?: string[];
properties?: Record<
string,
{
type?: string;
title?: string;
description?: string;
writeOnly?: boolean;
format?: string;
default?: unknown;
}
>;
};
if (parsed.type !== "object" || !parsed.properties) {
return explicitFields.length > 0 ? schema.admin_form! : null;
}
const inferredFields = Object.entries(parsed.properties).map(
([key, property]): PluginAdminFormField | null => {
const propertyType = property.type;
if (!propertyType || !["string", "number", "integer", "boolean"].includes(propertyType)) {
return null;
}
const secret = property.writeOnly === true || property.format === "password";
const control =
propertyType === "boolean"
? "SWITCH"
: propertyType === "number" || propertyType === "integer"
? "NUMBER"
: secret
? "PASSWORD"
: "TEXT";
return {
key,
label: property.title || humanizeConfigKey(key),
description: property.description,
control,
placeholder: "",
required: parsed.required?.includes(key) ?? false,
secret,
multiline: false,
default_value:
typeof property.default === "string" ||
typeof property.default === "number" ||
typeof property.default === "boolean"
? property.default
: undefined,
options: [],
rows: 0,
};
},
);
if (explicitFields.length > 0) {
const explicitKeys = new Set(explicitFields.map((field) => field.key));
const sensitiveKeys = new Set(
Object.entries(parsed.properties)
.filter(([, property]) => property.writeOnly === true || property.format === "password")
.map(([key]) => key),
);
return {
...schema.admin_form,
fields: [
...explicitFields.map((field) =>
sensitiveKeys.has(field.key) ? { ...field, secret: true } : field,
),
...inferredFields.filter(
(field): field is PluginAdminFormField => field != null && !explicitKeys.has(field.key),
),
],
};
}
if (inferredFields.some((field) => field == null)) return null;
return {
...schema.admin_form,
fields: inferredFields.filter((field): field is PluginAdminFormField => field != null),
};
} catch {
return explicitFields.length > 0 ? schema.admin_form! : null;
}
}
@@ -172,6 +172,7 @@ describe("parseFieldTypes (#15)", () => {
root_folder: { type: "string" },
tags: { type: "array", items: { type: "integer" } },
labels: { type: "array", items: { type: "string" } },
flags: { type: "array", items: { type: "boolean" } },
enabled: { type: "boolean" },
},
});
@@ -180,6 +181,7 @@ describe("parseFieldTypes (#15)", () => {
root_folder: "string",
tags: "array:int",
labels: "array",
flags: "array:bool",
enabled: "boolean",
});
});
@@ -443,3 +445,63 @@ describe("coerceFieldValue array:num coercion (CodeRabbit #5)", () => {
expect(coerceFieldValue(numArrayField, ["abc"], "array:num")).toEqual(["abc"]);
});
});
describe("coerceFieldValue array:bool coercion", () => {
it("coerces boolean multi-select values using their declared item type", () => {
expect(coerceFieldValue(numArrayField, ["true", "false", true], "array:bool")).toEqual([
true,
false,
true,
]);
});
});
describe("section visibility", () => {
const sectionDescriptor: PluginAdminForm = {
fields: [
{
key: "advanced_enabled",
label: "Advanced",
control: "SWITCH",
required: false,
secret: false,
multiline: false,
default_value: false,
},
{
key: "endpoint",
label: "Endpoint",
control: "TEXT",
required: true,
secret: false,
multiline: false,
},
],
sections: [
{
key: "advanced",
title: "Advanced",
collapsible: false,
collapsed_default: false,
field_keys: ["endpoint"],
show_when: [{ field: "advanced_enabled", equals: ["true"] }],
},
],
};
it("does not validate required fields in a hidden section", () => {
expect(validateSchemaValues(sectionDescriptor, { endpoint: "stale" })).toEqual({});
expect(validateSchemaValues(sectionDescriptor, { advanced_enabled: true }).endpoint).toMatch(
/required/i,
);
});
it("does not persist stale values from a hidden section", () => {
expect(buildSchemaValues(sectionDescriptor, { endpoint: "stale" })).toEqual({
advanced_enabled: false,
});
expect(
buildSchemaValues(sectionDescriptor, { advanced_enabled: true, endpoint: "active" }),
).toEqual({ advanced_enabled: true, endpoint: "active" });
});
});
@@ -45,7 +45,7 @@ export function validateSchemaValues(
): Record<string, string> {
const errors: Record<string, string> = {};
for (const field of descriptor.fields) {
if (!evaluateShowWhen(field.show_when, values, descriptor.fields)) continue;
if (!fieldIsVisible(descriptor, field, values)) continue;
const raw = effectiveValue(field, values);
if (field.required && isEmpty(raw)) {
errors[field.key] = `${field.label || field.key} is required`;
@@ -120,6 +120,7 @@ export type FieldType =
| "number"
| "boolean"
| "array"
| "array:bool"
| "array:int"
| "array:num";
@@ -150,6 +151,7 @@ export function parseFieldTypes(jsonSchema: string | undefined | null): Record<s
items && typeof items === "object" ? (items as { type?: unknown }).type : undefined;
if (itemType === "integer") out[key] = "array:int";
else if (itemType === "number") out[key] = "array:num";
else if (itemType === "boolean") out[key] = "array:bool";
else out[key] = "array";
} else if (type === "string" || type === "integer" || type === "number" || type === "boolean") {
out[key] = type;
@@ -191,10 +193,12 @@ export function coerceFieldValue(
return raw;
}
case "array":
case "array:bool":
case "array:int":
case "array:num": {
const arr = Array.isArray(raw) ? raw : [];
if (fieldType === "array") return arr;
if (fieldType === "array:bool") return arr.map((v) => coerceBoolean(v));
if (fieldType === "array:int") return arr.map((v) => coerceNumericString(v));
return arr.map((v) => coerceNumberString(v));
}
@@ -237,6 +241,23 @@ export function effectiveValue(
return values[field.key] !== undefined ? values[field.key] : field.default_value;
}
export function fieldIsVisible(
descriptor: PluginAdminForm,
field: PluginAdminFormField,
values: Record<string, unknown>,
): boolean {
if (!evaluateShowWhen(field.show_when, values, descriptor.fields)) return false;
const containingSections = (descriptor.sections ?? []).filter((section) =>
section.field_keys.includes(field.key),
);
return (
containingSections.length === 0 ||
containingSections.some((section) =>
evaluateShowWhen(section.show_when, values, descriptor.fields),
)
);
}
export function buildSchemaValues(
descriptor: PluginAdminForm,
draft: Record<string, unknown>,
@@ -244,7 +265,7 @@ export function buildSchemaValues(
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const field of descriptor.fields) {
if (!evaluateShowWhen(field.show_when, draft, descriptor.fields)) continue; // don't persist hidden fields' stale values
if (!fieldIsVisible(descriptor, field, draft)) continue; // don't persist hidden fields' stale values
// Fall back to the declared default for untouched fields so an unmodified
// default persists exactly as it is displayed.
const rawSource = draft[field.key] !== undefined ? draft[field.key] : field.default_value;
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
connectWatchProviderAPIKey,
fetchWatchProviders,
pollWatchProviderDeviceAuth,
startWatchProviderDeviceAuth,
@@ -42,6 +43,7 @@ vi.mock("@/api/client", () => ({
return {
path,
method: init?.method ?? "GET",
body: init?.body,
};
}),
}));
@@ -61,6 +63,18 @@ describe("watch provider queries", () => {
path: "/watch-providers/trakt/auth/poll",
method: "POST",
});
await expect(
connectWatchProviderAPIKey("plugin:4:floppy", "token", {
floppy: { base_url: "https://floppy.example.com" },
}),
).resolves.toMatchObject({
path: "/watch-providers/plugin:4:floppy/auth/api-key",
method: "POST",
body: JSON.stringify({
api_key: "token",
connection_config: { floppy: { base_url: "https://floppy.example.com" } },
}),
});
await expect(
updateWatchProviderConnection("trakt", { scrobble_enabled: true }),
).resolves.toMatchObject({
+18 -3
View File
@@ -3,11 +3,15 @@ import { api, ApiClientError } from "@/api/client";
import { favoriteKeys, watchlistKeys, watchProviderKeys } from "./keys";
import { toast } from "sonner";
import { storage } from "@/utils/storage";
import type { PluginConfigSchema } from "@/api/types";
export type WatchProviderConnectionConfig = Record<string, Record<string, unknown>>;
export interface WatchProviderSummary {
key: string;
display_name: string;
capabilities: WatchProviderCapabilities;
connection_config_schema?: PluginConfigSchema[];
}
export const WatchProviderAuthMethod = {
@@ -52,6 +56,7 @@ export interface WatchProviderConnection {
sync_watchlist_order_enabled: boolean;
scrobble_enabled: boolean;
credentials_configured: boolean;
connection_config_schema?: PluginConfigSchema[];
last_inbound_sync_at?: string;
last_progress_sync_at?: string;
last_outbound_sync_at?: string;
@@ -159,10 +164,14 @@ export function pollWatchProviderDeviceAuth(provider: string, authSessionId: str
});
}
export function connectWatchProviderAPIKey(provider: string, apiKey: string) {
export function connectWatchProviderAPIKey(
provider: string,
apiKey: string,
connectionConfig: WatchProviderConnectionConfig = {},
) {
return api<WatchProviderConnection>(`/watch-providers/${provider}/auth/api-key`, {
method: "POST",
body: JSON.stringify({ api_key: apiKey }),
body: JSON.stringify({ api_key: apiKey, connection_config: connectionConfig }),
});
}
@@ -248,7 +257,13 @@ export function usePollWatchProviderDeviceAuth(provider: string) {
export function useConnectWatchProviderAPIKey(provider: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (apiKey: string) => connectWatchProviderAPIKey(provider, apiKey),
mutationFn: ({
apiKey,
connectionConfig,
}: {
apiKey: string;
connectionConfig?: WatchProviderConnectionConfig;
}) => connectWatchProviderAPIKey(provider, apiKey, connectionConfig),
onSuccess: (connection) => {
const profileId = getActiveProfileId();
queryClient.setQueryData(watchProviderKeys.connection(profileId, provider), connection);
@@ -32,6 +32,14 @@ import {
type WatchProviderSyncRun,
} from "@/hooks/queries/watchProviders";
import { Input } from "@/components/ui/input";
import { SchemaForm } from "@/components/admin/plugins/SchemaForm";
import type { PluginConfigSchema } from "@/api/types";
import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProviders";
import {
buildConnectionConfig,
connectionSchemasAreValid,
renderableConnectionSchemas,
} from "./watchProviderConnectionConfig";
function formatRelativeTime(value?: string) {
if (!value) return "Never";
@@ -214,17 +222,29 @@ function AuthCodeBlock({
function APIKeyBlock({
displayName,
providerKey,
configSchemas,
pending,
onSubmit,
onCancel,
}: {
displayName: string;
providerKey: string;
configSchemas: PluginConfigSchema[];
pending: boolean;
onSubmit: (apiKey: string) => void;
onSubmit: (apiKey: string, connectionConfig: WatchProviderConnectionConfig) => void;
onCancel: () => void;
}) {
const [value, setValue] = useState("");
const [connectionConfig, setConnectionConfig] = useState<WatchProviderConnectionConfig>({});
const [configValidity, setConfigValidity] = useState<Record<string, boolean>>({});
const trimmed = value.trim();
const renderableSchemas = renderableConnectionSchemas(configSchemas);
const configValid = connectionSchemasAreValid(
renderableSchemas,
connectionConfig,
configValidity,
);
return (
<div className="border-primary/30 bg-primary/5 rounded-xl border border-dashed p-4">
@@ -245,6 +265,29 @@ function APIKeyBlock({
<X className="h-4 w-4" />
</Button>
</div>
{renderableSchemas.map((schema) => (
<div key={schema.key} className="mt-4 space-y-2">
<div>
<div className="text-sm font-medium">{schema.title || schema.key}</div>
{schema.description ? (
<div className="text-muted-foreground mt-0.5 text-xs leading-snug">
{schema.description}
</div>
) : null}
</div>
<SchemaForm
descriptor={schema.admin_form}
values={connectionConfig[schema.key] ?? {}}
onChange={(next) =>
setConnectionConfig((current) => ({ ...current, [schema.key]: next }))
}
idPrefix={`watch-provider-${providerKey}-${schema.key}`}
onValidityChange={(valid) =>
setConfigValidity((current) => ({ ...current, [schema.key]: valid }))
}
/>
</div>
))}
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-stretch">
<Input
type="password"
@@ -258,8 +301,10 @@ function APIKeyBlock({
<Button
type="button"
size="sm"
disabled={pending || trimmed.length === 0}
onClick={() => onSubmit(trimmed)}
disabled={pending || trimmed.length === 0 || !configValid}
onClick={() =>
onSubmit(trimmed, buildConnectionConfig(renderableSchemas, connectionConfig))
}
className="sm:flex-none"
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
@@ -454,12 +499,15 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
});
};
const handleSubmitAPIKey = (apiKey: string) => {
connectAPIKey.mutate(apiKey, {
onSuccess: () => {
setApiKeyPrompt(false);
const handleSubmitAPIKey = (apiKey: string, connectionConfig: WatchProviderConnectionConfig) => {
connectAPIKey.mutate(
{ apiKey, connectionConfig },
{
onSuccess: () => {
setApiKeyPrompt(false);
},
},
});
);
};
const handleCancelAPIKey = () => {
@@ -549,6 +597,8 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
<div className="mt-4">
<APIKeyBlock
displayName={displayName}
providerKey={providerKey}
configSchemas={connection.connection_config_schema ?? []}
pending={connectAPIKey.isPending}
onSubmit={handleSubmitAPIKey}
onCancel={handleCancelAPIKey}
@@ -622,14 +672,18 @@ function WatchProviderCard({ providerKey }: { providerKey: string }) {
disabled={isBusy}
onChange={(checked) => updateConnection.mutate({ export_watched_enabled: checked })}
/>
<ToggleRow
id={`watch-provider-${providerKey}-export-unwatched`}
label="Send unwatched changes"
description="When you mark something unwatched, remove matching history from this provider."
checked={connection.export_unwatched_enabled}
disabled={isBusy}
onChange={(checked) => updateConnection.mutate({ export_unwatched_enabled: checked })}
/>
{connection.capabilities.export_unwatched ? (
<ToggleRow
id={`watch-provider-${providerKey}-export-unwatched`}
label="Send unwatched changes"
description="When you mark something unwatched, remove matching history from this provider."
checked={connection.export_unwatched_enabled}
disabled={isBusy}
onChange={(checked) =>
updateConnection.mutate({ export_unwatched_enabled: checked })
}
/>
) : null}
{connection.capabilities.import_favorites ||
connection.capabilities.export_favorites ? (
<ToggleRow
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import type { PluginConfigSchema } from "@/api/types";
import {
activeConnectionSchemas,
buildConnectionConfig,
connectionSchemasAreValid,
renderableConnectionSchemas,
} from "./watchProviderConnectionConfig";
const optionalSchema: PluginConfigSchema = {
key: "optional",
title: "Optional server settings",
description: "Only sent when configured.",
json_schema: JSON.stringify({
type: "object",
properties: { base_url: { type: "string" } },
required: ["base_url"],
}),
required: false,
admin_form: {
fields: [
{
key: "base_url",
label: "Server URL",
control: "TEXT",
required: true,
secret: false,
multiline: false,
},
],
},
};
describe("watch provider connection config", () => {
it("omits an untouched optional schema without blocking Connect", () => {
expect(activeConnectionSchemas([optionalSchema], {})).toEqual([]);
expect(connectionSchemasAreValid([optionalSchema], {}, { optional: false })).toBe(true);
expect(buildConnectionConfig([optionalSchema], {})).toEqual({});
});
it("validates and submits an optional schema after the user enters a value", () => {
const drafts = { optional: { base_url: "https://floppy.example.com" } };
expect(connectionSchemasAreValid([optionalSchema], drafts, { optional: false })).toBe(false);
expect(connectionSchemasAreValid([optionalSchema], drafts, { optional: true })).toBe(true);
expect(buildConnectionConfig([optionalSchema], drafts)).toEqual({
optional: { base_url: "https://floppy.example.com" },
});
});
it("keeps required schemas active before any values are entered", () => {
const requiredSchema = { ...optionalSchema, key: "required", required: true };
expect(activeConnectionSchemas([requiredSchema], {})).toEqual([requiredSchema]);
expect(connectionSchemasAreValid([requiredSchema], {}, {})).toBe(false);
});
it("derives a usable form for a required JSON-schema-only block", () => {
const headless = { ...optionalSchema, required: true, admin_form: undefined };
const schemas = renderableConnectionSchemas([headless]);
expect(schemas).toHaveLength(1);
const renderable = schemas[0]!;
expect(renderable.admin_form.fields).toEqual([
expect.objectContaining({
key: "base_url",
label: "Base Url",
control: "TEXT",
required: true,
}),
]);
expect(connectionSchemasAreValid([renderable], {}, {})).toBe(false);
});
});
@@ -0,0 +1,59 @@
import type { PluginConfigSchema } from "@/api/types";
import { adminFormForConfigSchema } from "@/components/admin/plugins/configSchemaAdminForm";
import { buildSchemaValues, parseFieldTypes } from "@/components/admin/plugins/schemaFormUtils";
import type { WatchProviderConnectionConfig } from "@/hooks/queries/watchProviders";
export type RenderableConnectionSchema = PluginConfigSchema & {
admin_form: NonNullable<PluginConfigSchema["admin_form"]>;
};
export function renderableConnectionSchemas(
schemas: PluginConfigSchema[],
): RenderableConnectionSchema[] {
return schemas.flatMap((schema) => {
const adminForm = adminFormForConfigSchema(schema);
return adminForm == null ? [] : [{ ...schema, admin_form: adminForm }];
});
}
function hasEnteredValue(value: unknown): boolean {
if (value == null) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.some(hasEnteredValue);
if (typeof value === "object") return Object.values(value).some(hasEnteredValue);
return true;
}
export function activeConnectionSchemas(
schemas: PluginConfigSchema[],
drafts: WatchProviderConnectionConfig,
): RenderableConnectionSchema[] {
return schemas.filter(
(schema): schema is RenderableConnectionSchema =>
schema.admin_form != null && (schema.required || hasEnteredValue(drafts[schema.key])),
);
}
export function connectionSchemasAreValid(
schemas: PluginConfigSchema[],
drafts: WatchProviderConnectionConfig,
validity: Record<string, boolean>,
): boolean {
return activeConnectionSchemas(schemas, drafts).every((schema) => validity[schema.key] ?? false);
}
export function buildConnectionConfig(
schemas: PluginConfigSchema[],
drafts: WatchProviderConnectionConfig,
): WatchProviderConnectionConfig {
return Object.fromEntries(
activeConnectionSchemas(schemas, drafts).map((schema) => [
schema.key,
buildSchemaValues(
schema.admin_form,
drafts[schema.key] ?? {},
parseFieldTypes(schema.json_schema),
),
]),
);
}