From e56a1b3e039f25700429e16f9e17089a929f4a79 Mon Sep 17 00:00:00 2001 From: Jeremy Smith <145979804+JeremySNR@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:20:50 +0100 Subject: [PATCH 1/8] fix(api): throttle api_keys last_used_at writes in auth middleware (#381) * fix(api): throttle api_keys last_used_at writes in auth middleware Every API key request spawned a goroutine that ran an UPDATE on api_keys, so a key driving HLS segments or a polling integration hit the table with one write per request, and a stalled database could pile those goroutines up without bound. The jellycompat authenticator already guards this same write with a once-per-minute throttle per key; the main middleware was missing it. Bring the two in line. Track the last write per key ID and only launch the update once a minute has passed, with a timeout on the background write. The map is keyed by key ID so it stays bounded. * fix(auth): bound API key last-used throttling --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- internal/api/middleware/auth.go | 8 +-- internal/auth/api_key_last_used.go | 80 ++++++++++++++++++++++++ internal/auth/api_key_last_used_test.go | 82 +++++++++++++++++++++++++ internal/jellycompat/auth_api_key.go | 45 +++----------- 4 files changed, 176 insertions(+), 39 deletions(-) create mode 100644 internal/auth/api_key_last_used.go create mode 100644 internal/auth/api_key_last_used_test.go diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index f081ea83..d6edc8c3 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -47,6 +47,8 @@ type AuthMiddleware struct { sessionValidator SessionValidator apiKeyValidator APIKeyValidator // nil if API keys not configured apiKeyUserLoader APIKeyUserLoader // nil if API keys not configured + + apiKeyLastUsed *auth.APIKeyLastUsedTracker } // NewAuthMiddleware creates a new AuthMiddleware with the given token validator @@ -57,6 +59,7 @@ func NewAuthMiddleware(tv TokenValidator, sv SessionValidator, akv APIKeyValidat sessionValidator: sv, apiKeyValidator: akv, apiKeyUserLoader: akul, + apiKeyLastUsed: auth.NewAPIKeyLastUsedTracker(akv, nil), } } @@ -98,10 +101,7 @@ func (am *AuthMiddleware) RequireAuth(next http.Handler) http.Handler { return } - // Update last_used_at asynchronously. - go func(id int64) { - _ = am.apiKeyValidator.UpdateLastUsed(context.Background(), id) - }(apiKey.ID) + am.apiKeyLastUsed.Touch(apiKey.ID) claims = &auth.Claims{ UserID: user.ID, diff --git a/internal/auth/api_key_last_used.go b/internal/auth/api_key_last_used.go new file mode 100644 index 00000000..267e020a --- /dev/null +++ b/internal/auth/api_key_last_used.go @@ -0,0 +1,80 @@ +package auth + +import ( + "context" + "log/slog" + "sync" + "time" +) + +const ( + apiKeyLastUsedInterval = time.Minute + apiKeyLastUsedTimeout = 5 * time.Second +) + +// APIKeyLastUsedUpdater persists an API key's last-used timestamp. +type APIKeyLastUsedUpdater interface { + UpdateLastUsed(ctx context.Context, id int64) error +} + +// APIKeyLastUsedTracker coalesces last-used writes per key without blocking +// authentication requests. Stale entries are pruned inline once per interval, +// avoiding both unbounded historical-key retention and another goroutine. +type APIKeyLastUsedTracker struct { + updater APIKeyLastUsedUpdater + now func() time.Time + + mu sync.Mutex + lastUsedAt map[int64]time.Time + nextPrune time.Time +} + +// NewAPIKeyLastUsedTracker creates a tracker. A nil clock uses time.Now. +func NewAPIKeyLastUsedTracker(updater APIKeyLastUsedUpdater, now func() time.Time) *APIKeyLastUsedTracker { + if now == nil { + now = time.Now + } + return &APIKeyLastUsedTracker{ + updater: updater, + now: now, + lastUsedAt: make(map[int64]time.Time), + } +} + +// Touch records key usage asynchronously, at most once per interval per key. +func (t *APIKeyLastUsedTracker) Touch(id int64) { + if t == nil || t.updater == nil || !t.shouldUpdate(id) { + return + } + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), apiKeyLastUsedTimeout) + defer cancel() + if err := t.updater.UpdateLastUsed(ctx, id); err != nil { + slog.DebugContext(ctx, "api key last-used update failed", "component", "auth", "id", id, "error", err) + } + }() +} + +func (t *APIKeyLastUsedTracker) shouldUpdate(id int64) bool { + now := t.now() + + t.mu.Lock() + defer t.mu.Unlock() + + if t.nextPrune.IsZero() || !now.Before(t.nextPrune) { + cutoff := now.Add(-apiKeyLastUsedInterval) + for keyID, lastUsed := range t.lastUsedAt { + if !lastUsed.After(cutoff) { + delete(t.lastUsedAt, keyID) + } + } + t.nextPrune = now.Add(apiKeyLastUsedInterval) + } + + if lastUsed, ok := t.lastUsedAt[id]; ok && now.Sub(lastUsed) < apiKeyLastUsedInterval { + return false + } + t.lastUsedAt[id] = now + return true +} diff --git a/internal/auth/api_key_last_used_test.go b/internal/auth/api_key_last_used_test.go new file mode 100644 index 00000000..3dfc6727 --- /dev/null +++ b/internal/auth/api_key_last_used_test.go @@ -0,0 +1,82 @@ +package auth + +import ( + "context" + "testing" + "time" +) + +type recordingLastUsedUpdater struct { + calls chan lastUsedCall +} + +type lastUsedCall struct { + id int64 + deadline time.Time +} + +func (u *recordingLastUsedUpdater) UpdateLastUsed(ctx context.Context, id int64) error { + deadline, _ := ctx.Deadline() + u.calls <- lastUsedCall{id: id, deadline: deadline} + return nil +} + +func TestAPIKeyLastUsedTrackerThrottlesPerKey(t *testing.T) { + now := time.Date(2026, time.July, 20, 12, 0, 0, 0, time.UTC) + tracker := NewAPIKeyLastUsedTracker(nil, func() time.Time { return now }) + + if !tracker.shouldUpdate(1) { + t.Fatal("first use should update") + } + if tracker.shouldUpdate(1) { + t.Fatal("repeat use inside the interval should be throttled") + } + if !tracker.shouldUpdate(2) { + t.Fatal("a different key should update independently") + } + + now = now.Add(apiKeyLastUsedInterval) + if !tracker.shouldUpdate(1) { + t.Fatal("use at the next interval should update") + } +} + +func TestAPIKeyLastUsedTrackerPrunesExpiredKeys(t *testing.T) { + now := time.Date(2026, time.July, 20, 12, 0, 0, 0, time.UTC) + tracker := NewAPIKeyLastUsedTracker(nil, func() time.Time { return now }) + + tracker.shouldUpdate(1) + tracker.shouldUpdate(2) + + now = now.Add(apiKeyLastUsedInterval) + tracker.shouldUpdate(3) + + if len(tracker.lastUsedAt) != 1 { + t.Fatalf("retained entries = %d, want 1", len(tracker.lastUsedAt)) + } + if _, ok := tracker.lastUsedAt[3]; !ok { + t.Fatal("current key was not retained") + } +} + +func TestAPIKeyLastUsedTrackerAddsWriteDeadline(t *testing.T) { + updater := &recordingLastUsedUpdater{calls: make(chan lastUsedCall, 1)} + tracker := NewAPIKeyLastUsedTracker(updater, nil) + tracker.Touch(42) + + select { + case call := <-updater.calls: + if call.id != 42 { + t.Fatalf("key id = %d, want 42", call.id) + } + if call.deadline.IsZero() { + t.Fatal("last-used update context has no deadline") + } + remaining := time.Until(call.deadline) + if remaining <= 0 || remaining > apiKeyLastUsedTimeout { + t.Fatalf("deadline after %s, want within (0, %s]", remaining, apiKeyLastUsedTimeout) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for last-used update") + } +} diff --git a/internal/jellycompat/auth_api_key.go b/internal/jellycompat/auth_api_key.go index 534d685d..4940ee13 100644 --- a/internal/jellycompat/auth_api_key.go +++ b/internal/jellycompat/auth_api_key.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/Silo-Server/silo-server/internal/auth" "github.com/Silo-Server/silo-server/internal/models" "github.com/Silo-Server/silo-server/internal/userstore" ) @@ -36,8 +37,7 @@ type AdminAPIKeyAuthenticator struct { // authorization decision, which is re-checked on every request. profiles *apiKeyProfileCache - lastUsedMu sync.Mutex - lastUsedAt map[int64]time.Time + apiKeyLastUsed *auth.APIKeyLastUsedTracker } type adminAPIKeyAuthResult struct { @@ -61,12 +61,12 @@ func NewAdminAPIKeyAuthenticator(keys apiKeyValidator, users apiKeyUserLoader, p now = time.Now } return &AdminAPIKeyAuthenticator{ - keys: keys, - users: users, - provider: provider, - now: now, - profiles: newAPIKeyProfileCache(), - lastUsedAt: make(map[int64]time.Time), + keys: keys, + users: users, + provider: provider, + now: now, + profiles: newAPIKeyProfileCache(), + apiKeyLastUsed: auth.NewAPIKeyLastUsedTracker(keys, now), } } @@ -138,7 +138,7 @@ func (a *AdminAPIKeyAuthenticator) authenticate(r *http.Request) adminAPIKeyAuth if !res.ok { return res } - a.touchLastUsed(apiKey.ID) + a.apiKeyLastUsed.Touch(apiKey.ID) return adminAPIKeyAuthResult{ ctx: context.WithValue(r.Context(), adminAPIKeyKey, true), status: http.StatusOK, @@ -182,28 +182,6 @@ func (a *AdminAPIKeyAuthenticator) validate(ctx context.Context, token string) ( return apiKey, user, adminAPIKeyAuthResult{ok: true} } -// touchLastUsed records key usage without blocking the request, throttled to at -// most once per apiKeyLastUsedInterval per key so per-request validation on the -// hot path (e.g. HLS segments) does not issue a DB write each time. -func (a *AdminAPIKeyAuthenticator) touchLastUsed(id int64) { - now := a.now() - a.lastUsedMu.Lock() - if last, ok := a.lastUsedAt[id]; ok && now.Sub(last) < apiKeyLastUsedInterval { - a.lastUsedMu.Unlock() - return - } - a.lastUsedAt[id] = now - a.lastUsedMu.Unlock() - - go func(id int64) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := a.keys.UpdateLastUsed(ctx, id); err != nil { - slog.Debug("jellycompat api key last-used update failed", "id", id, "error", err) - } - }(id) -} - // resolveSession authenticates an sa_ API key and returns a compat session // synthesized for the key user's primary profile, so ordinary browse/stream // handlers (which read SessionFromContext) work unchanged for an API key. @@ -224,7 +202,7 @@ func (a *AdminAPIKeyAuthenticator) resolveSession(ctx context.Context, token str if !res.ok { return nil, res, true } - a.touchLastUsed(apiKey.ID) + a.apiKeyLastUsed.Touch(apiKey.ID) profile, err := a.primaryProfile(ctx, user.ID) if err != nil { @@ -298,9 +276,6 @@ const ( // reassigned/renamed primary profile is picked up, never authorization // (the key and user are re-validated on every request). apiKeyProfileCacheTTL = 5 * time.Minute - // apiKeyLastUsedInterval throttles api_keys.last_used_at writes so that - // per-request validation on the hot path does not write each time. - apiKeyLastUsedInterval = time.Minute ) type apiKeyProfileCacheEntry struct { From f637404c4644b68045b166b98aac82de9bde910c Mon Sep 17 00:00:00 2001 From: Jeremy Smith <145979804+JeremySNR@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:25:24 +0100 Subject: [PATCH 2/8] fix(userstore): batch allowed-library and profile lookups when listing (#380) * fix(userstore): batch allowed-library and profile lookups when listing Listing profiles cost 1 + P queries: one allowed-libraries lookup fired per profile row while the cursor was still open, which on Postgres also checks out a second pooled connection mid-scan. The admin sessions dashboard makes it worse, calling ListProfiles once per streaming user just to resolve names. The SQLite store had the same pattern for both profiles and collections, even though the Postgres collections path was already written with array_agg to avoid exactly this. Collect the rows first, then fetch the child lists in one batched query and stitch them together in Go. No behaviour change, just fewer round trips. * fix(userstore): avoid sqlite batch variable limits --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- internal/userdb/collections.go | 50 +++++++++- internal/userdb/listing_batch_test.go | 92 +++++++++++++++++++ internal/userdb/profile_libraries.go | 34 +++++++ internal/userdb/profiles.go | 7 +- .../userstore/pgstore/profile_libraries.go | 41 +++++++++ internal/userstore/pgstore/profiles.go | 7 +- internal/userstore/profile_libraries.go | 22 +++++ internal/userstore/profile_libraries_test.go | 31 +++++++ 8 files changed, 271 insertions(+), 13 deletions(-) create mode 100644 internal/userdb/listing_batch_test.go create mode 100644 internal/userstore/profile_libraries.go create mode 100644 internal/userstore/profile_libraries_test.go diff --git a/internal/userdb/collections.go b/internal/userdb/collections.go index 91fb8cf3..cb31a8ff 100644 --- a/internal/userdb/collections.go +++ b/internal/userdb/collections.go @@ -114,13 +114,53 @@ func ListCollections(db *sql.DB, profileID string) ([]Collection, error) { if err := rows.Scan(&c.ID, &c.ProfileID, &c.CreatorProfileID, &c.Name, &c.CollectionType, &c.IsShared, &c.QueryDefinition, &c.SortConfig, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } - c.AllowedProfileIDs, err = listCollectionProfiles(db, c.ID) - if err != nil { - return nil, err - } collections = append(collections, c) } - return collections, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + if err := attachCollectionProfiles(db, profileID, collections); err != nil { + return nil, err + } + return collections, nil +} + +// attachCollectionProfiles fills AllowedProfileIDs for every collection with a +// single batched query, avoiding the N+1 round trip a per-collection lookup +// would create. +func attachCollectionProfiles(db *sql.DB, profileID string, collections []Collection) error { + if len(collections) == 0 { + return nil + } + rows, err := db.Query( + `SELECT allowed.collection_id, allowed.profile_id + FROM personal_collection_profiles AS visible + JOIN personal_collection_profiles AS allowed + ON allowed.collection_id = visible.collection_id + WHERE visible.profile_id = ? + ORDER BY allowed.profile_id ASC`, + profileID, + ) + if err != nil { + return err + } + defer rows.Close() + + byCollection := make(map[string][]string, len(collections)) + for rows.Next() { + var collectionID, profileID string + if err := rows.Scan(&collectionID, &profileID); err != nil { + return err + } + byCollection[collectionID] = append(byCollection[collectionID], profileID) + } + if err := rows.Err(); err != nil { + return err + } + for i := range collections { + collections[i].AllowedProfileIDs = byCollection[collections[i].ID] + } + return nil } // UpdateCollection renames a collection and updates its updated_at timestamp. diff --git a/internal/userdb/listing_batch_test.go b/internal/userdb/listing_batch_test.go new file mode 100644 index 00000000..2a11ba80 --- /dev/null +++ b/internal/userdb/listing_batch_test.go @@ -0,0 +1,92 @@ +package userdb + +import ( + "database/sql" + "reflect" + "testing" + + "github.com/Silo-Server/silo-server/internal/userstore" +) + +func newListingTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + if err := InitSchema(db); err != nil { + t.Fatalf("InitSchema: %v", err) + } + return db +} + +func TestListProfilesAttachesAllowedLibrariesInOrder(t *testing.T) { + db := newListingTestDB(t) + for _, profile := range []Profile{ + {ID: "p1", Name: "One", AllowedLibraryIDs: []int{5, 2}, CreatedAt: "2026-01-01T00:00:00Z"}, + {ID: "p2", Name: "Two", AllowedLibraryIDs: []int{3}, CreatedAt: "2026-01-02T00:00:00Z"}, + {ID: "p3", Name: "Three", CreatedAt: "2026-01-03T00:00:00Z"}, + } { + if err := CreateProfile(db, profile); err != nil { + t.Fatalf("CreateProfile(%s): %v", profile.ID, err) + } + } + + profiles, err := ListProfiles(db) + if err != nil { + t.Fatalf("ListProfiles: %v", err) + } + if len(profiles) != 3 { + t.Fatalf("ListProfiles returned %d profiles, want 3", len(profiles)) + } + if got, want := profiles[0].AllowedLibraryIDs, []int{2, 5}; !reflect.DeepEqual(got, want) { + t.Fatalf("p1 AllowedLibraryIDs = %v, want %v", got, want) + } + if got, want := profiles[1].AllowedLibraryIDs, []int{3}; !reflect.DeepEqual(got, want) { + t.Fatalf("p2 AllowedLibraryIDs = %v, want %v", got, want) + } + if profiles[2].AllowedLibraryIDs != nil { + t.Fatalf("p3 AllowedLibraryIDs = %v, want nil", profiles[2].AllowedLibraryIDs) + } +} + +func TestListCollectionsAttachesAllowedProfilesInOrder(t *testing.T) { + db := newListingTestDB(t) + collection, err := CreateCollection(db, userstore.CreateCollectionInput{ + CreatorProfileID: "p1", + Name: "Shared", + IsShared: true, + AllowedProfileIDs: []string{"p3", "p2"}, + }) + if err != nil { + t.Fatalf("CreateCollection: %v", err) + } + + collections, err := ListCollections(db, "p2") + if err != nil { + t.Fatalf("ListCollections: %v", err) + } + if len(collections) != 1 || collections[0].ID != collection.ID { + t.Fatalf("ListCollections = %+v, want collection %s", collections, collection.ID) + } + if got, want := collections[0].AllowedProfileIDs, []string{"p1", "p2", "p3"}; !reflect.DeepEqual(got, want) { + t.Fatalf("AllowedProfileIDs = %v, want %v", got, want) + } +} + +func TestBatchAttachDoesNotScaleBindParametersWithRows(t *testing.T) { + db := newListingTestDB(t) + const aboveSQLiteVariableLimit = 32767 + + profiles := make([]Profile, aboveSQLiteVariableLimit) + if err := attachAllowedLibraries(db, profiles); err != nil { + t.Fatalf("attachAllowedLibraries: %v", err) + } + + collections := make([]Collection, aboveSQLiteVariableLimit) + if err := attachCollectionProfiles(db, "viewer", collections); err != nil { + t.Fatalf("attachCollectionProfiles: %v", err) + } +} diff --git a/internal/userdb/profile_libraries.go b/internal/userdb/profile_libraries.go index 6bcd316f..0e897c98 100644 --- a/internal/userdb/profile_libraries.go +++ b/internal/userdb/profile_libraries.go @@ -3,6 +3,8 @@ package userdb import ( "database/sql" "fmt" + + "github.com/Silo-Server/silo-server/internal/userstore" ) func listProfileAllowedLibraries(db *sql.DB, profileID string) ([]int, error) { @@ -29,6 +31,38 @@ func listProfileAllowedLibraries(db *sql.DB, profileID string) ([]int, error) { return libraryIDs, nil } +// attachAllowedLibraries fills AllowedLibraryIDs for every profile with a +// single batched query, avoiding the N+1 round trip a per-profile lookup +// would create. +func attachAllowedLibraries(db *sql.DB, profiles []Profile) error { + if len(profiles) == 0 { + return nil + } + rows, err := db.Query( + `SELECT profile_id, library_id + FROM profile_allowed_libraries + ORDER BY library_id ASC`, + ) + if err != nil { + return fmt.Errorf("listing allowed libraries: %w", err) + } + defer rows.Close() + + var allowedLibraries []userstore.ProfileAllowedLibrary + for rows.Next() { + var allowedLibrary userstore.ProfileAllowedLibrary + if err := rows.Scan(&allowedLibrary.ProfileID, &allowedLibrary.LibraryID); err != nil { + return fmt.Errorf("scanning allowed library: %w", err) + } + allowedLibraries = append(allowedLibraries, allowedLibrary) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterating allowed libraries: %w", err) + } + userstore.AttachAllowedLibraries(profiles, allowedLibraries) + return nil +} + func replaceProfileAllowedLibrariesTx(tx *sql.Tx, profileID string, libraryIDs []int) error { if _, err := tx.Exec("DELETE FROM profile_allowed_libraries WHERE profile_id = ?", profileID); err != nil { return fmt.Errorf("clearing allowed libraries for profile %s: %w", profileID, err) diff --git a/internal/userdb/profiles.go b/internal/userdb/profiles.go index 09cf606e..54f80b0f 100644 --- a/internal/userdb/profiles.go +++ b/internal/userdb/profiles.go @@ -128,15 +128,14 @@ func ListProfiles(db *sql.DB) ([]Profile, error) { ); err != nil { return nil, fmt.Errorf("scanning profile row: %w", err) } - p.AllowedLibraryIDs, err = listProfileAllowedLibraries(db, p.ID) - if err != nil { - return nil, err - } profiles = append(profiles, p) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating profile rows: %w", err) } + if err := attachAllowedLibraries(db, profiles); err != nil { + return nil, err + } return profiles, nil } diff --git a/internal/userstore/pgstore/profile_libraries.go b/internal/userstore/pgstore/profile_libraries.go index 534a5b6a..f15d668c 100644 --- a/internal/userstore/pgstore/profile_libraries.go +++ b/internal/userstore/pgstore/profile_libraries.go @@ -6,6 +6,8 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" + + "github.com/Silo-Server/silo-server/internal/userstore" ) type profileLibraryQuerier interface { @@ -41,6 +43,45 @@ func listProfileAllowedLibraries(ctx context.Context, q profileLibraryQuerier, u return libraryIDs, nil } +// attachAllowedLibraries fills AllowedLibraryIDs for every profile with a +// single batched query, avoiding the N+1 round trip a per-profile lookup +// would create. +func (s *PostgresUserStore) attachAllowedLibraries(ctx context.Context, profiles []userstore.Profile) error { + if len(profiles) == 0 { + return nil + } + ids := make([]string, len(profiles)) + for i := range profiles { + ids[i] = profiles[i].ID + } + rows, err := s.pool.Query(ctx, + `SELECT profile_id, library_id + FROM user_profile_allowed_libraries + WHERE user_id = $1 AND profile_id = ANY($2) + ORDER BY library_id ASC`, + s.userID, + ids, + ) + if err != nil { + return fmt.Errorf("listing allowed libraries for user %d: %w", s.userID, err) + } + defer rows.Close() + + var allowedLibraries []userstore.ProfileAllowedLibrary + for rows.Next() { + var allowedLibrary userstore.ProfileAllowedLibrary + if err := rows.Scan(&allowedLibrary.ProfileID, &allowedLibrary.LibraryID); err != nil { + return fmt.Errorf("scanning allowed library for user %d: %w", s.userID, err) + } + allowedLibraries = append(allowedLibraries, allowedLibrary) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterating allowed libraries for user %d: %w", s.userID, err) + } + userstore.AttachAllowedLibraries(profiles, allowedLibraries) + return nil +} + func replaceProfileAllowedLibraries(ctx context.Context, q profileLibraryQuerier, userID int, profileID string, libraryIDs []int) error { if _, err := q.Exec(ctx, "DELETE FROM user_profile_allowed_libraries WHERE user_id = $1 AND profile_id = $2", diff --git a/internal/userstore/pgstore/profiles.go b/internal/userstore/pgstore/profiles.go index 4131a23a..44e89fe2 100644 --- a/internal/userstore/pgstore/profiles.go +++ b/internal/userstore/pgstore/profiles.go @@ -126,15 +126,14 @@ func (s *PostgresUserStore) ListProfiles(ctx context.Context) ([]userstore.Profi if err != nil { return nil, fmt.Errorf("scanning profile row: %w", err) } - p.AllowedLibraryIDs, err = listProfileAllowedLibraries(ctx, s.pool, s.userID, p.ID) - if err != nil { - return nil, err - } profiles = append(profiles, *p) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating profile rows: %w", err) } + if err := s.attachAllowedLibraries(ctx, profiles); err != nil { + return nil, err + } return profiles, nil } diff --git a/internal/userstore/profile_libraries.go b/internal/userstore/profile_libraries.go new file mode 100644 index 00000000..6e2e4235 --- /dev/null +++ b/internal/userstore/profile_libraries.go @@ -0,0 +1,22 @@ +package userstore + +// ProfileAllowedLibrary associates one profile with one allowed library. +type ProfileAllowedLibrary struct { + ProfileID string + LibraryID int +} + +// AttachAllowedLibraries replaces each profile's AllowedLibraryIDs with the +// matching associations, preserving their input order. +func AttachAllowedLibraries(profiles []Profile, allowedLibraries []ProfileAllowedLibrary) { + byProfile := make(map[string][]int, len(profiles)) + for _, allowedLibrary := range allowedLibraries { + byProfile[allowedLibrary.ProfileID] = append( + byProfile[allowedLibrary.ProfileID], + allowedLibrary.LibraryID, + ) + } + for i := range profiles { + profiles[i].AllowedLibraryIDs = byProfile[profiles[i].ID] + } +} diff --git a/internal/userstore/profile_libraries_test.go b/internal/userstore/profile_libraries_test.go new file mode 100644 index 00000000..3dcce786 --- /dev/null +++ b/internal/userstore/profile_libraries_test.go @@ -0,0 +1,31 @@ +package userstore + +import ( + "reflect" + "testing" +) + +func TestAttachAllowedLibraries(t *testing.T) { + profiles := []Profile{ + {ID: "p1", AllowedLibraryIDs: []int{99}}, + {ID: "p2", AllowedLibraryIDs: []int{99}}, + {ID: "p3", AllowedLibraryIDs: []int{99}}, + } + allowedLibraries := []ProfileAllowedLibrary{ + {ProfileID: "p2", LibraryID: 1}, + {ProfileID: "p1", LibraryID: 2}, + {ProfileID: "p1", LibraryID: 5}, + } + + AttachAllowedLibraries(profiles, allowedLibraries) + + if got, want := profiles[0].AllowedLibraryIDs, []int{2, 5}; !reflect.DeepEqual(got, want) { + t.Fatalf("p1 AllowedLibraryIDs = %v, want %v", got, want) + } + if got, want := profiles[1].AllowedLibraryIDs, []int{1}; !reflect.DeepEqual(got, want) { + t.Fatalf("p2 AllowedLibraryIDs = %v, want %v", got, want) + } + if profiles[2].AllowedLibraryIDs != nil { + t.Fatalf("p3 AllowedLibraryIDs = %v, want nil", profiles[2].AllowedLibraryIDs) + } +} From 800f2543acb4074881907c6551fd1731f2ee8163 Mon Sep 17 00:00:00 2001 From: RXWatcher Date: Mon, 20 Jul 2026 19:16:21 +0200 Subject: [PATCH 3/8] fix(player): stop infinite HLS startup spinner (#417) * fix(player): bound HLS startup recovery * fix(player): span HLS guard across startup phases --------- Co-authored-by: rxwatcher Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- web/src/player/components/VideoPlayer.tsx | 60 +++++++++++++++--- .../player/hooks/useTranscodeQuality.test.tsx | 28 +++++++++ web/src/player/hooks/useTranscodeQuality.ts | 18 ++++++ web/src/player/utils/hlsStartupGuard.test.ts | 62 +++++++++++++++++++ web/src/player/utils/hlsStartupGuard.ts | 59 ++++++++++++++++++ 5 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 web/src/player/utils/hlsStartupGuard.test.ts create mode 100644 web/src/player/utils/hlsStartupGuard.ts diff --git a/web/src/player/components/VideoPlayer.tsx b/web/src/player/components/VideoPlayer.tsx index 9d557fbb..edcba91f 100644 --- a/web/src/player/components/VideoPlayer.tsx +++ b/web/src/player/components/VideoPlayer.tsx @@ -34,6 +34,7 @@ import type { } from "../realtime-protocol"; import { resolvePendingSeekTime } from "../utils/pendingSeek"; import { resolveVersionAudioLanguage } from "../utils/effectiveAudioLanguage"; +import { HlsStartupGuard } from "../utils/hlsStartupGuard"; import { normalizeSubtitleMode } from "../utils/subtitleMode"; import type { PlaybackExitState, @@ -218,6 +219,7 @@ export function VideoPlayer({ const containerRef = useRef(null); const isMountedRef = useRef(true); const hlsRef = useRef(null); + const hlsStartupGuardRef = useRef(null); const mediaRecoveryAttemptsRef = useRef(0); const lastRecoveryRef = useRef(0); const streamOriginRef = useRef(0); @@ -336,6 +338,39 @@ export function VideoPlayer({ initialPosition, qualityPreference, }); + const { cancelPendingTranscodeStart, startupGeneration } = transcodeQuality; + const hlsStartupExpected = + transcodeQuality.isTranscoding || transcodeQuality.transcodeStreamUrl !== null; + + const failHlsStartup = useCallback(() => { + console.error("[hls.js] Playback startup timed out or exhausted recovery attempts"); + cancelPendingTranscodeStart(); + + const activeHls = hlsRef.current; + hlsRef.current = null; + activeHls?.destroy(); + + const video = videoRef.current; + if (video) { + video.removeAttribute("src"); + video.load(); + } + setError("Playback failed. The media could not be loaded."); + }, [cancelPendingTranscodeStart]); + + useEffect(() => { + if (!hlsStartupExpected || startupGeneration === 0) return; + + const guard = new HlsStartupGuard(failHlsStartup); + hlsStartupGuardRef.current = guard; + + return () => { + guard.dispose(); + if (hlsStartupGuardRef.current === guard) { + hlsStartupGuardRef.current = null; + } + }; + }, [failHlsStartup, hlsStartupExpected, startupGeneration]); // Derive effective stream URL and play method. // Both transcode and remux go through HLS, so treat them as "transcode" for the player. @@ -1238,7 +1273,7 @@ export function VideoPlayer({ // -- hls.js lifecycle -- useEffect(() => { const video = videoRef.current; - if (!video || !isPlayerReady) return; + if (!video || !isPlayerReady || hlsStartupGuardRef.current?.hasFailed()) return; let hls: HlsType | null = null; let destroyed = false; @@ -1251,10 +1286,11 @@ export function VideoPlayer({ const cleanupStartupListeners = () => { video.removeEventListener("loadeddata", attemptAutoplayWhenReady); video.removeEventListener("canplay", attemptAutoplayWhenReady); + video.removeEventListener("loadedmetadata", attemptAutoplayWhenReady); }; const attemptAutoplayWhenReady = () => { - if (destroyed || autoplayStarted) return; + if (destroyed || autoplayStarted || hlsStartupGuardRef.current?.hasFailed()) return; // HAVE_FUTURE_DATA means the browser has enough media to advance beyond // the current frame. Starting earlier can produce a visible first-frame // freeze where audio advances before video begins moving. @@ -1273,7 +1309,7 @@ export function VideoPlayer({ if (effectivePlayMethod === "transcode") { try { const Hls = await hlsPromise; - if (destroyed) return; + if (destroyed || hlsStartupGuardRef.current?.hasFailed()) return; if (Hls.isSupported()) { const maxBufferLength = selectedVersionBitrate >= 25000 ? 60 : 120; @@ -1317,8 +1353,12 @@ export function VideoPlayer({ lastRecoveryRef.current = now; if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { - console.warn("[hls.js] Fatal network error, attempting recovery..."); - hls?.startLoad(); + if (hlsStartupGuardRef.current?.handleFatalNetworkError() ?? true) { + console.warn("[hls.js] Fatal network error, attempting recovery..."); + hls?.startLoad(); + } else { + console.error("[hls.js] Fatal startup network error, giving up"); + } } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { if (mediaRecoveryAttemptsRef.current === 0) { console.warn("[hls.js] Fatal media error, attempting recovery..."); @@ -1411,6 +1451,10 @@ export function VideoPlayer({ } setBuffering(false); }; + const markPlaybackStarted = () => { + hlsStartupGuardRef.current?.markPlaybackStarted(); + setAwaitingFirstFrame(false); + }; const onTimeUpdate = () => { const nextTime = toMediaTime(video.currentTime, streamOriginRef.current); const resolved = resolvePendingSeekTime(nextTime, pendingSeekTime); @@ -1421,13 +1465,13 @@ export function VideoPlayer({ // timeupdate is the most reliable signal that frames are rendering. // Also clears any stale buffering state from HLS segment transitions // where `waiting` fired but `canplay`/`playing` never followed. - setAwaitingFirstFrame(false); + markPlaybackStarted(); clearBuffering(); }; const onSeeked = () => { setPendingSeekTime(null); setCurrentTime(toMediaTime(video.currentTime, streamOriginRef.current)); - setAwaitingFirstFrame(false); + markPlaybackStarted(); clearBuffering(); if (roomSyncWaiting && watchTogetherSync.attachedSessionId === sessionId) { watchTogetherSync.reportReady(); @@ -1469,7 +1513,7 @@ export function VideoPlayer({ }; const onPlaying = () => { clearBuffering(); - setAwaitingFirstFrame(false); + markPlaybackStarted(); }; const onStalled = () => { if (watchTogetherRoomActive && watchTogetherSync.attachedSessionId === sessionId) { diff --git a/web/src/player/hooks/useTranscodeQuality.test.tsx b/web/src/player/hooks/useTranscodeQuality.test.tsx index 40a32407..cac659e3 100644 --- a/web/src/player/hooks/useTranscodeQuality.test.tsx +++ b/web/src/player/hooks/useTranscodeQuality.test.tsx @@ -150,6 +150,34 @@ describe("useTranscodeQuality", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("cancels an in-flight startup before a manifest arrives", async () => { + let requestSignal: AbortSignal | undefined; + fetchMock.mockImplementationOnce((_, init: RequestInit) => { + requestSignal = init.signal as AbortSignal; + return new Promise((_, reject) => { + requestSignal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + }); + + const { result } = renderQuality(); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + expect(result.current.startupGeneration).toBe(1); + expect(result.current.isTranscoding).toBe(true); + + act(() => { + result.current.cancelPendingTranscodeStart(); + }); + + expect(requestSignal?.aborted).toBe(true); + await waitFor(() => expect(result.current.isTranscoding).toBe(false)); + expect(result.current.error).toBeNull(); + }); + it("rolls back a failed burn-in selection so the same track can be retried", async () => { const { result } = renderQuality(); diff --git a/web/src/player/hooks/useTranscodeQuality.ts b/web/src/player/hooks/useTranscodeQuality.ts index a8483d81..095f0771 100644 --- a/web/src/player/hooks/useTranscodeQuality.ts +++ b/web/src/player/hooks/useTranscodeQuality.ts @@ -76,6 +76,10 @@ interface UseTranscodeQualityResult { canSeekAnywhere: boolean; durationSeconds: number | null; isTranscoding: boolean; + /** Increments when a user-visible HLS startup attempt begins. */ + startupGeneration: number; + /** Cancels a transcode start that has not produced playable media yet. */ + cancelPendingTranscodeStart: () => void; error: string | null; switchedFileId: number | null; effectiveVersion: PlayerFileVersion | undefined; @@ -188,6 +192,7 @@ export function useTranscodeQuality({ const [canSeekAnywhere, setCanSeekAnywhere] = useState(true); const [durationSeconds, setDurationSeconds] = useState(null); const [isTranscoding, setIsTranscoding] = useState(false); + const [startupGeneration, setStartupGeneration] = useState(0); const [error, setError] = useState(null); const [switchedFileId, setSwitchedFileId] = useState(null); const switchAbortRef = useRef(null); @@ -336,6 +341,7 @@ export function useTranscodeQuality({ const abortController = new AbortController(); switchAbortRef.current = abortController; + setStartupGeneration((generation) => generation + 1); setIsTranscoding(true); setActiveQualityId(qualityId); setError(null); @@ -469,6 +475,16 @@ export function useTranscodeQuality({ [sessionId, activeQualityId, qualityOptions, config, effectiveVersion, playMethod], ); + const cancelPendingTranscodeStart = useCallback(() => { + if (dispatchTimerRef.current != null) { + clearTimeout(dispatchTimerRef.current); + dispatchTimerRef.current = null; + } + switchAbortRef.current?.abort(); + switchAbortRef.current = null; + setIsTranscoding(false); + }, []); + const switchQuality = useCallback( (qualityId: string, currentPosition: number, forceRestart?: boolean) => { requestedQualityIdRef.current = qualityId; @@ -579,6 +595,8 @@ export function useTranscodeQuality({ canSeekAnywhere, durationSeconds, isTranscoding, + startupGeneration, + cancelPendingTranscodeStart, error, switchedFileId, effectiveVersion, diff --git a/web/src/player/utils/hlsStartupGuard.test.ts b/web/src/player/utils/hlsStartupGuard.test.ts new file mode 100644 index 00000000..b5e6fdba --- /dev/null +++ b/web/src/player/utils/hlsStartupGuard.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { HLS_STARTUP_TIMEOUT_MS, HlsStartupGuard } from "./hlsStartupGuard"; + +describe("HlsStartupGuard", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("fails startup when no playable frame arrives before the deadline", () => { + const onFailure = vi.fn(); + const guard = new HlsStartupGuard(onFailure); + + vi.advanceTimersByTime(HLS_STARTUP_TIMEOUT_MS - 1); + expect(onFailure).not.toHaveBeenCalled(); + expect(guard.hasFailed()).toBe(false); + + vi.advanceTimersByTime(1); + expect(onFailure).toHaveBeenCalledOnce(); + expect(guard.hasFailed()).toBe(true); + guard.dispose(); + }); + + it("allows one fatal network recovery before failing startup", () => { + const onFailure = vi.fn(); + const guard = new HlsStartupGuard(onFailure); + + expect(guard.handleFatalNetworkError()).toBe(true); + expect(onFailure).not.toHaveBeenCalled(); + + expect(guard.handleFatalNetworkError()).toBe(false); + expect(onFailure).toHaveBeenCalledOnce(); + expect(guard.hasFailed()).toBe(true); + }); + + it("disarms startup limits after playable media arrives", () => { + const onFailure = vi.fn(); + const guard = new HlsStartupGuard(onFailure); + + guard.markPlaybackStarted(); + vi.advanceTimersByTime(HLS_STARTUP_TIMEOUT_MS); + + expect(onFailure).not.toHaveBeenCalled(); + expect(guard.hasFailed()).toBe(false); + expect(guard.handleFatalNetworkError()).toBe(true); + expect(guard.handleFatalNetworkError()).toBe(true); + }); + + it("does not fail after disposal", () => { + const onFailure = vi.fn(); + const guard = new HlsStartupGuard(onFailure); + + guard.dispose(); + vi.advanceTimersByTime(HLS_STARTUP_TIMEOUT_MS); + + expect(onFailure).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/player/utils/hlsStartupGuard.ts b/web/src/player/utils/hlsStartupGuard.ts new file mode 100644 index 00000000..03568bfa --- /dev/null +++ b/web/src/player/utils/hlsStartupGuard.ts @@ -0,0 +1,59 @@ +export const HLS_STARTUP_TIMEOUT_MS = 60_000; + +const MAX_FATAL_NETWORK_RECOVERIES = 1; + +type StartupState = "starting" | "playable" | "failed" | "disposed"; + +export class HlsStartupGuard { + private state: StartupState = "starting"; + private fatalNetworkRecoveries = 0; + private timeoutId: ReturnType | null; + + constructor(private readonly onFailure: () => void) { + this.timeoutId = setTimeout(() => this.fail(), HLS_STARTUP_TIMEOUT_MS); + } + + handleFatalNetworkError(): boolean { + if (this.state === "playable") return true; + if (this.state !== "starting") return false; + + if (this.fatalNetworkRecoveries < MAX_FATAL_NETWORK_RECOVERIES) { + this.fatalNetworkRecoveries++; + return true; + } + + this.fail(); + return false; + } + + markPlaybackStarted() { + if (this.state !== "starting") return; + + this.state = "playable"; + this.clearTimeout(); + } + + hasFailed() { + return this.state === "failed"; + } + + dispose() { + this.state = "disposed"; + this.clearTimeout(); + } + + private fail() { + if (this.state !== "starting") return; + + this.state = "failed"; + this.clearTimeout(); + this.onFailure(); + } + + private clearTimeout() { + if (this.timeoutId === null) return; + + clearTimeout(this.timeoutId); + this.timeoutId = null; + } +} From 845b96e703d5e41a0a2c40a8ae149892746f5287 Mon Sep 17 00:00:00 2001 From: Suspense Date: Mon, 20 Jul 2026 19:25:46 +0200 Subject: [PATCH 4/8] fix(playback): preserve remux copy on seek (#422) * fix(playback): preserve remux copy on seek * fix(playback): harden remux replacement transactions --------- Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com> --- internal/api/handlers/playback.go | 1101 +++++++++---- internal/api/handlers/playback_test.go | 1384 +++++++++++++++-- internal/playback/copy_seek_anchor.go | 149 ++ internal/playback/copy_seek_anchor_test.go | 174 +++ internal/playback/recipecard.go | 258 +-- internal/playback/recipecard_test.go | 84 +- internal/playback/session.go | 56 + internal/playback/session_test.go | 73 + internal/playback/transcode.go | 61 +- internal/playback/transcode_manager.go | 40 +- internal/playback/transcode_manifest_test.go | 13 +- .../playback/transcode_restart_guard_test.go | 41 + internal/streamtoken/token.go | 28 +- internal/transcodenode/server.go | 92 +- internal/transcodenode/server_test.go | 43 + web/src/player/components/VideoPlayer.tsx | 4 + web/src/player/components/WatchPage.tsx | 1 + web/src/player/hooks/usePlaybackSession.ts | 28 +- .../player/hooks/useTranscodeQuality.test.tsx | 67 +- web/src/player/hooks/useTranscodeQuality.ts | 30 +- web/src/player/types.ts | 13 + 21 files changed, 3082 insertions(+), 658 deletions(-) create mode 100644 internal/playback/copy_seek_anchor.go create mode 100644 internal/playback/copy_seek_anchor_test.go diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 0143a30d..cee87d58 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -18,6 +18,7 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/google/uuid" apimw "github.com/Silo-Server/silo-server/internal/api/middleware" "github.com/Silo-Server/silo-server/internal/catalog" @@ -50,6 +51,10 @@ type SessionManagerInterface interface { EndTransport(sessionID string) error SetEffectiveMediaFileID(sessionID string, fileID int) error SetTranscodeNodeURL(sessionID, url string) error + SetTranscodeRoute(sessionID string, route playback.TranscodeRoute) error + ApplyReplacement(sessionID string, replacement playback.SessionReplacement) (playback.SessionReplacementRollback, error) + ApplyReplacementIfRoute(sessionID string, expected playback.TranscodeRoute, replacement playback.SessionReplacement) (playback.SessionReplacementRollback, bool, error) + RollbackReplacement(sessionID string, rollback playback.SessionReplacementRollback) error SetWebSocket(sessionID string, connected bool) error SetRealtimeConnection(sessionID string, connected bool) error SetProgressPersistenceDisabled(sessionID string, disabled bool) error @@ -128,6 +133,14 @@ type PlaybackOriginalLanguageLookup interface { GetOriginalLanguage(ctx context.Context, contentID string) (string, error) } +type copySeekAnchorResolver func( + ctx context.Context, + ffmpegPath string, + inputPath string, + requestedSeekSeconds float64, + segmentDuration int, +) (float64, int, error) + // PlaybackHandler handles playback session HTTP endpoints. type PlaybackHandler struct { sessionMgr SessionManagerInterface @@ -170,6 +183,7 @@ type PlaybackHandler struct { // playbackConfig(), which falls back to defaults when unset. PlaybackConfig func() config.PlaybackConfig FFmpegLogSink playback.FFmpegLogSink + copySeekAnchor copySeekAnchorResolver realtimeCommandMu sync.Mutex realtimeCommands map[string]playbackCommandRecord // tm owns the transcode-session lifecycle (live map, recipe cards, and @@ -434,11 +448,24 @@ type changeAudioRequest struct { // changeAudioResponse represents the JSON response for PATCH /playback/{session_id}/audio. type changeAudioResponse struct { - AudioTrackIndex int `json:"audio_track_index"` - PlayMethod string `json:"play_method"` - StreamURL string `json:"stream_url"` - SwitchMode string `json:"switch_mode"` - PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"` + AudioTrackIndex int `json:"audio_track_index"` + PlayMethod string `json:"play_method"` + StreamURL string `json:"stream_url"` + SwitchMode string `json:"switch_mode"` + PlayerStartSeconds *float64 `json:"player_start_seconds,omitempty"` + StreamOriginSeconds *float64 `json:"stream_origin_seconds,omitempty"` + TimelineOffsetSeconds *float64 `json:"timeline_offset_seconds,omitempty"` + CanSeekAnywhere *bool `json:"can_seek_anywhere,omitempty"` + PlaybackInfo *playbackInfoResult `json:"playback_info,omitempty"` +} + +func (resp *changeAudioResponse) setCopyTimeline(position, origin float64) { + playerStart := max(0, position-origin) + canSeekAnywhere := false + resp.PlayerStartSeconds = &playerStart + resp.StreamOriginSeconds = &origin + resp.TimelineOffsetSeconds = &origin + resp.CanSeekAnywhere = &canSeekAnywhere } type transcodeStartRequest struct { @@ -514,6 +541,7 @@ func buildTranscodeStartResponse( file *models.MediaFile, switchedFileID *int, manifestURL string, + streamOriginSeconds float64, ) transcodeStartResponse { resp := transcodeStartResponse{ SessionID: req.SessionID, @@ -529,13 +557,29 @@ func buildTranscodeStartResponse( resp.CanSeekAnywhere = true return resp } - resp.PlayerStartSeconds = 0 - resp.StreamOriginSeconds = req.SeekSeconds - resp.TimelineOffsetSeconds = req.SeekSeconds + resp.PlayerStartSeconds = max(0, req.SeekSeconds-streamOriginSeconds) + resp.StreamOriginSeconds = streamOriginSeconds + resp.TimelineOffsetSeconds = streamOriginSeconds resp.CanSeekAnywhere = false return resp } +func (h *PlaybackHandler) resolveLegacyCopySeekAnchor( + ctx context.Context, + ffmpegPath string, + inputPath string, + requestedSeekSeconds float64, + segmentDuration int, +) (float64, int, error) { + resolver := h.copySeekAnchor + if resolver == nil { + resolver = playback.ResolveCopySeekAnchor + } + probeCtx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + return resolver(probeCtx, ffmpegPath, inputPath, requestedSeekSeconds, segmentDuration) +} + func (h *PlaybackHandler) ensurePlaybackProbe(ctx context.Context, file *models.MediaFile) *models.MediaFile { if h == nil || h.ProbeEnsurer == nil || file == nil { return file @@ -741,6 +785,186 @@ func remoteTransportID(session *playback.Session) string { return session.ID } +func sessionTranscodeRoute(session *playback.Session) playback.TranscodeRoute { + if session == nil { + return playback.TranscodeRoute{} + } + return playback.TranscodeRoute{ + NodeURL: session.TranscodeNodeURL, + TransportID: session.TranscodeTransportID, + } +} + +const legacyTransportMarker = "-legacy-" + +func isLegacyTransportSession(session *playback.Session) bool { + if session == nil { + return false + } + if session.TranscodeTransportID == "" { + return true + } + return strings.HasPrefix(session.TranscodeTransportID, session.ID+legacyTransportMarker) +} + +func newLegacyTransportID(sessionID string) string { + return sessionID + legacyTransportMarker + uuid.NewString() +} + +func (h *PlaybackHandler) transcodeRouteMatches( + sessionID string, + expectedLocal *playback.TranscodeSession, + route playback.TranscodeRoute, +) bool { + unlock := h.tm.LockSessionLifecycle(sessionID) + defer unlock() + session, err := h.sessionMgr.GetSession(sessionID) + if err != nil || session == nil { + return false + } + return h.tm.GetTranscodeSession(sessionID) == expectedLocal && + sessionTranscodeRoute(session) == route +} + +func (h *PlaybackHandler) commitLegacyRemoteReplacement( + sessionID string, + previousLocal *playback.TranscodeSession, + previousRoute playback.TranscodeRoute, + replacement playback.SessionReplacement, +) error { + unlock := h.tm.LockSessionLifecycle(sessionID) + if h.tm.GetTranscodeSession(sessionID) != previousLocal { + unlock() + return playback.ErrSessionSuperseded + } + + _, published, err := h.sessionMgr.ApplyReplacementIfRoute(sessionID, previousRoute, replacement) + if err != nil { + unlock() + return err + } + if !published { + unlock() + return playback.ErrSessionSuperseded + } + if previousLocal != nil { + // A crash monitor can remove the predecessor while the remote successor is + // preparing. A false result is harmless: the new route is already complete, + // and CloseTranscodeSessionIf never touches a different local successor. + h.tm.CloseTranscodeSessionIf(sessionID, previousLocal, "") + } + unlock() + + if previousRoute.NodeURL != "" { + previousProcessID := previousRoute.TransportID + if previousProcessID == "" { + previousProcessID = sessionID + } + h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL) + } + return nil +} + +func (h *PlaybackHandler) commitLegacyRemoteLastWriter( + sessionID string, + successorRoute playback.TranscodeRoute, + replacement playback.SessionReplacement, +) error { + unlock := h.tm.LockSessionLifecycle(sessionID) + current, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + unlock() + return err + } + previousRoute := sessionTranscodeRoute(current) + previousLocal := h.tm.GetTranscodeSession(sessionID) + if _, err := h.sessionMgr.ApplyReplacement(sessionID, replacement); err != nil { + unlock() + return err + } + if previousLocal != nil { + h.tm.CloseTranscodeSessionIf(sessionID, previousLocal, "") + } + unlock() + + if previousRoute.NodeURL != "" && previousRoute != successorRoute { + previousProcessID := previousRoute.TransportID + if previousProcessID == "" { + previousProcessID = sessionID + } + h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL) + } + return nil +} + +func (h *PlaybackHandler) commitLegacyLocalReplacement( + ctx context.Context, + sessionID string, + previousLocal *playback.TranscodeSession, + previousRoute playback.TranscodeRoute, + opts playback.TranscodeOpts, + replacement func(*playback.TranscodeSession) playback.SessionReplacement, +) (*playback.TranscodeSession, error) { + if opts.OutputSubdir == "" { + return nil, errors.New("transactional local replacement requires a generation output directory") + } + + unlock := h.tm.LockSessionLifecycle(sessionID) + if h.tm.GetTranscodeSession(sessionID) != previousLocal { + unlock() + return nil, playback.ErrSessionSuperseded + } + current, err := h.sessionMgr.GetSession(sessionID) + if err != nil { + unlock() + return nil, err + } + if sessionTranscodeRoute(current) != previousRoute { + unlock() + return nil, playback.ErrSessionSuperseded + } + + successor, err := h.startLocalPlaybackTransport(ctx, opts) + if err != nil { + unlock() + return nil, err + } + if _, err := successor.WaitForManifest(8 * time.Second); err != nil { + _ = successor.Close() + unlock() + return nil, fmt.Errorf("local successor not ready: %w", err) + } + + rollback, published, err := h.sessionMgr.ApplyReplacementIfRoute(sessionID, previousRoute, replacement(successor)) + if err != nil || !published { + _ = successor.Close() + unlock() + if err != nil { + return nil, err + } + return nil, playback.ErrSessionSuperseded + } + if !h.tm.SwapTranscodeSessionIf(sessionID, previousLocal, successor) { + rollbackErr := h.sessionMgr.RollbackReplacement(sessionID, rollback) + _ = successor.Close() + unlock() + return nil, errors.Join(playback.ErrSessionSuperseded, rollbackErr) + } + unlock() + + if previousLocal != nil { + _ = previousLocal.Close() + } + if previousRoute.NodeURL != "" { + previousProcessID := previousRoute.TransportID + if previousProcessID == "" { + previousProcessID = sessionID + } + h.tm.StopRemoteTranscode(previousProcessID, previousRoute.NodeURL) + } + return successor, nil +} + func (h *PlaybackHandler) closeTranscodeForSession(session *playback.Session) { if session == nil { return @@ -2032,6 +2256,7 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. writeError(w, http.StatusForbidden, "forbidden", "Session belongs to another user") return } + previousRoute := sessionTranscodeRoute(session) var req changeAudioRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -2095,6 +2320,60 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. } else if transcodeAudio { targetAudioCodec = "aac" } + + // A legacy copy-video restart needs a fresh keyframe origin for the new + // position. Resolve it before mutating the durable session or stopping the + // current transport so a probe failure leaves the active stream intact. + restartSegmentDuration := session.SegmentDuration + if restartSegmentDuration <= 0 { + restartSegmentDuration = playback.DefaultSegmentDuration + } + if ts := h.tm.GetTranscodeSession(sessionID); ts != nil { + if liveDuration := ts.Opts().SegmentDuration; liveDuration > 0 { + restartSegmentDuration = liveDuration + } + } + restartSeekSeconds := alignedSeekSeconds(req.Position, restartSegmentDuration, targetVideoCodec) + restartStartSegment := computeStartSegment(restartSeekSeconds, restartSegmentDuration) + restartStreamOriginSeconds := 0.0 + restartCopyAnchorResolved := false + legacyCopyRestart := session.PlayMethod == playback.PlayTranscode && + strings.EqualFold(targetVideoCodec, "copy") && isLegacyTransportSession(session) + if legacyCopyRestart { + restartCopyAnchorResolved = true + if req.Position > 0 { + anchor, anchorSegment, anchorErr := h.resolveLegacyCopySeekAnchor( + r.Context(), + h.playbackConfig().FFmpegPath, + file.FilePath, + req.Position, + restartSegmentDuration, + ) + if anchorErr != nil { + slog.ErrorContext(r.Context(), "failed to resolve copy-video audio-switch seek anchor", "component", "api", + "playback_session_id", sessionID, + "requested_seek_seconds", req.Position, + "error", anchorErr, + ) + writeError(w, http.StatusInternalServerError, "remux_seek_anchor_failed", "Failed to resolve remux seek position") + return + } + restartStreamOriginSeconds = anchor + restartStartSegment = anchorSegment + } + } + var deferredRemoteCopyPlan *nodepool.Plan + if legacyCopyRestart && strings.TrimSpace(session.TranscodeNodeURL) != "" && + h.NodePlanner != nil && h.JWTSecret != "" { + estKbps := targetBitrateKbps + if estKbps <= 0 { + estKbps = fileBitrateKbps(file) + } + plan := h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, true, estKbps) + if plan.ProxyNode != nil && plan.TranscodeNode != nil { + deferredRemoteCopyPlan = &plan + } + } slog.InfoContext(r.Context(), "audio switch computed playback state", "component", "api", "playback_session_id", sessionID, "previous_base_play_method", baseMethod, @@ -2104,46 +2383,6 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. "audio_codec", newTrack.Codec, "transcode_audio", transcodeAudio, ) - if err := h.sessionMgr.UpdateAudioTrack(sessionID, req.AudioTrackIndex, newMethod); err != nil { - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update audio track") - return - } - if err := h.sessionMgr.UpdateStreamState(sessionID, playback.SessionStreamState{ - PlayMethod: session.PlayMethod, - BasePlayMethod: newMethod, - AudioTrackIndex: req.AudioTrackIndex, - TranscodeAudio: transcodeAudio, - ClientIP: session.ClientIP, - StreamBitrateKbps: streamBitrateKbps, - TargetResolution: targetResolution, - TargetVideoCodec: targetVideoCodec, - TargetAudioCodec: targetAudioCodec, - TargetBitrateKbps: targetBitrateKbps, - // Carry the byte-affecting recipe forward: an audio switch changes only the - // audio selection, so subtitles and cadence must survive the state update - // (UpdateStreamState overwrites these fields unconditionally). - SubtitleTrackIndex: session.SubtitleTrackIndex, - SubtitleBurnIn: session.SubtitleBurnIn, - SegmentDuration: session.SegmentDuration, - }); err != nil { - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update stream state") - return - } - - // Handle transcode restart. - if session.PlayMethod == playback.PlayTranscode { - if ts := h.tm.GetTranscodeSession(sessionID); ts != nil { - ts.SetAudioTrackIndex(req.AudioTrackIndex) - tsOpts := ts.Opts() - startSegment := computeStartSegment(req.Position, tsOpts.SegmentDuration) - seekSeconds := alignedSeekSeconds(req.Position, tsOpts.SegmentDuration, tsOpts.TargetCodecVideo) - // Throttler + exit monitor re-arm via the session's restart hook. - if restartErr := h.tm.RestartSessionLocked(context.WithoutCancel(r.Context()), sessionID, ts, seekSeconds, startSegment); restartErr != nil { - slog.ErrorContext(r.Context(), "failed to restart transcode for audio switch", "component", "api", "session", sessionID, "error", restartErr) - } - } - } - updatedSession := *session updatedSession.AudioTrackIndex = req.AudioTrackIndex updatedSession.BasePlayMethod = newMethod @@ -2155,13 +2394,110 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. updatedSession.TargetVideoCodec = targetVideoCodec updatedSession.TargetAudioCodec = targetAudioCodec updatedSession.TargetBitrateKbps = targetBitrateKbps + updatedSession.SegmentDuration = restartSegmentDuration + + audioSwitchReplacement := func(route playback.TranscodeRoute) playback.SessionReplacement { + return playback.SessionReplacement{ + EffectiveMediaFileID: session.MediaFileID, + StreamState: playback.SessionStreamState{ + PlayMethod: updatedSession.PlayMethod, + BasePlayMethod: newMethod, + AudioTrackIndex: req.AudioTrackIndex, + TranscodeAudio: transcodeAudio, + RemuxDVMode: session.RemuxDVMode, + ClientIP: session.ClientIP, + ClientName: session.ClientName, + ClientVersion: session.ClientVersion, + ClientUserAgent: session.ClientUserAgent, + StreamBitrateKbps: streamBitrateKbps, + TargetResolution: targetResolution, + TargetVideoCodec: targetVideoCodec, + TargetAudioCodec: targetAudioCodec, + TargetBitrateKbps: targetBitrateKbps, + TranscodeHWAccel: updatedSession.TranscodeHWAccel, + TranscodeNodeURL: route.NodeURL, + TranscodeTransportID: route.TransportID, + TranscodeRouteSet: true, + SubtitleTrackIndex: session.SubtitleTrackIndex, + SubtitleBurnIn: session.SubtitleBurnIn, + SegmentDuration: restartSegmentDuration, + }, + } + } + audioStatePublished := false + publishAudioSwitch := func(route playback.TranscodeRoute) error { + if _, err := h.sessionMgr.ApplyReplacement(sessionID, audioSwitchReplacement(route)); err != nil { + return err + } + audioStatePublished = true + updatedSession.TranscodeNodeURL = route.NodeURL + updatedSession.TranscodeTransportID = route.TransportID + return nil + } + + // Local audio switches stage a successor in a generation-scoped directory. + // The predecessor keeps serving until the successor has a manifest and the + // full session replacement has been published atomically. + if session.PlayMethod == playback.PlayTranscode && deferredRemoteCopyPlan == nil { + if previousLocal := h.tm.GetTranscodeSession(sessionID); previousLocal != nil { + opts := previousLocal.Opts() + outputSubdir := newLegacyTransportID(sessionID) + opts.OutputSubdir = outputSubdir + opts.OutputDir = filepath.Join(h.playbackConfig().TranscodeDir, outputSubdir) + opts.TranscodeTransportID = "" + opts.AudioTrackIndex = req.AudioTrackIndex + opts.SeekSeconds = restartSeekSeconds + opts.StartSegmentNumber = restartStartSegment + opts.StreamOriginSeconds = restartStreamOriginSeconds + opts.CopySeekAnchorResolved = restartCopyAnchorResolved + opts.FastStart = true + successor, restartErr := h.commitLegacyLocalReplacement( + context.WithoutCancel(r.Context()), + sessionID, + previousLocal, + previousRoute, + opts, + func(successor *playback.TranscodeSession) playback.SessionReplacement { + updatedSession.TranscodeHWAccel = successor.Opts().HWAccel + return audioSwitchReplacement(playback.TranscodeRoute{}) + }, + ) + if restartErr != nil { + if errors.Is(restartErr, playback.ErrSessionSuperseded) { + writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request") + return + } + slog.ErrorContext(r.Context(), "failed to prepare transcode for audio switch", "component", "api", "session", sessionID, "error", restartErr) + writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to restart transcode session") + return + } + audioStatePublished = true + updatedSession.TranscodeNodeURL = "" + updatedSession.TranscodeTransportID = "" + successor.SetRestartHook(func(ctx context.Context) { + h.maybeStartThrottler(ctx, successor) + h.tm.MonitorLocalTranscodeExit(sessionID, successor) + }) + h.maybeStartThrottler(r.Context(), successor) + h.tm.MonitorLocalTranscodeExit(sessionID, successor) + } + } + + if session.PlayMethod != playback.PlayTranscode { + if err := publishAudioSwitch(playback.TranscodeRoute{}); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update audio track") + return + } + } // The switched recipe travels in the freshly minted stream token on the new // serve URL below, so a post-restart reconstruct resumes with the switched // audio/method. For transcode the full-recipe manifest URL is rebuilt further // down (proxy or local); for direct/remux the identity token on StreamURL // carries the new audio selection. - h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex) + if audioStatePublished { + h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex) + } // For a local transcode, playbackStreamURL returns the bare manifest URL // without the full-recipe ?st= token, so a post-restart reconstruct would @@ -2193,22 +2529,28 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. if estKbps <= 0 { estKbps = fileBitrateKbps(file) } - plan := h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, needsTranscode, estKbps) + var plan nodepool.Plan + if deferredRemoteCopyPlan != nil { + plan = *deferredRemoteCopyPlan + } else { + plan = h.NodePlanner.PlanSession(sessionID, session.TranscodeNodeURL, needsTranscode, estKbps) + } if proxyNode := plan.ProxyNode; proxyNode != nil && (!needsTranscode || plan.TranscodeNode != nil) { // Remote (offloaded) transcode: the API server owns no local // TranscodeSession (the LOCAL restart block above was a no-op), so // the node's ffmpeg is still serving the OLD audio track. POST a - // fresh /transcode/start with the new AudioTrackIndex — the node - // tears down the existing session for this ID and restarts ffmpeg - // (handleStart in internal/transcodenode/server.go), which IS the - // remote restart mechanism — then mint the replacement proxy URL + // fresh /transcode/start with the new AudioTrackIndex. Encoded legacy + // streams retain the node's same-ID replacement behavior; copy-video + // streams prepare a distinct successor and retire the predecessor only + // after readiness and route publication. Then mint the proxy URL // from a FULL recipe card so a later node restart reconstructs with // the switched audio (the lean identity-only claims used for remux // below omit the byte-affecting encode fields and would 404). isOffloaded := strings.TrimSpace(session.TranscodeNodeURL) != "" if needsTranscode && plan.TranscodeNode != nil && isOffloaded { nodeURL := plan.TranscodeNode.URL - _ = h.sessionMgr.SetTranscodeNodeURL(sessionID, nodeURL) + atomicLegacyReplacement := legacyCopyRestart + previousLocalTranscode := h.tm.GetTranscodeSession(sessionID) // Restart from the FULL live recipe, not a partial re-derivation. // An audio switch alters only audio selection — subtitle burn-in and @@ -2219,19 +2561,13 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. // post-restart reconstruct, so recover them here. Embed a concrete // segment duration (not 0): the node's recipe token treats // SegmentDuration<=0 as "incomplete" and would 404 on a node restart. - segmentDuration := session.SegmentDuration - if segmentDuration <= 0 { - segmentDuration = playback.DefaultSegmentDuration - } - seekSeconds := alignedSeekSeconds(req.Position, segmentDuration, updatedSession.TargetVideoCodec) + segmentDuration := restartSegmentDuration subtitleTrackIndex := session.SubtitleTrackIndex subtitleBurnIn := session.SubtitleBurnIn subtitleCodec := "" if subtitleBurnIn && subtitleTrackIndex >= 0 { subtitleCodec = embeddedSubtitleCodec(file, subtitleTrackIndex) } - startSegment := computeStartSegment(seekSeconds, segmentDuration) - // Derive the encode recipe the same way HandleStartTranscode // does — from the durable session target fields plus the file — // changing only the audio track. SourceVideoCodec/TotalDuration @@ -2241,23 +2577,29 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. // transport ID; restarting under the bare session ID would // spawn a duplicate job beside it. restartTransportID := remoteTransportID(&updatedSession) + if atomicLegacyReplacement { + restartTransportID = newLegacyTransportID(sessionID) + } nodeReq := transcodenode.TranscodeStartRequest{ - SessionID: restartTransportID, - InputPath: file.FilePath, - SourceVideoCodec: file.CodecVideo, - SeekSeconds: seekSeconds, - StartSegmentNumber: startSegment, - TargetResolution: updatedSession.TargetResolution, - TargetCodecVideo: updatedSession.TargetVideoCodec, - TargetCodecAudio: updatedSession.TargetAudioCodec, - TargetBitrateKbps: updatedSession.TargetBitrateKbps, - SegmentDuration: segmentDuration, - HWAccel: session.TranscodeHWAccel, - AudioTrackIndex: req.AudioTrackIndex, - SubtitleTrackIndex: subtitleTrackIndex, - SubtitleBurnIn: subtitleBurnIn, - SubtitleCodec: subtitleCodec, - TotalDuration: float64(file.Duration), + SessionID: restartTransportID, + InputPath: file.FilePath, + SourceVideoCodec: file.CodecVideo, + SeekSeconds: restartSeekSeconds, + StreamOriginSeconds: restartStreamOriginSeconds, + CopySeekAnchorResolved: restartCopyAnchorResolved, + StartSegmentNumber: restartStartSegment, + TargetResolution: updatedSession.TargetResolution, + TargetCodecVideo: updatedSession.TargetVideoCodec, + TargetCodecAudio: updatedSession.TargetAudioCodec, + TargetBitrateKbps: updatedSession.TargetBitrateKbps, + SegmentDuration: segmentDuration, + HWAccel: session.TranscodeHWAccel, + AudioTrackIndex: req.AudioTrackIndex, + SubtitleTrackIndex: subtitleTrackIndex, + SubtitleBurnIn: subtitleBurnIn, + SubtitleCodec: subtitleCodec, + TotalDuration: float64(file.Duration), + RequireReady: atomicLegacyReplacement, } if strings.TrimSpace(nodeReq.HWAccel) == "" { nodeReq.HWAccel = h.playbackConfig().HWAccel @@ -2269,59 +2611,94 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. nodeReq.VideoBitstreamFilter = playback.DV7ToHDR10BitstreamFilter } - body, _ := json.Marshal(nodeReq) - ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 10*time.Second) - defer cancel() - httpReq, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, nodeURL+"/transcode/start", bytes.NewReader(body)) - if reqErr != nil { - slog.ErrorContext(r.Context(), "failed to build remote transcode restart for audio switch", "component", "api", "session", sessionID, "node", nodeURL, "error", reqErr) - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to build transcode request") - return - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+h.JWTSecret) - - nodeResp, doErr := http.DefaultClient.Do(httpReq) - if doErr != nil { - slog.ErrorContext(r.Context(), "remote transcode restart for audio switch failed", "component", "api", "session", sessionID, "node", nodeURL, "error", doErr) + startResp, status, startErr := h.startRemotePlaybackTransport( + context.WithoutCancel(r.Context()), + nodeURL, + nodeReq, + ) + if startErr != nil { + if atomicLegacyReplacement { + h.tm.StopRemoteTranscode(restartTransportID, nodeURL) + } + slog.ErrorContext(r.Context(), "remote transcode restart for audio switch failed", "component", "api", "session", sessionID, "node", nodeURL, "error", startErr) writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable") return } - defer nodeResp.Body.Close() - if nodeResp.StatusCode != http.StatusAccepted { - slog.ErrorContext(r.Context(), "remote transcode restart for audio switch rejected", "component", "api", "session", sessionID, "node", nodeURL, "status", nodeResp.StatusCode) + if status != http.StatusAccepted { + if atomicLegacyReplacement { + h.tm.StopRemoteTranscode(restartTransportID, nodeURL) + } + slog.ErrorContext(r.Context(), "remote transcode restart for audio switch rejected", "component", "api", "session", sessionID, "node", nodeURL, "status", status) writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request") return } - - var startResp transcodenode.TranscodeStartResponse - if decErr := json.NewDecoder(nodeResp.Body).Decode(&startResp); decErr != nil { - slog.WarnContext(r.Context(), "remote transcode restart response decode failed", "component", "api", "session", sessionID, "node", nodeURL, "error", decErr) + effectiveHWAccel := effectiveRemoteHWAccel(startResp, nodeReq) + updatedSession.TranscodeHWAccel = effectiveHWAccel + successorTransportID := restartTransportID + if !atomicLegacyReplacement { + // Empty is the durable legacy representation for a node process + // running under the public playback session ID. + successorTransportID = session.TranscodeTransportID } - effectiveHWAccel := strings.TrimSpace(startResp.HWAccel) - if effectiveHWAccel == "" { - effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel) + successorRoute := playback.TranscodeRoute{NodeURL: nodeURL, TransportID: successorTransportID} + if atomicLegacyReplacement { + if routeErr := h.commitLegacyRemoteReplacement( + sessionID, + previousLocalTranscode, + previousRoute, + audioSwitchReplacement(successorRoute), + ); routeErr != nil { + h.tm.StopRemoteTranscode(restartTransportID, nodeURL) + if errors.Is(routeErr, playback.ErrSessionSuperseded) { + writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request") + return + } + slog.ErrorContext(r.Context(), "publish remote audio-switch transcode route", "component", "api", "session", sessionID, "node", nodeURL, "error", routeErr) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session") + return + } + audioStatePublished = true + if !h.transcodeRouteMatches(sessionID, nil, successorRoute) { + writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request") + return + } + } else { + if routeErr := h.commitLegacyRemoteLastWriter( + sessionID, + successorRoute, + audioSwitchReplacement(successorRoute), + ); routeErr != nil { + slog.ErrorContext(r.Context(), "publish remote audio-switch transcode route", "component", "api", "session", sessionID, "node", nodeURL, "error", routeErr) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session") + return + } + audioStatePublished = true } + updatedSession.TranscodeNodeURL = successorRoute.NodeURL + updatedSession.TranscodeTransportID = successorRoute.TransportID + h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex) card := playback.NewRecipeCard(updatedSession.UserID, updatedSession.ProfileID, updatedSession.MediaFileID, nodeURL, playback.TranscodeOpts{ - InputPath: nodeReq.InputPath, - SessionID: sessionID, - TranscodeTransportID: restartTransportID, - VideoBitstreamFilter: nodeReq.VideoBitstreamFilter, - SourceVideoCodec: nodeReq.SourceVideoCodec, - SeekSeconds: nodeReq.SeekSeconds, - StartSegmentNumber: nodeReq.StartSegmentNumber, - TargetResolution: nodeReq.TargetResolution, - TargetCodecVideo: nodeReq.TargetCodecVideo, - TargetCodecAudio: nodeReq.TargetCodecAudio, - TargetBitrateKbps: nodeReq.TargetBitrateKbps, - SegmentDuration: nodeReq.SegmentDuration, - HWAccel: effectiveHWAccel, - AudioTrackIndex: nodeReq.AudioTrackIndex, - SubtitleTrackIndex: nodeReq.SubtitleTrackIndex, - SubtitleBurnIn: nodeReq.SubtitleBurnIn, - SubtitleCodec: nodeReq.SubtitleCodec, - TotalDuration: nodeReq.TotalDuration, + InputPath: nodeReq.InputPath, + SessionID: sessionID, + TranscodeTransportID: restartTransportID, + VideoBitstreamFilter: nodeReq.VideoBitstreamFilter, + SourceVideoCodec: nodeReq.SourceVideoCodec, + SeekSeconds: nodeReq.SeekSeconds, + StreamOriginSeconds: nodeReq.StreamOriginSeconds, + CopySeekAnchorResolved: nodeReq.CopySeekAnchorResolved, + StartSegmentNumber: nodeReq.StartSegmentNumber, + TargetResolution: nodeReq.TargetResolution, + TargetCodecVideo: nodeReq.TargetCodecVideo, + TargetCodecAudio: nodeReq.TargetCodecAudio, + TargetBitrateKbps: nodeReq.TargetBitrateKbps, + SegmentDuration: nodeReq.SegmentDuration, + HWAccel: effectiveHWAccel, + AudioTrackIndex: nodeReq.AudioTrackIndex, + SubtitleTrackIndex: nodeReq.SubtitleTrackIndex, + SubtitleBurnIn: nodeReq.SubtitleBurnIn, + SubtitleCodec: nodeReq.SubtitleCodec, + TotalDuration: nodeReq.TotalDuration, }) resp.StreamURL = h.buildProxyManifestURL(card, proxyNode) } else { @@ -2347,7 +2724,6 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. } if plan.TranscodeNode != nil { tokenClaims.TranscodeNode = plan.TranscodeNode.URL - _ = h.sessionMgr.SetTranscodeNodeURL(sessionID, plan.TranscodeNode.URL) } if token, signErr := streamtoken.Sign(tokenClaims, h.JWTSecret, playback.MaxTokenTTL); signErr == nil { switch updatedSession.PlayMethod { @@ -2360,6 +2736,16 @@ func (h *PlaybackHandler) HandleChangeAudioTrack(w http.ResponseWriter, r *http. } } } + if !audioStatePublished { + if err := publishAudioSwitch(previousRoute); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update audio track") + return + } + h.persistAudioPreference(r.Context(), userID, session.ProfileID, file, req.AudioTrackIndex) + } + if legacyCopyRestart { + resp.setCopyTimeline(req.Position, restartStreamOriginSeconds) + } h.syncSessionsNow(r.Context(), "audio_change") writeJSON(w, http.StatusOK, resp) @@ -2511,33 +2897,24 @@ type transcodeStartState struct { hwAccel string } -// finalizeTranscodeStart updates the playback session state after a transcode -// has been started (either locally or on a remote node). -func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeStartState) { - if st.switchedFileID != nil { - if err := h.sessionMgr.SetEffectiveMediaFileID(st.req.SessionID, *st.switchedFileID); err != nil { - slog.ErrorContext(r.Context(), "failed to update effective media file", "component", "api", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) - } +func effectiveRemoteHWAccel( + response transcodenode.TranscodeStartResponse, + request transcodenode.TranscodeStartRequest, +) string { + hwAccel := strings.TrimSpace(response.HWAccel) + if hwAccel == "" { + hwAccel = strings.TrimSpace(request.HWAccel) } + return hwAccel +} +func transcodeStartReplacement(st transcodeStartState, route playback.TranscodeRoute) playback.SessionReplacement { streamBitrateKbps := st.req.TargetBitrateKbps if streamBitrateKbps <= 0 { streamBitrateKbps = st.file.Bitrate } transcodeAudio := playback.TranscodesAudio(st.req.TargetCodecAudio) baseMethod := semanticPlayMethod(st.session) - - slog.InfoContext(r.Context(), "transcode start preserved base playback state", "component", "api", - "playback_session_id", st.req.SessionID, - "base_play_method", baseMethod, - "transport_play_method", playback.PlayTranscode, - "audio_track_index", st.session.AudioTrackIndex, - "target_codec_video", st.req.TargetCodecVideo, - "target_codec_audio", st.req.TargetCodecAudio, - "copy_video_original", strings.EqualFold(st.req.TargetCodecVideo, "copy"), - "transcode_audio", transcodeAudio, - ) - // Persist the byte-affecting recipe (subtitles + segment cadence) so a later // offloaded audio switch can rebuild the exact same stream. The session is the // only recovery source for offloaded transcodes (no local ts.Opts()). Normalize @@ -2547,26 +2924,49 @@ func (h *PlaybackHandler) finalizeTranscodeStart(r *http.Request, st transcodeSt segmentDuration = playback.DefaultSegmentDuration } - if err := h.sessionMgr.UpdateStreamState(st.req.SessionID, playback.SessionStreamState{ - PlayMethod: playback.PlayTranscode, - BasePlayMethod: baseMethod, - AudioTrackIndex: st.session.AudioTrackIndex, - TranscodeAudio: transcodeAudio, - ClientIP: st.session.ClientIP, - StreamBitrateKbps: streamBitrateKbps, - TargetResolution: st.req.TargetResolution, - TargetVideoCodec: st.req.TargetCodecVideo, - TargetAudioCodec: st.req.TargetCodecAudio, - TargetBitrateKbps: st.req.TargetBitrateKbps, - TranscodeHWAccel: st.hwAccel, - SubtitleTrackIndex: st.req.SubtitleTrackIndex, - SubtitleBurnIn: st.req.SubtitleBurnIn, - SegmentDuration: segmentDuration, - }); err != nil { - slog.ErrorContext(r.Context(), "failed to update transcode stream state", "component", "api", "session", st.req.SessionID, "error", err, "playback_session_id", st.req.SessionID) + effectiveFileID := st.file.ID + if st.switchedFileID != nil { + effectiveFileID = *st.switchedFileID } + return playback.SessionReplacement{ + EffectiveMediaFileID: effectiveFileID, + StreamState: playback.SessionStreamState{ + PlayMethod: playback.PlayTranscode, + BasePlayMethod: baseMethod, + AudioTrackIndex: st.session.AudioTrackIndex, + TranscodeAudio: transcodeAudio, + RemuxDVMode: st.session.RemuxDVMode, + ClientIP: st.session.ClientIP, + ClientName: st.session.ClientName, + ClientVersion: st.session.ClientVersion, + ClientUserAgent: st.session.ClientUserAgent, + StreamBitrateKbps: streamBitrateKbps, + TargetResolution: st.req.TargetResolution, + TargetVideoCodec: st.req.TargetCodecVideo, + TargetAudioCodec: st.req.TargetCodecAudio, + TargetBitrateKbps: st.req.TargetBitrateKbps, + TranscodeHWAccel: st.hwAccel, + TranscodeNodeURL: route.NodeURL, + TranscodeTransportID: route.TransportID, + TranscodeRouteSet: true, + SubtitleTrackIndex: st.req.SubtitleTrackIndex, + SubtitleBurnIn: st.req.SubtitleBurnIn, + SegmentDuration: segmentDuration, + }, + } +} - h.syncSessionsNow(r.Context(), "transcode_start") +func logTranscodeStartState(r *http.Request, st transcodeStartState) { + slog.InfoContext(r.Context(), "transcode start preserved base playback state", "component", "api", + "playback_session_id", st.req.SessionID, + "base_play_method", semanticPlayMethod(st.session), + "transport_play_method", playback.PlayTranscode, + "audio_track_index", st.session.AudioTrackIndex, + "target_codec_video", st.req.TargetCodecVideo, + "target_codec_audio", st.req.TargetCodecAudio, + "copy_video_original", strings.EqualFold(st.req.TargetCodecVideo, "copy"), + "transcode_audio", st.req.TargetCodecAudio != "" && !strings.EqualFold(st.req.TargetCodecAudio, "copy"), + ) } // HandleStartTranscode handles POST /playback/transcode/start. @@ -2605,15 +3005,12 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re if !h.ensureUserTranscodingAllowed(w, r, userID, requiresVideoTranscode) { return } - // Close any existing transcode so a new one can start at different quality. - // Check both local sessions AND remote node assignments — without the - // remote check, switching quality on a transcode node never sends DELETE, - // leaving the old ffmpeg running and its segments on disk. - if h.tm.GetTranscodeSession(req.SessionID) != nil || session.TranscodeNodeURL != "" { - // Restarting the transcode under the SAME session id (quality/seek - // change) — keep the card; it is re-saved with the new opts below. - h.closeTranscodeForSession(session) - } + // Keep the active transport alive through every fallible preflight. A local + // replacement is retired under the lifecycle lock immediately before spawn; + // a legacy remote replacement is prepared under a distinct process identity + // and retired only after the node accepts and the session publishes its successor. + previousLocalTranscode := h.tm.GetTranscodeSession(req.SessionID) + previousRoute := sessionTranscodeRoute(session) abortCurrentSession := func(reason string, cause error) { if abortErr := h.abortPlaybackSession(r.Context(), session); abortErr != nil && !errors.Is(abortErr, playback.ErrSessionNotFound) { slog.ErrorContext(r.Context(), "failed to abort playback session", "component", "api", @@ -2676,22 +3073,6 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re } } - // Resume and seek-start requests generally cannot safely stream-copy video - // into HLS output. Arbitrary HEVC seek points often land on non-keyframes, - // which can leave Chromium stuck on a frozen frame while audio continues - // advancing. MPEG-2 compatibility HLS is allowed to keep copy-video so Apple - // devices can avoid a full video transcode for those files. - if req.SeekSeconds > 0 && strings.EqualFold(req.TargetCodecVideo, "copy") && !playback.IsMPEG2VideoCodec(file.CodecVideo) { - slog.InfoContext(r.Context(), "forcing video transcode for seeked copy request", "component", "api", - "playback_session_id", req.SessionID, - "seek_seconds", req.SeekSeconds, - "source_video_codec", file.CodecVideo, - "requested_target_codec_video", req.TargetCodecVideo, - "effective_target_codec_video", "h264", - ) - req.TargetCodecVideo = "h264" - } - // Subtitle burn-in composites subtitles into the video frames, which is // impossible with -c:v copy. If the requested recipe would stream-copy // video (e.g. a remux "original" restart that adds burn-in), force an @@ -2714,8 +3095,8 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re // audio-switch restart derives it from the durable session route, but this // client-driven restart endpoint historically dropped it — the client asking // for "copy" has no way to know the source needs the strip. Derived after - // the seek/burn-in guards above so a copy request they rewrite to h264 - // never carries a copy-only bitstream filter. + // the burn-in guard above so a copy request it rewrites to h264 never carries + // a copy-only bitstream filter. videoBitstreamFilter := "" if strings.EqualFold(req.TargetCodecVideo, "copy") && (session.RemuxDVMode == playback.RemuxDVStripToHDR10V3 || file.PrimaryDVProfile() == 7) { @@ -2723,8 +3104,8 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re } // The request-level permission check above intentionally runs before the - // existing transcode is closed. Recheck when server-side normalization has - // upgraded an allowed copy-video request into actual video encoding. + // existing transcode is closed. Recheck when subtitle burn-in normalization + // has upgraded an allowed copy-video request into actual video encoding. if !requiresVideoTranscode && !strings.EqualFold(req.TargetCodecVideo, "copy") && !h.ensureUserTranscodingAllowed(w, r, userID, true) { return @@ -2792,6 +3173,44 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re subtitleCodec = resolvedCodec } + // A copy-video input seek starts at the demuxer's preceding keyframe. Keep + // the requested position as FFmpeg's -ss input, but resolve that keyframe + // before selecting local/offloaded transport so filenames, reconstruction, + // and the client timeline all describe the media that is actually emitted. + playbackCfg := h.playbackConfig() + transportSeekSeconds := alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo) + startSegmentNumber := computeStartSegment(transportSeekSeconds, req.SegmentDuration) + streamOriginSeconds := 0.0 + if videoCopy { + streamOriginSeconds = req.SeekSeconds + if req.SeekSeconds > 0 { + anchor, anchorSegment, anchorErr := h.resolveLegacyCopySeekAnchor( + r.Context(), + playbackCfg.FFmpegPath, + file.FilePath, + req.SeekSeconds, + req.SegmentDuration, + ) + if anchorErr != nil { + slog.ErrorContext(r.Context(), "failed to resolve copy-video seek anchor", "component", "api", + "playback_session_id", req.SessionID, + "requested_seek_seconds", req.SeekSeconds, + "error", anchorErr, + ) + writeError(w, http.StatusInternalServerError, "remux_seek_anchor_failed", "Failed to resolve remux seek position") + return + } + streamOriginSeconds = anchor + startSegmentNumber = anchorSegment + slog.DebugContext(r.Context(), "resolved copy-video seek anchor", "component", "api", + "playback_session_id", req.SessionID, + "requested_seek_seconds", req.SeekSeconds, + "stream_origin_seconds", streamOriginSeconds, + "start_segment_number", startSegmentNumber, + ) + } + } + // Determine whether to run locally or forward to a remote transcode node. var plan nodepool.Plan if h.NodePlanner != nil { @@ -2805,44 +3224,99 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re if tcNode != nil { // Remote transcode: forward to the assigned node. - if err := h.sessionMgr.SetTranscodeNodeURL(req.SessionID, tcNode.URL); err != nil { - slog.ErrorContext(r.Context(), "set transcode node URL", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) + replacementTransportID := req.SessionID + reconstructionTransportID := "" + atomicLegacyReplacement := videoCopy && isLegacyTransportSession(session) + if atomicLegacyReplacement { + replacementTransportID = newLegacyTransportID(req.SessionID) + reconstructionTransportID = replacementTransportID } - nodeReq := transcodenode.TranscodeStartRequest{ - SessionID: req.SessionID, - InputPath: file.FilePath, - SourceVideoCodec: file.CodecVideo, - VideoBitstreamFilter: videoBitstreamFilter, - SeekSeconds: alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo), - StartSegmentNumber: computeStartSegment(req.SeekSeconds, req.SegmentDuration), - TargetResolution: req.TargetResolution, - TargetCodecVideo: req.TargetCodecVideo, - TargetCodecAudio: req.TargetCodecAudio, - TargetBitrateKbps: req.TargetBitrateKbps, - SegmentDuration: req.SegmentDuration, - HWAccel: h.playbackConfig().HWAccel, - AudioTrackIndex: session.AudioTrackIndex, - SubtitleTrackIndex: req.SubtitleTrackIndex, - SubtitleBurnIn: req.SubtitleBurnIn, - SubtitleCodec: subtitleCodec, - TotalDuration: float64(file.Duration), + SessionID: replacementTransportID, + InputPath: file.FilePath, + SourceVideoCodec: file.CodecVideo, + VideoBitstreamFilter: videoBitstreamFilter, + SeekSeconds: transportSeekSeconds, + StreamOriginSeconds: streamOriginSeconds, + CopySeekAnchorResolved: videoCopy, + StartSegmentNumber: startSegmentNumber, + TargetResolution: req.TargetResolution, + TargetCodecVideo: req.TargetCodecVideo, + TargetCodecAudio: req.TargetCodecAudio, + TargetBitrateKbps: req.TargetBitrateKbps, + SegmentDuration: req.SegmentDuration, + HWAccel: playbackCfg.HWAccel, + AudioTrackIndex: session.AudioTrackIndex, + SubtitleTrackIndex: req.SubtitleTrackIndex, + SubtitleBurnIn: req.SubtitleBurnIn, + SubtitleCodec: subtitleCodec, + TotalDuration: float64(file.Duration), + RequireReady: atomicLegacyReplacement, } nodeResp, status, err := h.startRemotePlaybackTransport(context.Background(), tcNode.URL, nodeReq) if err != nil { + if atomicLegacyReplacement { + h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL) + } slog.ErrorContext(r.Context(), "remote transcode start failed", "component", "api", "error", err, "node", tcNode.URL, "session", req.SessionID, "playback_session_id", req.SessionID) writeError(w, http.StatusBadGateway, "transcode_node_unavailable", "Transcode node is unavailable") return } if status != http.StatusAccepted { + if atomicLegacyReplacement { + h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL) + } slog.ErrorContext(r.Context(), "remote transcode start rejected", "component", "api", "status", status, "node", tcNode.URL) writeError(w, http.StatusBadGateway, "transcode_start_failed", "Transcode node rejected the request") return } - effectiveHWAccel := strings.TrimSpace(nodeResp.HWAccel) - if effectiveHWAccel == "" { - effectiveHWAccel = strings.TrimSpace(nodeReq.HWAccel) + effectiveHWAccel := effectiveRemoteHWAccel(nodeResp, nodeReq) + startState := transcodeStartState{ + req: req, + file: file, + session: session, + switchedFileID: switchedFileID, + hwAccel: effectiveHWAccel, + } + successorRoute := playback.TranscodeRoute{ + NodeURL: tcNode.URL, + TransportID: reconstructionTransportID, + } + if atomicLegacyReplacement { + if err := h.commitLegacyRemoteReplacement( + req.SessionID, + previousLocalTranscode, + previousRoute, + transcodeStartReplacement(startState, successorRoute), + ); err != nil { + h.tm.StopRemoteTranscode(replacementTransportID, tcNode.URL) + if errors.Is(err, playback.ErrSessionSuperseded) { + writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request") + return + } + slog.ErrorContext(r.Context(), "publish legacy transcode route", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session") + return + } + if !h.transcodeRouteMatches(req.SessionID, nil, successorRoute) { + writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request") + return + } + logTranscodeStartState(r, startState) + h.syncSessionsNow(r.Context(), "transcode_start") + } else { + if err := h.commitLegacyRemoteLastWriter( + req.SessionID, + successorRoute, + transcodeStartReplacement(startState, successorRoute), + ); err != nil { + slog.ErrorContext(r.Context(), "publish remote transcode route", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session") + return + } + logTranscodeStartState(r, startState) + h.syncSessionsNow(r.Context(), "transcode_start") } // The remote transcode's full recipe rides the proxy manifest token so the @@ -2850,33 +3324,29 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re // (and a node could someday self-reconstruct from it). Node-side segment // reconstruction is a follow-up (see spec multi-node section). card := playback.NewRecipeCard(session.UserID, session.ProfileID, session.MediaFileID, tcNode.URL, playback.TranscodeOpts{ - InputPath: nodeReq.InputPath, - SessionID: nodeReq.SessionID, - SourceVideoCodec: nodeReq.SourceVideoCodec, - VideoBitstreamFilter: nodeReq.VideoBitstreamFilter, - SeekSeconds: nodeReq.SeekSeconds, - StartSegmentNumber: nodeReq.StartSegmentNumber, - TargetResolution: nodeReq.TargetResolution, - TargetCodecVideo: nodeReq.TargetCodecVideo, - TargetCodecAudio: nodeReq.TargetCodecAudio, - TargetBitrateKbps: nodeReq.TargetBitrateKbps, - SegmentDuration: nodeReq.SegmentDuration, - HWAccel: effectiveHWAccel, - AudioTrackIndex: nodeReq.AudioTrackIndex, - SubtitleTrackIndex: nodeReq.SubtitleTrackIndex, - SubtitleBurnIn: nodeReq.SubtitleBurnIn, - SubtitleCodec: nodeReq.SubtitleCodec, - TotalDuration: nodeReq.TotalDuration, + InputPath: nodeReq.InputPath, + SessionID: req.SessionID, + TranscodeTransportID: reconstructionTransportID, + SourceVideoCodec: nodeReq.SourceVideoCodec, + VideoBitstreamFilter: nodeReq.VideoBitstreamFilter, + SeekSeconds: nodeReq.SeekSeconds, + StreamOriginSeconds: nodeReq.StreamOriginSeconds, + CopySeekAnchorResolved: nodeReq.CopySeekAnchorResolved, + StartSegmentNumber: nodeReq.StartSegmentNumber, + TargetResolution: nodeReq.TargetResolution, + TargetCodecVideo: nodeReq.TargetCodecVideo, + TargetCodecAudio: nodeReq.TargetCodecAudio, + TargetBitrateKbps: nodeReq.TargetBitrateKbps, + SegmentDuration: nodeReq.SegmentDuration, + HWAccel: effectiveHWAccel, + AudioTrackIndex: nodeReq.AudioTrackIndex, + SubtitleTrackIndex: nodeReq.SubtitleTrackIndex, + SubtitleBurnIn: nodeReq.SubtitleBurnIn, + SubtitleCodec: nodeReq.SubtitleCodec, + TotalDuration: nodeReq.TotalDuration, }) manifestURL := h.buildProxyManifestURL(card, plan.ProxyNode) - h.finalizeTranscodeStart(r, transcodeStartState{ - req: req, - file: file, - session: session, - switchedFileID: switchedFileID, - hwAccel: effectiveHWAccel, - }) - writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL)) + writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL, streamOriginSeconds)) return } @@ -2890,53 +3360,112 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re } // Snapshot once so the directory, ffmpeg path, and hwaccel of this // session stay consistent even if the config reloads mid-start. - playbackCfg := h.playbackConfig() if err := os.MkdirAll(playbackCfg.TranscodeDir, 0o755); err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "Failed to prepare transcode directory") return } - // Hold the per-session lifecycle lock across teardown → spawn → register so a - // concurrent reconstruct (or another fresh start) cannot run a second ffmpeg - // writer against this session's output directory. The in-lock close tears down - // any session a reconstruct rebuilt between the earlier close and here so the - // fresh ffmpeg is the sole writer. - unlock := h.tm.LockSessionLifecycle(req.SessionID) - h.tm.CloseTranscodeSession(req.SessionID, "") - transcodeSession, err := h.startLocalPlaybackTransport(r.Context(), playback.TranscodeOpts{ - InputPath: file.FilePath, - OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID), - SessionID: req.SessionID, - SourceVideoCodec: file.CodecVideo, - VideoBitstreamFilter: videoBitstreamFilter, - SeekSeconds: alignedSeekSeconds(req.SeekSeconds, req.SegmentDuration, req.TargetCodecVideo), - StartSegmentNumber: computeStartSegment(req.SeekSeconds, req.SegmentDuration), - TargetResolution: req.TargetResolution, - TargetCodecVideo: req.TargetCodecVideo, - TargetCodecAudio: req.TargetCodecAudio, - TargetBitrateKbps: req.TargetBitrateKbps, - SegmentDuration: req.SegmentDuration, - FFmpegPath: playbackCfg.FFmpegPath, - HWAccel: playbackCfg.HWAccel, - HWDevice: playbackCfg.HWDevice, - AudioTrackIndex: session.AudioTrackIndex, - SubtitleTrackIndex: req.SubtitleTrackIndex, - SubtitleBurnIn: req.SubtitleBurnIn, - SubtitleCodec: subtitleCodec, - TotalDuration: float64(file.Duration), - FastStart: true, - NodeType: "integrated", - ExecutionMode: "integrated", - FFmpegLogSink: h.FFmpegLogSink, - }) - if err != nil { - unlock() - writeError(w, http.StatusInternalServerError, "internal_error", "Failed to start transcode session") - return + localOpts := playback.TranscodeOpts{ + InputPath: file.FilePath, + OutputDir: filepath.Join(playbackCfg.TranscodeDir, req.SessionID), + SessionID: req.SessionID, + SourceVideoCodec: file.CodecVideo, + VideoBitstreamFilter: videoBitstreamFilter, + SeekSeconds: transportSeekSeconds, + StreamOriginSeconds: streamOriginSeconds, + CopySeekAnchorResolved: videoCopy, + StartSegmentNumber: startSegmentNumber, + TargetResolution: req.TargetResolution, + TargetCodecVideo: req.TargetCodecVideo, + TargetCodecAudio: req.TargetCodecAudio, + TargetBitrateKbps: req.TargetBitrateKbps, + SegmentDuration: req.SegmentDuration, + FFmpegPath: playbackCfg.FFmpegPath, + HWAccel: playbackCfg.HWAccel, + HWDevice: playbackCfg.HWDevice, + AudioTrackIndex: session.AudioTrackIndex, + SubtitleTrackIndex: req.SubtitleTrackIndex, + SubtitleBurnIn: req.SubtitleBurnIn, + SubtitleCodec: subtitleCodec, + TotalDuration: float64(file.Duration), + FastStart: true, + NodeType: "integrated", + ExecutionMode: "integrated", + FFmpegLogSink: h.FFmpegLogSink, } - - h.tm.RegisterTranscodeSession(req.SessionID, transcodeSession) - unlock() + startState := transcodeStartState{ + req: req, + file: file, + session: session, + switchedFileID: switchedFileID, + } + localRoute := playback.TranscodeRoute{} + var transcodeSession *playback.TranscodeSession + if videoCopy { + // Copy-mode successors must coexist with the predecessor until readiness. + // A generation-scoped directory prevents two ffmpeg processes from writing + // the same manifest and segments during that overlap. + outputSubdir := newLegacyTransportID(req.SessionID) + localOpts.OutputSubdir = outputSubdir + localOpts.OutputDir = filepath.Join(playbackCfg.TranscodeDir, outputSubdir) + transcodeSession, err = h.commitLegacyLocalReplacement( + context.WithoutCancel(r.Context()), + req.SessionID, + previousLocalTranscode, + previousRoute, + localOpts, + func(successor *playback.TranscodeSession) playback.SessionReplacement { + startState.hwAccel = successor.Opts().HWAccel + return transcodeStartReplacement(startState, localRoute) + }, + ) + if err != nil { + if errors.Is(err, playback.ErrSessionSuperseded) { + writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request") + return + } + slog.ErrorContext(r.Context(), "prepare local copy transcode replacement", "component", "api", "error", err, "session", req.SessionID, "playback_session_id", req.SessionID) + writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to start transcode session") + return + } + if !h.transcodeRouteMatches(req.SessionID, transcodeSession, localRoute) { + writeError(w, http.StatusConflict, "transcode_replaced", "A newer playback transport replaced this request") + return + } + } else { + // Encoded legacy starts keep their historical serialized last-writer-wins + // behavior. Re-read the predecessor under the lifecycle lock instead of + // rejecting this request because another overlapping start finished first. + unlock := h.tm.LockSessionLifecycle(req.SessionID) + currentSession, currentErr := h.sessionMgr.GetSession(req.SessionID) + if currentErr != nil { + unlock() + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to verify playback transport") + return + } + currentRoute := sessionTranscodeRoute(currentSession) + currentProcessID := remoteTransportID(currentSession) + h.tm.CloseTranscodeSession(req.SessionID, "") + transcodeSession, err = h.startLocalPlaybackTransport(r.Context(), localOpts) + if err != nil { + unlock() + writeError(w, http.StatusInternalServerError, "transcode_start_failed", "Failed to start transcode session") + return + } + h.tm.RegisterTranscodeSession(req.SessionID, transcodeSession) + startState.hwAccel = transcodeSession.Opts().HWAccel + if _, err := h.sessionMgr.ApplyReplacement(req.SessionID, transcodeStartReplacement(startState, localRoute)); err != nil { + h.tm.CloseTranscodeSessionIf(req.SessionID, transcodeSession, "") + unlock() + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to publish transcode session") + return + } + unlock() + if currentRoute.NodeURL != "" { + h.tm.StopRemoteTranscode(currentProcessID, currentRoute.NodeURL) + } + } + logTranscodeStartState(r, startState) // Re-arm the throttler and exit monitor after every Restart of this // handler-created session, regardless of which code path triggers it @@ -2960,24 +3489,18 @@ func (h *PlaybackHandler) HandleStartTranscode(w http.ResponseWriter, r *http.Re fmt.Sprintf("/playback/transcode/%s/master.m3u8", req.SessionID), h.signSessionToken(card), ) - h.finalizeTranscodeStart(r, transcodeStartState{ - req: req, - file: file, - session: session, - switchedFileID: switchedFileID, - hwAccel: transcodeSession.Opts().HWAccel, - }) - writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL)) + h.syncSessionsNow(r.Context(), "transcode_start") + writeJSON(w, http.StatusAccepted, buildTranscodeStartResponse(req, file, switchedFileID, manifestURL, streamOriginSeconds)) } // HandleGetTranscodeManifest handles GET /playback/transcode/{session_id}/master.m3u8. // Auth is optional — the session UUID serves as an access token (same pattern // as /stream/{session_id}). When auth context is present, ownership is verified. // -// Known-duration sessions expose a synthetic full VOD manifest so the player -// can seek immediately. Copy-mode seeks that would start mid-GOP are forced to -// encoded HLS earlier in HandleStartTranscode; otherwise BuildPlaybackManifest -// still uses the same synthetic VOD path when the session duration is known. +// Known-duration encoded sessions expose a synthetic full VOD manifest so the +// player can seek immediately. Copy-video sessions expose FFmpeg's real +// keyframe-aligned manifest and use the resolved stream origin returned by +// HandleStartTranscode. func (h *PlaybackHandler) HandleGetTranscodeManifest(w http.ResponseWriter, r *http.Request) { sessionID := chi.URLParam(r, "session_id") session, status, card := h.loadTranscodeServeSession(r, sessionID) diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 582c5e7c..7a53a9ab 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -5,11 +5,17 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" + "math" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" + "sort" "strings" + "sync" + "sync/atomic" "testing" "time" @@ -42,6 +48,40 @@ type testPlaybackFileResolver struct { file *models.MediaFile } +type firstBlockingSessionManager struct { + *playback.SessionManager + blocked atomic.Bool + entered chan struct{} + release chan struct{} +} + +func (m *firstBlockingSessionManager) UpdateStreamState(sessionID string, state playback.SessionStreamState) error { + return m.SessionManager.UpdateStreamState(sessionID, state) +} + +func (m *firstBlockingSessionManager) ApplyReplacementIfRoute( + sessionID string, + expected playback.TranscodeRoute, + replacement playback.SessionReplacement, +) (playback.SessionReplacementRollback, bool, error) { + if m.blocked.CompareAndSwap(false, true) { + close(m.entered) + <-m.release + } + return m.SessionManager.ApplyReplacementIfRoute(sessionID, expected, replacement) +} + +func (m *firstBlockingSessionManager) ApplyReplacement( + sessionID string, + replacement playback.SessionReplacement, +) (playback.SessionReplacementRollback, error) { + if m.blocked.CompareAndSwap(false, true) { + close(m.entered) + <-m.release + } + return m.SessionManager.ApplyReplacement(sessionID, replacement) +} + func (r testPlaybackFileResolver) GetByID(context.Context, int) (*models.MediaFile, error) { return r.file, nil } @@ -128,6 +168,18 @@ func (failingSessionManager) EndTransport(string) error { return nil } func (failingSessionManager) SetEffectiveMediaFileID(string, int) error { return nil } func (failingSessionManager) SetTranscodeNodeURL(string, string) error { return nil } +func (failingSessionManager) SetTranscodeRoute(string, playback.TranscodeRoute) error { + return nil +} +func (failingSessionManager) ApplyReplacement(string, playback.SessionReplacement) (playback.SessionReplacementRollback, error) { + return playback.SessionReplacementRollback{}, nil +} +func (failingSessionManager) ApplyReplacementIfRoute(string, playback.TranscodeRoute, playback.SessionReplacement) (playback.SessionReplacementRollback, bool, error) { + return playback.SessionReplacementRollback{}, true, nil +} +func (failingSessionManager) RollbackReplacement(string, playback.SessionReplacementRollback) error { + return nil +} func (failingSessionManager) SetWebSocket(string, bool) error { return nil } @@ -178,12 +230,50 @@ func writePlaybackTestFFmpeg(t *testing.T) string { t.Helper() path := filepath.Join(t.TempDir(), "fake-ffmpeg.sh") - if err := os.WriteFile(path, []byte("#!/bin/sh\nsleep 30\n"), 0o755); err != nil { + script := "#!/bin/sh\n" + + "last=\"\"\n" + + "for arg in \"$@\"; do last=\"$arg\"; done\n" + + "case \"$last\" in\n" + + " *.m3u8) out=\"$(dirname \"$last\")\"; mkdir -p \"$out\"; " + + "printf x > \"$out/init.mp4\"; printf x > \"$out/seg_0.m4s\"; " + + "printf x > \"$out/seg_1.m4s\"; printf x > \"$out/seg_2.m4s\"; " + + "printf '#EXTM3U\\n#EXT-X-VERSION:7\\n#EXT-X-TARGETDURATION:2\\n" + + "#EXT-X-MEDIA-SEQUENCE:0\\n#EXT-X-MAP:URI=\"init.mp4\"\\n" + + "#EXTINF:2.0,\\nseg_0.m4s\\n#EXTINF:2.0,\\nseg_1.m4s\\n" + + "#EXTINF:2.0,\\nseg_2.m4s\\n' > \"$last\" ;;\n" + + "esac\n" + + "sleep 30\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { t.Fatalf("write fake ffmpeg: %v", err) } return path } +func writePlaybackTestFFmpegFailingAfterFirstStart(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "fake-ffmpeg.sh") + countPath := filepath.Join(dir, "started") + script := "#!/bin/sh\n" + + "if [ -e \"" + countPath + "\" ]; then echo 'intentional successor failure' >&2; exit 1; fi\n" + + ": > \"" + countPath + "\"\n" + + "last=\"\"\n" + + "for arg in \"$@\"; do last=\"$arg\"; done\n" + + "out=\"$(dirname \"$last\")\"; mkdir -p \"$out\"\n" + + "printf x > \"$out/init.mp4\"; printf x > \"$out/seg_0.m4s\"; " + + "printf x > \"$out/seg_1.m4s\"; printf x > \"$out/seg_2.m4s\"\n" + + "printf '#EXTM3U\\n#EXT-X-VERSION:7\\n#EXT-X-TARGETDURATION:2\\n" + + "#EXT-X-MEDIA-SEQUENCE:0\\n#EXT-X-MAP:URI=\"init.mp4\"\\n" + + "#EXTINF:2.0,\\nseg_0.m4s\\n#EXTINF:2.0,\\nseg_1.m4s\\n" + + "#EXTINF:2.0,\\nseg_2.m4s\\n' > \"$last\"\n" + + "sleep 30\n" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write fail-after-first fake ffmpeg: %v", err) + } + return path +} + func playbackTestConfig(ffmpegPath, transcodeDir string) func() config.PlaybackConfig { return func() config.PlaybackConfig { return config.PlaybackConfig{ @@ -369,18 +459,19 @@ func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) { file, nil, "/playback/transcode/session-copy/master.m3u8", + 16, ) if copyResp.CanSeekAnywhere { t.Fatal("copy-mode response should require explicit restart seeks") } - if copyResp.PlayerStartSeconds != 0 { - t.Fatalf("copy-mode PlayerStartSeconds = %v, want 0", copyResp.PlayerStartSeconds) + if math.Abs(copyResp.PlayerStartSeconds-2.261) > 0.0001 { + t.Fatalf("copy-mode PlayerStartSeconds = %v, want 2.261", copyResp.PlayerStartSeconds) } - if copyResp.StreamOriginSeconds != 18.261 { - t.Fatalf("copy-mode StreamOriginSeconds = %v, want 18.261", copyResp.StreamOriginSeconds) + if copyResp.StreamOriginSeconds != 16 { + t.Fatalf("copy-mode StreamOriginSeconds = %v, want 16", copyResp.StreamOriginSeconds) } - if copyResp.TimelineOffsetSeconds != 18.261 { - t.Fatalf("copy-mode TimelineOffsetSeconds = %v, want 18.261", copyResp.TimelineOffsetSeconds) + if copyResp.TimelineOffsetSeconds != 16 { + t.Fatalf("copy-mode TimelineOffsetSeconds = %v, want 16", copyResp.TimelineOffsetSeconds) } encodedResp := buildTranscodeStartResponse( @@ -392,6 +483,7 @@ func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) { file, nil, "/playback/transcode/session-encoded/master.m3u8", + 0, ) if !encodedResp.CanSeekAnywhere { t.Fatal("encoded response should advertise seek-anywhere") @@ -1320,22 +1412,27 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t var remoteStartRequests int var remoteStartReq transcodenode.TranscodeStartRequest + var remoteDeletes []string remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/transcode/start" { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/transcode/start": + remoteStartRequests++ + if err := json.NewDecoder(r.Body).Decode(&remoteStartReq); err != nil { + t.Errorf("decode remote start request: %v", err) + } + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(transcodenode.TranscodeStartResponse{ + SessionID: remoteStartReq.SessionID, + Status: "accepted", + HWAccel: "none", + }) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/transcode/"): + remoteDeletes = append(remoteDeletes, r.URL.Path) + w.WriteHeader(http.StatusNoContent) + default: t.Errorf("unexpected remote request %s %s", r.Method, r.URL.Path) http.Error(w, "unexpected", http.StatusBadRequest) - return } - remoteStartRequests++ - if err := json.NewDecoder(r.Body).Decode(&remoteStartReq); err != nil { - t.Errorf("decode remote start request: %v", err) - } - w.WriteHeader(http.StatusAccepted) - _ = json.NewEncoder(w).Encode(transcodenode.TranscodeStartResponse{ - SessionID: remoteStartReq.SessionID, - Status: "accepted", - HWAccel: "none", - }) })) defer remote.Close() @@ -1380,7 +1477,13 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t t.Fatalf("remote AudioTrackIndex = %d, want 1", remoteStartReq.AudioTrackIndex) } if remoteStartReq.SessionID != sessionID { - t.Fatalf("remote SessionID = %q, want %q", remoteStartReq.SessionID, sessionID) + t.Fatalf("remote SessionID = %q, want existing encoded transport %q", remoteStartReq.SessionID, sessionID) + } + if remoteStartReq.RequireReady { + t.Fatal("encoded audio switch unexpectedly changed to legacy copy replacement lifecycle") + } + if len(remoteDeletes) != 0 { + t.Fatalf("remote DELETEs = %v, want node-managed same-ID replacement", remoteDeletes) } // Seek 121.5s with the node-default 2s segments => align to 120s / segment 60. if remoteStartReq.StartSegmentNumber != 60 { @@ -1443,6 +1546,248 @@ func TestHandleChangeAudioTrack_RemoteTranscodeRestartsNodeAndMintsFullRecipe(t if claims.TranscodeNode != remote.URL { t.Fatalf("token TranscodeNode = %q, want %q", claims.TranscodeNode, remote.URL) } + if claims.TranscodeTransportID != remoteStartReq.SessionID { + t.Fatalf("token TranscodeTransportID = %q, want %q", claims.TranscodeTransportID, remoteStartReq.SessionID) + } + updated, err := sessionMgr.GetSession(sessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if updated.TranscodeNodeURL != remote.URL || updated.TranscodeTransportID != "" { + t.Fatalf("published transcode route = %q/%q", updated.TranscodeNodeURL, updated.TranscodeTransportID) + } +} + +func TestHandleChangeAudioTrack_RemoteCopyRestartUsesFreshSeekAnchor(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-copy.mkv"), + Resolution: "1080p", + CodecVideo: "hevc", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{ + {Codec: "ac3", Default: true}, + {Codec: "dts"}, + }, + } + + var remoteStartReq transcodenode.TranscodeStartRequest + var remoteDeletes []string + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/transcode/start": + if err := json.NewDecoder(r.Body).Decode(&remoteStartReq); err != nil { + t.Errorf("decode remote start request: %v", err) + } + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(transcodenode.TranscodeStartResponse{ + SessionID: remoteStartReq.SessionID, + Status: "accepted", + HWAccel: "none", + }) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/transcode/"): + remoteDeletes = append(remoteDeletes, r.URL.Path) + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected remote request %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusBadRequest) + } + })) + defer remote.Close() + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.JWTSecret = "test-secret" + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 96, 48, nil + } + group := "rack-a" + proxies := nodepool.NewProxyPool() + proxies.SetNodes([]*nodepool.Node{{ + ID: 1, Name: "proxy-1", Type: nodepool.NodeTypeProxy, URL: "http://proxy-1", + Enabled: true, Healthy: true, Group: &group, + }}) + transcodes := nodepool.NewTranscodePool() + transcodes.SetNodes([]*nodepool.Node{{ + ID: 2, Name: "transcode-1", Type: nodepool.NodeTypeTranscode, URL: remote.URL, + Enabled: true, Healthy: true, Group: &group, + }}) + handler.NodePlanner = nodepool.NewPlanner(proxies, transcodes) + + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + if err := sessionMgr.UpdateStreamState(session.ID, playback.SessionStreamState{ + PlayMethod: playback.PlayTranscode, + BasePlayMethod: playback.PlayRemux, + AudioTrackIndex: 0, + TranscodeAudio: true, + TargetVideoCodec: "copy", + TargetAudioCodec: "aac", + TranscodeHWAccel: "none", + SegmentDuration: 2, + }); err != nil { + t.Fatalf("UpdateStreamState: %v", err) + } + if err := sessionMgr.SetTranscodeNodeURL(session.ID, remote.URL); err != nil { + t.Fatalf("SetTranscodeNodeURL: %v", err) + } + + changeReq := httptest.NewRequest( + http.MethodPatch, + "/api/v1/playback/"+session.ID+"/audio", + strings.NewReader(`{"audio_track_index":1,"position":100}`), + ).WithContext(newAuthorizedPlaybackContext()) + changeReq = withPlaybackRouteParam(changeReq, "session_id", session.ID) + + rr := httptest.NewRecorder() + handler.HandleChangeAudioTrack(rr, changeReq) + if rr.Code != http.StatusOK { + t.Fatalf("change status = %d, body = %s", rr.Code, rr.Body.String()) + } + if remoteStartReq.TargetCodecVideo != "copy" || remoteStartReq.SeekSeconds != 100 || + remoteStartReq.StreamOriginSeconds != 96 || !remoteStartReq.CopySeekAnchorResolved || + remoteStartReq.StartSegmentNumber != 48 { + t.Fatalf("remote copy restart recipe = %+v", remoteStartReq) + } + if !strings.HasPrefix(remoteStartReq.SessionID, session.ID+legacyTransportMarker) { + t.Fatalf("remote SessionID = %q, want legacy replacement for %q", remoteStartReq.SessionID, session.ID) + } + if !remoteStartReq.RequireReady { + t.Fatal("remote copy replacement did not require readiness before predecessor retirement") + } + if len(remoteDeletes) != 1 || remoteDeletes[0] != "/transcode/"+session.ID { + t.Fatalf("remote DELETEs = %v, want predecessor %q", remoteDeletes, session.ID) + } + + var resp changeAudioResponse + if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil { + t.Fatalf("decode change response: %v", err) + } + token := strings.TrimSuffix(strings.TrimPrefix(resp.StreamURL, "http://proxy-1/stream/transcode/"), "/master.m3u8") + claims, err := streamtoken.Verify(token, handler.JWTSecret) + if err != nil { + t.Fatalf("verify stream token: %v", err) + } + if claims.TargetCodec != "copy" || claims.SeekSeconds != 100 || + claims.StreamOriginSeconds != 96 || !claims.CopySeekAnchorResolved || + claims.StartSegmentNumber != 48 { + t.Fatalf("remote copy reconstruction claims = %+v", claims) + } + if claims.TranscodeTransportID != remoteStartReq.SessionID { + t.Fatalf("token TranscodeTransportID = %q, want %q", claims.TranscodeTransportID, remoteStartReq.SessionID) + } +} + +func TestHandleChangeAudioTrack_RemoteCopyRejectionPreservesPredecessorState(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-copy-rejected.mkv"), + Resolution: "1080p", + CodecVideo: "hevc", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}, {Codec: "dts"}}, + } + + var replacementID string + var deletedIDs []string + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/transcode/start": + var startReq transcodenode.TranscodeStartRequest + if err := json.NewDecoder(r.Body).Decode(&startReq); err != nil { + t.Errorf("decode remote start request: %v", err) + } + replacementID = startReq.SessionID + http.Error(w, "rejected", http.StatusInternalServerError) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/transcode/"): + deletedIDs = append(deletedIDs, strings.TrimPrefix(r.URL.Path, "/transcode/")) + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected remote request %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusBadRequest) + } + })) + defer remote.Close() + + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + if err := sessionMgr.UpdateStreamState(session.ID, playback.SessionStreamState{ + PlayMethod: playback.PlayTranscode, + BasePlayMethod: playback.PlayRemux, + AudioTrackIndex: 0, + TranscodeAudio: true, + TargetVideoCodec: "copy", + TargetAudioCodec: "aac", + TranscodeHWAccel: "none", + SegmentDuration: 2, + }); err != nil { + t.Fatalf("UpdateStreamState: %v", err) + } + if err := sessionMgr.SetTranscodeNodeURL(session.ID, remote.URL); err != nil { + t.Fatalf("SetTranscodeNodeURL: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.JWTSecret = "test-secret" + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 96, 48, nil + } + group := "rack-a" + proxies := nodepool.NewProxyPool() + proxies.SetNodes([]*nodepool.Node{{ + ID: 1, Name: "proxy-1", Type: nodepool.NodeTypeProxy, URL: "http://proxy-1", + Enabled: true, Healthy: true, Group: &group, + }}) + transcodes := nodepool.NewTranscodePool() + transcodes.SetNodes([]*nodepool.Node{{ + ID: 2, Name: "transcode-1", Type: nodepool.NodeTypeTranscode, URL: remote.URL, + Enabled: true, Healthy: true, Group: &group, + }}) + handler.NodePlanner = nodepool.NewPlanner(proxies, transcodes) + + request := httptest.NewRequest( + http.MethodPatch, + "/api/v1/playback/"+session.ID+"/audio", + strings.NewReader(`{"audio_track_index":1,"position":100}`), + ).WithContext(newAuthorizedPlaybackContext()) + request = withPlaybackRouteParam(request, "session_id", session.ID) + response := httptest.NewRecorder() + handler.HandleChangeAudioTrack(response, request) + if response.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d, body = %s", response.Code, http.StatusBadGateway, response.Body.String()) + } + if !strings.HasPrefix(replacementID, session.ID+legacyTransportMarker) { + t.Fatalf("replacement ID = %q, want distinct legacy transport for %q", replacementID, session.ID) + } + if len(deletedIDs) != 1 || deletedIDs[0] != replacementID { + t.Fatalf("deleted transports = %v, want only rejected replacement %q", deletedIDs, replacementID) + } + persisted, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if persisted.TranscodeNodeURL != remote.URL || persisted.TranscodeTransportID != "" { + t.Fatalf("predecessor route changed after rejection: %q/%q", persisted.TranscodeNodeURL, persisted.TranscodeTransportID) + } + if persisted.AudioTrackIndex != 0 || persisted.BasePlayMethod != playback.PlayRemux || + persisted.TargetVideoCodec != "copy" || persisted.TargetAudioCodec != "aac" { + t.Fatalf("predecessor state changed after rejection: %+v", persisted) + } } // TestHandleChangeAudioTrack_RemoteTranscodeNodeUnreachableSurfacesError @@ -1570,22 +1915,848 @@ func TestHandleStartTranscode_LocalPathPropagatesSelectedAudioTrack(t *testing.T } } -func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) { +func TestHandleChangeAudioTrack_LocalCopyRestartUsesFreshSeekAnchor(t *testing.T) { sessionMgr := playback.NewSessionManager(0, 0) - filePath := writePlaybackTestMediaFile(t, "movie-mpeg2.mkv") file := &models.MediaFile{ - ID: 42, - ContentID: "movie-1", - FilePath: filePath, - Resolution: "1080p", - CodecVideo: "mpeg2video", - CodecAudio: "dts", - Container: "mkv", - Bitrate: 25000, - Duration: 3600, - AudioTracks: []models.AudioTrack{ - {Codec: "dts", Default: true}, - }, + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-local-copy.mkv"), + Resolution: "1080p", + CodecVideo: "hevc", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}, {Codec: "dts"}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, 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()) + handler.copySeekAnchor = func(_ context.Context, _, _ string, requested float64, _ int) (float64, int, error) { + switch requested { + case 18: + return 16, 8, nil + case 100: + return 96, 48, nil + default: + return 0, 0, fmt.Errorf("unexpected seek %v", requested) + } + } + + startReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18,"target_codec_video":"copy","target_codec_audio":"aac","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 copy-video 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":100}`), + ).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 != "copy" || opts.SeekSeconds != 100 || + opts.StreamOriginSeconds != 96 || !opts.CopySeekAnchorResolved || + opts.StartSegmentNumber != 48 || opts.AudioTrackIndex != 1 { + t.Fatalf("local copy 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 != "copy" || claims.SeekSeconds != 100 || + claims.StreamOriginSeconds != 96 || !claims.CopySeekAnchorResolved || + claims.StartSegmentNumber != 48 || claims.AudioTrackIndex != 1 { + t.Fatalf("local copy reconstruction claims = %+v", claims) + } + if resp.PlayerStartSeconds == nil || *resp.PlayerStartSeconds != 4 || + resp.StreamOriginSeconds == nil || *resp.StreamOriginSeconds != 96 || + resp.TimelineOffsetSeconds == nil || *resp.TimelineOffsetSeconds != 96 || + resp.CanSeekAnywhere == nil || *resp.CanSeekAnywhere { + t.Fatalf("local copy response timeline = %+v", resp) + } +} + +func TestHandleChangeAudioTrack_LocalCopyFailurePreservesPredecessor(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-local-copy-failure.mkv"), + Resolution: "1080p", + CodecVideo: "hevc", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}, {Codec: "dts"}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.PlaybackConfig = playbackTestConfig( + writePlaybackTestFFmpegFailingAfterFirstStart(t), + t.TempDir(), + ) + handler.copySeekAnchor = func(_ context.Context, _, _ string, requested float64, _ int) (float64, int, error) { + return requested - 4, int((requested - 4) / 2), nil + } + + startReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18,"target_codec_video":"copy","target_codec_audio":"aac","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 || !predecessor.IsRunning() { + t.Fatal("expected running predecessor") + } + 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":100}`), + ).WithContext(newAuthorizedPlaybackContext()) + changeReq = withPlaybackRouteParam(changeReq, "session_id", session.ID) + changeRR := httptest.NewRecorder() + handler.HandleChangeAudioTrack(changeRR, changeReq) + if changeRR.Code != http.StatusInternalServerError { + t.Fatalf("change status = %d, body = %s", changeRR.Code, changeRR.Body.String()) + } + if current := handler.tm.GetTranscodeSession(session.ID); current != predecessor { + t.Fatal("failed successor replaced the predecessor") + } + if !predecessor.IsRunning() { + t.Fatal("failed successor stopped the predecessor") + } + persisted, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if persisted.AudioTrackIndex != 0 { + t.Fatalf("persisted audio track = %d, want predecessor track 0", persisted.AudioTrackIndex) + } +} + +func TestHandleStartTranscode_SeekedCopyRemainsCopyVideo(t *testing.T) { + for _, tc := range []struct { + name string + videoCodec string + }{ + {name: "h264", videoCodec: "h264"}, + {name: "hevc", videoCodec: "hevc"}, + {name: "mpeg2", videoCodec: "mpeg2video"}, + } { + t.Run(tc.name, func(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-"+tc.name+".mkv"), + Resolution: "1080p", + CodecVideo: tc.videoCodec, + CodecAudio: "dts", + Container: "mkv", + Bitrate: 25000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "dts", Default: true}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, 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()) + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 16, 8, nil + } + + transcodeReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"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-2.261) > 0.0001 || response.StreamOriginSeconds != 16 || + response.TimelineOffsetSeconds != 16 || response.CanSeekAnywhere { + t.Fatalf("copy 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.TargetCodecVideo != "copy" || opts.SourceVideoCodec != tc.videoCodec || opts.TargetCodecAudio != "aac" { + t.Fatalf("copy recipe = video %q source %q audio %q", opts.TargetCodecVideo, opts.SourceVideoCodec, opts.TargetCodecAudio) + } + if opts.SeekSeconds != 18.261 || opts.StreamOriginSeconds != 16 || !opts.CopySeekAnchorResolved || opts.StartSegmentNumber != 8 || opts.TargetResolution != "" { + t.Fatalf("copy seek recipe = seek %v origin %v segment %d resolution %q", opts.SeekSeconds, opts.StreamOriginSeconds, opts.StartSegmentNumber, opts.TargetResolution) + } + + persisted, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if persisted.TargetVideoCodec != "copy" || semanticPlayMethod(persisted) != playback.PlayRemux { + t.Fatalf("persisted recipe = target %q semantic method %q", persisted.TargetVideoCodec, semanticPlayMethod(persisted)) + } + }) + } +} + +func TestHandleStartTranscode_CopyAnchorFailureKeepsActiveTransport(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-anchor-failure.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "aac", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "aac", 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()) + + initialReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":0,"target_resolution":"720p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":2000,"segment_duration":2,"subtitle_track_index":-1}`), + ).WithContext(newAuthorizedPlaybackContext()) + initialRR := httptest.NewRecorder() + handler.HandleStartTranscode(initialRR, initialReq) + if initialRR.Code != http.StatusAccepted { + t.Fatalf("initial start status = %d, body = %s", initialRR.Code, initialRR.Body.String()) + } + active := handler.tm.GetTranscodeSession(session.ID) + if active == nil || !active.IsRunning() { + t.Fatal("expected initial transcode to be running") + } + t.Cleanup(func() { _ = active.Close() }) + + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 0, 0, errors.New("probe failed") + } + replacementReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":100,"target_codec_video":"copy","target_codec_audio":"aac","segment_duration":2,"subtitle_track_index":-1}`), + ).WithContext(newAuthorizedPlaybackContext()) + replacementRR := httptest.NewRecorder() + handler.HandleStartTranscode(replacementRR, replacementReq) + if replacementRR.Code != http.StatusInternalServerError { + t.Fatalf("replacement status = %d, body = %s", replacementRR.Code, replacementRR.Body.String()) + } + if got := handler.tm.GetTranscodeSession(session.ID); got != active { + t.Fatalf("active transcode was replaced after failed preflight: got %p, want %p", got, active) + } + if !active.IsRunning() { + t.Fatal("active transcode was stopped before copy seek anchor preflight completed") + } +} + +func TestHandleStartTranscode_SeekedCopyPreservedOnRemoteNode(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-remote.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + var remoteStartReq transcodenode.TranscodeStartRequest + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/transcode/start" { + t.Errorf("unexpected remote request %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusBadRequest) + return + } + if err := json.NewDecoder(r.Body).Decode(&remoteStartReq); err != nil { + t.Errorf("decode remote start request: %v", err) + } + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(transcodenode.TranscodeStartResponse{ + SessionID: remoteStartReq.SessionID, + Status: "accepted", + HWAccel: "none", + }) + })) + defer remote.Close() + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.JWTSecret = "test-secret" + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 16, 8, nil + } + pool := nodepool.NewTranscodePool() + pool.SetNodes([]*nodepool.Node{{ + Name: "transcode-1", Type: nodepool.NodeTypeTranscode, URL: remote.URL, + Enabled: true, Healthy: true, + }}) + handler.NodePlanner = nodepool.NewPlanner(nodepool.NewProxyPool(), pool) + + transcodeReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"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()) + } + if remoteStartReq.TargetCodecVideo != "copy" || remoteStartReq.SeekSeconds != 18.261 || + remoteStartReq.StreamOriginSeconds != 16 || !remoteStartReq.CopySeekAnchorResolved || remoteStartReq.StartSegmentNumber != 8 { + t.Fatalf("remote copy recipe = codec %q seek %v origin %v segment %d", remoteStartReq.TargetCodecVideo, remoteStartReq.SeekSeconds, remoteStartReq.StreamOriginSeconds, remoteStartReq.StartSegmentNumber) + } + if !strings.HasPrefix(remoteStartReq.SessionID, session.ID+legacyTransportMarker) { + t.Fatalf("remote SessionID = %q, want legacy transport for %q", remoteStartReq.SessionID, session.ID) + } + if !remoteStartReq.RequireReady { + t.Fatal("remote copy start did not require readiness before route publication") + } + + 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-2.261) > 0.0001 || response.StreamOriginSeconds != 16 || + response.TimelineOffsetSeconds != 16 || response.CanSeekAnywhere { + t.Fatalf("remote copy response timeline = %+v", response) + } + manifestURL, err := url.Parse(response.ManifestURL) + if err != nil { + t.Fatalf("parse manifest 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 != "copy" || claims.SeekSeconds != 18.261 || claims.StreamOriginSeconds != 16 || !claims.CopySeekAnchorResolved || + claims.StartSegmentNumber != 8 || claims.TranscodeNode != remote.URL { + t.Fatalf("remote reconstruction claims = codec %q seek %v origin %v segment %d node %q", claims.TargetCodec, claims.SeekSeconds, claims.StreamOriginSeconds, claims.StartSegmentNumber, claims.TranscodeNode) + } + if claims.TranscodeTransportID != remoteStartReq.SessionID { + t.Fatalf("remote reconstruction transport = %q, want %q", claims.TranscodeTransportID, remoteStartReq.SessionID) + } + persisted, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if persisted.TranscodeNodeURL != remote.URL || persisted.TranscodeTransportID != remoteStartReq.SessionID { + t.Fatalf("published remote route = %q/%q", persisted.TranscodeNodeURL, persisted.TranscodeTransportID) + } +} + +func TestHandleStartTranscode_RemoteRejectionPreservesLegacyPredecessor(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-remote-reject.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + var replacementID string + var deletedIDs []string + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/transcode/start": + var startReq transcodenode.TranscodeStartRequest + if err := json.NewDecoder(r.Body).Decode(&startReq); err != nil { + t.Errorf("decode remote start request: %v", err) + } + replacementID = startReq.SessionID + http.Error(w, "rejected", http.StatusInternalServerError) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/transcode/"): + deletedIDs = append(deletedIDs, strings.TrimPrefix(r.URL.Path, "/transcode/")) + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected remote request %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusBadRequest) + } + })) + defer remote.Close() + if err := sessionMgr.SetTranscodeNodeURL(session.ID, remote.URL); err != nil { + t.Fatalf("SetTranscodeNodeURL: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.JWTSecret = "test-secret" + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 16, 8, nil + } + pool := nodepool.NewTranscodePool() + pool.SetNodes([]*nodepool.Node{{ + Name: "transcode-1", Type: nodepool.NodeTypeTranscode, URL: remote.URL, + Enabled: true, Healthy: true, + }}) + handler.NodePlanner = nodepool.NewPlanner(nodepool.NewProxyPool(), pool) + + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18,"target_codec_video":"copy","target_codec_audio":"aac","segment_duration":2,"subtitle_track_index":-1}`), + ).WithContext(newAuthorizedPlaybackContext()) + response := httptest.NewRecorder() + handler.HandleStartTranscode(response, request) + if response.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d, body = %s", response.Code, http.StatusBadGateway, response.Body.String()) + } + if !strings.HasPrefix(replacementID, session.ID+legacyTransportMarker) { + t.Fatalf("replacement ID = %q, want distinct legacy transport for %q", replacementID, session.ID) + } + if len(deletedIDs) != 1 || deletedIDs[0] != replacementID { + t.Fatalf("deleted transports = %v, want only rejected replacement %q", deletedIDs, replacementID) + } + persisted, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if persisted.TranscodeNodeURL != remote.URL || persisted.TranscodeTransportID != "" { + t.Fatalf("predecessor route changed after rejection: %q/%q", persisted.TranscodeNodeURL, persisted.TranscodeTransportID) + } +} + +func TestHandleStartTranscode_ConcurrentRemoteReplacementsPublishOneWinner(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-remote-concurrent.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + var remoteMu sync.Mutex + var preparedIDs []string + var deletedIDs []string + prepared := make(chan struct{}, 2) + release := make(chan struct{}) + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/transcode/start": + var startReq transcodenode.TranscodeStartRequest + if err := json.NewDecoder(r.Body).Decode(&startReq); err != nil { + t.Errorf("decode remote start request: %v", err) + } + remoteMu.Lock() + preparedIDs = append(preparedIDs, startReq.SessionID) + remoteMu.Unlock() + prepared <- struct{}{} + <-release + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(transcodenode.TranscodeStartResponse{ + SessionID: startReq.SessionID, + Status: "accepted", + HWAccel: "none", + }) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/transcode/"): + remoteMu.Lock() + deletedIDs = append(deletedIDs, strings.TrimPrefix(r.URL.Path, "/transcode/")) + remoteMu.Unlock() + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected remote request %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusBadRequest) + } + })) + defer remote.Close() + if err := sessionMgr.SetTranscodeNodeURL(session.ID, remote.URL); err != nil { + t.Fatalf("SetTranscodeNodeURL: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.JWTSecret = "test-secret" + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 16, 8, nil + } + pool := nodepool.NewTranscodePool() + pool.SetNodes([]*nodepool.Node{{ + Name: "transcode-1", Type: nodepool.NodeTypeTranscode, URL: remote.URL, + Enabled: true, Healthy: true, + }}) + handler.NodePlanner = nodepool.NewPlanner(nodepool.NewProxyPool(), pool) + + responses := make(chan int, 2) + start := func() { + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18,"target_codec_video":"copy","target_codec_audio":"aac","segment_duration":2,"subtitle_track_index":-1}`), + ).WithContext(newAuthorizedPlaybackContext()) + response := httptest.NewRecorder() + handler.HandleStartTranscode(response, request) + responses <- response.Code + } + go start() + go start() + <-prepared + <-prepared + close(release) + + statuses := []int{<-responses, <-responses} + sort.Ints(statuses) + if statuses[0] != http.StatusAccepted || statuses[1] != http.StatusConflict { + t.Fatalf("replacement statuses = %v, want [%d %d]", statuses, http.StatusAccepted, http.StatusConflict) + } + persisted, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + remoteMu.Lock() + preparedSnapshot := append([]string(nil), preparedIDs...) + deletedSnapshot := append([]string(nil), deletedIDs...) + remoteMu.Unlock() + if len(preparedSnapshot) != 2 || preparedSnapshot[0] == preparedSnapshot[1] { + t.Fatalf("prepared transport IDs = %v, want two distinct successors", preparedSnapshot) + } + winner := persisted.TranscodeTransportID + if winner != preparedSnapshot[0] && winner != preparedSnapshot[1] { + t.Fatalf("published transport = %q, not one of %v", winner, preparedSnapshot) + } + loser := preparedSnapshot[0] + if loser == winner { + loser = preparedSnapshot[1] + } + sort.Strings(deletedSnapshot) + wantDeletes := []string{session.ID, loser} + sort.Strings(wantDeletes) + if len(deletedSnapshot) != 2 || deletedSnapshot[0] != wantDeletes[0] || deletedSnapshot[1] != wantDeletes[1] { + t.Fatalf("deleted transports = %v, want predecessor and loser %v", deletedSnapshot, wantDeletes) + } +} + +func TestHandleStartTranscode_RemoteSuccessorCannotBeOverwrittenByStaleFinalization(t *testing.T) { + baseSessionMgr := playback.NewSessionManager(0, 0) + sessionMgr := &firstBlockingSessionManager{ + SessionManager: baseSessionMgr, + entered: make(chan struct{}), + release: make(chan struct{}), + } + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-remote-state-order.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}}, + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + var remoteMu sync.Mutex + preparedBitrates := make(map[string]int) + var deletedIDs []string + remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/transcode/start": + var startReq transcodenode.TranscodeStartRequest + if err := json.NewDecoder(r.Body).Decode(&startReq); err != nil { + t.Errorf("decode remote start request: %v", err) + } + remoteMu.Lock() + preparedBitrates[startReq.SessionID] = startReq.TargetBitrateKbps + remoteMu.Unlock() + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(transcodenode.TranscodeStartResponse{ + SessionID: startReq.SessionID, + Status: "accepted", + HWAccel: "none", + }) + case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/transcode/"): + deletedID := strings.TrimPrefix(r.URL.Path, "/transcode/") + remoteMu.Lock() + deletedIDs = append(deletedIDs, deletedID) + remoteMu.Unlock() + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected remote request %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusBadRequest) + } + })) + defer remote.Close() + if err := sessionMgr.SetTranscodeNodeURL(session.ID, remote.URL); err != nil { + t.Fatalf("SetTranscodeNodeURL: %v", err) + } + + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + handler.JWTSecret = "test-secret" + handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 16, 8, nil + } + pool := nodepool.NewTranscodePool() + pool.SetNodes([]*nodepool.Node{{ + Name: "transcode-1", Type: nodepool.NodeTypeTranscode, URL: remote.URL, + Enabled: true, Healthy: true, + }}) + handler.NodePlanner = nodepool.NewPlanner(nodepool.NewProxyPool(), pool) + + start := func(bitrate int) <-chan int { + result := make(chan int, 1) + go func() { + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(fmt.Sprintf( + `{"session_id":%q,"seek_seconds":18,"target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":%d,"segment_duration":2,"subtitle_track_index":-1}`, + session.ID, + bitrate, + )), + ).WithContext(newAuthorizedPlaybackContext()) + response := httptest.NewRecorder() + handler.HandleStartTranscode(response, request) + result <- response.Code + }() + return result + } + + firstResult := start(1111) + <-sessionMgr.entered + secondResult := start(2222) + + // Give the second request time to prepare its successor and contend on the + // lifecycle commit while the first request's durable state publication is + // deliberately paused. + deadline := time.Now().Add(2 * time.Second) + for { + remoteMu.Lock() + preparedCount := len(preparedBitrates) + remoteMu.Unlock() + if preparedCount == 2 { + break + } + if time.Now().After(deadline) { + t.Fatal("second replacement was not prepared") + } + time.Sleep(time.Millisecond) + } + close(sessionMgr.release) + + if status := <-firstResult; status != http.StatusAccepted { + t.Fatalf("first status = %d, want %d", status, http.StatusAccepted) + } + if status := <-secondResult; status != http.StatusConflict { + t.Fatalf("superseded second status = %d, want %d", status, http.StatusConflict) + } + persisted, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + remoteMu.Lock() + winnerBitrate := preparedBitrates[persisted.TranscodeTransportID] + deletedSnapshot := append([]string(nil), deletedIDs...) + remoteMu.Unlock() + if winnerBitrate != 1111 { + t.Fatalf("published transport bitrate = %d, want committed successor bitrate 1111", winnerBitrate) + } + if persisted.TargetBitrateKbps != winnerBitrate { + t.Fatalf("persisted bitrate = %d, want route winner bitrate %d", persisted.TargetBitrateKbps, winnerBitrate) + } + if len(deletedSnapshot) != 2 { + t.Fatalf("deleted transports = %v, want predecessor and first successor", deletedSnapshot) + } +} + +func TestHandleStartTranscode_ConcurrentLocalEncodedStartsRemainLastWriterWins(t *testing.T) { + baseSessionMgr := playback.NewSessionManager(0, 0) + sessionMgr := &firstBlockingSessionManager{ + SessionManager: baseSessionMgr, + entered: make(chan struct{}), + release: make(chan struct{}), + } + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-local-encoded-overlap.mkv"), + Resolution: "2160p", + CodecVideo: "hevc", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 25000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", 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()) + t.Cleanup(func() { handler.tm.CloseTranscodeSession(session.ID, "") }) + + start := func(bitrate int) <-chan int { + result := make(chan int, 1) + go func() { + request := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(fmt.Sprintf( + `{"session_id":%q,"target_resolution":"1080p","target_codec_video":"h264","target_codec_audio":"aac","target_bitrate_kbps":%d,"segment_duration":2,"subtitle_track_index":-1}`, + session.ID, + bitrate, + )), + ).WithContext(newAuthorizedPlaybackContext()) + response := httptest.NewRecorder() + handler.HandleStartTranscode(response, request) + result <- response.Code + }() + return result + } + + firstResult := start(1111) + <-sessionMgr.entered + secondResult := start(2222) + close(sessionMgr.release) + if status := <-firstResult; status != http.StatusAccepted { + t.Fatalf("first status = %d, want %d", status, http.StatusAccepted) + } + if status := <-secondResult; status != http.StatusAccepted { + t.Fatalf("second status = %d, want %d", status, http.StatusAccepted) + } + persisted, err := sessionMgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if persisted.TargetBitrateKbps != 2222 { + t.Fatalf("persisted bitrate = %d, want last writer bitrate 2222", persisted.TargetBitrateKbps) + } +} + +func TestHandleStartTranscode_SeekedCopyAllowedWhenVideoTranscodingDisabled(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + sessionMgr.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) { + return playback.SessionLimits{TranscodingDisabled: true}, nil + }) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-copy-permission.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}}, } session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) if err != nil { @@ -1595,111 +2766,76 @@ func TestHandleStartTranscode_MPEG2SeekedCopyRemainsCopyVideo(t *testing.T) { handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) handler.ItemAccess = allowAllPlaybackItemAccess{} handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir()) - + handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) { + return 16, 8, nil + } transcodeReq := httptest.NewRequest( - "POST", + http.MethodPost, "/api/v1/playback/transcode/start", - strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"1080p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), - ) - transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) - + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":18.261,"target_resolution":"","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}`), + ).WithContext(newAuthorizedPlaybackContext()) transcodeRR := httptest.NewRecorder() handler.HandleStartTranscode(transcodeRR, transcodeReq) - if transcodeRR.Code != 202 { + if transcodeRR.Code != http.StatusAccepted { t.Fatalf("transcode status = %d, body = %s", transcodeRR.Code, transcodeRR.Body.String()) } - transcodeSession := handler.tm.GetTranscodeSession(session.ID) if transcodeSession == nil { - t.Fatal("expected local transcode session") + t.Fatal("expected local copy-video session") } - t.Cleanup(func() { - _ = transcodeSession.Close() - }) - opts := transcodeSession.Opts() - if got := opts.TargetCodecVideo; got != "copy" { - t.Fatalf("mpeg2 seeked copy target video codec = %q, want copy", got) - } - if got := opts.SourceVideoCodec; got != "mpeg2video" { - t.Fatalf("mpeg2 seeked copy source video codec = %q, want mpeg2video", got) - } - if got := opts.TargetCodecAudio; got != "aac" { - t.Fatalf("mpeg2 seeked copy target audio codec = %q, want aac", got) + t.Cleanup(func() { _ = transcodeSession.Close() }) + if got := transcodeSession.Opts().TargetCodecVideo; got != "copy" { + t.Fatalf("target video codec = %q, want copy", got) } } -func TestHandleStartTranscode_ForcedVideoEncodingRechecksPermission(t *testing.T) { - for _, tc := range []struct { - name string - requestBody func(sessionID string) string - }{ - { - name: "seeked copy video", - requestBody: func(sessionID string) string { - return `{"session_id":"` + sessionID + `","seek_seconds":18.261,"target_resolution":"1080p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":-1,"subtitle_burn_in":false}` - }, +func TestHandleStartTranscode_SubtitleBurnInRechecksVideoTranscodePermission(t *testing.T) { + sessionMgr := playback.NewSessionManager(0, 0) + sessionMgr.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) { + return playback.SessionLimits{TranscodingDisabled: true}, nil + }) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + FilePath: writePlaybackTestMediaFile(t, "movie-burn-in.mkv"), + Resolution: "1080p", + CodecVideo: "h264", + CodecAudio: "ac3", + Container: "mkv", + Bitrate: 8000, + Duration: 3600, + AudioTracks: []models.AudioTrack{{Codec: "ac3", Default: true}}, + SubtitleTracks: []models.SubtitleTrack{ + {Index: 0, Language: "en", Codec: "subrip"}, }, - { - name: "subtitle burn in", - requestBody: func(sessionID string) string { - return `{"session_id":"` + sessionID + `","seek_seconds":0,"target_resolution":"1080p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":0,"subtitle_burn_in":true}` - }, - }, - } { - t.Run(tc.name, func(t *testing.T) { - sessionMgr := playback.NewSessionManager(0, 0) - sessionMgr.SetLimitProvider(func(context.Context, int) (playback.SessionLimits, error) { - return playback.SessionLimits{TranscodingDisabled: true}, nil - }) - file := &models.MediaFile{ - ID: 42, - ContentID: "movie-1", - FilePath: writePlaybackTestMediaFile(t, "movie.mkv"), - Resolution: "1080p", - CodecVideo: "h264", - CodecAudio: "ac3", - Container: "mkv", - Bitrate: 8000, - Duration: 3600, - AudioTracks: []models.AudioTrack{ - {Codec: "ac3", Default: true}, - }, - SubtitleTracks: []models.SubtitleTrack{ - {Index: 0, Language: "en", Codec: "subrip"}, - }, - } - session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) - if err != nil { - t.Fatalf("StartSession: %v", err) - } + } + session, err := sessionMgr.StartSession(1, "profile-1", file.ID, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } - handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) - handler.ItemAccess = allowAllPlaybackItemAccess{} + handler := NewPlaybackHandler(sessionMgr, testPlaybackFileResolver{file: file}) + handler.ItemAccess = allowAllPlaybackItemAccess{} + transcodeReq := httptest.NewRequest( + http.MethodPost, + "/api/v1/playback/transcode/start", + strings.NewReader(`{"session_id":"`+session.ID+`","seek_seconds":0,"target_resolution":"1080p","target_codec_video":"copy","target_codec_audio":"aac","target_bitrate_kbps":0,"segment_duration":2,"subtitle_track_index":0,"subtitle_burn_in":true}`), + ).WithContext(newAuthorizedPlaybackContext()) + transcodeRR := httptest.NewRecorder() + handler.HandleStartTranscode(transcodeRR, transcodeReq) - transcodeReq := httptest.NewRequest( - http.MethodPost, - "/api/v1/playback/transcode/start", - strings.NewReader(tc.requestBody(session.ID)), - ) - transcodeReq = transcodeReq.WithContext(newAuthorizedPlaybackContext()) - - transcodeRR := httptest.NewRecorder() - handler.HandleStartTranscode(transcodeRR, transcodeReq) - - if transcodeRR.Code != http.StatusForbidden { - t.Fatalf("status = %d, want %d, body = %s", transcodeRR.Code, http.StatusForbidden, transcodeRR.Body.String()) - } - var response errorResponse - if err := json.NewDecoder(transcodeRR.Body).Decode(&response); err != nil { - t.Fatalf("decode response: %v", err) - } - if response.Error != "transcoding_disabled" { - t.Fatalf("error = %q, want transcoding_disabled", response.Error) - } - if transcodeSession := handler.tm.GetTranscodeSession(session.ID); transcodeSession != nil { - t.Fatal("transcode session started despite disabled video transcoding") - } - }) + if transcodeRR.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d, body = %s", transcodeRR.Code, http.StatusForbidden, transcodeRR.Body.String()) + } + var response errorResponse + if err := json.NewDecoder(transcodeRR.Body).Decode(&response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Error != "transcoding_disabled" { + t.Fatalf("error = %q, want transcoding_disabled", response.Error) + } + if transcodeSession := handler.tm.GetTranscodeSession(session.ID); transcodeSession != nil { + t.Fatal("transcode session started despite disabled video transcoding") } } diff --git a/internal/playback/copy_seek_anchor.go b/internal/playback/copy_seek_anchor.go new file mode 100644 index 00000000..051aabfb --- /dev/null +++ b/internal/playback/copy_seek_anchor.go @@ -0,0 +1,149 @@ +package playback + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math" + "os/exec" + "strconv" + "strings" + "time" + + "golang.org/x/sync/singleflight" +) + +const ( + maxConcurrentCopySeekProbes = 4 + copySeekProbeTimeout = 15 * time.Second +) + +var ( + copySeekProbeGroup singleflight.Group + copySeekProbeSlots = make(chan struct{}, maxConcurrentCopySeekProbes) +) + +type copySeekProbePacket struct { + PTSSeconds string `json:"pts_time"` + DTSSeconds string `json:"dts_time"` + Flags string `json:"flags"` +} + +type copySeekProbeOutput struct { + Packets []copySeekProbePacket `json:"packets"` +} + +type copySeekAnchor struct { + seconds float64 + segment int +} + +// ResolveCopySeekAnchor returns the keyframe timestamp FFmpeg's input seek will +// actually use for a copy-video restart. FFmpeg cannot discard the pre-roll +// between that keyframe and requestedSeekSeconds while preserving -c:v copy, +// so callers need both timestamps: requestedSeekSeconds remains the -ss input, +// while the returned anchor defines the stream's real timeline origin. +// +// The tiny read interval is intentional. ffprobe asks the demuxer to seek to +// requestedSeekSeconds, which lands on the same preceding key packet FFmpeg +// uses, then emits only the packets at that seek point instead of scanning the +// media from the beginning. +func ResolveCopySeekAnchor( + ctx context.Context, + ffmpegPath string, + inputPath string, + requestedSeekSeconds float64, + segmentDuration int, +) (float64, int, error) { + if requestedSeekSeconds <= 0 { + return 0, 0, nil + } + if strings.TrimSpace(inputPath) == "" { + return 0, 0, fmt.Errorf("resolve copy seek anchor: empty input path") + } + if segmentDuration <= 0 { + segmentDuration = DefaultSegmentDuration + } + + key := strings.Join([]string{ + ffprobePathFromFFmpeg(ffmpegPath), + inputPath, + strconv.FormatFloat(requestedSeekSeconds, 'f', 6, 64), + strconv.Itoa(segmentDuration), + }, "\x00") + resultCh := copySeekProbeGroup.DoChan(key, func() (any, error) { + probeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), copySeekProbeTimeout) + defer cancel() + select { + case copySeekProbeSlots <- struct{}{}: + defer func() { <-copySeekProbeSlots }() + case <-probeCtx.Done(): + return copySeekAnchor{}, probeCtx.Err() + } + seconds, segment, err := resolveCopySeekAnchor(probeCtx, ffmpegPath, inputPath, requestedSeekSeconds, segmentDuration) + return copySeekAnchor{seconds: seconds, segment: segment}, err + }) + + select { + case <-ctx.Done(): + return 0, 0, ctx.Err() + case result := <-resultCh: + if result.Err != nil { + return 0, 0, result.Err + } + anchor := result.Val.(copySeekAnchor) + return anchor.seconds, anchor.segment, nil + } +} + +func resolveCopySeekAnchor( + ctx context.Context, + ffmpegPath string, + inputPath string, + requestedSeekSeconds float64, + segmentDuration int, +) (float64, int, error) { + + interval := strconv.FormatFloat(requestedSeekSeconds, 'f', 6, 64) + "%+0.001" + cmd := exec.CommandContext(ctx, ffprobePathFromFFmpeg(ffmpegPath), + "-v", "error", + "-select_streams", "v:0", + "-read_intervals", interval, + "-show_entries", "packet=pts_time,dts_time,flags", + "-of", "json", + inputPath, + ) + var stdout bytes.Buffer + stderr := newBoundedTailBuffer(stderrTailMaxBytes) + cmd.Stdout = &stdout + cmd.Stderr = stderr + if err := cmd.Run(); err != nil { + if tail := truncateStderr(stderr.String()); tail != "" { + return 0, 0, fmt.Errorf("resolve copy seek anchor: ffprobe failed: %w (stderr: %s)", err, tail) + } + return 0, 0, fmt.Errorf("resolve copy seek anchor: ffprobe failed: %w", err) + } + + var output copySeekProbeOutput + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + return 0, 0, fmt.Errorf("resolve copy seek anchor: decode ffprobe output: %w", err) + } + for _, packet := range output.Packets { + if !strings.Contains(packet.Flags, "K") { + continue + } + timestamp := packet.PTSSeconds + if timestamp == "" || strings.EqualFold(timestamp, "N/A") { + timestamp = packet.DTSSeconds + } + anchor, err := strconv.ParseFloat(timestamp, 64) + if err != nil || math.IsNaN(anchor) || math.IsInf(anchor, 0) { + continue + } + anchor = math.Max(0, anchor) + return anchor, int(anchor / float64(segmentDuration)), nil + } + + return 0, 0, fmt.Errorf("resolve copy seek anchor: ffprobe returned no key packet") +} diff --git a/internal/playback/copy_seek_anchor_test.go b/internal/playback/copy_seek_anchor_test.go new file mode 100644 index 00000000..e6313d2e --- /dev/null +++ b/internal/playback/copy_seek_anchor_test.go @@ -0,0 +1,174 @@ +package playback + +import ( + "context" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func TestResolveCopySeekAnchorUsesKeyPacketTimestamp(t *testing.T) { + dir := t.TempDir() + ffmpegPath := filepath.Join(dir, "ffmpeg") + ffprobePath := filepath.Join(dir, "ffprobe") + if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake ffmpeg: %v", err) + } + probe := `#!/bin/sh +printf '%s' '{"packets":[{"pts_time":"N/A","dts_time":"14.500000","flags":"K__"}]}' +` + if err := os.WriteFile(ffprobePath, []byte(probe), 0o755); err != nil { + t.Fatalf("write fake ffprobe: %v", err) + } + + anchor, segment, err := ResolveCopySeekAnchor(context.Background(), ffmpegPath, "/media/movie.mkv", 18.261, 2) + if err != nil { + t.Fatalf("ResolveCopySeekAnchor: %v", err) + } + if anchor != 14.5 || segment != 7 { + t.Fatalf("resolved anchor = %v, segment = %d; want 14.5, 7", anchor, segment) + } +} + +func TestResolveCopySeekAnchorCoalescesMatchingConcurrentProbes(t *testing.T) { + dir := t.TempDir() + ffmpegPath := filepath.Join(dir, "ffmpeg") + ffprobePath := filepath.Join(dir, "ffprobe") + countPath := filepath.Join(dir, "probe-count") + if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake ffmpeg: %v", err) + } + probe := "#!/bin/sh\n" + + "printf x >> \"" + countPath + "\"\n" + + "sleep 0.1\n" + + "printf '%s' '{\"packets\":[{\"pts_time\":\"16.000000\",\"flags\":\"K__\"}]}'\n" + if err := os.WriteFile(ffprobePath, []byte(probe), 0o755); err != nil { + t.Fatalf("write fake ffprobe: %v", err) + } + + start := make(chan struct{}) + errs := make(chan error, 2) + var ready sync.WaitGroup + ready.Add(2) + for range 2 { + go func() { + ready.Done() + <-start + anchor, segment, err := ResolveCopySeekAnchor(context.Background(), ffmpegPath, "/media/movie.mkv", 18, 2) + if err == nil && (anchor != 16 || segment != 8) { + err = fmt.Errorf("resolved anchor = %v, segment = %d", anchor, segment) + } + errs <- err + }() + } + ready.Wait() + close(start) + for range 2 { + if err := <-errs; err != nil { + t.Fatal(err) + } + } + count, err := os.ReadFile(countPath) + if err != nil { + t.Fatalf("read probe count: %v", err) + } + if string(count) != "x" { + t.Fatalf("ffprobe executions = %d, want 1", len(count)) + } +} + +func TestResolveCopySeekAnchorMatchesRealLongGOPHEVC(t *testing.T) { + if testing.Short() { + t.Skip("real FFmpeg integration test") + } + ffmpegPath, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("ffmpeg is not installed") + } + if _, err := exec.LookPath(ffprobePathFromFFmpeg(ffmpegPath)); err != nil { + t.Skip("ffprobe is not installed beside ffmpeg") + } + encoders, err := exec.Command(ffmpegPath, "-hide_banner", "-encoders").CombinedOutput() + if err != nil || !strings.Contains(string(encoders), "libx265") { + t.Skip("ffmpeg does not provide libx265") + } + + sourcePath := filepath.Join(t.TempDir(), "long-gop-hevc.mkv") + encodeCtx, cancelEncode := context.WithTimeout(context.Background(), 20*time.Second) + defer cancelEncode() + encode := exec.CommandContext(encodeCtx, ffmpegPath, + "-hide_banner", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=24", + "-t", "22", + "-c:v", "libx265", "-preset", "ultrafast", + "-x265-params", "keyint=240:min-keyint=240:scenecut=0:log-level=error:pools=1:frame-threads=1", + "-an", "-y", sourcePath, + ) + if output, err := encode.CombinedOutput(); err != nil { + t.Fatalf("generate long-GOP HEVC fixture: %v\n%s", err, output) + } + + probeCtx, cancelProbe := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelProbe() + anchor, segment, err := ResolveCopySeekAnchor(probeCtx, ffmpegPath, sourcePath, 18.261, 2) + if err != nil { + t.Fatalf("ResolveCopySeekAnchor: %v", err) + } + if math.Abs(anchor-10) > 0.001 || segment != 5 { + t.Fatalf("resolved anchor = %v, segment = %d; want 10, 5", anchor, segment) + } + + outputDir := filepath.Join(t.TempDir(), "hls") + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatalf("create HLS output: %v", err) + } + opts := TranscodeOpts{ + InputPath: sourcePath, + OutputDir: outputDir, + SessionID: "copy-anchor-integration", + SourceVideoCodec: "hevc", + SeekSeconds: 18.261, + StreamOriginSeconds: anchor, + CopySeekAnchorResolved: true, + TargetCodecVideo: "copy", + TargetCodecAudio: "copy", + SegmentDuration: 2, + StartSegmentNumber: segment, + FFmpegPath: ffmpegPath, + } + packageCtx, cancelPackage := context.WithTimeout(context.Background(), 10*time.Second) + defer cancelPackage() + packageHLS := exec.CommandContext(packageCtx, ffmpegPath, buildFFmpegArgs(opts)...) + if output, err := packageHLS.CombinedOutput(); err != nil { + t.Fatalf("package copy HLS: %v\n%s", err, output) + } + + manifestPath := filepath.Join(outputDir, "stream.m3u8") + manifest, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read copy HLS manifest: %v", err) + } + if !strings.Contains(string(manifest), "#EXT-X-MEDIA-SEQUENCE:5") { + t.Fatalf("copy HLS media sequence does not use resolved anchor:\n%s", manifest) + } + firstFrame, err := exec.Command(ffprobePathFromFFmpeg(ffmpegPath), + "-v", "error", + "-select_streams", "v:0", + "-read_intervals", "%+0.1", + "-show_entries", "frame=best_effort_timestamp_time,key_frame", + "-of", "csv=p=0", + manifestPath, + ).CombinedOutput() + if err != nil { + t.Fatalf("probe copy HLS first frame: %v\n%s", err, firstFrame) + } + if !strings.Contains(string(firstFrame), "10.000000") { + t.Fatalf("copy HLS first frame = %q, want resolved 10-second keyframe", firstFrame) + } +} diff --git a/internal/playback/recipecard.go b/internal/playback/recipecard.go index 994b91c7..1a830513 100644 --- a/internal/playback/recipecard.go +++ b/internal/playback/recipecard.go @@ -44,25 +44,27 @@ type RecipeCard struct { // Encode parameters — mirror of the byte-affecting TranscodeOpts fields. // Unused (zero) for direct/remux cards, which carry no segment-based encode. - InputPath string `json:"input_path"` - OutputSubdir string `json:"output_subdir,omitempty"` - SourceVideoCodec string `json:"source_video_codec,omitempty"` - VideoBitstreamFilter string `json:"video_bitstream_filter,omitempty"` - SeekSeconds float64 `json:"seek_seconds"` - TargetResolution string `json:"target_resolution,omitempty"` - TargetCodecVideo string `json:"target_codec_video,omitempty"` - TargetCodecAudio string `json:"target_codec_audio,omitempty"` - SegmentDuration int `json:"segment_duration"` - StartSegmentNumber int `json:"start_segment_number"` - HWAccel string `json:"hw_accel,omitempty"` - HWDevice string `json:"hw_device,omitempty"` - SubtitleTrackIndex int `json:"subtitle_track_index"` - SubtitleBurnIn bool `json:"subtitle_burn_in,omitempty"` - SubtitleCodec string `json:"subtitle_codec,omitempty"` - AudioTrackIndex int `json:"audio_track_index"` - TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"` - TotalDuration float64 `json:"total_duration"` - FastStart bool `json:"fast_start,omitempty"` + InputPath string `json:"input_path"` + OutputSubdir string `json:"output_subdir,omitempty"` + SourceVideoCodec string `json:"source_video_codec,omitempty"` + VideoBitstreamFilter string `json:"video_bitstream_filter,omitempty"` + SeekSeconds float64 `json:"seek_seconds"` + StreamOriginSeconds float64 `json:"stream_origin_seconds,omitempty"` + CopySeekAnchorResolved bool `json:"copy_seek_anchor_resolved,omitempty"` + TargetResolution string `json:"target_resolution,omitempty"` + TargetCodecVideo string `json:"target_codec_video,omitempty"` + TargetCodecAudio string `json:"target_codec_audio,omitempty"` + SegmentDuration int `json:"segment_duration"` + StartSegmentNumber int `json:"start_segment_number"` + HWAccel string `json:"hw_accel,omitempty"` + HWDevice string `json:"hw_device,omitempty"` + SubtitleTrackIndex int `json:"subtitle_track_index"` + SubtitleBurnIn bool `json:"subtitle_burn_in,omitempty"` + SubtitleCodec string `json:"subtitle_codec,omitempty"` + AudioTrackIndex int `json:"audio_track_index"` + TargetBitrateKbps int `json:"target_bitrate_kbps,omitempty"` + TotalDuration float64 `json:"total_duration"` + FastStart bool `json:"fast_start,omitempty"` } // NewRecipeCard builds a RecipeCard from the durable identity fields plus the @@ -72,33 +74,35 @@ type RecipeCard struct { // operator's config change applies to reconstructed sessions too. func NewRecipeCard(userID int, profileID string, mediaFileID int, transcodeNodeURL string, opts TranscodeOpts) RecipeCard { return RecipeCard{ - SessionID: opts.SessionID, - UserID: userID, - ProfileID: profileID, - MediaFileID: mediaFileID, - TranscodeNodeURL: transcodeNodeURL, - TranscodeTransportID: opts.TranscodeTransportID, - PlayMethod: PlayTranscode, - TranscodeAudio: TranscodesAudio(opts.TargetCodecAudio), - InputPath: opts.InputPath, - OutputSubdir: opts.OutputSubdir, - SourceVideoCodec: opts.SourceVideoCodec, - VideoBitstreamFilter: opts.VideoBitstreamFilter, - SeekSeconds: opts.SeekSeconds, - TargetResolution: opts.TargetResolution, - TargetCodecVideo: opts.TargetCodecVideo, - TargetCodecAudio: opts.TargetCodecAudio, - SegmentDuration: opts.SegmentDuration, - StartSegmentNumber: opts.StartSegmentNumber, - HWAccel: opts.HWAccel, - HWDevice: opts.HWDevice, - SubtitleTrackIndex: opts.SubtitleTrackIndex, - SubtitleBurnIn: opts.SubtitleBurnIn, - SubtitleCodec: opts.SubtitleCodec, - AudioTrackIndex: opts.AudioTrackIndex, - TargetBitrateKbps: opts.TargetBitrateKbps, - TotalDuration: opts.TotalDuration, - FastStart: opts.FastStart, + SessionID: opts.SessionID, + UserID: userID, + ProfileID: profileID, + MediaFileID: mediaFileID, + TranscodeNodeURL: transcodeNodeURL, + TranscodeTransportID: opts.TranscodeTransportID, + PlayMethod: PlayTranscode, + TranscodeAudio: TranscodesAudio(opts.TargetCodecAudio), + InputPath: opts.InputPath, + OutputSubdir: opts.OutputSubdir, + SourceVideoCodec: opts.SourceVideoCodec, + VideoBitstreamFilter: opts.VideoBitstreamFilter, + SeekSeconds: opts.SeekSeconds, + StreamOriginSeconds: opts.StreamOriginSeconds, + CopySeekAnchorResolved: opts.CopySeekAnchorResolved, + TargetResolution: opts.TargetResolution, + TargetCodecVideo: opts.TargetCodecVideo, + TargetCodecAudio: opts.TargetCodecAudio, + SegmentDuration: opts.SegmentDuration, + StartSegmentNumber: opts.StartSegmentNumber, + HWAccel: opts.HWAccel, + HWDevice: opts.HWDevice, + SubtitleTrackIndex: opts.SubtitleTrackIndex, + SubtitleBurnIn: opts.SubtitleBurnIn, + SubtitleCodec: opts.SubtitleCodec, + AudioTrackIndex: opts.AudioTrackIndex, + TargetBitrateKbps: opts.TargetBitrateKbps, + TotalDuration: opts.TotalDuration, + FastStart: opts.FastStart, } } @@ -141,32 +145,34 @@ func NewRemuxRecipeCard(sessionID string, userID int, profileID string, mediaFil // they are environment-specific and not pinned in the card. func (c RecipeCard) TranscodeOpts(outputDir, ffmpegPath string, logSink FFmpegLogSink) TranscodeOpts { return TranscodeOpts{ - InputPath: c.InputPath, - OutputSubdir: c.OutputSubdir, - OutputDir: outputDir, - SessionID: c.SessionID, - TranscodeTransportID: c.TranscodeTransportID, - SourceVideoCodec: c.SourceVideoCodec, - VideoBitstreamFilter: c.VideoBitstreamFilter, - SeekSeconds: c.SeekSeconds, - TargetResolution: c.TargetResolution, - TargetCodecVideo: c.TargetCodecVideo, - TargetCodecAudio: c.TargetCodecAudio, - SegmentDuration: c.SegmentDuration, - StartSegmentNumber: c.StartSegmentNumber, - FFmpegPath: ffmpegPath, - HWAccel: c.HWAccel, - HWDevice: c.HWDevice, - SubtitleTrackIndex: c.SubtitleTrackIndex, - SubtitleBurnIn: c.SubtitleBurnIn, - SubtitleCodec: c.SubtitleCodec, - AudioTrackIndex: c.AudioTrackIndex, - TargetBitrateKbps: c.TargetBitrateKbps, - TotalDuration: c.TotalDuration, - FastStart: c.FastStart, - NodeType: "integrated", - ExecutionMode: "integrated", - FFmpegLogSink: logSink, + InputPath: c.InputPath, + OutputSubdir: c.OutputSubdir, + OutputDir: outputDir, + SessionID: c.SessionID, + TranscodeTransportID: c.TranscodeTransportID, + SourceVideoCodec: c.SourceVideoCodec, + VideoBitstreamFilter: c.VideoBitstreamFilter, + SeekSeconds: c.SeekSeconds, + StreamOriginSeconds: c.StreamOriginSeconds, + CopySeekAnchorResolved: c.CopySeekAnchorResolved, + TargetResolution: c.TargetResolution, + TargetCodecVideo: c.TargetCodecVideo, + TargetCodecAudio: c.TargetCodecAudio, + SegmentDuration: c.SegmentDuration, + StartSegmentNumber: c.StartSegmentNumber, + FFmpegPath: ffmpegPath, + HWAccel: c.HWAccel, + HWDevice: c.HWDevice, + SubtitleTrackIndex: c.SubtitleTrackIndex, + SubtitleBurnIn: c.SubtitleBurnIn, + SubtitleCodec: c.SubtitleCodec, + AudioTrackIndex: c.AudioTrackIndex, + TargetBitrateKbps: c.TargetBitrateKbps, + TotalDuration: c.TotalDuration, + FastStart: c.FastStart, + NodeType: "integrated", + ExecutionMode: "integrated", + FFmpegLogSink: logSink, } } @@ -185,32 +191,34 @@ const MaxTokenTTL = 24 * time.Hour // change applies to reconstructed sessions too. func (c RecipeCard) ToClaims() streamtoken.Claims { return streamtoken.Claims{ - SessionID: c.SessionID, - MediaPath: c.InputPath, - OutputSubdir: c.OutputSubdir, - PlayMethod: string(c.PlayMethod), - TranscodeAudio: c.TranscodeAudio, - RemuxDVMode: string(c.RemuxDVMode), - TranscodeNode: c.TranscodeNodeURL, - TranscodeTransportID: c.TranscodeTransportID, - TargetCodec: c.TargetCodecVideo, - TargetRes: c.TargetResolution, - AudioTrackIndex: c.AudioTrackIndex, - UserID: c.UserID, - ProfileID: c.ProfileID, - MediaFileID: c.MediaFileID, - SourceVideoCodec: c.SourceVideoCodec, - VideoBitstreamFilter: c.VideoBitstreamFilter, - SeekSeconds: c.SeekSeconds, - SegmentDuration: c.SegmentDuration, - StartSegmentNumber: c.StartSegmentNumber, - SubtitleTrackIndex: c.SubtitleTrackIndex, - SubtitleBurnIn: c.SubtitleBurnIn, - SubtitleCodec: c.SubtitleCodec, - TargetBitrateKbps: c.TargetBitrateKbps, - TotalDuration: c.TotalDuration, - FastStart: c.FastStart, - TargetCodecAudio: c.TargetCodecAudio, + SessionID: c.SessionID, + MediaPath: c.InputPath, + OutputSubdir: c.OutputSubdir, + PlayMethod: string(c.PlayMethod), + TranscodeAudio: c.TranscodeAudio, + RemuxDVMode: string(c.RemuxDVMode), + TranscodeNode: c.TranscodeNodeURL, + TranscodeTransportID: c.TranscodeTransportID, + TargetCodec: c.TargetCodecVideo, + TargetRes: c.TargetResolution, + AudioTrackIndex: c.AudioTrackIndex, + UserID: c.UserID, + ProfileID: c.ProfileID, + MediaFileID: c.MediaFileID, + SourceVideoCodec: c.SourceVideoCodec, + VideoBitstreamFilter: c.VideoBitstreamFilter, + SeekSeconds: c.SeekSeconds, + StreamOriginSeconds: c.StreamOriginSeconds, + CopySeekAnchorResolved: c.CopySeekAnchorResolved, + SegmentDuration: c.SegmentDuration, + StartSegmentNumber: c.StartSegmentNumber, + SubtitleTrackIndex: c.SubtitleTrackIndex, + SubtitleBurnIn: c.SubtitleBurnIn, + SubtitleCodec: c.SubtitleCodec, + TargetBitrateKbps: c.TargetBitrateKbps, + TotalDuration: c.TotalDuration, + FastStart: c.FastStart, + TargetCodecAudio: c.TargetCodecAudio, } } @@ -227,31 +235,33 @@ func RecipeCardFromClaims(c *streamtoken.Claims) RecipeCard { method = PlayTranscode } return RecipeCard{ - SessionID: c.SessionID, - UserID: c.UserID, - ProfileID: c.ProfileID, - MediaFileID: c.MediaFileID, - TranscodeNodeURL: c.TranscodeNode, - TranscodeTransportID: c.TranscodeTransportID, - PlayMethod: method, - TranscodeAudio: c.TranscodeAudio, - RemuxDVMode: RemuxDVMode(c.RemuxDVMode), - InputPath: c.MediaPath, - OutputSubdir: c.OutputSubdir, - SourceVideoCodec: c.SourceVideoCodec, - VideoBitstreamFilter: c.VideoBitstreamFilter, - SeekSeconds: c.SeekSeconds, - TargetResolution: c.TargetRes, - TargetCodecVideo: c.TargetCodec, - TargetCodecAudio: c.TargetCodecAudio, - SegmentDuration: c.SegmentDuration, - StartSegmentNumber: c.StartSegmentNumber, - SubtitleTrackIndex: c.SubtitleTrackIndex, - SubtitleBurnIn: c.SubtitleBurnIn, - SubtitleCodec: c.SubtitleCodec, - AudioTrackIndex: c.AudioTrackIndex, - TargetBitrateKbps: c.TargetBitrateKbps, - TotalDuration: c.TotalDuration, - FastStart: c.FastStart, + SessionID: c.SessionID, + UserID: c.UserID, + ProfileID: c.ProfileID, + MediaFileID: c.MediaFileID, + TranscodeNodeURL: c.TranscodeNode, + TranscodeTransportID: c.TranscodeTransportID, + PlayMethod: method, + TranscodeAudio: c.TranscodeAudio, + RemuxDVMode: RemuxDVMode(c.RemuxDVMode), + InputPath: c.MediaPath, + OutputSubdir: c.OutputSubdir, + SourceVideoCodec: c.SourceVideoCodec, + VideoBitstreamFilter: c.VideoBitstreamFilter, + SeekSeconds: c.SeekSeconds, + StreamOriginSeconds: c.StreamOriginSeconds, + CopySeekAnchorResolved: c.CopySeekAnchorResolved, + TargetResolution: c.TargetRes, + TargetCodecVideo: c.TargetCodec, + TargetCodecAudio: c.TargetCodecAudio, + SegmentDuration: c.SegmentDuration, + StartSegmentNumber: c.StartSegmentNumber, + SubtitleTrackIndex: c.SubtitleTrackIndex, + SubtitleBurnIn: c.SubtitleBurnIn, + SubtitleCodec: c.SubtitleCodec, + AudioTrackIndex: c.AudioTrackIndex, + TargetBitrateKbps: c.TargetBitrateKbps, + TotalDuration: c.TotalDuration, + FastStart: c.FastStart, } } diff --git a/internal/playback/recipecard_test.go b/internal/playback/recipecard_test.go index a0cc5467..85660138 100644 --- a/internal/playback/recipecard_test.go +++ b/internal/playback/recipecard_test.go @@ -9,26 +9,28 @@ import ( func TestRecipeCardRoundTripOpts(t *testing.T) { opts := TranscodeOpts{ - InputPath: "/media/movie.mkv", - OutputDir: "/tmp/silo-transcode/abc", - SessionID: "abc", - SourceVideoCodec: "hevc", - VideoBitstreamFilter: "dovi_rpu=strip=1", - SeekSeconds: 900, - TargetResolution: "1080p", - TargetCodecVideo: "h264", - TargetCodecAudio: "aac", - SegmentDuration: 2, - StartSegmentNumber: 450, - HWAccel: "qsv", - HWDevice: "/dev/dri/renderD128", - SubtitleTrackIndex: 3, - SubtitleBurnIn: true, - SubtitleCodec: "hdmv_pgs_subtitle", - AudioTrackIndex: 1, - TargetBitrateKbps: 8000, - TotalDuration: 7200, - FastStart: true, + InputPath: "/media/movie.mkv", + OutputDir: "/tmp/silo-transcode/abc", + SessionID: "abc", + SourceVideoCodec: "hevc", + VideoBitstreamFilter: "dovi_rpu=strip=1", + SeekSeconds: 900, + StreamOriginSeconds: 896, + CopySeekAnchorResolved: true, + TargetResolution: "1080p", + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + StartSegmentNumber: 450, + HWAccel: "qsv", + HWDevice: "/dev/dri/renderD128", + SubtitleTrackIndex: 3, + SubtitleBurnIn: true, + SubtitleCodec: "hdmv_pgs_subtitle", + AudioTrackIndex: 1, + TargetBitrateKbps: 8000, + TotalDuration: 7200, + FastStart: true, } card := NewRecipeCard(42, "profile-1", 77, "", opts) @@ -44,6 +46,9 @@ func TestRecipeCardRoundTripOpts(t *testing.T) { if got.SeekSeconds != 900 { t.Errorf("SeekSeconds = %v, want 900", got.SeekSeconds) } + if got.StreamOriginSeconds != 896 || !got.CopySeekAnchorResolved { + t.Errorf("copy seek anchor lost: origin=%v resolved=%v", got.StreamOriginSeconds, got.CopySeekAnchorResolved) + } if !got.SubtitleBurnIn { t.Errorf("SubtitleBurnIn lost in round trip") } @@ -196,23 +201,25 @@ func TestRecipeCardLegacyDecodeHasEmptyPlayMethod(t *testing.T) { // not asserted here. func TestRecipeCardClaimsRoundTrip(t *testing.T) { card := NewRecipeCard(42, "profile-1", 77, "http://node:9000", TranscodeOpts{ - InputPath: "/media/movie.mkv", - SessionID: "abc", - SourceVideoCodec: "hevc", - VideoBitstreamFilter: "dovi_rpu=strip=1", - SeekSeconds: 900, - TargetResolution: "1080p", - TargetCodecVideo: "h264", - TargetCodecAudio: "aac", - SegmentDuration: 2, - StartSegmentNumber: 450, - SubtitleTrackIndex: 3, - SubtitleBurnIn: true, - SubtitleCodec: "hdmv_pgs_subtitle", - AudioTrackIndex: 1, - TargetBitrateKbps: 8000, - TotalDuration: 7200, - FastStart: true, + InputPath: "/media/movie.mkv", + SessionID: "abc", + SourceVideoCodec: "hevc", + VideoBitstreamFilter: "dovi_rpu=strip=1", + SeekSeconds: 900, + StreamOriginSeconds: 896, + CopySeekAnchorResolved: true, + TargetResolution: "1080p", + TargetCodecVideo: "h264", + TargetCodecAudio: "aac", + SegmentDuration: 2, + StartSegmentNumber: 450, + SubtitleTrackIndex: 3, + SubtitleBurnIn: true, + SubtitleCodec: "hdmv_pgs_subtitle", + AudioTrackIndex: 1, + TargetBitrateKbps: 8000, + TotalDuration: 7200, + FastStart: true, }) claims := card.ToClaims() @@ -227,7 +234,8 @@ func TestRecipeCardClaimsRoundTrip(t *testing.T) { // Byte-affecting encode parameters. if got.InputPath != card.InputPath || got.SourceVideoCodec != card.SourceVideoCodec || got.VideoBitstreamFilter != card.VideoBitstreamFilter || - got.SeekSeconds != card.SeekSeconds || got.TargetResolution != card.TargetResolution || + got.SeekSeconds != card.SeekSeconds || got.StreamOriginSeconds != card.StreamOriginSeconds || + got.CopySeekAnchorResolved != card.CopySeekAnchorResolved || got.TargetResolution != card.TargetResolution || got.TargetCodecVideo != card.TargetCodecVideo || got.TargetCodecAudio != card.TargetCodecAudio || got.SegmentDuration != card.SegmentDuration || got.StartSegmentNumber != card.StartSegmentNumber || got.SubtitleTrackIndex != card.SubtitleTrackIndex || got.SubtitleBurnIn != card.SubtitleBurnIn || diff --git a/internal/playback/session.go b/internal/playback/session.go index 976a0c1c..57d824ba 100644 --- a/internal/playback/session.go +++ b/internal/playback/session.go @@ -93,6 +93,14 @@ type SessionStreamState struct { SegmentDuration int } +// TranscodeRoute identifies the process serving a playback session. An empty +// NodeURL means the integrated server owns the process; an empty TransportID +// means a remote process uses the public playback session ID. +type TranscodeRoute struct { + NodeURL string + TransportID string +} + // SessionReplacement is the complete mutable session state associated with a // protocol-v3 replacement plan. Position is optional because ordinary failure // recovery must preserve the player's latest progress while seek recovery @@ -834,6 +842,36 @@ func (m *SessionManager) ApplyReplacement(sessionID string, replacement SessionR if !ok { return SessionReplacementRollback{}, ErrSessionNotFound } + return m.applyReplacementLocked(s, sessionID, replacement) +} + +// ApplyReplacementIfRoute applies a complete replacement only while the +// session still routes to expected. It publishes route and stream state in one +// critical section so callers never expose a successor with predecessor state. +func (m *SessionManager) ApplyReplacementIfRoute( + sessionID string, + expected TranscodeRoute, + replacement SessionReplacement, +) (SessionReplacementRollback, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.sessions[sessionID] + if !ok { + return SessionReplacementRollback{}, false, ErrSessionNotFound + } + if s.TranscodeNodeURL != expected.NodeURL || s.TranscodeTransportID != expected.TransportID { + return SessionReplacementRollback{}, false, nil + } + rollback, err := m.applyReplacementLocked(s, sessionID, replacement) + return rollback, err == nil, err +} + +func (m *SessionManager) applyReplacementLocked( + s *Session, + sessionID string, + replacement SessionReplacement, +) (SessionReplacementRollback, error) { if replacement.EffectiveMediaFileID <= 0 { return SessionReplacementRollback{}, errors.New("replacement effective media file id is invalid") } @@ -926,6 +964,24 @@ func (m *SessionManager) SetTranscodeNodeURL(sessionID, url string) error { return nil } +// SetTranscodeRoute atomically assigns the node and process identity used to +// serve a transcode. +func (m *SessionManager) SetTranscodeRoute(sessionID string, route TranscodeRoute) error { + m.mu.Lock() + defer m.mu.Unlock() + + s, ok := m.sessions[sessionID] + if !ok { + return ErrSessionNotFound + } + + s.TranscodeNodeURL = route.NodeURL + s.TranscodeTransportID = route.TransportID + s.streamRevision++ + m.touchSessionLocked(s) + return nil +} + // SetEffectiveMediaFileID updates the currently delivered source file while // preserving the originally requested file selection. func (m *SessionManager) SetEffectiveMediaFileID(sessionID string, fileID int) error { diff --git a/internal/playback/session_test.go b/internal/playback/session_test.go index ba2a23cc..0812abf2 100644 --- a/internal/playback/session_test.go +++ b/internal/playback/session_test.go @@ -661,6 +661,79 @@ func TestSetTranscodeNodeURL_NotFound(t *testing.T) { } } +func TestSetTranscodeRoutePublishesNodeAndTransportTogether(t *testing.T) { + mgr := playback.NewSessionManager(0, 0) + session, err := mgr.StartSession(1, "profile-1", 42, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + + if err := mgr.SetTranscodeRoute(session.ID, playback.TranscodeRoute{NodeURL: "http://node:8070", TransportID: session.ID + "-legacy-next"}); err != nil { + t.Fatalf("SetTranscodeRoute: %v", err) + } + got, err := mgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if got.TranscodeNodeURL != "http://node:8070" || got.TranscodeTransportID != session.ID+"-legacy-next" { + t.Fatalf("transcode route = %q/%q", got.TranscodeNodeURL, got.TranscodeTransportID) + } +} + +func TestApplyReplacementIfRouteRejectsStalePredecessor(t *testing.T) { + mgr := playback.NewSessionManager(0, 0) + session, err := mgr.StartSession(1, "profile-1", 42, playback.PlayRemux, true) + if err != nil { + t.Fatalf("StartSession: %v", err) + } + if err := mgr.SetTranscodeRoute(session.ID, playback.TranscodeRoute{NodeURL: "http://old-node", TransportID: "old-transport"}); err != nil { + t.Fatalf("SetTranscodeRoute: %v", err) + } + replacement := playback.SessionReplacement{ + EffectiveMediaFileID: 42, + StreamState: playback.SessionStreamState{ + PlayMethod: playback.PlayTranscode, + BasePlayMethod: playback.PlayRemux, + TranscodeAudio: true, + TranscodeRouteSet: true, + SubtitleTrackIndex: -1, + TranscodeNodeURL: "http://new-node", + TranscodeTransportID: "new-transport", + }, + } + + _, published, err := mgr.ApplyReplacementIfRoute( + session.ID, + playback.TranscodeRoute{NodeURL: "http://stale-node", TransportID: "stale-transport"}, + replacement, + ) + if err != nil { + t.Fatalf("ApplyReplacementIfRoute stale: %v", err) + } + if published { + t.Fatal("stale predecessor unexpectedly published a replacement") + } + + _, published, err = mgr.ApplyReplacementIfRoute( + session.ID, + playback.TranscodeRoute{NodeURL: "http://old-node", TransportID: "old-transport"}, + replacement, + ) + if err != nil { + t.Fatalf("ApplyReplacementIfRoute current: %v", err) + } + if !published { + t.Fatal("current predecessor did not publish its replacement") + } + got, err := mgr.GetSession(session.ID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if got.TranscodeNodeURL != "http://new-node" || got.TranscodeTransportID != "new-transport" { + t.Fatalf("transcode route = %q/%q", got.TranscodeNodeURL, got.TranscodeTransportID) + } +} + func TestSetEffectiveMediaFileID(t *testing.T) { mgr := playback.NewSessionManager(0, 0) session, err := mgr.StartSession(1, "profile-1", 100, playback.PlayDirect, false) diff --git a/internal/playback/transcode.go b/internal/playback/transcode.go index 51c9f58e..9dd94804 100644 --- a/internal/playback/transcode.go +++ b/internal/playback/transcode.go @@ -38,16 +38,24 @@ type TranscodeOpts struct { SourceVideoCodec string VideoBitstreamFilter string // validated copy-mode BSF, e.g. dovi_rpu=strip=1 SeekSeconds float64 - TargetResolution string // e.g., 1080p, 720p - TargetCodecVideo string // e.g., h264 (or hevc if allowed) - TargetCodecAudio string // e.g., aac - SegmentDuration int // seconds, default 6 - StartSegmentNumber int // -hls_segment_start_number, default 0 - FFmpegPath string // optional explicit ffmpeg binary path - HWAccel string // auto, qsv, vaapi, nvenc, none - HWDevice string // e.g., /dev/dri/renderD128 (default if empty) - SubtitleTrackIndex int // -1 = no subtitles - SubtitleBurnIn bool + // StreamOriginSeconds is the keyframe timestamp at which a copy-video + // stream actually begins. SeekSeconds remains the client-requested -ss so + // FFmpeg performs exactly one demuxer seek; this origin keeps response and + // reconstruction timelines aligned with the resulting media pre-roll. + StreamOriginSeconds float64 + // CopySeekAnchorResolved distinguishes a valid zero-second origin from + // older/shared recipes that never resolved a copy seek anchor. + CopySeekAnchorResolved bool + TargetResolution string // e.g., 1080p, 720p + TargetCodecVideo string // e.g., h264 (or hevc if allowed) + TargetCodecAudio string // e.g., aac + SegmentDuration int // seconds, default 6 + StartSegmentNumber int // -hls_segment_start_number, default 0 + FFmpegPath string // optional explicit ffmpeg binary path + HWAccel string // auto, qsv, vaapi, nvenc, none + HWDevice string // e.g., /dev/dri/renderD128 (default if empty) + SubtitleTrackIndex int // -1 = no subtitles + SubtitleBurnIn bool // SubtitleCodec is the probed codec of the burn-in track (e.g. "subrip", // "hdmv_pgs_subtitle"). Bitmap codecs (PGS/DVD/DVB) select the overlay // filter_complex pipeline; text codecs use the libass subtitles filter. @@ -1533,6 +1541,28 @@ func (s *TranscodeSession) cleanStaleSegments(startSegment int) { // copy-mode sessions, stale segments at or after the restart point are // cleaned to prevent serving data from the wrong timeline position. func (s *TranscodeSession) Restart(ctx context.Context, seekSeconds float64, startSegment int) error { + return s.restart(ctx, seekSeconds, startSegment, 0, false) +} + +// RestartWithCopySeekAnchor restarts a copy-video stream with the keyframe +// origin resolved for this specific seek. Keeping this metadata explicit +// prevents a prior seek's origin from being reused after an audio switch. +func (s *TranscodeSession) RestartWithCopySeekAnchor( + ctx context.Context, + seekSeconds float64, + startSegment int, + streamOriginSeconds float64, +) error { + return s.restart(ctx, seekSeconds, startSegment, streamOriginSeconds, true) +} + +func (s *TranscodeSession) restart( + ctx context.Context, + seekSeconds float64, + startSegment int, + streamOriginSeconds float64, + copySeekAnchorResolved bool, +) error { s.mu.Lock() // Single-flight: a second caller arriving while a restart is in // progress must not kill the process the first restart just started. @@ -1574,6 +1604,14 @@ func (s *TranscodeSession) Restart(ctx context.Context, seekSeconds float64, sta opts.SeekSeconds = seekSeconds opts.StartSegmentNumber = startSegment + if strings.EqualFold(opts.TargetCodecVideo, "copy") { + // A seek restart describes a new emitted timeline. Never retain the + // previous copy seek's keyframe origin when the caller has not resolved + // a replacement; falling back to SeekSeconds is conservative and matches + // the behavior of recipes created before copy anchors were introduced. + opts.StreamOriginSeconds = streamOriginSeconds + opts.CopySeekAnchorResolved = copySeekAnchorResolved + } opts.FastStart = false // seek-restarts use veryfast for better quality args := buildFFmpegArgs(opts) @@ -1871,6 +1909,9 @@ func (s *TranscodeSession) SegmentStartTime(segNum int) (float64, bool, error) { s.mu.Lock() manifestPath := filepath.Join(s.outputDir, "stream.m3u8") baseSeekSeconds := s.opts.SeekSeconds + if strings.EqualFold(s.opts.TargetCodecVideo, "copy") && s.opts.CopySeekAnchorResolved { + baseSeekSeconds = s.opts.StreamOriginSeconds + } s.mu.Unlock() manifest, err := os.ReadFile(manifestPath) diff --git a/internal/playback/transcode_manager.go b/internal/playback/transcode_manager.go index e5640ebe..3264f064 100644 --- a/internal/playback/transcode_manager.go +++ b/internal/playback/transcode_manager.go @@ -198,6 +198,23 @@ func (m *TranscodeManager) SwapTranscodeSession(sessionID string, successor *Tra return predecessor } +// SwapTranscodeSessionIf publishes successor only while the live map still +// contains expected. Callers use it after staging a process in a distinct +// output directory so a stale replacement cannot overwrite a newer process. +func (m *TranscodeManager) SwapTranscodeSessionIf( + sessionID string, + expected *TranscodeSession, + successor *TranscodeSession, +) bool { + m.transcodeMu.Lock() + defer m.transcodeMu.Unlock() + if m.transcodes[sessionID] != expected { + return false + } + m.transcodes[sessionID] = successor + return true +} + // StopRemoteTranscode removes only the remote node process. It deliberately // leaves the local live-map entry untouched for an atomic remote-to-local v3 // replacement. @@ -246,12 +263,33 @@ func (m *TranscodeManager) LockSessionLifecycle(sessionID string) func() { // replaced it, the stale handle is not re-spawned and ErrSessionSuperseded is // returned. func (m *TranscodeManager) RestartSessionLocked(ctx context.Context, sessionID string, ts *TranscodeSession, seekSeconds float64, startSegment int) error { + return m.restartSessionLocked(sessionID, ts, func() error { + return ts.Restart(ctx, seekSeconds, startSegment) + }) +} + +func (m *TranscodeManager) restartSessionLocked(sessionID string, ts *TranscodeSession, restart func() error) error { unlock := m.LockSessionLifecycle(sessionID) defer unlock() if live := m.GetTranscodeSession(sessionID); live != ts { return ErrSessionSuperseded } - return ts.Restart(ctx, seekSeconds, startSegment) + return restart() +} + +// RestartSessionLockedWithCopySeekAnchor is RestartSessionLocked with the +// resolved keyframe origin for a legacy copy-video seek restart. +func (m *TranscodeManager) RestartSessionLockedWithCopySeekAnchor( + ctx context.Context, + sessionID string, + ts *TranscodeSession, + seekSeconds float64, + startSegment int, + streamOriginSeconds float64, +) error { + return m.restartSessionLocked(sessionID, ts, func() error { + return ts.RestartWithCopySeekAnchor(ctx, seekSeconds, startSegment, streamOriginSeconds) + }) } // markReconstructing records that sessionID's ffmpeg is mid-reconstruct and diff --git a/internal/playback/transcode_manifest_test.go b/internal/playback/transcode_manifest_test.go index 8b7f11b0..21509bd7 100644 --- a/internal/playback/transcode_manifest_test.go +++ b/internal/playback/transcode_manifest_test.go @@ -300,9 +300,12 @@ func TestRestartSeekTarget_CopyModeUsesManifestTimelineWhenAvailable(t *testing. session := &TranscodeSession{ outputDir: tempDir, opts: TranscodeOpts{ - SeekSeconds: 18.261, - TargetCodecVideo: "copy", - SegmentDuration: 2, + SeekSeconds: 18.261, + StreamOriginSeconds: 18, + CopySeekAnchorResolved: true, + TargetCodecVideo: "copy", + SegmentDuration: 2, + StartSegmentNumber: 9, }, } @@ -313,8 +316,8 @@ func TestRestartSeekTarget_CopyModeUsesManifestTimelineWhenAvailable(t *testing. if !ok { t.Fatal("RestartSeekTarget returned ok=false") } - if math.Abs(got-20.93) > 0.0001 { - t.Fatalf("RestartSeekTarget(10) = %.6f, want 20.93", got) + if math.Abs(got-20.669) > 0.0001 { + t.Fatalf("RestartSeekTarget(10) = %.6f, want 20.669", got) } } diff --git a/internal/playback/transcode_restart_guard_test.go b/internal/playback/transcode_restart_guard_test.go index 12422d38..cdcd76ab 100644 --- a/internal/playback/transcode_restart_guard_test.go +++ b/internal/playback/transcode_restart_guard_test.go @@ -74,6 +74,47 @@ func TestRestartInvokesRestartHook(t *testing.T) { } } +func TestRestartCopySeekOriginIsReplacedOrCleared(t *testing.T) { + truePath, err := exec.LookPath("true") + if err != nil { + t.Skipf("`true` not found in PATH: %v", err) + } + + newSession := func() *TranscodeSession { + return &TranscodeSession{ + outputDir: t.TempDir(), + opts: TranscodeOpts{ + TargetCodecVideo: "copy", + SegmentDuration: 2, + SeekSeconds: 18, + StreamOriginSeconds: 10, + CopySeekAnchorResolved: true, + StartSegmentNumber: 5, + FFmpegPath: truePath, + }, + } + } + + resolved := newSession() + if err := resolved.RestartWithCopySeekAnchor(context.Background(), 100, 48, 96); err != nil { + t.Fatalf("RestartWithCopySeekAnchor: %v", err) + } + resolvedOpts := resolved.Opts() + if resolvedOpts.SeekSeconds != 100 || resolvedOpts.StreamOriginSeconds != 96 || + !resolvedOpts.CopySeekAnchorResolved || resolvedOpts.StartSegmentNumber != 48 { + t.Fatalf("resolved restart opts = %+v", resolvedOpts) + } + + unresolved := newSession() + if err := unresolved.Restart(context.Background(), 100, 50); err != nil { + t.Fatalf("Restart: %v", err) + } + unresolvedOpts := unresolved.Opts() + if unresolvedOpts.StreamOriginSeconds != 0 || unresolvedOpts.CopySeekAnchorResolved { + t.Fatalf("generic restart retained stale copy origin: %+v", unresolvedOpts) + } +} + // TestRestartIsSingleFlight covers the other half: Restart must be // single-flight per session. A second caller arriving while a restart is in // progress must return immediately without killing the process the first diff --git a/internal/streamtoken/token.go b/internal/streamtoken/token.go index fb3c0734..4df90711 100644 --- a/internal/streamtoken/token.go +++ b/internal/streamtoken/token.go @@ -46,19 +46,21 @@ type Claims struct { // Reconstruction recipe — the byte-affecting encode parameters, mirroring the // former playback.RecipeCard. Zero for direct/remux tokens, which reconstruct // from identity alone plus the client-supplied position. - SourceVideoCodec string `json:"svc,omitempty"` - VideoBitstreamFilter string `json:"vbsf,omitempty"` - OutputSubdir string `json:"osd,omitempty"` - SeekSeconds float64 `json:"seek,omitempty"` - SegmentDuration int `json:"segd,omitempty"` - StartSegmentNumber int `json:"ssn,omitempty"` - SubtitleTrackIndex int `json:"sti,omitempty"` - SubtitleBurnIn bool `json:"sbi,omitempty"` - SubtitleCodec string `json:"sbc,omitempty"` - TargetBitrateKbps int `json:"tbr,omitempty"` - TotalDuration float64 `json:"dur,omitempty"` - FastStart bool `json:"fs,omitempty"` - TargetCodecAudio string `json:"tca,omitempty"` + SourceVideoCodec string `json:"svc,omitempty"` + VideoBitstreamFilter string `json:"vbsf,omitempty"` + OutputSubdir string `json:"osd,omitempty"` + SeekSeconds float64 `json:"seek,omitempty"` + StreamOriginSeconds float64 `json:"origin,omitempty"` + CopySeekAnchorResolved bool `json:"origin_ok,omitempty"` + SegmentDuration int `json:"segd,omitempty"` + StartSegmentNumber int `json:"ssn,omitempty"` + SubtitleTrackIndex int `json:"sti,omitempty"` + SubtitleBurnIn bool `json:"sbi,omitempty"` + SubtitleCodec string `json:"sbc,omitempty"` + TargetBitrateKbps int `json:"tbr,omitempty"` + TotalDuration float64 `json:"dur,omitempty"` + FastStart bool `json:"fs,omitempty"` + TargetCodecAudio string `json:"tca,omitempty"` // Recipe staleness hint, bumped on each re-mint after a recipe mutation // (audio/quality/seek switch). An optional client-side hint only. diff --git a/internal/transcodenode/server.go b/internal/transcodenode/server.go index 0da2af94..0ac57de6 100644 --- a/internal/transcodenode/server.go +++ b/internal/transcodenode/server.go @@ -27,25 +27,27 @@ import ( // TranscodeStartRequest is the JSON body for POST /transcode/start. type TranscodeStartRequest struct { - SessionID string `json:"session_id"` - InputPath string `json:"input_path"` - SourceVideoCodec string `json:"source_video_codec"` - VideoBitstreamFilter string `json:"video_bitstream_filter,omitempty"` - SeekSeconds float64 `json:"seek_seconds"` - StartSegmentNumber int `json:"start_segment_number"` - TargetResolution string `json:"target_resolution"` - TargetCodecVideo string `json:"target_codec_video"` - TargetCodecAudio string `json:"target_codec_audio"` - TargetAudioChannels int `json:"target_audio_channels,omitempty"` - TargetBitrateKbps int `json:"target_bitrate_kbps"` - SegmentDuration int `json:"segment_duration"` - HWAccel string `json:"hw_accel"` - AudioTrackIndex int `json:"audio_track_index"` - SubtitleTrackIndex int `json:"subtitle_track_index"` - SubtitleBurnIn bool `json:"subtitle_burn_in"` - SubtitleCodec string `json:"subtitle_codec,omitempty"` - TotalDuration float64 `json:"total_duration"` - RequireReady bool `json:"require_ready,omitempty"` + SessionID string `json:"session_id"` + InputPath string `json:"input_path"` + SourceVideoCodec string `json:"source_video_codec"` + VideoBitstreamFilter string `json:"video_bitstream_filter,omitempty"` + SeekSeconds float64 `json:"seek_seconds"` + StreamOriginSeconds float64 `json:"stream_origin_seconds,omitempty"` + CopySeekAnchorResolved bool `json:"copy_seek_anchor_resolved,omitempty"` + StartSegmentNumber int `json:"start_segment_number"` + TargetResolution string `json:"target_resolution"` + TargetCodecVideo string `json:"target_codec_video"` + TargetCodecAudio string `json:"target_codec_audio"` + TargetAudioChannels int `json:"target_audio_channels,omitempty"` + TargetBitrateKbps int `json:"target_bitrate_kbps"` + SegmentDuration int `json:"segment_duration"` + HWAccel string `json:"hw_accel"` + AudioTrackIndex int `json:"audio_track_index"` + SubtitleTrackIndex int `json:"subtitle_track_index"` + SubtitleBurnIn bool `json:"subtitle_burn_in"` + SubtitleCodec string `json:"subtitle_codec,omitempty"` + TotalDuration float64 `json:"total_duration"` + RequireReady bool `json:"require_ready,omitempty"` } // TranscodeStartResponse is the JSON response for POST /transcode/start. @@ -474,31 +476,33 @@ func (s *Server) handleStart(w http.ResponseWriter, r *http.Request) { outputDir := filepath.Join(cfg.Playback.TranscodeDir, req.SessionID) opts := playback.TranscodeOpts{ - InputPath: req.InputPath, - OutputDir: outputDir, - SessionID: req.SessionID, - SourceVideoCodec: req.SourceVideoCodec, - VideoBitstreamFilter: req.VideoBitstreamFilter, - SeekSeconds: req.SeekSeconds, - StartSegmentNumber: req.StartSegmentNumber, - TargetResolution: req.TargetResolution, - TargetCodecVideo: req.TargetCodecVideo, - TargetCodecAudio: req.TargetCodecAudio, - TargetAudioChannels: req.TargetAudioChannels, - TargetBitrateKbps: req.TargetBitrateKbps, - SegmentDuration: req.SegmentDuration, - FFmpegPath: cfg.Playback.FFmpegPath, - HWAccel: req.HWAccel, - HWDevice: "", - AudioTrackIndex: req.AudioTrackIndex, - SubtitleTrackIndex: req.SubtitleTrackIndex, - SubtitleBurnIn: req.SubtitleBurnIn, - SubtitleCodec: req.SubtitleCodec, - TotalDuration: req.TotalDuration, - FastStart: true, - NodeType: "transcode", - ExecutionMode: "transcode_node", - FFmpegLogSink: s.ffmpegSink, + InputPath: req.InputPath, + OutputDir: outputDir, + SessionID: req.SessionID, + SourceVideoCodec: req.SourceVideoCodec, + VideoBitstreamFilter: req.VideoBitstreamFilter, + SeekSeconds: req.SeekSeconds, + StreamOriginSeconds: req.StreamOriginSeconds, + CopySeekAnchorResolved: req.CopySeekAnchorResolved, + StartSegmentNumber: req.StartSegmentNumber, + TargetResolution: req.TargetResolution, + TargetCodecVideo: req.TargetCodecVideo, + TargetCodecAudio: req.TargetCodecAudio, + TargetAudioChannels: req.TargetAudioChannels, + TargetBitrateKbps: req.TargetBitrateKbps, + SegmentDuration: req.SegmentDuration, + FFmpegPath: cfg.Playback.FFmpegPath, + HWAccel: req.HWAccel, + HWDevice: "", + AudioTrackIndex: req.AudioTrackIndex, + SubtitleTrackIndex: req.SubtitleTrackIndex, + SubtitleBurnIn: req.SubtitleBurnIn, + SubtitleCodec: req.SubtitleCodec, + TotalDuration: req.TotalDuration, + FastStart: true, + NodeType: "transcode", + ExecutionMode: "transcode_node", + FFmpegLogSink: s.ffmpegSink, } if opts.HWAccel == "" && cfg.Playback.HWAccel != "" { diff --git a/internal/transcodenode/server_test.go b/internal/transcodenode/server_test.go index bdcb5246..2f4f6170 100644 --- a/internal/transcodenode/server_test.go +++ b/internal/transcodenode/server_test.go @@ -71,6 +71,49 @@ func TestHandleStartRequireReadyRejectsExitedFFmpeg(t *testing.T) { } } +func TestHandleStartDistinctReplacementFailurePreservesPredecessor(t *testing.T) { + server := newTestServer(t) + server.sessions["public-session"] = &playback.TranscodeSession{} + server.activeJobs.Store(1) + + ffmpegPath := filepath.Join(t.TempDir(), "failing-ffmpeg.sh") + if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatal(err) + } + server.watcher.Config().Playback.FFmpegPath = ffmpegPath + requestBody, err := json.Marshal(TranscodeStartRequest{ + SessionID: "public-session-legacy-replacement", + InputPath: "/media/movie.mkv", + TargetCodecVideo: "copy", + TargetCodecAudio: "aac", + SegmentDuration: 2, + RequireReady: true, + }) + if err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(requestBody)) + rr := httptest.NewRecorder() + server.handleStart(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String()) + } + server.mu.RLock() + predecessor := server.sessions["public-session"] + _, replacementRegistered := server.sessions["public-session-legacy-replacement"] + server.mu.RUnlock() + if predecessor == nil { + t.Fatal("failed distinct replacement removed the active predecessor") + } + if replacementRegistered { + t.Fatal("failed distinct replacement was registered") + } + if got := server.activeJobs.Load(); got != 1 { + t.Fatalf("active jobs = %d, want predecessor only", got) + } +} + func signCard(t *testing.T, card playback.RecipeCard) string { t.Helper() tok, err := streamtoken.Sign(card.ToClaims(), testSecret, time.Hour) diff --git a/web/src/player/components/VideoPlayer.tsx b/web/src/player/components/VideoPlayer.tsx index edcba91f..229781ca 100644 --- a/web/src/player/components/VideoPlayer.tsx +++ b/web/src/player/components/VideoPlayer.tsx @@ -43,6 +43,7 @@ import type { PlayerPlaybackStateChange, PlayerPlaybackTransport, PlaybackSessionPlaybackInfo, + PlaybackTransportRestart, PlayerAudioTrack, PlayerChapter, PlayMethod, @@ -84,6 +85,7 @@ interface VideoPlayerProps { onSwitchVersion?: (fileId: number, currentPosition: number) => void; subtitleUrls: PlayerSubtitleInfo[]; initialPosition: number; + transportRestart?: PlaybackTransportRestart | null; preferredSubtitleLanguage?: string | null; preferredSubtitleTrackSignature?: PlayerSubtitleTrackSignature | null; subtitleMode?: SubtitleMode; @@ -174,6 +176,7 @@ export function VideoPlayer({ onSwitchVersion, subtitleUrls, initialPosition, + transportRestart, preferredSubtitleLanguage, preferredSubtitleTrackSignature, subtitleMode, @@ -337,6 +340,7 @@ export function VideoPlayer({ playMethod, initialPosition, qualityPreference, + transportRestart, }); const { cancelPendingTranscodeStart, startupGeneration } = transcodeQuality; const hlsStartupExpected = diff --git a/web/src/player/components/WatchPage.tsx b/web/src/player/components/WatchPage.tsx index 118db8b9..9ab4bd60 100644 --- a/web/src/player/components/WatchPage.tsx +++ b/web/src/player/components/WatchPage.tsx @@ -460,6 +460,7 @@ export function WatchPage({ onSwitchVersion={handleSwitchVersion} subtitleUrls={playableSubtitles} initialPosition={session.initialPosition} + transportRestart={session.transportRestart} preferredSubtitleLanguage={preferredSubtitleLanguage} preferredSubtitleTrackSignature={preferredSubtitleTrackSignature} subtitleMode={subtitleMode} diff --git a/web/src/player/hooks/usePlaybackSession.ts b/web/src/player/hooks/usePlaybackSession.ts index 1c83c9bb..76e8d956 100644 --- a/web/src/player/hooks/usePlaybackSession.ts +++ b/web/src/player/hooks/usePlaybackSession.ts @@ -14,6 +14,7 @@ import type { ChangeAudioResponse, PlaybackSessionPlaybackInfo, PlaybackSessionResponse, + PlaybackTransportRestart, PlayMethod, PlayerFileVersion, PlayerPlaybackVariant, @@ -31,6 +32,7 @@ interface PlaybackSessionState { durationSeconds: number | null; subtitleUrls: PlayerSubtitleInfo[]; playbackInfo: PlaybackSessionPlaybackInfo | null; + transportRestart: PlaybackTransportRestart | null; loading: boolean; replacing: boolean; errorTitle: string | null; @@ -215,6 +217,7 @@ export function usePlaybackSession( durationSeconds: null, subtitleUrls: [], playbackInfo: null, + transportRestart: null, loading: true, replacing: false, errorTitle: null, @@ -222,6 +225,7 @@ export function usePlaybackSession( }); const sessionIdRef = useRef(null); + const audioSwitchRevisionRef = useRef(0); const stateRef = useRef(state); const activeRequestKeyRef = useRef(null); const switchingRef = useRef(false); @@ -304,6 +308,7 @@ export function usePlaybackSession( : undefined, })), playbackInfo: session.playback_info ?? null, + transportRestart: null, loading: false, replacing: false, errorTitle: null, @@ -538,19 +543,28 @@ export function usePlaybackSession( }); const token = config.getAccessToken(); + const nextStreamUrl = buildPlayerStreamUrl( + config.apiBaseUrl, + resp.stream_url, + token, + resp.play_method, + currentPosition, + ); + audioSwitchRevisionRef.current += 1; setState((prev) => ({ ...prev, - streamUrl: buildPlayerStreamUrl( - config.apiBaseUrl, - resp.stream_url, - token, - resp.play_method, - currentPosition, - ), + streamUrl: nextStreamUrl, playMethod: resp.play_method, audioTrackIndex: resp.audio_track_index, playbackInfo: resp.playback_info ?? prev.playbackInfo, initialPosition: currentPosition, + transportRestart: { + revision: audioSwitchRevisionRef.current, + streamUrl: nextStreamUrl, + playerStartSeconds: resp.player_start_seconds ?? currentPosition, + streamOriginSeconds: resp.stream_origin_seconds ?? resp.timeline_offset_seconds ?? 0, + canSeekAnywhere: resp.can_seek_anywhere ?? true, + }, })); } catch (err) { console.error("Failed to switch audio track:", err); diff --git a/web/src/player/hooks/useTranscodeQuality.test.tsx b/web/src/player/hooks/useTranscodeQuality.test.tsx index cac659e3..97d4c95c 100644 --- a/web/src/player/hooks/useTranscodeQuality.test.tsx +++ b/web/src/player/hooks/useTranscodeQuality.test.tsx @@ -28,7 +28,7 @@ function wrapper({ children }: { children: ReactNode }) { return {children}; } -function transcodeStartResponse() { +function transcodeStartResponse(overrides: Record = {}) { return { ok: true, status: 200, @@ -40,6 +40,7 @@ function transcodeStartResponse() { player_start_seconds: 0, timeline_offset_seconds: 0, can_seek_anywhere: true, + ...overrides, }), }; } @@ -75,6 +76,70 @@ function renderQuality() { } describe("useTranscodeQuality", () => { + it("adopts an audio-switch transport without starting another transcode", async () => { + const { result } = renderHook( + () => + useTranscodeQuality({ + sessionId: "sess-1", + selectedVersion: version, + versions: [version], + playMethod: "remux", + initialPosition: 100, + transportRestart: { + revision: 1, + streamUrl: "/api/v1/playback/transcode/sess-1/master.m3u8?st=token", + playerStartSeconds: 4, + streamOriginSeconds: 96, + canSeekAnywhere: false, + }, + }), + { wrapper }, + ); + + await waitFor(() => + expect(result.current.transcodeStreamUrl).toContain("master.m3u8?st=token"), + ); + expect(result.current.playerStartSeconds).toBe(4); + expect(result.current.streamOriginSeconds).toBe(96); + expect(result.current.canSeekAnywhere).toBe(false); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("preserves video copy when auto-starting a resumed remux", async () => { + fetchMock.mockImplementationOnce(() => + Promise.resolve( + transcodeStartResponse({ + player_start_seconds: 2.261, + stream_origin_seconds: 16, + timeline_offset_seconds: 16, + can_seek_anywhere: false, + }), + ), + ); + const { result } = renderHook( + () => + useTranscodeQuality({ + sessionId: "sess-1", + selectedVersion: version, + versions: [version], + playMethod: "remux", + initialPosition: 478.25, + }), + { wrapper }, + ); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const body = sentBodies()[0]!; + expect(body.seek_seconds).toBe(478.25); + expect(body.target_codec_video).toBe("copy"); + expect(body.target_resolution).toBe(""); + expect(body.target_bitrate_kbps).toBe(0); + await waitFor(() => expect(result.current.streamOriginSeconds).toBe(16)); + expect(result.current.playerStartSeconds).toBe(2.261); + expect(result.current.canSeekAnywhere).toBe(false); + }); + it("coalesces same-tick restarts into a single start with the final params", async () => { const { result } = renderQuality(); diff --git a/web/src/player/hooks/useTranscodeQuality.ts b/web/src/player/hooks/useTranscodeQuality.ts index 095f0771..26935652 100644 --- a/web/src/player/hooks/useTranscodeQuality.ts +++ b/web/src/player/hooks/useTranscodeQuality.ts @@ -2,7 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { usePlayerConfig } from "../context/PlayerConfigContext"; import { PlayerFetchError, playerFetch } from "../player-fetch"; import { describeTranscodingPolicyError } from "../playback-errors"; -import type { PlayMethod, PlayerFileVersion, QualityOption, TranscodeStartRequest } from "../types"; +import type { + PlaybackTransportRestart, + PlayMethod, + PlayerFileVersion, + QualityOption, + TranscodeStartRequest, +} from "../types"; import { QUALITY_TO_RESOLUTION } from "./useCodecDetection"; /** Quality tier definition. ID is frontend-only; backend receives resolution + bitrate separately. */ @@ -51,6 +57,7 @@ interface UseTranscodeQualityParams { playMethod: PlayMethod | null; initialPosition: number; qualityPreference?: string | null; + transportRestart?: PlaybackTransportRestart | null; } interface UseTranscodeQualityResult { @@ -183,6 +190,7 @@ export function useTranscodeQuality({ playMethod, initialPosition, qualityPreference, + transportRestart, }: UseTranscodeQualityParams): UseTranscodeQualityResult { const config = usePlayerConfig(); const [activeQualityId, setActiveQualityId] = useState("original"); @@ -265,6 +273,23 @@ export function useTranscodeQuality({ [], ); + useEffect(() => { + if (!sessionId || !transportRestart) return; + + switchAbortRef.current?.abort(); + switchAbortRef.current = null; + if (dispatchTimerRef.current != null) { + clearTimeout(dispatchTimerRef.current); + dispatchTimerRef.current = null; + } + setTranscodeStreamUrl(transportRestart.streamUrl); + setPlayerStartSeconds(transportRestart.playerStartSeconds); + setStreamOriginSeconds(transportRestart.streamOriginSeconds); + setCanSeekAnywhere(transportRestart.canSeekAnywhere); + setIsTranscoding(false); + setError(null); + }, [sessionId, transportRestart]); + const startTranscode = useCallback( (qualityId: string, currentPosition: number, forceRestart = false) => { if (!sessionId) return; @@ -536,7 +561,7 @@ export function useTranscodeQuality({ ); useEffect(() => { - if (!sessionId || (playMethod !== "transcode" && playMethod !== "remux")) { + if (!sessionId || transportRestart || (playMethod !== "transcode" && playMethod !== "remux")) { return; } @@ -581,6 +606,7 @@ export function useTranscodeQuality({ selectedVersion?.file_id, sessionId, startTranscode, + transportRestart, ]); return { diff --git a/web/src/player/types.ts b/web/src/player/types.ts index 4d63f68a..75c4c06e 100644 --- a/web/src/player/types.ts +++ b/web/src/player/types.ts @@ -210,9 +210,22 @@ export interface ChangeAudioResponse { play_method: PlayMethod; stream_url: string; switch_mode: "reload"; + player_start_seconds?: number; + stream_origin_seconds?: number; + timeline_offset_seconds?: number; + can_seek_anywhere?: boolean; playback_info?: PlaybackSessionPlaybackInfo; } +/** A server-prepared stream replacement that the player should adopt without restarting it. */ +export interface PlaybackTransportRestart { + revision: number; + streamUrl: string; + playerStartSeconds: number; + streamOriginSeconds: number; + canSeekAnywhere: boolean; +} + /** Client codec capabilities sent to the server. */ export interface ClientCodecCapabilities { codecs_video: string[]; From 84f8495e4f0f72cb1b908388c3f9b687ab8ecd52 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:26:35 -0400 Subject: [PATCH 5/8] feat(web): collapse per-scan rows on the admin Libraries page (#436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): collapse per-scan rows on the Libraries page Bulk single-file scans (e.g. autoscan picking up a season drop) rendered one full-width row per scan under the library, each with its own stop button that actually cancelled every scan for the library. With 70+ queued files the table became an unusable wall of paths. Each library's scans now collapse into one summary line (N running · N queued) with a single cancel control, compact rows for the first few scans (file basename, full path on hover, progress for running ones), and a Show all expander capped to a scrollable height. The Scan Queue popover gets the same running-first collapse per library group, shows basenames instead of full paths, and caps its staggered fade-in delay so deep rows don't appear seconds late. Co-Authored-By: Claude Fable 5 * fix(web): address scan-collapse review feedback - Keep the 'Entire library' target visible on pathless (full-library) scan rows in the inline active-work list. - Drop the redundant '+ N more' button in LibraryScanTasks; the header 'Show all N' toggle is the single expand control there. - Extract useCollapsedScans so the table rows and Scan Queue popover share one collapse implementation, and always derive the popover target from formatActiveScanTarget instead of a hardcoded fallback. - Update the active-scan progress test for the split mode/target markup. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- web/src/lib/scanRuns.ts | 8 + web/src/pages/AdminLibraries.test.tsx | 4 +- web/src/pages/AdminLibraries.tsx | 344 +++++++++++++++++--------- 3 files changed, 243 insertions(+), 113 deletions(-) diff --git a/web/src/lib/scanRuns.ts b/web/src/lib/scanRuns.ts index 16299064..5adad237 100644 --- a/web/src/lib/scanRuns.ts +++ b/web/src/lib/scanRuns.ts @@ -26,6 +26,14 @@ export function formatActiveScanMode(scan: Pick) { } } +export function formatActiveScanTarget(scan: Pick) { + if (!scan.path) { + return "Entire library"; + } + const segments = scan.path.replace(/\/+$/, "").split("/"); + return segments[segments.length - 1] || scan.path; +} + export function formatActiveScanTrigger(trigger: string) { switch (trigger) { case "autoscan": diff --git a/web/src/pages/AdminLibraries.test.tsx b/web/src/pages/AdminLibraries.test.tsx index 523d738d..5874c235 100644 --- a/web/src/pages/AdminLibraries.test.tsx +++ b/web/src/pages/AdminLibraries.test.tsx @@ -299,7 +299,9 @@ describe("AdminLibraries", () => { const markup = renderPage(); expect(markup).toContain("Processing files · 10 / 20 (50%)"); - expect(markup).toContain("Full library scan · Entire library"); + // Mode and target render as separate compact-row elements. + expect(markup).toContain("Full library scan"); + expect(markup).toContain("Entire library"); }); it("renders the collapsed Ambiguous Roots section when no roots exist", () => { diff --git a/web/src/pages/AdminLibraries.tsx b/web/src/pages/AdminLibraries.tsx index b6e80b5a..36cd53ba 100644 --- a/web/src/pages/AdminLibraries.tsx +++ b/web/src/pages/AdminLibraries.tsx @@ -95,6 +95,7 @@ import { compareActiveScans, formatActiveScanMode, formatActiveScanProgress, + formatActiveScanTarget, formatActiveScanTime, formatActiveScanTrigger, } from "@/lib/scanRuns"; @@ -789,89 +790,14 @@ function ScanQueuePopover({ {/* Library groups */}
{groups.map((group, gi) => ( -
- {/* Library header row */} -
- - {getLibraryScanGroupName(group.library, group.libraryID)} - -
- -
- - {/* Scan rows */} - {group.scans.map((scan, si) => ( -
- {/* Status dot */} -
- {scan.status === "running" ? ( - <> - - - - ) : ( - - )} -
- - {/* Details */} -
-
- - {formatActiveScanMode(scan)} - - {scan.trigger && ( - - {formatActiveScanTrigger(scan.trigger)} - - )} -
-
- {scan.path ? ( - - {scan.path} - - ) : ( - Entire library - )} - · - - {scan.status === "running" - ? formatActiveScanTime(scan.started_at, "Started") - : "Waiting for capacity"} - -
- {formatActiveScanProgress(scan) && ( -
- {formatActiveScanProgress(scan)} -
- )} -
-
- ))} - - {/* Separator between groups */} - {gi < groups.length - 1 &&
} -
+ ))}
@@ -879,6 +805,146 @@ function ScanQueuePopover({ ); } +// How many scan rows a group shows before the rest collapse behind an expander. +const COLLAPSED_SCAN_ROW_LIMIT = 4; + +function useCollapsedScans(scans: ScanRun[]) { + const [expanded, setExpanded] = useState(false); + const visibleScans = expanded ? scans : scans.slice(0, COLLAPSED_SCAN_ROW_LIMIT); + return { + expanded, + setExpanded, + visibleScans, + hiddenCount: scans.length - visibleScans.length, + collapsible: scans.length > COLLAPSED_SCAN_ROW_LIMIT, + }; +} + +function ScanQueueGroup({ + group, + groupIndex, + isLast, + cancelling, + onCancel, +}: { + group: { + libraryID: number; + library: Library | null; + scans: ScanRun[]; + runningCount: number; + queuedCount: number; + }; + groupIndex: number; + isLast: boolean; + cancelling: boolean; + onCancel: (libraryID: number) => void; +}) { + const { expanded, setExpanded, visibleScans, hiddenCount, collapsible } = useCollapsedScans( + group.scans, + ); + + return ( +
+ {/* Library header row */} +
+ + {getLibraryScanGroupName(group.library, group.libraryID)} + +
+ +
+ + {/* Scan rows */} + {visibleScans.map((scan, si) => ( +
+ {/* Status dot */} +
+ {scan.status === "running" ? ( + <> + + + + ) : ( + + )} +
+ + {/* Details */} +
+
+ {formatActiveScanMode(scan)} + {scan.trigger && ( + + {formatActiveScanTrigger(scan.trigger)} + + )} +
+
+ + {formatActiveScanTarget(scan)} + + · + + {scan.status === "running" + ? formatActiveScanTime(scan.started_at, "Started") + : "Waiting for capacity"} + +
+ {formatActiveScanProgress(scan) && ( +
+ {formatActiveScanProgress(scan)} +
+ )} +
+
+ ))} + {collapsible ? ( + + ) : null} + + {/* Separator between groups */} + {!isLast &&
} +
+ ); +} + function getLibraryScanGroupName(library: Library | null, libraryID: number) { return library?.name ?? `Library #${libraryID}`; } @@ -954,57 +1020,111 @@ function LibraryActiveWorkRow({
) : null} - {activeLibraryScans.map((scan) => ( - 0 ? ( + - ))} + ) : null}
); } -function LibraryScanTaskRow({ - scan, +function LibraryScanTasks({ + scans, cancelling, libraryID, onCancelScans, }: { - scan: ScanRun; + scans: ScanRun[]; cancelling: boolean; libraryID: number; onCancelScans: (libraryID: number) => void; }) { - const progress = formatActiveScanProgress(scan); + const { expanded, setExpanded, visibleScans, collapsible } = useCollapsedScans(scans); + const runningCount = scans.filter((scan) => scan.status === "running").length; + const queuedCount = scans.length - runningCount; return ( -
- onCancelScans(libraryID)} - /> - -
-
- Scan - {scan.status === "running" ? "Running" : "Queued"} -
-
- {formatActiveScanMode(scan)} - {scan.path ? ` · ${scan.path}` : " · Entire library"} -
- {progress ? ( -
{progress}
+
+
+ onCancelScans(libraryID)} + /> + 0 && "animate-spin")} + style={{ animationDuration: "2s" }} + /> + Scan + + {runningCount > 0 ? `${runningCount} running` : null} + {runningCount > 0 && queuedCount > 0 ? " · " : null} + {queuedCount > 0 ? `${queuedCount} queued` : null} + + {collapsible ? ( + ) : null}
+
+ {visibleScans.map((scan) => ( + + ))} +
+
+ ); +} + +function CompactScanRow({ scan }: { scan: ScanRun }) { + const progress = scan.status === "running" ? formatActiveScanProgress(scan) : ""; + + return ( +
+ + {scan.mode !== "file" ? ( + {formatActiveScanMode(scan)} + ) : null} + {scan.path ? ( + + {formatActiveScanTarget(scan)} + + ) : ( + + {formatActiveScanTarget(scan)} + + )} + {progress ? ( + · {progress} + ) : null}
); } From c90e7360541d0cd044df2bad643282b215d23d66 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:56:46 -0400 Subject: [PATCH 6/8] ci(docker): publish multi-arch images (linux/amd64 + linux/arm64) (#437) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(docker): publish multi-arch images (linux/amd64 + linux/arm64) The Dockerfile was already arch-aware (TARGETARCH in the Jellyfin apt repo); this adds linux/arm64 to the buildx platform list so the pushed manifest list serves both x86 servers and arm64 hosts (Apple Silicon, Graviton, Pi 4/5). Verified locally on the arm64 runner host: image builds and both silo and jellyfin-ffmpeg run. Co-Authored-By: Claude Fable 5 * ci(docker): move image builds to GitHub-hosted runners Replaces the single self-hosted job with a per-arch matrix (ubuntu-latest for amd64, ubuntu-24.04-arm for arm64) so each platform builds natively with no emulation, pushing by digest, plus a merge job that stitches the digests into one tagged manifest list. Layer caching moves from the persistent local builder to type=gha per-platform scopes. The private-SDK constraint that originally forced self-hosted no longer applies: silo-plugin-sdk is public and resolves from proxy.golang.org. Co-Authored-By: Claude Fable 5 * ci(docker): use normalized IMAGE_LC in merge-job metadata step Not a functional fix — metadata-action lowercases the images input itself (proven by run 29768620803) — but keeps all image references in the merge job on the explicit IMAGE_LC form. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .github/workflows/docker.yml | 94 ++++++++++++++++++++++++++++++++---- 1 file changed, 84 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d94c3fdd..8ee62e85 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -19,7 +19,15 @@ env: jobs: build: - runs-on: [self-hosted, linux] + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + - platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} permissions: contents: read packages: write @@ -28,6 +36,13 @@ jobs: - name: Checkout uses: actions/checkout@v5 + - name: Prepare platform slug and lowercase image name + run: | + echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV" + echo "IMAGE_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV" + env: + PLATFORM: ${{ matrix.platform }} + - name: Set up pnpm uses: pnpm/action-setup@v4 with: @@ -76,9 +91,6 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - with: - name: silo-builder - keep-state: true - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -92,19 +104,81 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=sha,prefix=,format=short - - name: Build and push + - name: Build and push by digest + id: build uses: docker/build-push-action@v6 with: context: . - push: true - tags: ${{ steps.meta.outputs.tags }} + platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} build-contexts: | frontend_dist=./web/dist build-args: | BUILD_REVISION=${{ github.sha }} BUILD_DIRTY=false + cache-from: type=gha,scope=docker-${{ env.PLATFORM_PAIR }} + cache-to: type=gha,scope=docker-${{ env.PLATFORM_PAIR }},mode=max + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_LC }},push-by-digest=true,name-canonical=true,push=true + + - name: Export digest + run: | + mkdir -p "${RUNNER_TEMP}/digests" + digest="${{ steps.build.outputs.digest }}" + touch "${RUNNER_TEMP}/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + runs-on: ubuntu-latest + needs: build + permissions: + packages: write + + steps: + - name: Prepare lowercase image name + run: echo "IMAGE_LC=${IMAGE_NAME,,}" >> "$GITHUB_ENV" + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: ${{ runner.temp }}/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_LC }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix=,format=short + + - name: Create and push manifest list + working-directory: ${{ runner.temp }}/digests + run: | + # shellcheck disable=SC2046 + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${REGISTRY}/${IMAGE_LC}@sha256:%s " *) + + - name: Inspect manifest list + run: | + docker buildx imagetools inspect "${REGISTRY}/${IMAGE_LC}:${GITHUB_SHA::7}" From 4f249fda8f34fd1589cdbc6b6a9ca08c1c98b5e6 Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:14:42 -0400 Subject: [PATCH 7/8] fix(watchsync): repair MDBList scrobble lifecycle --- internal/api/handlers/playback.go | 43 ++- internal/api/handlers/playback_test.go | 111 ++++++++ .../watchsync/providers/mdblist/provider.go | 264 ++++++++++++++++-- .../providers/mdblist/provider_test.go | 187 ++++++++++++- 4 files changed, 566 insertions(+), 39 deletions(-) diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index cee87d58..ecf883c5 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -1372,6 +1372,40 @@ func (h *PlaybackHandler) scrobbleEventForSession(ctx context.Context, session * return event } +func (h *PlaybackHandler) scrobbleEventForStoppedSession( + ctx context.Context, + session *playback.Session, + stopResult watchstate.PlaybackStopResult, +) (watchsync.ScrobbleEvent, bool) { + if session == nil || session.DisableProgressPersistence { + return watchsync.ScrobbleEvent{}, false + } + + mediaItemID := stopResult.MediaItemID + duration := stopResult.DurationSeconds + position := stopResult.FinalPositionSeconds + if mediaItemID == "" { + if h.fileResolver == nil { + return watchsync.ScrobbleEvent{}, false + } + file, err := h.loadFileByPreferredID(ctx, requestedMediaFileID(session), session.MediaFileID) + if err != nil || file == nil { + return watchsync.ScrobbleEvent{}, false + } + mediaItemID = playbackProgressTarget(file) + if mediaItemID == "" { + return watchsync.ScrobbleEvent{}, false + } + duration = float64(file.Duration) + position = session.Position + } + + event := h.scrobbleEventForSession(ctx, session, mediaItemID, duration, position) + event.HistoryID = stopResult.HistoryID + event.Completed = stopResult.Completed + return event, true +} + func intPtrValue(value *int) int { if value == nil { return 0 @@ -1463,15 +1497,12 @@ func (h *PlaybackHandler) finalizeSessionStop(ctx context.Context, session *play } stopResult := h.persistStopAndHistory(ctx, session) - if h.WatchScrobbler != nil && stopResult.MediaItemID != "" { - event := h.scrobbleEventForSession(ctx, session, stopResult.MediaItemID, stopResult.DurationSeconds, stopResult.FinalPositionSeconds) - event.HistoryID = stopResult.HistoryID - event.Completed = stopResult.Completed - if stopResult.Completed { + if h.WatchScrobbler != nil { + if event, ok := h.scrobbleEventForStoppedSession(ctx, session, stopResult); ok && (userInitiated || stopResult.Completed) { if err := h.WatchScrobbler.ScrobbleStop(ctx, event); err != nil { slog.WarnContext(ctx, "failed to queue watch provider stop scrobble", "component", "api", "session", session.ID, "error", err) } - } else if !stopResult.SkippedBelowMinResume { + } else if ok { if err := h.WatchScrobbler.ScrobblePause(ctx, event); err != nil { slog.WarnContext(ctx, "failed to queue watch provider pause scrobble", "component", "api", "session", session.ID, "error", err) } diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 7a53a9ab..02297f8f 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -32,6 +32,7 @@ import ( "github.com/Silo-Server/silo-server/internal/transcodenode" "github.com/Silo-Server/silo-server/internal/userdb" "github.com/Silo-Server/silo-server/internal/userstore" + "github.com/Silo-Server/silo-server/internal/watchsync" ) type testUserStoreProvider struct { @@ -133,6 +134,27 @@ func (noopPlaybackAdminStore) RecordHistory(context.Context, AdminPlaybackHistor func (noopPlaybackAdminStore) DeleteSession(context.Context, string) error { return nil } +type recordingPlaybackWatchScrobbler struct { + starts []watchsync.ScrobbleEvent + pauses []watchsync.ScrobbleEvent + stops []watchsync.ScrobbleEvent +} + +func (s *recordingPlaybackWatchScrobbler) ScrobbleStart(_ context.Context, event watchsync.ScrobbleEvent) error { + s.starts = append(s.starts, event) + return nil +} + +func (s *recordingPlaybackWatchScrobbler) ScrobblePause(_ context.Context, event watchsync.ScrobbleEvent) error { + s.pauses = append(s.pauses, event) + return nil +} + +func (s *recordingPlaybackWatchScrobbler) ScrobbleStop(_ context.Context, event watchsync.ScrobbleEvent) error { + s.stops = append(s.stops, event) + return nil +} + type testEpisodeLookup struct { episode *models.Episode } @@ -447,6 +469,95 @@ func TestPlaybackSessionProgressPersistenceCanBeDisabled(t *testing.T) { } } +func TestFinalizeSessionStop_UsesProviderLifecycleSemantics(t *testing.T) { + tests := []struct { + name string + position float64 + userInitiated bool + disablePersistence bool + wantPauseCalls int + wantStopCalls int + wantEventPosition float64 + }{ + { + name: "user exit stops below-resume-threshold scrobble", + position: 120, + userInitiated: true, + wantStopCalls: 1, + wantEventPosition: 120, + }, + { + name: "system teardown pauses reconstructable scrobble", + position: 120, + wantPauseCalls: 1, + wantEventPosition: 120, + }, + { + name: "completed system teardown stops scrobble", + position: 3500, + wantStopCalls: 1, + wantEventPosition: 3500, + }, + { + name: "user exit at zero still stops scrobble", + userInitiated: true, + wantStopCalls: 1, + wantEventPosition: 0, + }, + { + name: "disabled persistence does not scrobble", + position: 120, + userInitiated: true, + disablePersistence: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := newPlaybackTestStore(t) + file := &models.MediaFile{ + ID: 42, + ContentID: "movie-1", + Duration: 3600, + } + scrobbler := &recordingPlaybackWatchScrobbler{} + handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file}) + handler.StoreProvider = testUserStoreProvider{store: store} + handler.WatchScrobbler = scrobbler + + session := &playback.Session{ + ID: "session-1", + UserID: 1, + ProfileID: "profile-1", + MediaFileID: file.ID, + Position: tt.position, + DisableProgressPersistence: tt.disablePersistence, + StartedAt: time.Now().Add(-time.Minute), + } + handler.finalizeSessionStop(context.Background(), session, false, "", tt.userInitiated) + + if got := len(scrobbler.pauses); got != tt.wantPauseCalls { + t.Fatalf("pause calls = %d, want %d", got, tt.wantPauseCalls) + } + if got := len(scrobbler.stops); got != tt.wantStopCalls { + t.Fatalf("stop calls = %d, want %d", got, tt.wantStopCalls) + } + if len(scrobbler.pauses)+len(scrobbler.stops) == 1 { + event := scrobbler.pauses + if len(event) == 0 { + event = scrobbler.stops + } + if event[0].MediaItemID != file.ContentID { + t.Fatalf("media item ID = %q, want %q", event[0].MediaItemID, file.ContentID) + } + if event[0].PositionSeconds != tt.wantEventPosition { + t.Fatalf("position = %v, want %v", event[0].PositionSeconds, tt.wantEventPosition) + } + } + }) + } +} + func TestBuildTranscodeStartResponse_UnifiedSeekAnywhere(t *testing.T) { file := &models.MediaFile{Duration: 3600} diff --git a/internal/watchsync/providers/mdblist/provider.go b/internal/watchsync/providers/mdblist/provider.go index d6499f7d..cf5ca598 100644 --- a/internal/watchsync/providers/mdblist/provider.go +++ b/internal/watchsync/providers/mdblist/provider.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "math" "net/http" "net/url" "strconv" @@ -38,6 +39,7 @@ const ( // survives burst-limit blips; anything longer defers the sync run. maxInPlaceRetryWait = 15 * time.Second maxRetryAttempts = 2 + maxErrorBodyBytes = 4 << 10 // Without a Retry-After header we cannot tell a burst limit from an // exhausted daily quota, so default to backing off until the next @@ -125,19 +127,23 @@ func (p *Provider) RefreshToken(_ context.Context, _ watchsync.ServerConfig, con func (p *Provider) FetchWatched(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteWatch, error) { var rows []watchsync.RemoteWatch - for offset := 0; ; { + page := mdblistPageState{} + for { var payload mdblistWatchedResponse - path := fmt.Sprintf("/sync/watched?limit=%d&offset=%d", syncPageLimit, offset) + path := page.path("/sync/watched") if err := p.do(ctx, http.MethodGet, path, conn.AccessToken, nil, &payload); err != nil { return nil, err } - page := watchedRowsFromPayload(p.Key(), payload) - rows = append(rows, page...) + pageRows := watchedRowsFromPayload(p.Key(), payload) + rows = append(rows, pageRows...) fetched := len(payload.Movies) + len(payload.Episodes) - if fetched < syncPageLimit { + done, err := page.advance(payload.Pagination, fetched) + if err != nil { + return nil, fmt.Errorf("mdblist watched pagination: %w", err) + } + if done { break } - offset += fetched } return rows, nil } @@ -302,50 +308,76 @@ func (p *Provider) FetchHistory(ctx context.Context, cfg watchsync.ServerConfig, func (p *Provider) ExportHistory(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, plays []watchsync.LocalPlay) (watchsync.ExportResult, error) { payload := buildWatchedPayload(plays, true) - if payload.empty() { - return watchsync.ExportResult{}, nil - } return p.sendWatched(ctx, conn, plays, "/sync/watched", payload) } func (p *Provider) RemoveHistory(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, plays []watchsync.LocalPlay) (watchsync.ExportResult, error) { payload := buildWatchedPayload(plays, false) - if payload.empty() { - return watchsync.ExportResult{}, nil - } return p.sendWatched(ctx, conn, plays, "/sync/watched/remove", payload) } func (p *Provider) sendWatched(ctx context.Context, conn watchsync.Connection, plays []watchsync.LocalPlay, path string, payload mdblistWatchedPayload) (watchsync.ExportResult, error) { + if payload.empty() { + return watchedExportResult(plays, false), nil + } var body bytes.Buffer if err := json.NewEncoder(&body).Encode(payload); err != nil { return watchsync.ExportResult{}, fmt.Errorf("encode mdblist watched payload: %w", err) } - if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, nil); err != nil { + var response mdblistWatchedWriteResponse + if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, &response); err != nil { return watchsync.ExportResult{}, err } - result := watchsync.ExportResult{Sent: make([]string, 0, len(plays))} + return watchedExportResult(plays, !emptyJSONValue(response.NotFound)), nil +} + +func watchedExportResult(plays []watchsync.LocalPlay, responseHasNotFound bool) watchsync.ExportResult { + result := watchsync.ExportResult{ + Sent: make([]string, 0, len(plays)), + Failed: make(map[string]string), + } for _, play := range plays { + if play.HistoryID == "" { + continue + } + if !watchedPlaySupported(play) { + result.Failed[play.HistoryID] = "MDBList watched export requires a supported media kind and external ID" + continue + } + if responseHasNotFound { + // MDBList documents not_found only as an unstructured object. It does + // not guarantee enough identity information to safely attribute a + // partial rejection, so never claim that any item in this batch sent. + result.Failed[play.HistoryID] = "MDBList did not accept one or more watched items in the batch" + continue + } result.Sent = append(result.Sent, play.HistoryID) } - return result, nil + if len(result.Failed) == 0 { + result.Failed = nil + } + return result } func (p *Provider) FetchWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection) ([]watchsync.RemoteFavorite, error) { now := time.Now().UTC() var rows []watchsync.RemoteFavorite - for offset := 0; ; { + page := mdblistPageState{} + for { var payload mdblistWatchlistResponse - path := fmt.Sprintf("/watchlist/items?limit=%d&offset=%d", syncPageLimit, offset) + path := page.path("/watchlist/items") if err := p.do(ctx, http.MethodGet, path, conn.AccessToken, nil, &payload); err != nil { return nil, err } rows = append(rows, watchlistRowsFromPayload(p.Key(), payload, now)...) fetched := len(payload.Movies) + len(payload.Shows) - if fetched < syncPageLimit { + done, err := page.advance(payload.Pagination, fetched) + if err != nil { + return nil, fmt.Errorf("mdblist watchlist pagination: %w", err) + } + if done { break } - offset += fetched } return rows, nil } @@ -402,20 +434,44 @@ func (p *Provider) RemoveWatchlist(ctx context.Context, _ watchsync.ServerConfig func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, favorites []watchsync.LocalFavorite, path string) (watchsync.ExportResult, error) { payload := buildWatchlistPayload(favorites) if len(payload.Movies) == 0 && len(payload.Shows) == 0 { - return watchsync.ExportResult{}, nil + return watchlistExportResult(favorites, false), nil } var body bytes.Buffer if err := json.NewEncoder(&body).Encode(payload); err != nil { return watchsync.ExportResult{}, fmt.Errorf("encode mdblist watchlist payload: %w", err) } - if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, nil); err != nil { + var response mdblistWatchlistWriteResponse + if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, &response); err != nil { return watchsync.ExportResult{}, err } - result := watchsync.ExportResult{Sent: make([]string, 0, len(favorites)*2)} + return watchlistExportResult(favorites, response.NotFound > 0), nil +} + +func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFound bool) watchsync.ExportResult { + result := watchsync.ExportResult{ + Sent: make([]string, 0, len(favorites)), + Failed: make(map[string]string), + } for _, fav := range favorites { + if fav.MediaItemID == "" { + continue + } + if !watchlistFavoriteSupported(fav) { + result.Failed[fav.MediaItemID] = "MDBList watchlist export requires a movie or series with an external ID" + continue + } + if responseHasNotFound { + // The API returns only an aggregate count, so a partial rejection + // cannot be attributed safely to one favorite. + result.Failed[fav.MediaItemID] = "MDBList did not accept one or more watchlist items in the batch" + continue + } result.Sent = append(result.Sent, fav.MediaItemID) } - return result, nil + if len(result.Failed) == 0 { + result.Failed = nil + } + return result } func (p *Provider) Start(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, event watchsync.ScrobbleEvent) error { @@ -542,6 +598,10 @@ func (p *Provider) doOnce(ctx context.Context, method, path, target string, payl return -1, fmt.Errorf("mdblist request %s %s rejected: status %d (check api key)", method, path, resp.StatusCode) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + detail := responseErrorDetail(resp.Body) + if detail != "" { + return -1, fmt.Errorf("mdblist request %s %s failed: status %d: %s", method, path, resp.StatusCode, detail) + } return -1, fmt.Errorf("mdblist request %s %s failed: status %d", method, path, resp.StatusCode) } if out == nil || resp.StatusCode == http.StatusNoContent { @@ -553,6 +613,28 @@ func (p *Provider) doOnce(ctx context.Context, method, path, target string, payl return -1, nil } +func responseErrorDetail(body io.Reader) string { + raw, err := io.ReadAll(io.LimitReader(body, maxErrorBodyBytes)) + if err != nil { + return "" + } + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return "" + } + var envelope struct { + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(raw, &envelope) == nil && len(envelope.Error) > 0 { + raw = bytes.TrimSpace(envelope.Error) + } + var compact bytes.Buffer + if json.Compact(&compact, raw) == nil { + return compact.String() + } + return strings.Join(strings.Fields(string(raw)), " ") +} + // parseRetryAfter reads an RFC 7231 Retry-After value (delay-seconds or // HTTP-date). It returns 0 when the header is absent or unparseable. func parseRetryAfter(value string, now time.Time) time.Duration { @@ -669,8 +751,100 @@ func (e *mdblistWatchedEpisode) UnmarshalJSON(data []byte) error { } type mdblistWatchedResponse struct { - Movies []mdblistWatchedMovie `json:"movies"` - Episodes []mdblistWatchedEpisode `json:"episodes"` + Movies []mdblistWatchedMovie `json:"movies"` + Episodes []mdblistWatchedEpisode `json:"episodes"` + Pagination *mdblistPagination `json:"pagination"` +} + +type mdblistPagination struct { + NextCursor string `json:"next_cursor"` +} + +type mdblistPageState struct { + cursor string + offset int + legacyOffset bool +} + +func (s mdblistPageState) path(base string) string { + query := url.Values{"limit": {strconv.Itoa(syncPageLimit)}} + if s.cursor != "" { + query.Set("cursor", s.cursor) + } else if s.legacyOffset { + query.Set("offset", strconv.Itoa(s.offset)) + } + return base + "?" + query.Encode() +} + +func (s *mdblistPageState) advance(pagination *mdblistPagination, fetched int) (bool, error) { + if pagination != nil { + next := strings.TrimSpace(pagination.NextCursor) + if next == "" { + return true, nil + } + if next == s.cursor { + return false, errors.New("provider returned the same cursor twice") + } + s.cursor = next + return false, nil + } + if fetched < syncPageLimit { + return true, nil + } + // Older MDBList deployments omitted pagination metadata. Preserve the + // deprecated offset fallback for those responses while preferring cursors. + s.cursor = "" + s.legacyOffset = true + s.offset += fetched + return false, nil +} + +type mdblistWatchedWriteResponse struct { + NotFound json.RawMessage `json:"not_found"` +} + +type mdblistWatchlistWriteResponse struct { + NotFound int `json:"not_found"` +} + +func emptyJSONValue(raw json.RawMessage) bool { + if len(bytes.TrimSpace(raw)) == 0 { + return true + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return false + } + return emptyJSONTree(value) +} + +func emptyJSONTree(value any) bool { + switch typed := value.(type) { + case nil: + return true + case bool: + return !typed + case float64: + return typed == 0 + case string: + return strings.TrimSpace(typed) == "" + case []any: + for _, child := range typed { + if !emptyJSONTree(child) { + return false + } + } + return true + case map[string]any: + for _, child := range typed { + if !emptyJSONTree(child) { + return false + } + } + return true + default: + return false + } } type mdblistPlaybackItem struct { @@ -775,8 +949,9 @@ func (i mdblistWatchlistItem) preferredIDs() mdblistIDs { } type mdblistWatchlistResponse struct { - Movies []mdblistWatchlistItem `json:"movies"` - Shows []mdblistWatchlistItem `json:"shows"` + Movies []mdblistWatchlistItem `json:"movies"` + Shows []mdblistWatchlistItem `json:"shows"` + Pagination *mdblistPagination `json:"pagination"` } type mdblistUser struct { @@ -866,6 +1041,18 @@ func buildWatchedPayload(plays []watchsync.LocalPlay, includeWatchedAt bool) mdb return payload } +func watchedPlaySupported(play watchsync.LocalPlay) bool { + switch play.Kind { + case historyimport.KindMovie: + return idsFromLocal(play.IMDbID, play.TMDBID, play.TVDBID) != (mdblistIDs{}) + case historyimport.KindEpisode: + return idsFromLocal(play.IMDbID, play.TMDBID, play.TVDBID) != (mdblistIDs{}) || + idsFromLocal(play.SeriesIMDbID, play.SeriesTMDBID, play.SeriesTVDBID) != (mdblistIDs{}) + default: + return false + } +} + func buildWatchlistPayload(favorites []watchsync.LocalFavorite) mdblistWatchlistPayload { var payload mdblistWatchlistPayload for _, fav := range favorites { @@ -887,6 +1074,17 @@ func buildWatchlistPayload(favorites []watchsync.LocalFavorite) mdblistWatchlist return payload } +func watchlistFavoriteSupported(fav watchsync.LocalFavorite) bool { + if fav.Kind != historyimport.KindMovie && fav.Kind != historyimport.KindSeries { + return false + } + ids := idsFromLocal(fav.IMDbID, fav.TMDBID, fav.TVDBID) + if ids == (mdblistIDs{}) { + ids = idsFromProviderItemKey(fav.ProviderItemKey) + } + return ids != (mdblistIDs{}) +} + func buildScrobblePayload(event watchsync.ScrobbleEvent) map[string]any { progress := 0.0 if event.DurationSeconds > 0 { @@ -898,6 +1096,10 @@ func buildScrobblePayload(event watchsync.ScrobbleEvent) map[string]any { if progress < 0 { progress = 0 } + // MDBList validates progress as a decimal with at most five total digits. + // Two decimal places keeps every value in the 0-100 range within that + // contract and avoids raw floating-point expansion on the wire. + progress = math.Round(progress*100) / 100 payload := map[string]any{"progress": progress} switch event.Kind { case historyimport.KindEpisode: @@ -905,9 +1107,11 @@ func buildScrobblePayload(event watchsync.ScrobbleEvent) map[string]any { if showIDs == (mdblistIDs{}) { showIDs = idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID) } - payload["show"] = map[string]any{"ids": showIDs} - payload["season"] = event.SeasonNumber - payload["episode"] = event.EpisodeNumber + payload["show"] = map[string]any{ + "ids": showIDs, + "season": event.SeasonNumber, + "episode": event.EpisodeNumber, + } default: payload["movie"] = map[string]any{"ids": idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID)} } diff --git a/internal/watchsync/providers/mdblist/provider_test.go b/internal/watchsync/providers/mdblist/provider_test.go index 3b8240ab..537779e0 100644 --- a/internal/watchsync/providers/mdblist/provider_test.go +++ b/internal/watchsync/providers/mdblist/provider_test.go @@ -123,6 +123,31 @@ func TestFetchWatchedParsesMoviesAndEpisodes(t *testing.T) { } } +func TestFetchWatchedUsesCursorPagination(t *testing.T) { + var queries []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + queries = append(queries, r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("cursor") == "next-page" { + _, _ = w.Write([]byte(`{"movies":[],"episodes":[],"pagination":{"next_cursor":null}}`)) + return + } + _, _ = w.Write([]byte(`{"movies":[],"episodes":[],"pagination":{"next_cursor":"next-page"}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + if _, err := p.FetchWatched(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}); err != nil { + t.Fatalf("fetch watched: %v", err) + } + if len(queries) != 2 { + t.Fatalf("got %d requests, want 2", len(queries)) + } + if strings.Contains(queries[0], "offset=") || !strings.Contains(queries[1], "cursor=next-page") { + t.Fatalf("unexpected pagination queries: %#v", queries) + } +} + func TestFetchProgressParsesFlatArray(t *testing.T) { pausedAt := time.Date(2025, time.November, 1, 8, 30, 0, 0, time.UTC) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -241,6 +266,59 @@ func TestExportHistorySendsBulkPayload(t *testing.T) { } } +func TestExportHistoryDoesNotClaimNotFoundBatchWasSent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"added":{"movies":[]},"updated":{},"not_found":{"movies":[{"ids":{"imdb":"tt-missing"}}]}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + plays := []watchsync.LocalPlay{{HistoryID: "h1", Kind: historyimport.KindMovie, IMDbID: "tt-missing"}} + result, err := p.ExportHistory(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, plays) + if err != nil { + t.Fatalf("export: %v", err) + } + if len(result.Sent) != 0 || result.Failed["h1"] == "" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestExportHistoryTreatsEmptyNotFoundBucketsAsSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"added":{"movies":1},"not_found":{"movies":[],"episodes":[]}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + plays := []watchsync.LocalPlay{{HistoryID: "h1", Kind: historyimport.KindMovie, IMDbID: "tt0111161"}} + result, err := p.ExportHistory(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, plays) + if err != nil { + t.Fatalf("export: %v", err) + } + if len(result.Sent) != 1 || len(result.Failed) != 0 { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestExportHistoryReportsUnsupportedItemWithoutCallingMDBList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("MDBList should not be called for an unsupported item") + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + plays := []watchsync.LocalPlay{{HistoryID: "h1", Kind: historyimport.KindMovie}} + result, err := p.ExportHistory(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, plays) + if err != nil { + t.Fatalf("export: %v", err) + } + if len(result.Sent) != 0 || result.Failed["h1"] == "" { + t.Fatalf("unexpected result: %#v", result) + } +} + func TestScrobbleStartUsesEpisodeShape(t *testing.T) { var gotPath string var gotBody map[string]any @@ -270,16 +348,47 @@ func TestScrobbleStartUsesEpisodeShape(t *testing.T) { if progress, _ := gotBody["progress"].(float64); progress != 25 { t.Fatalf("expected progress 25, got %v", gotBody["progress"]) } - if season, _ := gotBody["season"].(float64); int(season) != 3 { - t.Fatalf("expected season 3, got %v", gotBody["season"]) - } show, _ := gotBody["show"].(map[string]any) + if season, _ := show["season"].(float64); int(season) != 3 { + t.Fatalf("expected show season 3, got %v", show["season"]) + } + if episode, _ := show["episode"].(float64); int(episode) != 7 { + t.Fatalf("expected show episode 7, got %v", show["episode"]) + } + if _, exists := gotBody["season"]; exists { + t.Fatalf("season must be nested under show: %#v", gotBody) + } ids, _ := show["ids"].(map[string]any) if ids["imdb"] != "tt0903747" { t.Fatalf("expected show imdb tt0903747, got %v", ids["imdb"]) } } +func TestScrobbleRoundsProgressToMDBListPrecision(t *testing.T) { + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode request: %v", err) + } + w.WriteHeader(http.StatusCreated) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + err := p.Start(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, watchsync.ScrobbleEvent{ + Kind: historyimport.KindMovie, + TMDBID: "950387", + PositionSeconds: 611.8, + DurationSeconds: 2163.4, + }) + if err != nil { + t.Fatalf("scrobble start: %v", err) + } + if got := gotBody["progress"]; got != 28.28 { + t.Fatalf("progress = %v, want 28.28", got) + } +} + func TestExportWatchlistSendsToWatchlistAdd(t *testing.T) { var gotPath string var gotBody map[string]any @@ -315,6 +424,63 @@ func TestExportWatchlistSendsToWatchlistAdd(t *testing.T) { } } +func TestExportWatchlistDoesNotClaimNotFoundBatchWasSent(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"added":1,"existing":0,"not_found":1}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + favorites := []watchsync.LocalFavorite{{MediaItemID: "m1", Kind: historyimport.KindMovie, IMDbID: "tt0111161"}} + result, err := p.ExportWatchlist(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, favorites) + if err != nil { + t.Fatalf("export watchlist: %v", err) + } + if len(result.Sent) != 0 || result.Failed["m1"] == "" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestExportWatchlistReportsUnsupportedItemWithoutCallingMDBList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Fatal("MDBList should not be called for an unsupported item") + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + favorites := []watchsync.LocalFavorite{{MediaItemID: "m1", Kind: historyimport.KindMovie}} + result, err := p.ExportWatchlist(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, favorites) + if err != nil { + t.Fatalf("export watchlist: %v", err) + } + if len(result.Sent) != 0 || result.Failed["m1"] == "" { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestFetchWatchlistUsesCursorPagination(t *testing.T) { + var cursors []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cursors = append(cursors, r.URL.Query().Get("cursor")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("cursor") == "watch-next" { + _, _ = w.Write([]byte(`{"movies":[],"shows":[],"pagination":{"next_cursor":null}}`)) + return + } + _, _ = w.Write([]byte(`{"movies":[],"shows":[],"pagination":{"next_cursor":"watch-next"}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + if _, err := p.FetchWatchlist(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}); err != nil { + t.Fatalf("fetch watchlist: %v", err) + } + if len(cursors) != 2 || cursors[0] != "" || cursors[1] != "watch-next" { + t.Fatalf("unexpected cursors: %#v", cursors) + } +} + func TestDoRetriesShortRateLimitInPlace(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -411,6 +577,21 @@ func TestDoReplaysBodyOnRateLimitRetry(t *testing.T) { } } +func TestDoIncludesMDBListValidationError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":{"progress":["Ensure that there are no more than 5 digits in total."]}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + err := p.do(context.Background(), http.MethodPost, "/scrobble/start", "key", strings.NewReader(`{}`), nil) + if err == nil || !strings.Contains(err.Error(), "no more than 5 digits") { + t.Fatalf("expected validation detail, got %v", err) + } +} + func TestParseRetryAfter(t *testing.T) { now := time.Date(2026, time.July, 5, 12, 0, 0, 0, time.UTC) cases := []struct { From a0851edef051adefe0e0ea71149b03427d888b5e Mon Sep 17 00:00:00 2001 From: Quick104 <31828688+Quick104@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:29:55 -0400 Subject: [PATCH 8/8] fix(watchsync): align MDBList API contracts --- internal/api/handlers/playback_test.go | 6 ++ .../watchsync/providers/mdblist/provider.go | 61 +++++++++---- .../providers/mdblist/provider_test.go | 90 +++++++++++++++++-- 3 files changed, 133 insertions(+), 24 deletions(-) diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 02297f8f..48f93e71 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -492,6 +492,12 @@ func TestFinalizeSessionStop_UsesProviderLifecycleSemantics(t *testing.T) { wantPauseCalls: 1, wantEventPosition: 120, }, + { + name: "system teardown pauses persisted incomplete scrobble", + position: 600, + wantPauseCalls: 1, + wantEventPosition: 600, + }, { name: "completed system teardown stops scrobble", position: 3500, diff --git a/internal/watchsync/providers/mdblist/provider.go b/internal/watchsync/providers/mdblist/provider.go index cf5ca598..bdc4f24c 100644 --- a/internal/watchsync/providers/mdblist/provider.go +++ b/internal/watchsync/providers/mdblist/provider.go @@ -424,17 +424,17 @@ func watchlistRowsFromPayload(providerKey string, payload mdblistWatchlistRespon } func (p *Provider) ExportWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) { - return p.sendWatchlist(ctx, conn, items, "/watchlist/items/add") + return p.sendWatchlist(ctx, conn, items, "/watchlist/items/add", false) } func (p *Provider) RemoveWatchlist(ctx context.Context, _ watchsync.ServerConfig, conn watchsync.Connection, items []watchsync.LocalFavorite) (watchsync.ExportResult, error) { - return p.sendWatchlist(ctx, conn, items, "/watchlist/items/remove") + return p.sendWatchlist(ctx, conn, items, "/watchlist/items/remove", true) } -func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, favorites []watchsync.LocalFavorite, path string) (watchsync.ExportResult, error) { +func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, favorites []watchsync.LocalFavorite, path string, removing bool) (watchsync.ExportResult, error) { payload := buildWatchlistPayload(favorites) if len(payload.Movies) == 0 && len(payload.Shows) == 0 { - return watchlistExportResult(favorites, false), nil + return watchlistExportResult(favorites, false, removing), nil } var body bytes.Buffer if err := json.NewEncoder(&body).Encode(payload); err != nil { @@ -444,13 +444,14 @@ func (p *Provider) sendWatchlist(ctx context.Context, conn watchsync.Connection, if err := p.do(ctx, http.MethodPost, path, conn.AccessToken, &body, &response); err != nil { return watchsync.ExportResult{}, err } - return watchlistExportResult(favorites, response.NotFound > 0), nil + return watchlistExportResult(favorites, !emptyJSONValue(response.NotFound), removing), nil } -func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFound bool) watchsync.ExportResult { +func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFound, removing bool) watchsync.ExportResult { result := watchsync.ExportResult{ - Sent: make([]string, 0, len(favorites)), - Failed: make(map[string]string), + Sent: make([]string, 0, len(favorites)), + NotFound: make([]string, 0, len(favorites)), + Failed: make(map[string]string), } for _, fav := range favorites { if fav.MediaItemID == "" { @@ -461,8 +462,15 @@ func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFo continue } if responseHasNotFound { - // The API returns only an aggregate count, so a partial rejection - // cannot be attributed safely to one favorite. + if removing { + // A successful removal leaves both removed and already-absent items + // absent remotely. MDBList returns only aggregate counts, so mark + // the batch reconciled without trying to attribute individual rows. + result.NotFound = append(result.NotFound, fav.MediaItemID) + continue + } + // Adds still need item-level identity that the aggregate response + // does not provide, so never claim that any item in the batch sent. result.Failed[fav.MediaItemID] = "MDBList did not accept one or more watchlist items in the batch" continue } @@ -471,6 +479,9 @@ func watchlistExportResult(favorites []watchsync.LocalFavorite, responseHasNotFo if len(result.Failed) == 0 { result.Failed = nil } + if len(result.NotFound) == 0 { + result.NotFound = nil + } return result } @@ -758,6 +769,7 @@ type mdblistWatchedResponse struct { type mdblistPagination struct { NextCursor string `json:"next_cursor"` + HasMore bool `json:"has_more"` } type mdblistPageState struct { @@ -779,13 +791,22 @@ func (s mdblistPageState) path(base string) string { func (s *mdblistPageState) advance(pagination *mdblistPagination, fetched int) (bool, error) { if pagination != nil { next := strings.TrimSpace(pagination.NextCursor) - if next == "" { + if next != "" { + if next == s.cursor { + return false, errors.New("provider returned the same cursor twice") + } + s.cursor = next + return false, nil + } + if !pagination.HasMore { return true, nil } - if next == s.cursor { - return false, errors.New("provider returned the same cursor twice") + if fetched == 0 { + return false, errors.New("provider reported more pages without returning items") } - s.cursor = next + s.cursor = "" + s.legacyOffset = true + s.offset += fetched return false, nil } if fetched < syncPageLimit { @@ -804,7 +825,7 @@ type mdblistWatchedWriteResponse struct { } type mdblistWatchlistWriteResponse struct { - NotFound int `json:"not_found"` + NotFound json.RawMessage `json:"not_found"` } func emptyJSONValue(raw json.RawMessage) bool { @@ -1108,9 +1129,13 @@ func buildScrobblePayload(event watchsync.ScrobbleEvent) map[string]any { showIDs = idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID) } payload["show"] = map[string]any{ - "ids": showIDs, - "season": event.SeasonNumber, - "episode": event.EpisodeNumber, + "ids": showIDs, + "season": map[string]any{ + "number": event.SeasonNumber, + "episode": map[string]any{ + "number": event.EpisodeNumber, + }, + }, } default: payload["movie"] = map[string]any{"ids": idsFromLocal(event.IMDbID, event.TMDBID, event.TVDBID)} diff --git a/internal/watchsync/providers/mdblist/provider_test.go b/internal/watchsync/providers/mdblist/provider_test.go index 537779e0..9316e515 100644 --- a/internal/watchsync/providers/mdblist/provider_test.go +++ b/internal/watchsync/providers/mdblist/provider_test.go @@ -148,6 +148,32 @@ func TestFetchWatchedUsesCursorPagination(t *testing.T) { } } +func TestFetchWatchedContinuesOffsetPaginationWhenHasMore(t *testing.T) { + var offsets []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offsets = append(offsets, r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("offset") == "1" { + _, _ = w.Write([]byte(`{"movies":[],"episodes":[],"pagination":{"has_more":false}}`)) + return + } + _, _ = w.Write([]byte(`{"movies":[{"watched_at":"2025-11-01T08:30:00Z","movie":{"ids":{"imdb":"tt0111161"}}}],"episodes":[],"pagination":{"has_more":true}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + rows, err := p.FetchWatched(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}) + if err != nil { + t.Fatalf("fetch watched: %v", err) + } + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1", len(rows)) + } + if len(offsets) != 2 || offsets[0] != "" || offsets[1] != "1" { + t.Fatalf("unexpected offsets: %#v", offsets) + } +} + func TestFetchProgressParsesFlatArray(t *testing.T) { pausedAt := time.Date(2025, time.November, 1, 8, 30, 0, 0, time.UTC) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -349,15 +375,20 @@ func TestScrobbleStartUsesEpisodeShape(t *testing.T) { t.Fatalf("expected progress 25, got %v", gotBody["progress"]) } show, _ := gotBody["show"].(map[string]any) - if season, _ := show["season"].(float64); int(season) != 3 { - t.Fatalf("expected show season 3, got %v", show["season"]) + season, _ := show["season"].(map[string]any) + if number, _ := season["number"].(float64); int(number) != 3 { + t.Fatalf("expected show season number 3, got %v", season["number"]) } - if episode, _ := show["episode"].(float64); int(episode) != 7 { - t.Fatalf("expected show episode 7, got %v", show["episode"]) + episode, _ := season["episode"].(map[string]any) + if number, _ := episode["number"].(float64); int(number) != 7 { + t.Fatalf("expected show episode number 7, got %v", episode["number"]) } if _, exists := gotBody["season"]; exists { t.Fatalf("season must be nested under show: %#v", gotBody) } + if _, exists := show["episode"]; exists { + t.Fatalf("episode must be nested under show season: %#v", show) + } ids, _ := show["ids"].(map[string]any) if ids["imdb"] != "tt0903747" { t.Fatalf("expected show imdb tt0903747, got %v", ids["imdb"]) @@ -397,7 +428,7 @@ func TestExportWatchlistSendsToWatchlistAdd(t *testing.T) { body, _ := io.ReadAll(r.Body) _ = json.Unmarshal(body, &gotBody) w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"added":1,"existing":0,"not_found":0}`)) + _, _ = w.Write([]byte(`{"added":{"movies":1,"shows":1},"existing":{"movies":0,"shows":0},"not_found":{"movies":0,"shows":0}}`)) })) defer server.Close() @@ -427,7 +458,7 @@ func TestExportWatchlistSendsToWatchlistAdd(t *testing.T) { func TestExportWatchlistDoesNotClaimNotFoundBatchWasSent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"added":1,"existing":0,"not_found":1}`)) + _, _ = w.Write([]byte(`{"added":{"movies":0,"shows":0},"existing":{"movies":0,"shows":0},"not_found":{"movies":1,"shows":0}}`)) })) defer server.Close() @@ -442,6 +473,27 @@ func TestExportWatchlistDoesNotClaimNotFoundBatchWasSent(t *testing.T) { } } +func TestRemoveWatchlistTreatsNotFoundBatchAsReconciled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/watchlist/items/remove" { + t.Fatalf("path = %q, want /watchlist/items/remove", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"added":{"movies":0,"shows":0},"existing":{"movies":0,"shows":0},"not_found":{"movies":1,"shows":0}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + favorites := []watchsync.LocalFavorite{{MediaItemID: "m1", Kind: historyimport.KindMovie, IMDbID: "tt0111161"}} + result, err := p.RemoveWatchlist(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}, favorites) + if err != nil { + t.Fatalf("remove watchlist: %v", err) + } + if len(result.NotFound) != 1 || result.NotFound[0] != "m1" || len(result.Sent) != 0 || len(result.Failed) != 0 { + t.Fatalf("unexpected result: %#v", result) + } +} + func TestExportWatchlistReportsUnsupportedItemWithoutCallingMDBList(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { t.Fatal("MDBList should not be called for an unsupported item") @@ -481,6 +533,32 @@ func TestFetchWatchlistUsesCursorPagination(t *testing.T) { } } +func TestFetchWatchlistContinuesOffsetPaginationWhenHasMore(t *testing.T) { + var offsets []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + offsets = append(offsets, r.URL.Query().Get("offset")) + w.Header().Set("Content-Type", "application/json") + if r.URL.Query().Get("offset") == "1" { + _, _ = w.Write([]byte(`{"movies":[],"shows":[],"pagination":{"has_more":false}}`)) + return + } + _, _ = w.Write([]byte(`{"movies":[{"title":"The Shawshank Redemption","release_year":1994,"ids":{"imdb":"tt0111161"}}],"shows":[],"pagination":{"has_more":true}}`)) + })) + defer server.Close() + + p := NewProvider(server.Client(), server.URL) + rows, err := p.FetchWatchlist(context.Background(), watchsync.ServerConfig{}, watchsync.Connection{AccessToken: "k"}) + if err != nil { + t.Fatalf("fetch watchlist: %v", err) + } + if len(rows) != 1 { + t.Fatalf("got %d rows, want 1", len(rows)) + } + if len(offsets) != 2 || offsets[0] != "" || offsets[1] != "1" { + t.Fatalf("unexpected offsets: %#v", offsets) + } +} + func TestDoRetriesShortRateLimitInPlace(t *testing.T) { attempts := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {