Compare commits

...
Author SHA1 Message Date
Cursor AgentandQuick e485b3ab9d fix(jellycompat): stop binding NUL advisory lock keys
#529 stripped NUL from durable playback-session JSON, but
replaceUnstartedNegotiation still joined token/device/item with U+0000
and bound that string as PostgreSQL text. Postgres rejects NUL in text
(SQLSTATE 22021), so every negotiated compat session with a DeviceId
failed to persist and stayed cache-only.

Join the lock key with chr(31) inside PostgreSQL, matching the marker
contribution lock, so the bind parameters stay valid text.

Co-authored-by: Quick <Quick104@users.noreply.github.com>
2026-08-12 18:09:30 +00:00
QuickandGitHub e36612e619 feat(web): improve desktop user settings layout (#615)
- Reorganize settings into task-focused groups with uniform cards
- Move search into the page header and refine desktop detail navigation
- Remove redundant category jump links
2026-08-12 10:44:03 -04:00
QuickandGitHub 75936cdae2 fix(web): show profile avatar in mobile settings link (#614)
- Preserve the profile initial fallback when no avatar is set
- Add an accessible settings label and avatar coverage
2026-08-12 09:34:55 -04:00
QuickandGitHub 1dcdd4b27a fix(web): dedupe multi-library collection options (#601) 2026-08-11 21:12:13 -04:00
QuickandGitHub 2dc7d5e36c fix(playback): honor device audio language selection (#596)
* fix(playback): honor device audio language selection

* fix(playback): harden device preference retries

* fix(playback): preserve device-aware retries
2026-08-11 16:19:30 -04:00
QuickandGitHub 461b51c02c fix(playback): restore V3 subtitle timing and stability (#598)
* fix(playback): restore copy remux timeline anchors

* fix(web): stop refused subtitle replan loops

* fix(web): render embedded text subtitles as sidecars

* fix(web): preserve signed audiobook timeline offsets

* fix(playback): harden V3 remux and subtitle recovery

* fix(playback): anchor audio-only remux timelines
2026-08-11 15:26:07 -04:00
39 changed files with 1546 additions and 663 deletions
+11 -9
View File
@@ -451,17 +451,19 @@ position, `can_seek_anywhere` is true when the runtime is known, and
`seek_restoration` is `player_position` — the client seeks locally.
**Copy remux over HLS** is served from FFmpeg's live, still-growing playlist,
which starts at the requested position. So `player_start` is `0`,
`stream_origin` and `timeline_offset` both equal the seek position,
`seek_window_start_seconds` is that position, and **`seek_window_end_seconds` is
deliberately absent**. An open end marks the window as incomplete; combined with
`can_seek_anywhere: false` it routes every seek back through the server as a
reanchor (§6), which is correct because the playlist has no bytes for positions
FFmpeg has not reached yet. `seek_restoration` is `source_position`.
which starts at the preceding keyframe selected by FFmpeg's input seek.
`stream_origin` and `timeline_offset` both equal that resolved source position,
while `player_start` is the requested position minus the resolved origin so the
client advances past copied pre-roll. `seek_window_start_seconds` is the resolved
origin, and **`seek_window_end_seconds` is deliberately absent**. An open end
marks the window as incomplete; combined with `can_seek_anywhere: false` it
routes every seek back through the server as a reanchor (§6), which is correct
because the playlist has no bytes for positions FFmpeg has not reached yet.
`seek_restoration` is `source_position`.
**Progressive remux** is a freshly generated chunked response with no byte-range
support, so it behaves the same way: player-zero is stream-zero is the seek
position, the window is open-ended, and seeks go through the server.
support, so it uses the same resolved keyframe origin and player pre-roll offset;
the window is open-ended and seeks go through the server.
### `source.duration_seconds`
+3
View File
@@ -9,6 +9,7 @@ import (
)
func requestAccessFilter(r *http.Request) catalog.AccessFilter {
deviceID := deviceMetadataFromRequest(r).DeviceID
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
@@ -17,10 +18,12 @@ func requestAccessFilter(r *http.Request) catalog.AccessFilter {
MaxPlaybackQuality: scope.MaxPlaybackQuality,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
return catalog.AccessFilter{
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -0,0 +1,18 @@
package handlers
import (
"net/http/httptest"
"testing"
)
func TestCatalogAccessFiltersCarryExplicitDeviceIdentity(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/items/movie-1", nil)
req.Header.Set(deviceIDHeader, " apple-tv ")
if got := requestAccessFilter(req).DeviceID; got != "apple-tv" {
t.Fatalf("requestAccessFilter().DeviceID = %q, want apple-tv", got)
}
if got := (&ItemsHandler{}).accessFilter(req).DeviceID; got != "apple-tv" {
t.Fatalf("ItemsHandler.accessFilter().DeviceID = %q, want apple-tv", got)
}
}
+3
View File
@@ -2115,6 +2115,7 @@ func filterSortClause(sort, order string) string {
}
func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
deviceID := deviceMetadataFromRequest(r).DeviceID
selectedFileID := 0
if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" {
if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 {
@@ -2141,6 +2142,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -2168,6 +2170,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
+28 -18
View File
@@ -133,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
@@ -175,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
@@ -615,32 +624,33 @@ func (h *PlaybackHandler) resolveOriginalLanguage(ctx context.Context, file *mod
return lang
}
// resolvedProfileAudioLanguage returns the effective playback.audio_language
// for the profile with no content context, resolved through the settings
// contract — the canonical replacement for reading the legacy
// user_profiles.language column, matching catalog's detail resolution. It may
// return playback.OriginalLanguageSentinel, which the caller resolves to a
// concrete language. Returns "" when nothing is stored: the contract default
// is null, "no preference".
func resolvedProfileAudioLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
if store == nil || profileID == "" {
return ""
// resolvedPlaybackAudioLanguage returns the effective playback.audio_language
// for one canonical settings context. It may return
// playback.OriginalLanguageSentinel, which the caller resolves to a concrete
// language. Returns "" when nothing is stored: the contract default is null,
// "no preference". Resolution and decoding failures are returned so playback
// does not silently substitute a different track.
func resolvedPlaybackAudioLanguage(ctx context.Context, store userstore.UserStore, rc settingsresolve.Context) (string, error) {
if store == nil || rc.ProfileID == "" {
return "", nil
}
contract, err := settingscontract.Load()
if err != nil {
return ""
return "", fmt.Errorf("loading settings contract: %w", err)
}
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
resolved, err := settingsresolve.New(contract).Resolve(ctx, store, rc,
[]string{settingskeys.PlaybackAudioLanguage}, nil)
if err != nil || len(resolved) == 0 {
return ""
if err != nil {
return "", fmt.Errorf("resolving playback audio language: %w", err)
}
if len(resolved) == 0 {
return "", nil
}
var language string
if json.Unmarshal(resolved[0].Value, &language) != nil {
return ""
if err := json.Unmarshal(resolved[0].Value, &language); err != nil {
return "", fmt.Errorf("decoding playback audio language: %w", err)
}
return strings.TrimSpace(language)
return strings.TrimSpace(language), nil
}
// --- Persistence helpers ---
+104 -10
View File
@@ -551,6 +551,8 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
file.AudioTracks = []models.AudioTrack{
{Language: "eng", Codec: "aac", Channels: 2, Layout: "stereo", Default: true},
{Language: "jpn", Codec: "aac", Channels: 2, Layout: "stereo"},
{Language: "spa", Codec: "aac", Channels: 2, Layout: "stereo"},
{Language: "fra", Codec: "aac", Channels: 2, Layout: "stereo"},
}
return file
}
@@ -564,18 +566,49 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
}
}
startPlayback := func(t *testing.T, store userstore.UserStore, file *models.MediaFile) int {
setCanonicalLanguage := func(t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity, language string) {
t.Helper()
identity.Key = settingskeys.PlaybackAudioLanguage
identity.ProfileID = "profile-1"
encoded, err := json.Marshal(language)
if err != nil {
t.Fatalf("encode canonical audio language: %v", err)
}
if _, err := store.UpsertSettingValue(context.Background(), identity, encoded); err != nil {
t.Fatalf("seed canonical audio language: %v", err)
}
}
startPlayback := func(
t *testing.T,
store userstore.UserStore,
file *models.MediaFile,
deviceID string,
mutate func(*playback.StartRequestV3),
) int {
t.Helper()
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.StoreProvider = testUserStoreProvider{store: store}
handler.ItemAccess = allowAllPlaybackItemAccess{}
if file.EpisodeID != "" {
handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: file.EpisodeID, SeriesID: "series-1"}}
}
startRequest := v3HandlerStartRequest()
startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassProgressiveV3] = playback.DeliveryCapabilityV3{Enabled: true, SupportedOnDevice: true}
if mutate != nil {
mutate(&startRequest)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start",
strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
// A client name or user agent is not a stable settings identity. Only
// the explicit device header may select a profile_device row.
req.Header.Set("User-Agent", "SiloTV/apple-tv")
if deviceID != "" {
req.Header.Set(deviceIDHeader, deviceID)
}
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
@@ -598,15 +631,11 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
t.Run("canonical value wins over legacy column", func(t *testing.T) {
store := newPlaybackTestStore(t)
setLegacyLanguage(t, store, "eng")
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(`"ja"`)); err != nil {
t.Fatalf("seed canonical audio language: %v", err)
}
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfile,
}, "ja")
if index := startPlayback(t, store, newFile(t)); index != 1 {
if index := startPlayback(t, store, newFile(t), "", nil); index != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (canonical \"ja\" track)", index)
}
})
@@ -617,10 +646,75 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
// No canonical value stored: the contract default is "no preference",
// so selection falls to the file's default track, not the column's.
if index := startPlayback(t, store, newFile(t)); index != 0 {
if index := startPlayback(t, store, newFile(t), "", nil); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (file default track)", index)
}
})
t.Run("device value applies only to the identified device", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{Scope: settingscontract.ScopeProfile}, "en")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
if index := startPlayback(t, store, newFile(t), "apple-tv", nil); index != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (device \"ja\" track)", index)
}
if index := startPlayback(t, store, newFile(t), "", nil); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (profile \"en\" track without device identity)", index)
}
if index := startPlayback(t, store, newFile(t), "iphone", nil); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (profile \"en\" track on another device)", index)
}
})
t.Run("series and library values outrank device value", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{Scope: settingscontract.ScopeProfile}, "en")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileLibrary,
LibraryID: 12,
}, "es")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileSeries,
SeriesID: "series-1",
}, "fr")
movie := newFile(t)
movie.MediaFolderID = 12
if index := startPlayback(t, store, movie, "apple-tv", nil); index != 2 {
t.Fatalf("AudioTrackIndex = %d, want 2 (library \"es\" track)", index)
}
episode := newFile(t)
episode.MediaFolderID = 12
episode.EpisodeID = "episode-1"
if index := startPlayback(t, store, episode, "apple-tv", nil); index != 3 {
t.Fatalf("AudioTrackIndex = %d, want 3 (series \"fr\" track)", index)
}
})
t.Run("explicit track selection outranks saved settings", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
file := newFile(t)
if index := startPlayback(t, store, file, "apple-tv", func(request *playback.StartRequestV3) {
explicit := 0
request.AudioTrackIndex = &explicit
request.AudioTrackID = playback.TrackIDV3(file.ID, "audio", explicit)
}); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (explicit \"en\" track)", index)
}
})
}
// An omitted start_position asks the server for its resume policy; an explicit
+176 -91
View File
@@ -26,21 +26,23 @@ import (
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/nodepool"
"github.com/Silo-Server/silo-server/internal/playback"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
)
const (
maxPlaybackV3BodyBytes = 256 << 10
maxPlaybackV3EventBodyBytes = 32 << 10
replanLeaseDurationV3 = 15 * time.Second
replanReleaseTimeoutV3 = 3 * time.Second
v3NodeCapabilityTTL = time.Minute
playbackNodeIntegratedV3 = "integrated"
subtitleFormatVTTV3 = "vtt"
subtitleMIMEVTTV3 = "text/vtt"
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
seekRestorationPlayerV3 = "player_position"
maxPlaybackV3BodyBytes = 256 << 10
maxPlaybackV3EventBodyBytes = 32 << 10
replanLeaseDurationV3 = 15 * time.Second
replanReleaseTimeoutV3 = 3 * time.Second
v3NodeCapabilityTTL = time.Minute
playbackNodeIntegratedV3 = "integrated"
subtitleFormatVTTV3 = "vtt"
subtitleMIMEVTTV3 = "text/vtt"
subtitleUnavailableReasonV3 = "subtitle_artifact_unavailable"
transcodeStartFailedReasonV3 = "transcode_start_failed"
seekRestorationPlayerV3 = "player_position"
// Failed capability fetches are memoized briefly so an unreachable node
// costs one timeout per window instead of one per planning request.
v3NodeCapabilityErrorTTL = 15 * time.Second
@@ -68,6 +70,13 @@ type preparedTransportV3 struct {
afterDurableCommit func()
}
type preparedTimelineV3 struct {
seekSeconds float64
streamOriginSeconds float64
startSegmentNumber int
copySeekAnchorResolved bool
}
type transportErrorV3 struct {
reason string
message string
@@ -354,11 +363,11 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
return
}
userID := apimw.GetUserID(r.Context())
digestBytes := sha256.Sum256(body)
requestDigest := hex.EncodeToString(digestBytes[:])
deviceID := deviceMetadataFromRequest(r).DeviceID
requestDigests := newPlaybackStartRequestDigestsV3(body, deviceID)
if existing, lookupErr := h.PlanStoreV3.GetAttemptByPlaybackAttemptID(r.Context(), req.PlaybackAttemptID); lookupErr == nil {
if existing.UserID != userID || existing.ProfileID != profileID || existing.RequestedMediaFileID != req.FileID ||
(existing.RequestDigest != "" && existing.RequestDigest != requestDigest) {
!requestDigests.matches(existing.RequestDigest) {
writeError(w, http.StatusConflict, "playback_attempt_reused", "The playback attempt ID belongs to a different request")
return
}
@@ -396,7 +405,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
return
}
if req.AudioTrackID == "" && req.AudioTrackIndex == nil {
audioIndex, err = h.preferredAudioTrackIndexV3(r.Context(), userID, profileID, requestedFile)
audioIndex, err = h.preferredAudioTrackIndexV3(r.Context(), userID, profileID, deviceID, requestedFile)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to load the saved audio preference")
return
@@ -426,7 +435,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
effectiveFile = h.ensurePlaybackProbe(r.Context(), alternate)
audioIndex = remapAudioIndexV3(requestedFile, effectiveFile, audioIndex)
if err := h.remapSubtitleSelectionV3(r.Context(), requestedFile, effectiveFile, &req); err != nil {
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3("subtitle_unavailable_in_version", err.Error(), false))
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3("subtitle_unavailable_in_version", err.Error(), false))
if persistErr != nil {
writeStartAttemptPersistenceErrorV3(w, persistErr)
return
@@ -449,7 +458,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
"file_id", effectiveFile.ID,
"quality_preference", req.QualityPreference,
)
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable))
response, persistErr := h.persistTerminalStartDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, playback.NewTerminalResponseV3(result.Terminal.Reason, result.Terminal.Message, result.Terminal.Retryable))
if persistErr != nil {
writeStartAttemptPersistenceErrorV3(w, persistErr)
return
@@ -478,7 +487,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
"bandwidth_estimate_kbps", intOrZeroHandlerV3(req.BandwidthEstimateKbps),
)
result.Plan.DegradationWarnings = append(result.Plan.DegradationWarnings, warnings...)
response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigest, requestedFile, effectiveFile, audioIndex, result)
response, statusErr := h.startPlannedPlaybackV3(r, userID, profileID, req, requestDigests, requestedFile, effectiveFile, audioIndex, result)
if statusErr != nil {
if statusErr.reason == "playback_attempt_reused" {
writeError(w, http.StatusConflict, "playback_attempt_reused", statusErr.message)
@@ -487,7 +496,7 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
if statusErr.reason == "internal_error" {
slog.ErrorContext(r.Context(), "protocol v3 start failed", "component", "api", "reason", statusErr.reason, "error", statusErr.cause)
}
persistedResponse, persistErr := h.startFailureDecisionV3(r.Context(), userID, profileID, req, requestDigest, requestedFile.ID, effectiveFile.ID, statusErr)
persistedResponse, persistErr := h.startFailureDecisionV3(r.Context(), userID, profileID, req, requestDigests, requestedFile.ID, effectiveFile.ID, statusErr)
if persistErr != nil {
writeStartAttemptPersistenceErrorV3(w, persistErr)
return
@@ -498,7 +507,32 @@ func (h *PlaybackHandler) handleStartPlaybackV3(w http.ResponseWriter, r *http.R
writeJSON(w, http.StatusCreated, response)
}
func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3) (playback.DecisionResponseV3, *transportErrorV3) {
type playbackStartRequestDigestsV3 struct {
current string
legacy string
}
// newPlaybackStartRequestDigestsV3 fingerprints both the body and normalized
// device identity because either can change the selected playback plan. It
// also retains the pre-device digest while attempts written by an older
// server can still be replayed during a rolling deployment.
func newPlaybackStartRequestDigestsV3(body []byte, deviceID string) playbackStartRequestDigestsV3 {
hasher := sha256.New()
_, _ = fmt.Fprintf(hasher, "%d:", len(body))
_, _ = hasher.Write(body)
_, _ = hasher.Write([]byte(deviceID))
legacy := sha256.Sum256(body)
return playbackStartRequestDigestsV3{
current: hex.EncodeToString(hasher.Sum(nil)),
legacy: hex.EncodeToString(legacy[:]),
}
}
func (d playbackStartRequestDigestsV3) matches(stored string) bool {
return stored == "" || stored == d.current || stored == d.legacy
}
func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFile, effectiveFile *models.MediaFile, audioIndex int, result playback.PlannerResultV3) (playback.DecisionResponseV3, *transportErrorV3) {
if result.Plan == nil {
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "The server produced no playback plan."}
}
@@ -566,7 +600,7 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
return playback.DecisionResponseV3{}, subtitleArtifactErrorV3("Failed to prepare the selected subtitle artifact.", err)
}
response := playback.DecisionResponseV3{ProtocolVersion: playback.ProtocolV3, ServerFeatures: playback.ServerFeaturesV3(), Outcome: playback.OutcomePlayableV3, SessionID: session.ID, PlaybackPlan: result.Plan}
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, StartResponse: response, RequestDigest: requestDigest, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
record := playback.AttemptRecordV3{PlaybackAttemptID: req.PlaybackAttemptID, SessionID: session.ID, UserID: userID, ProfileID: profileID, RequestedMediaFileID: requestedFile.ID, EffectiveMediaFileID: effectiveFile.ID, CurrentPlanID: result.Plan.PlanID, CurrentPlan: *result.Plan, FrozenRecipe: frozenRecipe, NormalizedRequest: req, StartResponse: response, RequestDigest: requestDigests.current, ExpiresAt: time.Now().Add(playback.MaxTokenTTL)}
if err := h.updateV3SessionState(r.Context(), session, effectiveFile, result, transport); err != nil {
transport.rollback()
abort()
@@ -575,12 +609,9 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
if err := h.PlanStoreV3.SaveAttempt(r.Context(), record); err != nil {
transport.rollback()
abort()
if errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "playback_attempt_reused", message: "The playback attempt ID was reused with different input."}
}
if errors.Is(err, playback.ErrPlaybackAttemptExistsV3) {
if errors.Is(err, playback.ErrPlaybackAttemptExistsV3) || errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
existing, lookupErr := h.PlanStoreV3.GetAttemptByPlaybackAttemptID(r.Context(), req.PlaybackAttemptID)
if lookupErr == nil && existing.UserID == userID && existing.ProfileID == profileID && existing.RequestedMediaFileID == req.FileID {
if lookupErr == nil && existing.UserID == userID && existing.ProfileID == profileID && existing.RequestedMediaFileID == req.FileID && requestDigests.matches(existing.RequestDigest) {
// Replaying a concurrent duplicate is only valid while its
// session is alive; otherwise tell the client to mint a new
// attempt rather than hand it a plan it can never stream.
@@ -589,6 +620,9 @@ func (h *PlaybackHandler) startPlannedPlaybackV3(r *http.Request, userID int, pr
}
return decisionResponseFromAttemptV3(existing), nil
}
if errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "playback_attempt_reused", message: "The playback attempt ID was reused with different input."}
}
}
return playback.DecisionResponseV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to persist the playback plan.", cause: err}
}
@@ -633,8 +667,12 @@ func (h *PlaybackHandler) persistSeriesSelectionsV3(ctx context.Context, userID
}
func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTransportV3, *transportErrorV3) {
timeline, timelineErr := h.prepareTransportTimelineV3(r.Context(), session, file, result)
if timelineErr != nil {
return preparedTransportV3{}, timelineErr
}
if result.Plan.Delivery != playback.DeliveryTranscodeHLSV3 && result.Plan.Delivery != playback.DeliveryRemuxHLSV3 {
return h.prepareIdentityTransportV3(session, result), nil
return h.prepareIdentityTransportV3(session, result, timeline), nil
}
if h.NodePlanner != nil {
plan := h.planNodeSessionV3(r.Context(), session, result)
@@ -644,7 +682,7 @@ func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.
err = validateAdvertisedTransformationsV3(result.Plan, transformations)
}
if err == nil {
transport, transportErr := h.prepareRemoteTransportV3(r, session, file, result, plan)
transport, transportErr := h.prepareRemoteTransportV3(r, session, file, result, plan, timeline)
if transportErr != nil {
if releaser, ok := h.NodePlanner.(sessionReservationReleaserV3); ok {
releaser.ReleaseSession(session.ID)
@@ -674,7 +712,54 @@ func (h *PlaybackHandler) prepareTransportV3(r *http.Request, session *playback.
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_node_capability_unavailable", message: "No available transcode executor can run the selected playback recipe.", retryable: true, cause: err}
}
}
return h.prepareLocalTransportV3(r, session, file, result)
return h.prepareLocalTransportV3(r, session, file, result, timeline)
}
func (h *PlaybackHandler) prepareTransportTimelineV3(ctx context.Context, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTimelineV3, *transportErrorV3) {
if result.Plan == nil {
return preparedTimelineV3{}, nil
}
requested := result.Plan.Timeline.SourceStartSeconds
switch result.Plan.Delivery {
case playback.DeliveryRemuxProgressiveV3, playback.DeliveryRemuxHLSV3:
// Audio-only remuxes have no copied video keyframe to resolve. Keep the
// requested position as their exact stream origin: the chunked output
// clock restarts at zero and later seeks require another server reanchor.
if file != nil && file.IsAudioOnly() {
configureCopyRemuxTimelineV3(result.Plan, requested)
return preparedTimelineV3{seekSeconds: requested, streamOriginSeconds: requested}, nil
}
origin, startSegment := 0.0, 0
if requested > 0 {
if file == nil || strings.TrimSpace(file.FilePath) == "" {
return preparedTimelineV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to resolve remux seek position.", retryable: true, cause: errors.New("copy seek anchor requires a media file path")}
}
resolver := h.copySeekAnchor
if resolver == nil {
resolver = playback.ResolveCopySeekAnchor
}
var err error
origin, startSegment, err = resolver(ctx, h.playbackConfig().FFmpegPath, file.FilePath, requested, 2)
if err != nil {
slog.ErrorContext(ctx, "failed to resolve protocol v3 copy-video seek anchor",
"component", "api",
"playback_session_id", session.ID,
"requested_seek_seconds", requested,
"error", err,
)
return preparedTimelineV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to resolve remux seek position.", retryable: true, cause: err}
}
}
configureCopyRemuxTimelineV3(result.Plan, origin)
return preparedTimelineV3{seekSeconds: requested, streamOriginSeconds: origin, startSegmentNumber: startSegment, copySeekAnchorResolved: true}, nil
case playback.DeliveryTranscodeHLSV3:
sourceMetadata := sourceExecutionMetadataV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, result.TargetVideoCodec, 2, sourceMetadata.DurationSeconds)
return preparedTimelineV3{seekSeconds: seekSeconds, streamOriginSeconds: result.Plan.Timeline.StreamOriginSeconds, startSegmentNumber: startSegment}, nil
default:
return preparedTimelineV3{}, nil
}
}
// planRequiresServerTransformationsV3 reports whether the plan carries any
@@ -692,7 +777,7 @@ func planRequiresServerTransformationsV3(plan *playback.PlanV3) bool {
return false
}
func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session, result playback.PlannerResultV3) preparedTransportV3 {
func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session, result playback.PlannerResultV3, timeline preparedTimelineV3) preparedTransportV3 {
routeSession := *session
routeSession.PlayMethod = result.PlayMethod
routeSession.BasePlayMethod = result.PlayMethod
@@ -709,8 +794,7 @@ func (h *PlaybackHandler) prepareIdentityTransportV3(session *playback.Session,
committed := false
streamURL := h.playbackStreamURL(&routeSession)
if result.Plan != nil && result.Plan.Delivery == playback.DeliveryRemuxProgressiveV3 {
configureProgressiveRemuxTimelineV3(result.Plan)
if seek := result.Plan.Timeline.StreamOriginSeconds; seek > 0 {
if seek := timeline.seekSeconds; seek > 0 {
streamURL = appendPlaybackQueryV3(streamURL, "seek", strconv.FormatFloat(seek, 'f', -1, 64))
}
}
@@ -756,14 +840,14 @@ func sessionOwnsResumeTimelineV3(file *models.MediaFile) bool {
}
// preferredAudioTrackIndexV3 answers what an omitted audio track means: the
// language this profile has settled on for this series, this library, or
// generally — the same resolution the catalog performs when it publishes
// `effective_audio_track_index`, so the track a client sees on the detail page
// is the track that plays when it does not ask for one.
// language this profile has settled on for this series, this library, this
// device, or generally — the same resolution the catalog performs when it
// publishes `effective_audio_track_index`, so the track a client sees on the
// detail page is the track that plays when it does not ask for one.
//
// The client sends a track identity only when the viewer picked one. Defaulting
// to ordinal zero instead would silently play the first track on the reel.
func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID int, profileID string, file *models.MediaFile) (int, error) {
func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID int, profileID, deviceID string, file *models.MediaFile) (int, error) {
if file == nil || len(file.AudioTracks) == 0 || h.StoreProvider == nil {
return 0, nil
}
@@ -772,8 +856,9 @@ func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID
slog.ErrorContext(ctx, "protocol v3 start: audio preference store lookup failed", "component", "api", "user_id", userID, "error", err)
return 0, err
}
seriesID := h.resolveSeriesID(ctx, file)
var seriesPref *playback.AudioTrackPreference
if seriesID := h.resolveSeriesID(ctx, file); seriesID != "" {
if seriesID != "" {
stored, prefErr := store.GetAudioPreference(ctx, profileID, seriesID)
if prefErr != nil {
slog.ErrorContext(ctx, "protocol v3 start: series audio preference lookup failed", "component", "api", "profile_id", profileID, "series_id", seriesID, "error", prefErr)
@@ -781,36 +866,38 @@ func (h *PlaybackHandler) preferredAudioTrackIndexV3(ctx context.Context, userID
}
if stored != nil {
seriesPref = &playback.AudioTrackPreference{AudioTrackIndex: stored.AudioTrackIndex, AudioLanguage: stored.AudioLanguage, TrackSignature: stored.TrackSignature}
if seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
seriesPref.AudioLanguage = h.resolveOriginalLanguage(ctx, file)
}
}
rc := settingsresolve.Context{
ProfileID: profileID,
DeviceID: deviceID,
LibraryIDs: []int{file.MediaFolderID},
}
if seriesID != "" {
rc.SeriesIDs = []string{seriesID}
}
preferredLang, err := resolvedPlaybackAudioLanguage(ctx, store, rc)
if err != nil {
slog.ErrorContext(ctx, "protocol v3 start: canonical audio preference lookup failed", "component", "api", "profile_id", profileID, "device_id", deviceID, "error", err)
return 0, err
}
if preferredLang == playback.OriginalLanguageSentinel {
preferredLang = h.resolveOriginalLanguage(ctx, file)
if preferredLang == "" {
preferredLang, err = resolvedPlaybackAudioLanguage(ctx, store, settingsresolve.Context{ProfileID: profileID})
if err != nil {
slog.ErrorContext(ctx, "protocol v3 start: profile audio preference fallback failed", "component", "api", "profile_id", profileID, "error", err)
return 0, err
}
if preferredLang == playback.OriginalLanguageSentinel {
preferredLang = h.resolveOriginalLanguage(ctx, file)
}
}
}
preferredLang := resolvedProfileAudioLanguage(ctx, store, profileID)
// A library override applies only where no series-sticky choice exists;
// having watched this series in a language outranks the library default.
libraryLang := ""
if seriesPref == nil {
stored, prefErr := store.GetLibraryPlaybackPreference(ctx, profileID, file.MediaFolderID)
if prefErr != nil {
slog.ErrorContext(ctx, "protocol v3 start: library audio preference lookup failed", "component", "api", "profile_id", profileID, "library_id", file.MediaFolderID, "error", prefErr)
return 0, prefErr
}
if stored != nil {
libraryLang = stored.AudioLanguage
}
}
if preferredLang == playback.OriginalLanguageSentinel || libraryLang == playback.OriginalLanguageSentinel {
originalLang := h.resolveOriginalLanguage(ctx, file)
if preferredLang == playback.OriginalLanguageSentinel {
preferredLang = originalLang
}
if libraryLang == playback.OriginalLanguageSentinel {
libraryLang = originalLang
}
}
if libraryLang != "" {
preferredLang = libraryLang
if seriesPref != nil {
// The specialized row supplies concrete track identity; canonical
// settings own the language and its scope precedence.
seriesPref.AudioLanguage = preferredLang
}
return normalizeAudioTrackIndex(file, playback.SelectAudioTrack(file.AudioTracks, preferredLang, seriesPref)), nil
}
@@ -849,16 +936,16 @@ func (h *PlaybackHandler) resumePositionV3(ctx context.Context, userID int, prof
return &position, nil
}
// A progressive remux is a freshly generated, chunked MP4 response and does
// not implement byte ranges. Its player clock therefore begins at zero at the
// requested source origin, and arbitrary seeks must request another server
// reanchor rather than issuing a Range request against the remux pipe.
func configureProgressiveRemuxTimelineV3(plan *playback.PlanV3) {
// A copy remux starts at the preceding keyframe selected by the demuxer, not
// necessarily at the requested source position. Its player clock therefore
// begins at the resolved stream origin and advances through the copied pre-roll
// before reaching the requested position. Neither progressive nor growing HLS
// copy transports can seek arbitrarily inside their current response.
func configureCopyRemuxTimelineV3(plan *playback.PlanV3, origin float64) {
if plan == nil {
return
}
origin := plan.Timeline.SourceStartSeconds
plan.Timeline.PlayerStartSeconds = 0
plan.Timeline.PlayerStartSeconds = max(0, plan.Timeline.SourceStartSeconds-origin)
plan.Timeline.StreamOriginSeconds = origin
plan.Timeline.TimelineOffsetSeconds = origin
plan.Timeline.SeekWindowStartSeconds = &origin
@@ -875,7 +962,7 @@ func appendPlaybackQueryV3(rawURL, key, value string) string {
return rawURL + separator + key + "=" + value
}
func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3) (preparedTransportV3, *transportErrorV3) {
func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, timeline preparedTimelineV3) (preparedTransportV3, *transportErrorV3) {
cfg := h.playbackConfig()
if err := os.MkdirAll(cfg.TranscodeDir, 0o755); err != nil {
return preparedTransportV3{}, &transportErrorV3{reason: "internal_error", message: "Failed to prepare the transcode directory.", cause: err}
@@ -888,13 +975,12 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
}
sourceMetadata := sourceExecutionMetadataV3(file, result)
sourceProfile, sourceBitDepth := sourceVideoTranscodeFactsV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceMetadata.DurationSeconds)
unlock := h.tm.LockSessionLifecycle(session.ID)
opts := playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink}
opts := playback.TranscodeOpts{InputPath: file.FilePath, OutputDir: outputDir, OutputSubdir: outputSubdir, SessionID: session.ID, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, FFmpegPath: cfg.FFmpegPath, HWAccel: cfg.HWAccel, HWDevice: cfg.HWDevice, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, FastStart: true, NodeType: playbackNodeIntegratedV3, ExecutionMode: playbackNodeIntegratedV3, FFmpegLogSink: h.FFmpegLogSink}
ts, err := h.startLocalPlaybackTransport(r.Context(), opts)
if err != nil {
unlock()
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to start the playback transport.", retryable: true, cause: err}
}
if _, readyErr := ts.WaitForManifest(playback.ManifestStartupTimeout); readyErr != nil {
wasRunning := ts.IsRunning()
@@ -921,7 +1007,7 @@ func (h *PlaybackHandler) prepareLocalTransportV3(r *http.Request, session *play
ts, err = h.startLocalPlaybackTransport(r.Context(), retryOpts)
if err != nil {
unlock()
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "Failed to start the playback transport.", retryable: true, cause: err}
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "Failed to start the playback transport.", retryable: true, cause: err}
}
if _, retryReadyErr := ts.WaitForManifest(playback.ManifestStartupTimeout); retryReadyErr != nil {
transportErr = manifestStartupTransportErrorV3(ts.IsRunning(), retryReadyErr)
@@ -973,10 +1059,10 @@ func manifestStartupTransportErrorV3(running bool, cause error) *transportErrorV
if running {
message = "The playback transport did not become ready in time."
}
return &transportErrorV3{reason: "transcode_start_failed", message: message, retryable: running, cause: cause}
return &transportErrorV3{reason: transcodeStartFailedReasonV3, message: message, retryable: running, cause: cause}
}
func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, nodePlan nodepool.Plan) (preparedTransportV3, *transportErrorV3) {
func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *playback.Session, file *models.MediaFile, result playback.PlannerResultV3, nodePlan nodepool.Plan, timeline preparedTimelineV3) (preparedTransportV3, *transportErrorV3) {
node := nodePlan.TranscodeNode
transportID := transportGenerationV3(session.ID, result.Plan.PlanID)
videoCodec := result.TargetVideoCodec
@@ -985,8 +1071,7 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
}
sourceMetadata := sourceExecutionMetadataV3(file, result)
sourceProfile, sourceBitDepth := sourceVideoTranscodeFactsV3(file, result)
seekSeconds, startSegment := configureHLSTimelineV3(result.Plan, videoCodec, 2, sourceMetadata.DurationSeconds)
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: seekSeconds, StartSegmentNumber: startSegment, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, RequireReady: true}
req := transcodenode.TranscodeStartRequest{SessionID: transportID, InputPath: file.FilePath, SourceVideoCodec: sourceMetadata.VideoCodec, SourceVideoProfile: sourceProfile, SourceVideoBitDepth: sourceBitDepth, SoftwareVideoDecode: sourceMetadata.SoftwareVideoDecode, VideoBitstreamFilter: videoBitstreamFilterForPlanV3(result.Plan), SeekSeconds: timeline.seekSeconds, StreamOriginSeconds: timeline.streamOriginSeconds, CopySeekAnchorResolved: timeline.copySeekAnchorResolved, StartSegmentNumber: timeline.startSegmentNumber, TargetResolution: result.TargetResolution, TargetCodecVideo: videoCodec, TargetCodecAudio: result.TargetAudioCodec, TargetAudioChannels: result.TargetAudioChannels, TargetAudioBitrateKbps: result.TargetAudioBitrateKbps, TargetBitrateKbps: result.TargetBitrateKbps, SegmentDuration: 2, HWAccel: h.playbackConfig().HWAccel, AudioTrackIndex: plannedAudioTrackIndexV3(result, session.AudioTrackIndex), SubtitleTrackIndex: result.SubtitleTransportTrackIndex, SubtitleBurnIn: result.SubtitleBurnIn, SubtitleCodec: result.SubtitleCodec, TotalDuration: sourceMetadata.DurationSeconds, RequireReady: true}
nodeResp, status, err := h.startRemotePlaybackTransport(r.Context(), node.URL, req)
if err != nil {
// A timeout can fire after the node actually started the job; the
@@ -997,10 +1082,10 @@ func (h *PlaybackHandler) prepareRemoteTransportV3(r *http.Request, session *pla
}
if status != http.StatusAccepted {
h.tm.StopRemoteTranscode(transportID, node.URL)
return preparedTransportV3{}, &transportErrorV3{reason: "transcode_start_failed", message: "The selected transcode node rejected the playback transport.", retryable: true}
return preparedTransportV3{}, &transportErrorV3{reason: transcodeStartFailedReasonV3, message: "The selected transcode node rejected the playback transport.", retryable: true}
}
hw := firstNonEmptyHandlerV3(strings.TrimSpace(nodeResp.HWAccel), strings.TrimSpace(req.HWAccel))
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, node.URL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SoftwareVideoDecode: req.SoftwareVideoDecode, VideoBitstreamFilter: req.VideoBitstreamFilter, SeekSeconds: req.SeekSeconds, StartSegmentNumber: req.StartSegmentNumber, TargetResolution: req.TargetResolution, TargetCodecVideo: req.TargetCodecVideo, TargetCodecAudio: req.TargetCodecAudio, TargetAudioChannels: req.TargetAudioChannels, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration})
card := playback.NewRecipeCard(session.UserID, session.ProfileID, file.ID, node.URL, playback.TranscodeOpts{InputPath: req.InputPath, SessionID: session.ID, TranscodeTransportID: transportID, SourceVideoCodec: req.SourceVideoCodec, SourceVideoProfile: req.SourceVideoProfile, SourceVideoBitDepth: req.SourceVideoBitDepth, SoftwareVideoDecode: req.SoftwareVideoDecode, 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, TargetAudioBitrateKbps: req.TargetAudioBitrateKbps, TargetBitrateKbps: req.TargetBitrateKbps, SegmentDuration: req.SegmentDuration, HWAccel: hw, AudioTrackIndex: req.AudioTrackIndex, SubtitleTrackIndex: req.SubtitleTrackIndex, SubtitleBurnIn: req.SubtitleBurnIn, SubtitleCodec: req.SubtitleCodec, TotalDuration: req.TotalDuration})
url := h.buildProxyManifestURL(card, nodePlan.ProxyNode)
committed := false
previousNodeURL := session.TranscodeNodeURL
@@ -2621,7 +2706,7 @@ func sessionStartErrorV3(err error) *transportErrorV3 {
}
}
func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFileID, effectiveFileID int, response playback.DecisionResponseV3) (playback.DecisionResponseV3, error) {
func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFileID, effectiveFileID int, response playback.DecisionResponseV3) (playback.DecisionResponseV3, error) {
record := playback.AttemptRecordV3{
PlaybackAttemptID: req.PlaybackAttemptID,
UserID: userID,
@@ -2630,12 +2715,12 @@ func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, us
EffectiveMediaFileID: effectiveFileID,
NormalizedRequest: req,
StartResponse: response,
RequestDigest: requestDigest,
RequestDigest: requestDigests.current,
ExpiresAt: time.Now().Add(playback.MaxTokenTTL),
}
if err := h.PlanStoreV3.SaveAttempt(ctx, record); err == nil {
return response, nil
} else if !errors.Is(err, playback.ErrPlaybackAttemptExistsV3) {
} else if !errors.Is(err, playback.ErrPlaybackAttemptExistsV3) && !errors.Is(err, playback.ErrIdempotencyKeyReusedV3) {
return playback.DecisionResponseV3{}, err
}
@@ -2644,15 +2729,15 @@ func (h *PlaybackHandler) persistTerminalStartDecisionV3(ctx context.Context, us
return playback.DecisionResponseV3{}, err
}
if existing.UserID != userID || existing.ProfileID != profileID ||
existing.RequestedMediaFileID != requestedFileID || existing.RequestDigest != requestDigest {
existing.RequestedMediaFileID != requestedFileID || !requestDigests.matches(existing.RequestDigest) {
return playback.DecisionResponseV3{}, playback.ErrIdempotencyKeyReusedV3
}
return decisionResponseFromAttemptV3(existing), nil
}
func (h *PlaybackHandler) startFailureDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigest string, requestedFileID, effectiveFileID int, failure *transportErrorV3) (playback.DecisionResponseV3, error) {
func (h *PlaybackHandler) startFailureDecisionV3(ctx context.Context, userID int, profileID string, req playback.StartRequestV3, requestDigests playbackStartRequestDigestsV3, requestedFileID, effectiveFileID int, failure *transportErrorV3) (playback.DecisionResponseV3, error) {
response := playback.NewTerminalResponseV3(failure.reason, failure.message, failure.retryable)
return h.persistTerminalStartDecisionV3(ctx, userID, profileID, req, requestDigest, requestedFileID, effectiveFileID, response)
return h.persistTerminalStartDecisionV3(ctx, userID, profileID, req, requestDigests, requestedFileID, effectiveFileID, response)
}
func writeStartAttemptPersistenceErrorV3(w http.ResponseWriter, err error) {
@@ -2884,12 +2969,12 @@ func configureHLSTimelineV3(plan *playback.PlanV3, videoCodec string, segmentDur
seek := alignedSeekSeconds(requested, segmentDuration, videoCodec)
startSegment := computeStartSegment(seek, segmentDuration)
plan.Timeline.SourceStartSeconds = requested
usesGrowingManifest := strings.EqualFold(videoCodec, "copy") ||
!playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
usesGrowingManifest := !playback.CanGenerateSyntheticManifest(durationSeconds, segmentDuration)
if usesGrowingManifest {
// Encoded streams seek to the preceding segment boundary. Preserve the
// requested sub-segment offset so playback still begins at the exact
// requested source position. Copy seeks are already exact, making this 0.
// requested source position. Copy remuxes are configured separately with
// their probed keyframe origin.
plan.Timeline.PlayerStartSeconds = max(0, requested-seek)
plan.Timeline.StreamOriginSeconds = seek
plan.Timeline.TimelineOffsetSeconds = seek
+248 -22
View File
@@ -3,6 +3,8 @@ package handlers
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -49,12 +51,12 @@ func (s failingProgressStoreV3) GetProgress(context.Context, string, string) (*u
return nil, s.err
}
type failingLibraryPlaybackPreferenceStoreV3 struct {
type failingSettingsResolutionStoreV3 struct {
userstore.UserStore
err error
}
func (s failingLibraryPlaybackPreferenceStoreV3) GetLibraryPlaybackPreference(context.Context, string, int) (*userstore.LibraryPlaybackPreference, error) {
func (s failingSettingsResolutionStoreV3) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
return nil, s.err
}
@@ -311,6 +313,86 @@ func TestHandleStartPlaybackV3CommitsStartSideEffectsOnce(t *testing.T) {
}
}
func TestHandleStartPlaybackV3RejectsAttemptReplayFromAnotherDevice(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
body := marshalV3StartRequest(t, v3HandlerStartRequest())
start := func(deviceID string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(body)).WithContext(newAuthorizedPlaybackContext())
req.Header.Set(deviceIDHeader, deviceID)
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
return rr
}
first := start(" living-room ")
sameDevice := start("living-room")
otherDevice := start("bedroom")
if first.Code != http.StatusCreated || sameDevice.Code != http.StatusCreated {
t.Fatalf("same-device statuses = %d, %d; first=%s replay=%s", first.Code, sameDevice.Code, first.Body.String(), sameDevice.Body.String())
}
if first.Body.String() != sameDevice.Body.String() {
t.Fatalf("normalized same-device replay changed response:\nfirst=%s\nreplay=%s", first.Body.String(), sameDevice.Body.String())
}
if otherDevice.Code != http.StatusConflict || !strings.Contains(otherDevice.Body.String(), "playback_attempt_reused") {
t.Fatalf("other-device status = %d, body = %s", otherDevice.Code, otherDevice.Body.String())
}
if got := len(manager.AllSessions()); got != 1 {
t.Fatalf("sessions = %d, want 1", got)
}
}
func TestHandleStartPlaybackV3AcceptsPreDeviceDigestReplay(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
body := marshalV3StartRequest(t, startRequest)
start := func(requestBody, deviceID string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(requestBody)).WithContext(newAuthorizedPlaybackContext())
req.Header.Set(deviceIDHeader, deviceID)
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
return rr
}
first := start(body, "living-room")
if first.Code != http.StatusCreated {
t.Fatalf("first status = %d, body = %s", first.Code, first.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID)
if err != nil {
t.Fatal(err)
}
legacyDigest := sha256.Sum256([]byte(body))
record.RequestDigest = hex.EncodeToString(legacyDigest[:])
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
replay := start(body, "living-room")
if replay.Code != http.StatusCreated || replay.Body.String() != first.Body.String() {
t.Fatalf("legacy replay status = %d, body = %s; first = %s", replay.Code, replay.Body.String(), first.Body.String())
}
changedRequest := startRequest
position := 42.0
changedRequest.StartPosition = &position
conflict := start(marshalV3StartRequest(t, changedRequest), "living-room")
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "playback_attempt_reused") {
t.Fatalf("changed legacy replay status = %d, body = %s", conflict.Code, conflict.Body.String())
}
}
func TestHandlePlaybackCapabilityV3AdvertisesTheFinalizedContract(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
@@ -530,22 +612,21 @@ func TestPreferredAudioTrackIndexV3PropagatesSeriesPreferenceReadFailure(t *test
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
}
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", file); !errors.Is(err, wantErr) {
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", "", file); !errors.Is(err, wantErr) {
t.Fatalf("preferredAudioTrackIndexV3 error = %v, want %v", err, wantErr)
}
}
func TestPreferredAudioTrackIndexV3PropagatesLibraryPreferenceReadFailure(t *testing.T) {
wantErr := errors.New("library playback preference store unavailable")
store := failingLibraryPlaybackPreferenceStoreV3{UserStore: newPlaybackTestStore(t), err: wantErr}
func TestPreferredAudioTrackIndexV3PropagatesCanonicalPreferenceReadFailure(t *testing.T) {
wantErr := errors.New("settings resolution unavailable")
store := failingSettingsResolutionStoreV3{UserStore: newPlaybackTestStore(t), err: wantErr}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.StoreProvider = testUserStoreProvider{store: store}
file := &models.MediaFile{
MediaFolderID: 9,
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
}
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", file); !errors.Is(err, wantErr) {
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", "living-room", file); !errors.Is(err, wantErr) {
t.Fatalf("preferredAudioTrackIndexV3 error = %v, want %v", err, wantErr)
}
}
@@ -566,6 +647,7 @@ func TestHandleReplanPlaybackV3UpdatesSelectedAudioAndReplaysIdempotently(t *tes
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
stubCopySeekAnchorV3(handler)
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
@@ -1186,6 +1268,7 @@ func TestHandleReplanPlaybackV3SeekReanchorPreservesFallbackRecipe(t *testing.T)
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
stubCopySeekAnchorV3(handler)
handler.PlaybackConfig = playbackTestConfig(writePlaybackTestFFmpeg(t), t.TempDir())
presetLocalRegistryV3(handler, playback.NewTransformationRegistryV3([]playback.TransformationSpecV3{
{Name: "audio_to_aac", RecipeVersion: "1", Available: true},
@@ -1269,6 +1352,7 @@ func TestHandleReplanPlaybackV3SeekReanchorWithoutFrozenRecipeFailsRetryably(t *
file := v3HandlerFixtureFile(t)
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
stubCopySeekAnchorV3(handler)
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
@@ -1368,6 +1452,7 @@ func TestHandleReplanPlaybackV3SeekFailureRecoveryNeverChangesMediaVersion(t *te
source.ID: source, alternate.ID: alternate,
}
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files})
stubCopySeekAnchorV3(handler)
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{
source.ContentID: {source, alternate},
}}
@@ -2054,29 +2139,40 @@ func TestFrozenSubtitleIdentityV3RejectsExternalAndEmbeddedInventoryDrift(t *tes
}
}
func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *testing.T) {
func TestPrepareTransportV3ProgressiveRemuxUsesResolvedCopyAnchor(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.copySeekAnchor = func(_ context.Context, _ string, inputPath string, requested float64, segmentDuration int) (float64, int, error) {
if inputPath != "/media/movie.mkv" || segmentDuration != 2 {
t.Fatalf("copy seek probe input=%q segment=%d", inputPath, segmentDuration)
}
return requested - 0.75, int((requested - 0.75) / float64(segmentDuration)), nil
}
session := &playback.Session{ID: "session-progressive", UserID: 7, ProfileID: "profile-1", MediaFileID: 42, PlayMethod: playback.PlayRemux, BasePlayMethod: playback.PlayRemux, AudioTrackIndex: 0}
for index, origin := range []float64{321.25, 654.5} {
file := &models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"}
for index, requested := range []float64{321.25, 654.5} {
plan := &playback.PlanV3{
PlanID: "plan:progressive",
Delivery: playback.DeliveryRemuxProgressiveV3,
EffectiveMediaFileID: 42,
Timeline: playback.TimelineV3{SourceStartSeconds: origin, PlayerStartSeconds: origin, CanSeekAnywhere: true, SeekRestoration: "player_position"},
Timeline: playback.TimelineV3{SourceStartSeconds: requested, PlayerStartSeconds: requested, CanSeekAnywhere: true, SeekRestoration: "player_position"},
}
transport, transportErr := handler.prepareTransportV3(httptest.NewRequest(http.MethodPost, "/", nil), session, file, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux})
if transportErr != nil {
t.Fatalf("prepare progressive transport: %v", transportErr)
}
transport := handler.prepareIdentityTransportV3(session, playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux})
parsed, err := url.Parse(transport.url)
if err != nil {
transport.rollback()
t.Fatal(err)
}
if parsed.Query().Get("st") == "" || parsed.Query().Get("seek") != strconv.FormatFloat(origin, 'f', -1, 64) {
if parsed.Query().Get("st") == "" || parsed.Query().Get("seek") != strconv.FormatFloat(requested, 'f', -1, 64) {
transport.rollback()
t.Fatalf("progressive reanchor URL %d = %q", index, transport.url)
}
if plan.Timeline.PlayerStartSeconds != 0 || plan.Timeline.StreamOriginSeconds != origin ||
origin := requested - 0.75
if plan.Timeline.PlayerStartSeconds != 0.75 || plan.Timeline.StreamOriginSeconds != origin ||
plan.Timeline.TimelineOffsetSeconds != origin || plan.Timeline.CanSeekAnywhere ||
plan.Timeline.SeekWindowStartSeconds == nil || *plan.Timeline.SeekWindowStartSeconds != origin ||
plan.Timeline.SeekWindowEndSeconds != nil || plan.Timeline.SeekRestoration != "source_position" {
@@ -2087,6 +2183,77 @@ func TestPrepareIdentityTransportV3ProgressiveRemuxNeverAdvertisesNativeSeek(t *
}
}
func TestPrepareTransportV3AudioOnlyRemuxSkipsVideoCopyAnchor(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
probeCalls := 0
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
probeCalls++
return 0, 0, errors.New("audio-only remux must not probe a video keyframe")
}
requested := 321.25
plan := &playback.PlanV3{
PlanID: "plan:audio-only-progressive",
Delivery: playback.DeliveryRemuxProgressiveV3,
Timeline: playback.TimelineV3{
SourceStartSeconds: requested,
PlayerStartSeconds: requested,
CanSeekAnywhere: true,
SeekRestoration: "player_position",
},
}
file := &models.MediaFile{
ID: 42,
BaseType: "audiobook",
FilePath: "/media/book.m4b",
CodecAudio: "aac",
AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2}},
}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-audio-only", MediaFileID: 42},
file,
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux, TargetAudioCodec: "aac"},
)
if transportErr != nil {
t.Fatalf("prepare audio-only transport: %v", transportErr)
}
defer transport.rollback()
if probeCalls != 0 {
t.Fatalf("audio-only copy anchor probes = %d, want 0", probeCalls)
}
parsed, err := url.Parse(transport.url)
if err != nil {
t.Fatal(err)
}
if parsed.Query().Get("seek") != strconv.FormatFloat(requested, 'f', -1, 64) {
t.Fatalf("audio-only seek URL = %q", transport.url)
}
if plan.Timeline.PlayerStartSeconds != 0 || plan.Timeline.CanSeekAnywhere ||
plan.Timeline.SeekRestoration != "source_position" || plan.Timeline.StreamOriginSeconds != requested ||
plan.Timeline.TimelineOffsetSeconds != requested || plan.Timeline.SeekWindowStartSeconds == nil ||
*plan.Timeline.SeekWindowStartSeconds != requested || plan.Timeline.SeekWindowEndSeconds != nil {
t.Fatalf("audio-only timeline changed = %#v", plan.Timeline)
}
}
func TestPrepareTransportV3CopyAnchorFailureIsRetryable(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
return 0, 0, errors.New("probe failed")
}
plan := &playback.PlanV3{PlanID: "plan:copy-failure", Delivery: playback.DeliveryRemuxHLSV3, Timeline: playback.TimelineV3{SourceStartSeconds: 120}}
_, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-copy-failure"},
&models.MediaFile{ID: 42, FilePath: "/media/movie.mkv"},
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux},
)
if transportErr == nil || transportErr.reason != "transcode_start_failed" || !transportErr.retryable || transportErr.cause == nil || transportErr.cause.Error() != "probe failed" {
t.Fatalf("transport error = %#v, want retryable copy anchor failure", transportErr)
}
}
func TestPrepareTransportV3RejectsNodeMissingRequiredTransformation(t *testing.T) {
startHits := 0
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -2167,6 +2334,52 @@ func TestPrepareTransportV3RequiresRemoteManifestReadiness(t *testing.T) {
}
}
func TestPrepareTransportV3SendsResolvedCopyAnchorToRemoteExecutor(t *testing.T) {
var startRequest transcodenode.TranscodeStartRequest
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/hw-capabilities":
writeJSON(w, http.StatusOK, playback.HWAccelInfo{})
case r.Method == http.MethodPost && r.URL.Path == "/transcode/start":
if err := json.NewDecoder(r.Body).Decode(&startRequest); err != nil {
t.Errorf("decode remote start: %v", err)
}
writeJSON(w, http.StatusAccepted, transcodenode.TranscodeStartResponse{SessionID: startRequest.SessionID, Status: "started"})
case r.Method == http.MethodDelete:
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer remote.Close()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.JWTSecret = "test-secret"
handler.NodePlanner = staticNodePlannerV3{plan: nodepool.Plan{TranscodeNode: &nodepool.Node{URL: remote.URL}}}
handler.copySeekAnchor = func(context.Context, string, string, float64, int) (float64, int, error) {
return 1085.501, 542, nil
}
plan := &playback.PlanV3{PlanID: "plan:remote-copy-anchor", Delivery: playback.DeliveryRemuxHLSV3, Timeline: playback.TimelineV3{SourceStartSeconds: 1086.2}}
transport, transportErr := handler.prepareTransportV3(
httptest.NewRequest(http.MethodPost, "/", nil),
&playback.Session{ID: "session-remote-copy-anchor", UserID: 7, ProfileID: "profile-1"},
&models.MediaFile{ID: 42, FilePath: "/media/movie.mkv", CodecVideo: "h264"},
playback.PlannerResultV3{Plan: plan, PlayMethod: playback.PlayRemux, TargetAudioCodec: "aac"},
)
if transportErr != nil {
t.Fatalf("prepare remote copy transport: %v", transportErr)
}
defer transport.rollback()
if startRequest.SeekSeconds != 1086.2 || startRequest.StreamOriginSeconds != 1085.501 ||
!startRequest.CopySeekAnchorResolved || startRequest.StartSegmentNumber != 542 {
t.Fatalf("remote copy timeline = %#v", startRequest)
}
if plan.Timeline.StreamOriginSeconds != 1085.501 || plan.Timeline.TimelineOffsetSeconds != 1085.501 ||
math.Abs(plan.Timeline.PlayerStartSeconds-0.699) > 0.0001 {
t.Fatalf("advertised copy timeline = %#v", plan.Timeline)
}
}
func TestPrepareTransportV3UsesFrozenSourceMetadataAfterProbeDrift(t *testing.T) {
var startRequest transcodenode.TranscodeStartRequest
remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -2261,7 +2474,11 @@ func TestPrepareLocalTransportV3ReturnsStableTerminalWhenFFmpegExitsBeforeReady(
SubtitleTrackIndex: -1, SubtitleTransportTrackIndex: -1,
}
request := httptest.NewRequest(http.MethodPost, "/", nil)
transport, transportErr := handler.prepareLocalTransportV3(request, &playback.Session{ID: "session-startup-failure", UserID: 7, ProfileID: "profile-1"}, file, result)
timeline, timelineErr := handler.prepareTransportTimelineV3(request.Context(), &playback.Session{ID: "session-startup-failure"}, file, result)
if timelineErr != nil {
t.Fatalf("prepare timeline: %v", timelineErr)
}
transport, transportErr := handler.prepareLocalTransportV3(request, &playback.Session{ID: "session-startup-failure", UserID: 7, ProfileID: "profile-1"}, file, result, timeline)
if transportErr == nil {
transport.rollback()
t.Fatal("failed ffmpeg startup returned a playable transport")
@@ -2301,7 +2518,8 @@ func TestManifestStartupTimeoutWhileRunningIsPersistedIdempotently(t *testing.T)
failure := manifestStartupTransportErrorV3(session.IsRunning(), waitErr)
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
request := v3HandlerStartRequest()
response, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, "digest", request.FileID, request.FileID, failure)
requestDigests := playbackStartRequestDigestsV3{current: "digest"}
response, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, requestDigests, request.FileID, request.FileID, failure)
if err != nil {
t.Fatalf("start failure decision: %v", err)
}
@@ -2312,7 +2530,7 @@ func TestManifestStartupTimeoutWhileRunningIsPersistedIdempotently(t *testing.T)
if err != nil || record.StartResponse.Terminal == nil || !record.StartResponse.Terminal.Retryable {
t.Fatalf("retryable startup timeout attempt = %#v, err=%v", record, err)
}
replayed, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, "digest", request.FileID, request.FileID, failure)
replayed, err := handler.startFailureDecisionV3(context.Background(), 1, request.ProfileID, request, requestDigests, request.FileID, request.FileID, failure)
if err != nil || replayed.Terminal == nil || replayed.Terminal.Reason != response.Terminal.Reason {
t.Fatalf("retryable timeout replay = %#v, err=%v", replayed, err)
}
@@ -2325,12 +2543,12 @@ func TestConfigureHLSTimelineV3MatchesTransportSeekSemantics(t *testing.T) {
// locally seekable — sending them past the produced head instead of back
// to the server. The runtime belongs on source.duration_seconds.
copyPlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
copySeek, copySegment := configureHLSTimelineV3(copyPlan, "copy", 2, 600)
if copySeek != 17.3 || copySegment != 8 || copyPlan.Timeline.StreamOriginSeconds != 17.3 || copyPlan.Timeline.TimelineOffsetSeconds != 17.3 || copyPlan.Timeline.PlayerStartSeconds != 0 || copyPlan.Timeline.CanSeekAnywhere ||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 17.3 ||
configureCopyRemuxTimelineV3(copyPlan, 16)
if copyPlan.Timeline.StreamOriginSeconds != 16 || copyPlan.Timeline.TimelineOffsetSeconds != 16 || math.Abs(copyPlan.Timeline.PlayerStartSeconds-1.3) > 0.0001 || copyPlan.Timeline.CanSeekAnywhere ||
copyPlan.Timeline.SeekWindowStartSeconds == nil || *copyPlan.Timeline.SeekWindowStartSeconds != 16 ||
copyPlan.Timeline.SeekWindowEndSeconds != nil ||
copyPlan.Timeline.SeekRestoration != "source_position" {
t.Fatalf("copy timeline=%#v seek=%v segment=%d", copyPlan.Timeline, copySeek, copySegment)
t.Fatalf("copy timeline=%#v", copyPlan.Timeline)
}
encodePlan := &playback.PlanV3{Timeline: playback.TimelineV3{SourceStartSeconds: 17.3}}
@@ -2532,6 +2750,12 @@ func v3HandlerFixtureFile(t *testing.T) *models.MediaFile {
return &models.MediaFile{ID: 42, ContentID: "movie-1", FilePath: writePlaybackTestMediaFile(t, "movie.mp4"), Container: "mp4", CodecVideo: "h264", CodecAudio: "aac", Resolution: "1080p", Bitrate: 8_000, AudioChannels: 2, Duration: 3600, VideoTracks: []models.VideoTrack{{Codec: "h264", Profile: "high", Level: 41, Width: 1920, Height: 1080, FrameRate: "24000/1001", Bitrate: 8_000, BitDepth: 8, VideoRange: "SDR", VideoRangeType: "SDR"}}, AudioTracks: []models.AudioTrack{{Codec: "aac", Channels: 2, Layout: "stereo", Default: true}}}
}
func stubCopySeekAnchorV3(handler *PlaybackHandler) {
handler.copySeekAnchor = func(_ context.Context, _ string, _ string, requested float64, segmentDuration int) (float64, int, error) {
return requested, computeStartSegment(requested, segmentDuration), nil
}
}
func v3HandlerStartRequest() playback.StartRequestV3 {
return playback.StartRequestV3{ProtocolVersion: playback.ProtocolV3, ClientFeatures: []string{playback.FeaturePlaybackPlanV3}, FileID: 42, ProfileID: "profile-1", PlaybackAttemptID: "attempt-handler-0001", QualityPreference: "original", SubtitleFidelityPreference: playback.SubtitleFidelityCompatibleV3, Capabilities: playback.ClientCodecCapabilitiesV3{VideoEvidence: playback.EvidenceExactV3, AudioEvidence: playback.EvidenceExactV3, CodecsVideo: []string{"h264"}, CodecsVideoHardware: []string{"h264"}, CodecsAudio: []string{"aac"}, Containers: []string{"mp4"}, MaxResolution: "1080p", VideoDecode: []playback.VideoDecodeCapabilityV3{{Codec: "h264", Profiles: []string{"high"}, Levels: []int{41}, BitDepths: []int{8}, MaxWidth: 1920, MaxHeight: 1080, MaxFrameRate: 60, MaxBitrateKbps: 20_000, Hardware: true}}}, ClientPlaybackContext: playback.ClientPlaybackContextV3{ProtocolVersion: playback.ProtocolV3, FormFactor: "tv", AppVersion: "test", Device: playback.DeviceContextV3{Platform: "android"}, Output: playback.OutputContextV3{OutputContextID: "route-1"}, Deliveries: map[string]playback.DeliveryCapabilityV3{playback.DeliveryClassOriginalHTTPV3: {Enabled: true, SupportedOnDevice: true, Subtitles: playback.DeliverySubtitleCapabilitiesV3{EmbeddedText: true, SidecarText: true}}}}}
}
@@ -2637,6 +2861,7 @@ func TestHandleReplanPlaybackV3TrackChangeStaysOnEffectiveAlternate(t *testing.T
files := map[int]*models.MediaFile{source.ID: source, alternate.ID: alternate}
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, mapPlaybackFileResolver{files: files})
stubCopySeekAnchorV3(handler)
handler.FileVersionFetcher = testPlaybackFileVersionFetcher{byContent: map[string][]*models.MediaFile{source.ContentID: {source, alternate}}}
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "false"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
@@ -2683,6 +2908,7 @@ func TestHandleReplanPlaybackV3TrackChangeOperation(t *testing.T) {
file.AudioTracks = append(file.AudioTracks, models.AudioTrack{Codec: "aac", Channels: 2, Layout: "stereo", Language: "spa"})
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
stubCopySeekAnchorV3(handler)
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{"allow_4k_transcode": "true"}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
+3
View File
@@ -30,6 +30,9 @@ type AccessFilter struct {
SelectedFileID int
UserID int
ProfileID string
// DeviceID identifies the requesting client for device-scoped setting
// resolution. It does not participate in catalog access control.
DeviceID string
// NamePrefix, when non-empty, restricts results to items whose
// LOWER(COALESCE(NULLIF(BTRIM(sort_title),''), title)) starts with the
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
+6 -3
View File
@@ -3105,6 +3105,7 @@ type audioPrefResolver struct {
store userstore.UserStore
valid bool
profileID string
deviceID string
contentID string
profileDone bool
@@ -3136,6 +3137,7 @@ func (s *DetailService) newAudioPrefResolver(ctx context.Context, filter AccessF
r := &audioPrefResolver{
svc: s,
profileID: filter.ProfileID,
deviceID: filter.DeviceID,
contentID: audioPreferenceContentID,
resolvedLang: map[int]string{},
}
@@ -3176,15 +3178,16 @@ func (r *audioPrefResolver) audioPreference(ctx context.Context) *playback.Audio
// audioLanguage resolves with every content identity in context. The language
// preference lives in the canonical table at profile_series/profile_library/
// profile scopes; the specialized audio row supplies only concrete track
// identity. Caching by library keeps a multi-file item at one canonical read
// per distinct folder rather than one read per file.
// profile_device/profile scopes; the specialized audio row supplies only
// concrete track identity. Caching by library keeps a multi-file item at one
// canonical read per distinct folder rather than one read per file.
func (r *audioPrefResolver) audioLanguage(ctx context.Context, libraryID int) string {
if lang, ok := r.resolvedLang[libraryID]; ok {
return lang
}
rc := settingsresolve.Context{
ProfileID: r.profileID,
DeviceID: r.deviceID,
LibraryIDs: []int{libraryID},
}
if strings.TrimSpace(r.contentID) != "" {
@@ -204,6 +204,42 @@ func TestEffectiveAudioTrackIndex_PrefersSeriesAudioPreferenceOverLibraryAndProf
}
}
func TestEffectiveAudioTrackIndex_ResolvesDeviceLanguageWithCanonicalPrecedence(t *testing.T) {
store := newDetailTestStore(t)
setProfileAudioLanguage(t, store, "en")
setScopedAudioLanguageForDevice(t, store, settingscontract.ScopeProfileDevice, "", 0, "apple-tv", "ja")
setScopedAudioLanguage(t, store, settingscontract.ScopeProfileLibrary, "", 12, "es")
setScopedAudioLanguage(t, store, settingscontract.ScopeProfileSeries, "series-1", 0, "fr")
service := &DetailService{}
service.SetUserStoreProvider(testDetailUserStoreProvider{store: store})
file := &models.MediaFile{
AudioTracks: []models.AudioTrack{
{Language: "en", Default: true},
{Language: "ja"},
{Language: "es"},
{Language: "fr"},
},
}
assertIndex := func(name, deviceID, contentID string, libraryID, want int) {
t.Helper()
file.MediaFolderID = libraryID
if got := service.effectiveAudioTrackIndex(context.Background(), AccessFilter{
UserID: 1,
ProfileID: "profile-1",
DeviceID: deviceID,
}, contentID, file); got != want {
t.Fatalf("%s: effectiveAudioTrackIndex() = %d, want %d", name, got, want)
}
}
assertIndex("device override", "apple-tv", "movie-1", 99, 1)
assertIndex("other device isolation", "iphone", "movie-1", 99, 0)
assertIndex("library over device", "apple-tv", "movie-1", 12, 2)
assertIndex("series over library and device", "apple-tv", "series-1", 12, 3)
}
func TestBuildPlaybackInfo_SetsEffectiveAudioLanguageFromOriginalWhenTrackLanguageMissing(t *testing.T) {
store := newDetailTestStore(t)
language := playback.OriginalLanguageSentinel
@@ -511,6 +547,19 @@ func setScopedAudioLanguage(
seriesID string,
libraryID int,
language string,
) {
t.Helper()
setScopedAudioLanguageForDevice(t, store, scope, seriesID, libraryID, "", language)
}
func setScopedAudioLanguageForDevice(
t *testing.T,
store userstore.UserStore,
scope settingscontract.Scope,
seriesID string,
libraryID int,
deviceID string,
language string,
) {
t.Helper()
encoded, err := json.Marshal(language)
@@ -523,6 +572,7 @@ func setScopedAudioLanguage(
ProfileID: "profile-1",
SeriesID: seriesID,
LibraryID: libraryID,
DeviceID: deviceID,
}, encoded); err != nil {
t.Fatalf("seeding %s: %v", settingskeys.PlaybackAudioLanguage, err)
}
@@ -42,6 +42,13 @@ var (
var jsonNULCodePoint = []byte(`\u0000`)
// compatNegotiationLockSQL takes token, device ID, and route item ID as
// separate text parameters and joins them with chr(31) inside PostgreSQL.
const compatNegotiationLockSQL = `
SELECT pg_advisory_xact_lock(
hashtextextended($1 || chr(31) || $2 || chr(31) || $3, 0)
)`
// marshalPlaybackSession removes NUL code points from every nested string in
// the JSON document. PostgreSQL JSONB rejects U+0000 even when Go's encoder
// represents it as \u0000. Escaped literal text (\\u0000) remains unchanged.
@@ -218,8 +225,14 @@ func (d *DurableCompatPlaybackStore) replaceUnstartedNegotiation(
var removed []string
if session.CompatToken != "" && session.ClientDeviceID != "" && session.RouteItemID != "" {
scope := session.CompatToken + "\x00" + session.ClientDeviceID + "\x00" + session.RouteItemID
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, scope); err != nil {
// Join the lock key inside PostgreSQL. A Go-side NUL delimiter cannot
// be bound as text: PostgreSQL rejects U+0000 (SQLSTATE 22021), the
// transaction aborts, and the negotiated session stays cache-only.
if _, err := tx.Exec(ctx, compatNegotiationLockSQL,
stripCompatNUL(session.CompatToken),
stripCompatNUL(session.ClientDeviceID),
stripCompatNUL(session.RouteItemID),
); err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
@@ -13,6 +14,15 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
func TestCompatNegotiationLockSQLDoesNotBindNULDelimiter(t *testing.T) {
if strings.Contains(compatNegotiationLockSQL, "\x00") {
t.Fatal("advisory lock SQL must not embed a NUL delimiter")
}
if !strings.Contains(compatNegotiationLockSQL, "chr(31)") {
t.Fatal("advisory lock SQL should join key parts with chr(31) inside PostgreSQL")
}
}
func TestMarshalPlaybackSessionStripsNestedNUL(t *testing.T) {
wantLiteral := `literal\u0000text`
data, err := marshalPlaybackSession(PlaybackSession{
+3 -1
View File
@@ -108,7 +108,9 @@ func resolveCopySeekAnchor(
interval := strconv.FormatFloat(requestedSeekSeconds, 'f', 6, 64) + "%+0.001"
cmd := exec.CommandContext(ctx, ffprobePathFromFFmpeg(ffmpegPath),
"-v", "error",
"-select_streams", "v:0",
// Match the remux transport's 0:V:0 map. Uppercase V excludes attached
// pictures, thumbnails, and cover art from the video stream ordinal.
"-select_streams", "V:0",
"-read_intervals", interval,
"-show_entries", "packet=pts_time,dts_time,flags",
"-of", "json",
@@ -17,10 +17,12 @@ func TestResolveCopySeekAnchorUsesKeyPacketTimestamp(t *testing.T) {
dir := t.TempDir()
ffmpegPath := filepath.Join(dir, "ffmpeg")
ffprobePath := filepath.Join(dir, "ffprobe")
argsPath := filepath.Join(dir, "ffprobe-args")
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\n' "$@" > "` + argsPath + `"
printf '%s' '{"packets":[{"pts_time":"N/A","dts_time":"14.500000","flags":"K__"}]}'
`
if err := os.WriteFile(ffprobePath, []byte(probe), 0o755); err != nil {
@@ -34,6 +36,13 @@ printf '%s' '{"packets":[{"pts_time":"N/A","dts_time":"14.500000","flags":"K__"}
if anchor != 14.5 || segment != 7 {
t.Fatalf("resolved anchor = %v, segment = %d; want 14.5, 7", anchor, segment)
}
args, err := os.ReadFile(argsPath)
if err != nil {
t.Fatalf("read ffprobe args: %v", err)
}
if !strings.Contains(string(args), "-select_streams\nV:0\n") {
t.Fatalf("ffprobe did not select the remux video stream:\n%s", args)
}
}
func TestResolveCopySeekAnchorCoalescesMatchingConcurrentProbes(t *testing.T) {
+1 -1
View File
@@ -145,7 +145,7 @@ function isCapturedProfileAuthorityActive(snapshot: ProfileRequestContextSnapsho
);
}
function getOrCreateDeviceId(): string {
export function getOrCreateDeviceId(): string {
const existing = storage.get(storage.KEYS.DEVICE_ID);
if (existing) {
return existing;
+13
View File
@@ -1714,6 +1714,19 @@
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.04);
}
/* Panel chrome only once there is a sidebar to enclose. Below this the
settings detail view is already a stack of surface-panel groups, and a
panel around them would nest a card in a card. */
@media (min-width: 1024px) {
.surface-panel-lg {
background: color-mix(in srgb, var(--surface) 90%, transparent);
border-radius: calc(var(--radius-2xl) + 0.35rem);
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.04),
0 24px 50px -34px rgb(0 0 0 / 0.65);
}
}
.caption-empty-state span {
display: block;
height: 0.35rem;
+26 -1
View File
@@ -11,6 +11,10 @@ const mocks = vi.hoisted(() => ({
prefetchQuery: vi.fn(),
renderSurface: true,
beginResult: undefined as boolean | undefined,
profile: {
name: "Admin",
avatar_url: "https://example.com/admin-avatar.webp",
} as { name: string; avatar_url?: string },
}));
vi.mock("react-router", async () => {
@@ -30,7 +34,7 @@ vi.mock("@tanstack/react-query", async () => {
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ user: { username: "Admin" } }) }));
vi.mock("@/hooks/useCurrentProfile", () => ({
useCurrentProfile: () => ({ profile: { name: "Admin" } }),
useCurrentProfile: () => ({ profile: mocks.profile }),
}));
vi.mock("@/hooks/useIsActingAdmin", () => ({ useIsActingAdmin: () => false }));
vi.mock("@/playback/watchPlaybackContext", () => ({
@@ -43,6 +47,11 @@ vi.mock("@/hooks/queries/catalogRead", () => ({ fetchCatalogItemDetail: vi.fn()
vi.mock("@/components/GlobalSearch", () => ({ GlobalSearch: () => null }));
vi.mock("@/components/ServerActivity", () => ({ default: () => null }));
vi.mock("@/components/SiloBrand", () => ({ SiloBrand: () => <span>Silo</span> }));
vi.mock("@/components/ui/avatar", () => ({
Avatar: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AvatarImage: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
AvatarFallback: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}));
vi.mock("@/components/ViewTransitionLink", () => ({
default: ({ children }: { children: ReactNode }) => <a href="/">{children}</a>,
}));
@@ -121,6 +130,10 @@ beforeEach(() => {
mocks.prefetchQuery.mockReset();
mocks.renderSurface = true;
mocks.beginResult = undefined;
mocks.profile = {
name: "Admin",
avatar_url: "https://example.com/admin-avatar.webp",
};
vi.stubGlobal("matchMedia", (query: string) => ({
matches: query === "(min-width: 64rem)",
media: query,
@@ -140,6 +153,18 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("Layout mobile profile", () => {
it("renders the current profile avatar in the settings link", () => {
renderLayout();
const settingsLink = screen.getByRole("link", { name: "Admin settings" });
const avatar = screen.getByRole("img", { name: "Admin" });
expect(settingsLink).toContainElement(avatar);
expect(avatar).toHaveAttribute("src", "https://example.com/admin-avatar.webp");
});
});
describe("Layout item navigation", () => {
it("declines interception on an item route, for a non-item href, and below lg", () => {
const view = renderLayout();
+13 -4
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { useQueryClient } from "@tanstack/react-query";
import { Menu, Search } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useAuth } from "@/hooks/useAuth";
import { useCurrentProfile } from "@/hooks/useCurrentProfile";
@@ -267,11 +268,19 @@ export default function Layout({ children }: LayoutProps) {
{showAdminActivity && <ServerActivity hideWhenEmpty />}
<Link
to="/settings"
className="bg-primary text-primary-foreground flex h-9 w-9 items-center justify-center rounded-xl text-xs font-bold shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]"
aria-label={`${profile?.name ?? user?.username ?? "User"} settings`}
className="flex h-9 w-9 items-center justify-center rounded-full transition-transform active:scale-[0.98]"
>
{profile?.name?.charAt(0).toUpperCase() ??
user?.username?.charAt(0).toUpperCase() ??
"?"}
<Avatar className="h-9 w-9 shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]">
{profile?.avatar_url ? (
<AvatarImage src={profile.avatar_url} alt={profile.name} />
) : null}
<AvatarFallback className="bg-primary text-primary-foreground text-xs font-bold">
{profile?.name?.charAt(0).toUpperCase() ??
user?.username?.charAt(0).toUpperCase() ??
"?"}
</AvatarFallback>
</Avatar>
</Link>
</div>
</div>
@@ -1,4 +1,3 @@
import { useState } from "react";
import { ChevronRight } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { Link } from "react-router";
@@ -35,14 +34,7 @@ export function SettingsOverviewNav({
idPrefix,
variant = "grouped-list",
}: SettingsOverviewNavProps) {
const firstGroupId = groups[0] ? groupSectionId(idPrefix, groups[0].label) : "";
const [activeGroupId, setActiveGroupId] = useState(firstGroupId);
const isDirectory = variant === "directory";
const resolvedActiveGroupId = groups.some(
(group) => groupSectionId(idPrefix, group.label) === activeGroupId,
)
? activeGroupId
: firstGroupId;
if (groups.length === 0) {
return (
@@ -55,136 +47,96 @@ export function SettingsOverviewNav({
);
}
// No category jump bar: it listed the same group names, with the same
// counts, directly above the headings that repeat them — and on an index
// this short it saved about one scroll flick.
return (
<div className={cn(isDirectory && "lg:space-y-8")}>
{isDirectory ? (
<nav
aria-label={`${ariaLabel} categories`}
className="surface-panel-subtle hidden max-w-2xl overflow-hidden rounded-xl border lg:flex"
>
{groups.map((group) => {
const sectionId = groupSectionId(idPrefix, group.label);
const isActive = sectionId === resolvedActiveGroupId;
<nav
aria-label={ariaLabel}
className={cn(
"grid items-start gap-6 lg:grid-cols-2",
isDirectory && "block space-y-6 lg:space-y-10",
)}
>
{groups.map((group) => {
const sectionId = groupSectionId(idPrefix, group.label);
return (
<a
key={group.label}
href={`#${sectionId}`}
aria-label={`${group.label}, ${group.items.length} settings sections`}
aria-current={isActive ? "location" : undefined}
onClick={() => setActiveGroupId(sectionId)}
className={cn(
"text-muted-foreground hover:bg-surface-hover/60 hover:text-foreground focus-visible:ring-ring/60 relative flex min-h-11 flex-1 items-center justify-center gap-2 px-4 text-sm font-medium transition-colors focus-visible:z-10 focus-visible:ring-[3px] focus-visible:outline-none focus-visible:ring-inset",
isActive &&
"text-foreground after:bg-primary after:absolute after:inset-x-4 after:bottom-0 after:h-0.5 after:rounded-full",
)}
>
<span>{group.label}</span>
<span className="text-muted-foreground text-xs tabular-nums">
{group.items.length}
</span>
</a>
);
})}
</nav>
) : null}
<nav
aria-label={ariaLabel}
className={cn(
"grid items-start gap-6 lg:grid-cols-2",
isDirectory && "block space-y-6 lg:space-y-10",
)}
>
{groups.map((group) => {
const sectionId = groupSectionId(idPrefix, group.label);
return (
<section
key={group.label}
aria-labelledby={sectionId}
className={cn(isDirectory && "scroll-mt-6")}
return (
<section key={group.label} aria-labelledby={sectionId}>
<h2
id={sectionId}
className={cn(
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
isDirectory &&
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
)}
>
<h2
id={sectionId}
className={cn(
"text-muted-foreground mb-2 px-1 text-[11px] font-semibold tracking-[0.16em] uppercase",
isDirectory &&
"lg:text-foreground lg:mb-3 lg:px-0 lg:text-base lg:font-semibold lg:tracking-tight lg:normal-case",
)}
>
{group.label}
{isDirectory ? (
<span className="text-muted-foreground ml-1.5 hidden font-medium lg:inline">
({group.items.length})
</span>
) : null}
</h2>
<ul
className={cn(
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
isDirectory &&
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
)}
>
{group.items.map((item) => {
const Icon = item.icon;
{group.label}
</h2>
<ul
className={cn(
"surface-panel overflow-hidden rounded-2xl border-0 shadow-none",
isDirectory &&
"lg:grid lg:grid-cols-2 lg:gap-3 lg:overflow-visible lg:rounded-none lg:bg-transparent 2xl:grid-cols-4",
)}
>
{group.items.map((item) => {
const Icon = item.icon;
return (
<li
key={item.id}
return (
<li
key={item.id}
className={cn(
"border-border/60 border-b last:border-b-0",
isDirectory && "lg:border-0",
)}
>
<Link
to={item.href}
className={cn(
"border-border/60 border-b last:border-b-0",
isDirectory && "lg:border-0",
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
isDirectory &&
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
)}
>
<Link
to={item.href}
<span
className={cn(
"hover:bg-surface-hover/70 focus-visible:bg-surface-hover/70 focus-visible:ring-ring/70 flex min-h-[4.5rem] items-center gap-3 px-4 py-3 transition-colors focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset",
isDirectory &&
"lg:bg-card/45 lg:border-border/70 lg:hover:border-ring/40 lg:h-28 lg:rounded-xl lg:border lg:px-4 lg:py-4 lg:shadow-sm lg:hover:-translate-y-0.5 lg:hover:shadow-md lg:focus-visible:-translate-y-0.5",
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
isDirectory && "lg:h-10 lg:w-10",
)}
>
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"bg-accent text-foreground flex h-9 w-9 shrink-0 items-center justify-center rounded-xl",
isDirectory && "lg:h-10 lg:w-10",
"block text-sm font-semibold",
isDirectory && "lg:text-[15px]",
)}
>
<Icon className="h-[18px] w-[18px]" aria-hidden="true" />
{item.label}
</span>
<span className="min-w-0 flex-1">
<span
className={cn(
"block text-sm font-semibold",
isDirectory && "lg:text-[15px]",
)}
>
{item.label}
</span>
<span
className={cn(
"text-muted-foreground mt-0.5 block text-xs leading-snug",
isDirectory && "lg:line-clamp-3",
)}
>
{item.description}
</span>
<span
className={cn(
"text-muted-foreground mt-0.5 block text-xs leading-snug",
isDirectory && "lg:line-clamp-3",
)}
>
{item.description}
</span>
<ChevronRight
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
);
})}
</nav>
</div>
</span>
<ChevronRight
className="text-muted-foreground h-4 w-4 shrink-0"
aria-hidden="true"
/>
</Link>
</li>
);
})}
</ul>
</section>
);
})}
</nav>
);
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import type { LibraryCollection } from "@/api/types";
import { buildAllUserCollectionOptions } from "./useAllUserCollections";
function libraryCollection(id: string, title: string): LibraryCollection {
return {
id,
title,
collection_type: "smart",
source_config: {},
last_sync_status: "success",
} as LibraryCollection;
}
describe("buildAllUserCollectionOptions", () => {
it("keeps personal collections first and identifies their source", () => {
const options = buildAllUserCollectionOptions(
[{ id: 1, name: "Movies" }],
[{ id: "personal", name: "Weekend Picks" }],
[[libraryCollection("library", "Staff Picks")]],
);
expect(options.map(({ id, source, group }) => ({ id, source, group }))).toEqual([
{ id: "personal", source: "user", group: "My Collections" },
{ id: "library", source: "library", group: "Movies" },
]);
});
it("lists a multi-library collection once with its combined scope", () => {
const shared = libraryCollection("shared", "Network Originals");
const options = buildAllUserCollectionOptions(
[
{ id: 1, name: "Movies" },
{ id: 2, name: "TV Shows" },
],
undefined,
[[shared], [shared]],
);
expect(options).toHaveLength(1);
expect(options[0]).toMatchObject({
id: "shared",
title: "Network Originals",
source: "library",
group: "Movies, TV Shows",
library_name: "Movies, TV Shows",
});
});
it("keeps separate same-titled collections distinct", () => {
const options = buildAllUserCollectionOptions(
[
{ id: 1, name: "Movies" },
{ id: 2, name: "TV Shows" },
],
undefined,
[
[libraryCollection("movies", "Network Originals")],
[libraryCollection("shows", "Network Originals")],
],
);
expect(options.map(({ id, group }) => ({ id, group }))).toEqual([
{ id: "movies", group: "Movies" },
{ id: "shows", group: "TV Shows" },
]);
});
});
+63 -36
View File
@@ -1,5 +1,5 @@
import { useQueries } from "@tanstack/react-query";
import type { LibraryCollection } from "@/api/types";
import type { Collection, LibraryCollection } from "@/api/types";
import { useUserLibraries } from "./libraries";
import { useCollections } from "./collections";
import { getLibraryCollectionList, libraryCollectionsQueryOptions } from "./libraryCollections";
@@ -16,6 +16,60 @@ export interface CollectionOption {
last_sync_status?: LibraryCollection["last_sync_status"];
}
type LibrarySummary = { id: number; name: string };
type UserCollectionSummary = Pick<Collection, "id" | "name">;
export function buildAllUserCollectionOptions(
libraries: readonly LibrarySummary[],
userCollections: readonly UserCollectionSummary[] | undefined,
libraryCollectionsByLibrary: ReadonlyArray<readonly LibraryCollection[] | undefined>,
): CollectionOption[] {
const collections: CollectionOption[] = [];
for (const collection of userCollections ?? []) {
collections.push({
id: collection.id,
title: collection.name,
source: "user",
group: "My Collections",
});
}
const libraryOptions = new Map<string, { option: CollectionOption; libraryNames: string[] }>();
for (let i = 0; i < libraries.length; i++) {
const library = libraries[i]!;
for (const collection of libraryCollectionsByLibrary[i] ?? []) {
const existing = libraryOptions.get(collection.id);
if (existing) {
if (!existing.libraryNames.includes(library.name)) {
existing.libraryNames.push(library.name);
const group = existing.libraryNames.join(", ");
existing.option.group = group;
existing.option.library_name = group;
}
continue;
}
const option: CollectionOption = {
id: collection.id,
title: collection.title,
source: "library",
group: library.name,
library_id: library.id,
library_name: library.name,
collection_type: collection.collection_type,
source_config: collection.source_config,
last_sync_status: collection.last_sync_status,
};
libraryOptions.set(collection.id, { option, libraryNames: [library.name] });
collections.push(option);
}
}
return collections;
}
export function useAllUserCollections() {
const { data: libraries } = useUserLibraries();
const { data: userCollections, isLoading: userCollectionsLoading } = useCollections();
@@ -29,41 +83,14 @@ export function useAllUserCollections() {
const isLoading = libraryQueries.some((q) => q.isLoading) || userCollectionsLoading;
const collections: CollectionOption[] = [];
// Add personal user collections first (grouped under "My Collections").
if (userCollections) {
for (const c of userCollections) {
collections.push({
id: c.id,
title: c.name,
source: "user",
group: "My Collections",
});
}
}
// Add library collections grouped by library name.
if (libraries) {
for (let i = 0; i < libraries.length; i++) {
const lib = libraries[i]!;
const result = libraryQueries[i];
const libraryCollections = Array.isArray(result?.data) ? result.data : [];
for (const c of libraryCollections) {
collections.push({
id: c.id,
title: c.title,
source: "library",
group: lib.name,
library_id: lib.id,
library_name: lib.name,
collection_type: c.collection_type,
source_config: c.source_config,
last_sync_status: c.last_sync_status,
});
}
}
}
const libraryCollectionsByLibrary = libraryQueries.map((result) =>
Array.isArray(result.data) ? result.data : undefined,
);
const collections = buildAllUserCollectionOptions(
libraries ?? [],
userCollections,
libraryCollectionsByLibrary,
);
return { collections, isLoading };
}
+50 -5
View File
@@ -49,16 +49,61 @@ describe("SettingsLayout", () => {
);
expect(screen.getByRole("heading", { name: "Settings" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Playback" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Appearance" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Library & Data" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Account" })).toBeInTheDocument();
for (const group of ["Playback", "Appearance", "Home & Discovery", "Connections", "Account"]) {
expect(screen.getByRole("heading", { name: group })).toBeInTheDocument();
}
expect(
screen.getByRole("link", { name: /Playback.*Quality, language, and skipping/ }),
screen.getByRole("link", { name: /Playback.*Quality, languages, skipping/ }),
).toHaveAttribute("href", "/settings/playback");
expect(screen.getByRole("link", { name: /Connect Apps.*Sign-in details/ })).toBeInTheDocument();
});
it("names each settings group exactly once", () => {
render(
<MemoryRouter initialEntries={["/settings"]}>
<SettingsLayout />
</MemoryRouter>,
);
// The category jump bar used to repeat every group name and count directly
// above the headings that already carry them.
expect(
screen.queryByRole("navigation", { name: "Settings sections categories" }),
).not.toBeInTheDocument();
for (const group of ["Playback", "Appearance", "Home & Discovery", "Connections", "Account"]) {
expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1);
expect(
screen.queryByRole("link", { name: new RegExp(`^${group}, \\d+ settings`) }),
).toBeNull();
}
});
it("uses one desktop grid and card geometry for every settings group", () => {
const markup = renderToStaticMarkup(
<MemoryRouter initialEntries={["/settings"]}>
<SettingsLayout />
</MemoryRouter>,
);
// Five groups, seventeen sections — every card the same height so no group
// is visually ranked above another.
expect(markup.match(/2xl:grid-cols-4/g)).toHaveLength(5);
expect(markup.match(/lg:h-28/g)).toHaveLength(17);
expect(markup).not.toContain("max-w-5xl");
});
it("keeps each settings section in exactly one group", () => {
const markup = renderToStaticMarkup(
<MemoryRouter initialEntries={["/settings"]}>
<SettingsLayout />
</MemoryRouter>,
);
for (const path of ["devices", "libraries", "watch-providers", "profiles"]) {
expect(markup.match(new RegExp(`href="/settings/${path}"`, "g"))).toHaveLength(1);
}
});
it("offers a clear return to the settings index from detail pages", () => {
render(
<MemoryRouter initialEntries={["/settings/playback"]}>
+272 -257
View File
@@ -61,6 +61,13 @@ const settingIndex = (...labels: string[]) => labels.map((label) => ({ label }))
*/
const WIDE_SETTINGS_PAGES = new Set(["devices"]);
/**
* Grouped by the question a person arrives with, not by which service stores
* the value: "how does it play", "how does it look", "what do I see", "what is
* it wired to", "who am I". Devices sits under Playback because every setting
* on that screen is a playback override, and Personalize sits with Home Screen
* because both shape what the app puts in front of you.
*/
const NAV_SECTIONS: NavSection[] = [
{
label: "Playback",
@@ -69,7 +76,7 @@ const NAV_SECTIONS: NavSection[] = [
path: "playback",
label: "Playback",
icon: Play,
description: "Quality, language, and skipping",
description: "Quality, languages, skipping, and what plays next.",
keywords: [
"video quality",
"bitrate",
@@ -98,7 +105,7 @@ const NAV_SECTIONS: NavSection[] = [
path: "subtitle-appearance",
label: "Subtitles",
icon: Subtitles,
description: "Language, behavior, and style",
description: "Subtitle language, when they appear, and how they look.",
keywords: [
"subtitle language",
"forced subtitles",
@@ -124,215 +131,11 @@ const NAV_SECTIONS: NavSection[] = [
"Subtitle position",
),
},
],
},
{
label: "Appearance",
items: [
{
path: "appearance",
label: "Appearance",
icon: Palette,
description: "Theme and interface tone",
keywords: [
"theme",
"profile theme",
"dark",
"light",
"custom theme",
"date format",
"time format",
"clock",
"24-hour",
"12-hour",
],
settings: settingIndex(
"Theme",
"Date & time",
"Date format",
"Time format",
"Current selection",
"Reset to Cinema Dark",
),
},
{
path: "interface",
label: "Navigation & Cards",
icon: PanelTop,
description: "Menus, poster size, and captions",
keywords: [
"navigation",
"menu",
"pin library",
"poster size",
"card size",
"hide title",
"hide year",
"artwork only",
"preset",
],
settings: settingIndex(
"Card preset",
"Poster size",
"Caption",
"Title & metadata",
"Title only",
"Artwork only",
"Primary menu",
"Choose destination or shortcut",
"Add to menu",
"Reset to default",
),
},
{
path: "theme-editor",
label: "Theme Editor",
icon: Wand2,
description: "Customize colors and CSS",
keywords: ["design tokens", "token overrides", "custom css", "community themes"],
settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
},
{
path: "accessibility",
label: "Accessibility",
icon: Eye,
description: "Readability and contrast",
keywords: ["contrast", "readability", "motion", "transparency", "text"],
settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
},
{
path: "home-screen",
label: "Home Screen",
icon: LayoutDashboard,
description: "Sections and layout",
keywords: ["sections", "rows", "continue watching", "next up", "library order"],
settings: settingIndex(
"Scope",
"Sections",
"Reset section customizations",
"Continue Watching",
"Next Up",
"Recently Added",
"Library order",
),
},
{
path: "card-overlays",
label: "Card Overlays",
icon: Layers,
description: "Badges on poster cards",
keywords: ["poster", "badges", "overlay", "accent color", "preset"],
settings: settingIndex(
"Preview",
"Preset",
"Accent color",
"Show icon",
"Position",
"How styling works",
),
},
{
path: "personalize",
label: "Personalize",
icon: Sparkles,
description: "Re-tune your taste profile",
keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
},
],
},
{
label: "Library & Data",
items: [
{
path: "libraries",
label: "Libraries",
icon: Library,
description: "Visibility and access",
keywords: [
"library visibility",
"access",
"disabled libraries",
"library order",
"playback preferences",
],
settings: settingIndex(
"Remember library pages",
"Library visibility",
"Library order",
"Spoken language",
"Subtitle language",
"Subtitle behavior",
"Forced subtitles",
"Playback preferences",
),
},
{
path: "history-import",
label: "History Import",
icon: Clock,
description: "Emby watch history",
keywords: ["emby", "watched history", "import", "mapping", "sync"],
settings: settingIndex(
"New import",
"Import history",
"Fetched",
"Matched",
"Unmatched",
"Progress",
"History",
"Skipped",
),
},
{
path: "webhook-sync",
label: "Webhook Sync",
icon: Server,
description: "Plex, Emby, and Jellyfin webhook intake",
keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
settings: settingIndex(
"Add a connection",
"Connected servers",
"Recent deliveries",
"Plex",
"Emby",
"Jellyfin",
"Server URL",
"Token",
),
},
{
path: "watch-providers",
label: "Watch Providers",
icon: Cloud,
description: "Trakt watch history and scrobbling",
keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
settings: settingIndex(
"Last imported",
"Last exported",
"Watched",
"Progress",
"Favorites",
"Exported",
"Import watched history",
"Import paused progress",
"Send watched changes",
"Send unwatched changes",
"Sync favorites",
"Sync favorite removals",
"Scrobble playback",
),
},
],
},
{
label: "Account",
items: [
{
path: "devices",
label: "Your Devices",
icon: MonitorSmartphone,
description: "Settings for each device you watch on",
description: "Per-device quality, HDR, and audio or subtitle sync.",
keywords: [
"devices",
"tv",
@@ -359,28 +162,159 @@ const NAV_SECTIONS: NavSection[] = [
"Forget this device",
),
},
],
},
{
label: "Appearance",
items: [
{
path: "notifications",
label: "Notifications",
icon: Bell,
description: "New-episode alerts and webhooks",
keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
path: "appearance",
label: "Appearance",
icon: Palette,
description: "Theme, interface tone, and date and time formats.",
keywords: [
"theme",
"profile theme",
"dark",
"light",
"custom theme",
"date format",
"time format",
"clock",
"24-hour",
"12-hour",
],
settings: settingIndex(
"New Episode Notifications",
"Email Notifications",
"Discord Notifications",
"Browser Notifications",
"Webhooks",
"Per-episode alerts",
"Digest",
"Webhook URL",
"Theme",
"Date & time",
"Date format",
"Time format",
"Current selection",
"Reset to Cinema Dark",
),
},
{
path: "interface",
label: "Navigation & Cards",
icon: PanelTop,
description: "Your primary menu, poster size, and card captions.",
keywords: [
"navigation",
"menu",
"pin library",
"poster size",
"card size",
"hide title",
"hide year",
"artwork only",
"preset",
],
settings: settingIndex(
"Card preset",
"Poster size",
"Caption",
"Title & metadata",
"Title only",
"Artwork only",
"Primary menu",
"Choose destination or shortcut",
"Add to menu",
"Reset to default",
),
},
{
path: "card-overlays",
label: "Card Overlays",
icon: Layers,
description: "Badges drawn on poster cards, and where they sit.",
keywords: ["poster", "badges", "overlay", "accent color", "preset"],
settings: settingIndex(
"Preview",
"Preset",
"Accent color",
"Show icon",
"Position",
"How styling works",
),
},
{
path: "accessibility",
label: "Accessibility",
icon: Eye,
description: "Text size, weight, and contrast for easier reading.",
keywords: ["contrast", "readability", "motion", "transparency", "text"],
settings: settingIndex("Text size", "Text weight", "Contrast", "High Contrast", "Preview"),
},
{
path: "theme-editor",
label: "Theme Editor",
icon: Wand2,
description: "Fine-tune theme colors and add your own CSS.",
keywords: ["design tokens", "token overrides", "custom css", "community themes"],
settings: settingIndex("Preview", "Token Overrides", "Custom CSS", "Community Themes"),
},
],
},
{
label: "Home & Discovery",
items: [
{
path: "home-screen",
label: "Home Screen",
icon: LayoutDashboard,
description: "Which rows appear on Home, and in what order.",
keywords: ["sections", "rows", "continue watching", "next up", "library order"],
settings: settingIndex(
"Scope",
"Sections",
"Reset section customizations",
"Continue Watching",
"Next Up",
"Recently Added",
"Library order",
),
},
{
path: "personalize",
label: "Personalize",
icon: Sparkles,
description: "Re-tune the taste profile behind your recommendations.",
keywords: ["taste profile", "recommendations", "ratings", "likes", "dislikes"],
settings: settingIndex("Refine your taste profile", "Taste profile", "Recommendations"),
},
{
path: "libraries",
label: "Libraries",
icon: Library,
description: "Which libraries you see, their order, and per-library audio.",
keywords: [
"library visibility",
"access",
"disabled libraries",
"library order",
"playback preferences",
],
settings: settingIndex(
"Remember library pages",
"Library visibility",
"Library order",
"Spoken language",
"Subtitle language",
"Subtitle behavior",
"Forced subtitles",
"Playback preferences",
),
},
],
},
{
label: "Connections",
items: [
{
path: "connect-apps",
label: "Connect Apps",
icon: Cast,
description: "Sign-in details for other apps",
description: "Sign-in details for Silo and Jellyfin-compatible apps.",
keywords: [
"jellyfin",
"infuse",
@@ -405,11 +339,72 @@ const NAV_SECTIONS: NavSection[] = [
"Every profile at a glance",
),
},
{
path: "watch-providers",
label: "Watch Providers",
icon: Cloud,
description: "Trakt watch history, favorites, and scrobbling.",
keywords: ["trakt", "import", "export", "scrobble", "favorites", "watch history"],
settings: settingIndex(
"Last imported",
"Last exported",
"Watched",
"Progress",
"Favorites",
"Exported",
"Import watched history",
"Import paused progress",
"Send watched changes",
"Send unwatched changes",
"Sync favorites",
"Sync favorite removals",
"Scrobble playback",
),
},
{
path: "webhook-sync",
label: "Webhook Sync",
icon: Server,
description: "Take progress from Plex, Emby, and Jellyfin webhooks.",
keywords: ["plex", "emby", "jellyfin", "webhook", "progress", "watched"],
settings: settingIndex(
"Add a connection",
"Connected servers",
"Recent deliveries",
"Plex",
"Emby",
"Jellyfin",
"Server URL",
"Token",
),
},
{
path: "history-import",
label: "History Import",
icon: Clock,
description: "Bring an existing Emby watch history into Silo.",
keywords: ["emby", "watched history", "import", "mapping", "sync"],
settings: settingIndex(
"New import",
"Import history",
"Fetched",
"Matched",
"Unmatched",
"Progress",
"History",
"Skipped",
),
},
],
},
{
label: "Account",
items: [
{
path: "profiles",
label: "Profiles",
icon: Users,
description: "Names, PINs, and access rules",
description: "Household profile names, PINs, and library access.",
keywords: ["profile name", "pin", "access", "primary profile", "household"],
settings: settingIndex(
"Profile name",
@@ -421,6 +416,23 @@ const NAV_SECTIONS: NavSection[] = [
),
primaryOrAdmin: true,
},
{
path: "notifications",
label: "Notifications",
icon: Bell,
description: "New-episode alerts by email, Discord, push, or webhook.",
keywords: ["new episodes", "email", "discord", "browser push", "webhooks"],
settings: settingIndex(
"New Episode Notifications",
"Email Notifications",
"Discord Notifications",
"Browser Notifications",
"Webhooks",
"Per-episode alerts",
"Digest",
"Webhook URL",
),
},
],
},
];
@@ -428,24 +440,21 @@ const NAV_SECTIONS: NavSection[] = [
interface SettingsOverviewProps {
sections: readonly { label: string; items: readonly NavItem[] }[];
profile: { name: string; avatar_url?: string } | null;
search: string;
onSearchChange: (value: string) => void;
resultCount: number;
totalCount: number;
}
function SettingsOverview({
sections,
profile,
search,
onSearchChange,
resultCount,
totalCount,
}: SettingsOverviewProps) {
/**
* The index is the same directory the admin settings index uses: category jump
* links, then one uniform card grid per group across the full page width.
*
* The two-column list it replaced paired groups side by side, so a short group
* next to a long one left a column of dead space taller than the short group
* itself — the exact shape of the desktop complaint.
*/
function SettingsOverview({ sections, profile }: SettingsOverviewProps) {
const profileName = profile?.name ?? "Your profile";
return (
<div className="mx-auto mt-6 w-full max-w-5xl space-y-6 sm:mt-8">
<div className="w-full space-y-6">
<Link
to="/profiles"
aria-label={`Current profile: ${profileName}`}
@@ -464,14 +473,6 @@ function SettingsOverview({
<ChevronRight className="text-muted-foreground h-4 w-4 shrink-0" aria-hidden="true" />
</Link>
<SettingsSearchInput
value={search}
onChange={onSearchChange}
resultCount={resultCount}
totalCount={totalCount}
className="w-full sm:max-w-md"
/>
<SettingsOverviewNav
groups={sections.map((section) => ({
...section,
@@ -485,6 +486,7 @@ function SettingsOverview({
}))}
ariaLabel="Settings sections"
idPrefix="settings-index"
variant="directory"
/>
</div>
);
@@ -555,9 +557,21 @@ export default function SettingsLayout() {
/>
</div>
<div className="mt-5 min-w-0 flex-1 lg:mt-10 lg:flex lg:gap-10">
<aside className="hidden lg:block lg:w-[220px] lg:shrink-0">
<nav aria-label="Settings sections" className="sticky top-6 space-y-5 pl-3">
{/* From lg up, the rail and the active page share one panel, the
same shell the admin settings use. Below lg the rail is hidden
and the page is already a stack of its own panels, so the extra
chrome would only nest a card inside a card — mobile keeps the
bare layout.
Admin scrolls its detail pane inside the panel; here the page
scrolls and the rail sticks, because Your Devices owns a
viewport-height scroller that a nested one would strand. */}
<div className="surface-panel-lg mt-5 flex min-w-0 flex-1 flex-col lg:mt-10 lg:min-h-[500px] lg:flex-row lg:overflow-hidden">
<aside className="border-border hidden lg:block lg:w-60 lg:flex-shrink-0 lg:border-r">
<nav
aria-label="Settings sections"
className="sticky top-6 space-y-5 py-5 pr-3 pl-5"
>
{filteredSections.map((section) => (
<SideNavSection
key={section.label}
@@ -581,8 +595,8 @@ export default function SettingsLayout() {
</nav>
</aside>
<div className="min-w-0 flex-1 pt-8 lg:pt-0">
<div className={cn("mx-auto w-full", wideSetting ? "max-w-6xl" : "max-w-3xl")}>
<div className="min-w-0 flex-1 p-4 sm:p-6">
<div className={cn("w-full", wideSetting ? "max-w-none" : "max-w-3xl")}>
<Outlet />
</div>
</div>
@@ -591,22 +605,23 @@ export default function SettingsLayout() {
) : (
<>
<PageBack to="/" preferHistory={false} floating />
<div className="page-header mt-10 gap-5 sm:mt-12">
<div className="page-header mt-10 mb-6 gap-5 sm:mt-12 sm:mb-8">
<div className="min-w-0 space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
<p className="page-subtitle text-sm sm:text-base">
Make Silo work the way you like.
</p>
</div>
<SettingsSearchInput
value={settingsSearch}
onChange={setSettingsSearch}
resultCount={filteredSettingsCount}
totalCount={flatItems.length}
className="w-full sm:max-w-sm lg:w-[26rem] lg:max-w-none"
showShortcutHint
/>
</div>
<SettingsOverview
sections={filteredSections}
profile={profile}
search={settingsSearch}
onSearchChange={setSettingsSearch}
resultCount={filteredSettingsCount}
totalCount={flatItems.length}
/>
<SettingsOverview sections={filteredSections} profile={profile} />
</>
)}
</main>
@@ -69,28 +69,20 @@ describe("AdminSettingsLayout", () => {
}
});
it("renders desktop category jump links with section counts", () => {
it("names each settings group exactly once", () => {
renderInteractiveLayout();
const categoryNavigation = screen.getByRole("navigation", {
name: "Admin settings sections categories",
});
expect(categoryNavigation).toContainElement(
screen.getByRole("link", { name: "Server, 4 settings sections" }),
);
expect(screen.getByRole("link", { name: "Media, 7 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-media",
);
expect(screen.getByRole("link", { name: "Connections, 6 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-connections",
);
expect(screen.getByRole("link", { name: "Data, 3 settings sections" })).toHaveAttribute(
"href",
"#admin-settings-index-data",
);
// The category jump bar used to repeat every group name and count directly
// above the headings that already carry them.
expect(
screen.queryByRole("navigation", { name: "Admin settings sections categories" }),
).not.toBeInTheDocument();
for (const group of ["Server", "Media", "Connections", "Data"]) {
expect(screen.getAllByRole("heading", { name: group })).toHaveLength(1);
expect(
screen.queryByRole("link", { name: new RegExp(`^${group}, \\d+ settings`) }),
).toBeNull();
}
});
it("uses one desktop grid and card geometry for every settings group", () => {
@@ -102,19 +94,6 @@ describe("AdminSettingsLayout", () => {
expect(markup.match(/lg:line-clamp-3/g)).toHaveLength(20);
});
it("marks a selected category jump link as the current location", async () => {
renderInteractiveLayout();
const server = screen.getByRole("link", { name: "Server, 4 settings sections" });
const data = screen.getByRole("link", { name: "Data, 3 settings sections" });
expect(server).toHaveAttribute("aria-current", "location");
await userEvent.click(data);
expect(server).not.toHaveAttribute("aria-current");
expect(data).toHaveAttribute("aria-current", "location");
});
it("renders every settings tab", () => {
const markup = renderLayout();
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { getAccessToken, getProfileToken } from "@/api/client";
import { getAccessToken, getOrCreateDeviceId, getProfileToken } from "@/api/client";
import type { AudiobookFile } from "@/lib/audiobooks/types";
import { PlayerConfigProvider, type PlayerConfig } from "@/player";
import { storage } from "@/utils/storage";
@@ -50,6 +50,7 @@ export function AudiobookPlaybackProvider({ children }: { children: ReactNode })
getAccessToken: () => getAccessToken(),
getProfileId: () => storage.get(storage.KEYS.PROFILE_ID),
getProfileToken: () => getProfileToken(),
getDeviceId: () => getOrCreateDeviceId(),
}),
[],
);
@@ -68,6 +68,7 @@ const playerConfig: PlayerConfig = {
apiBaseUrl: "/api/v1",
getAccessToken: () => "token",
getProfileId: () => "profile-1",
getDeviceId: () => "test-device",
getProfileToken: () => null,
};
@@ -100,6 +101,7 @@ function audioOnlyDecision(
startPosition: number,
timeline: Partial<{
stream_origin_seconds: number;
timeline_offset_seconds: number;
player_start_seconds: number;
can_seek_anywhere: boolean;
}> = {},
@@ -442,6 +444,7 @@ describe("useAudiobookPlayback", () => {
return jsonResponse(
audioOnlyDecision(`anchored-${sessionCount}`, body.start_position, {
stream_origin_seconds: body.start_position,
timeline_offset_seconds: body.start_position,
player_start_seconds: 0,
can_seek_anywhere: false,
}),
@@ -476,6 +479,108 @@ describe("useAudiobookPlayback", () => {
});
});
it("maps native seeks with the contract timeline offset", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/playback/start")) {
const body = JSON.parse(String(init?.body)) as { start_position: number };
return jsonResponse(
audioOnlyDecision("offset-contract", body.start_position, {
stream_origin_seconds: 300,
timeline_offset_seconds: 290,
player_start_seconds: 10,
can_seek_anywhere: true,
}),
{ status: 201 },
);
}
if (url.endsWith("/playback/route-events")) return new Response(null, { status: 202 });
if (url.includes("/progress") || init?.method === "DELETE") {
return new Response(null, { status: 204 });
}
return jsonResponse({});
}),
);
const { result } = renderAudiobookPlayback({ initialPositionSeconds: 300 });
const audio = makeAudio();
act(() => {
(result.current.audioRef as MutableRefObject<HTMLAudioElement>).current = audio;
});
await flushAsyncWork();
act(() => result.current.seekTo(360));
expect(audio.currentTime).toBe(70);
});
it("preserves negative timeline offsets and clamps absolute buffered ranges", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.endsWith("/playback/start")) {
const body = JSON.parse(String(init?.body)) as { start_position: number };
return jsonResponse(
audioOnlyDecision("negative-offset", body.start_position, {
timeline_offset_seconds: -5,
player_start_seconds: 5,
can_seek_anywhere: true,
}),
{ status: 201 },
);
}
if (url.endsWith("/playback/route-events")) return new Response(null, { status: 202 });
if (url.includes("/progress") || init?.method === "DELETE") {
return new Response(null, { status: 204 });
}
return jsonResponse({});
}),
);
const onPlayback = vi.fn<(playback: AudiobookPlayback) => void>();
function Harness() {
const playback = useAudiobookPlayback({
contentId: "c",
files,
initialPositionSeconds: 0,
});
useEffect(() => {
onPlayback(playback);
}, [playback]);
return createElement("audio", {
ref: playback.audioRef,
src: playback.streamUrl || undefined,
});
}
const { container } = render(createElement(Harness), { wrapper });
await flushAsyncWork();
const audio = container.querySelector("audio");
if (!audio) throw new Error("expected audio element");
Object.defineProperty(audio, "buffered", {
configurable: true,
value: {
length: 1,
start: () => 0,
end: () => 8,
} as TimeRanges,
});
act(() => {
audio.currentTime = 7;
fireEvent.timeUpdate(audio);
fireEvent.progress(audio);
});
await flushAsyncWork();
expect(onPlayback.mock.lastCall?.[0].currentTime).toBe(2);
expect(onPlayback.mock.lastCall?.[0].buffered?.start(0)).toBe(0);
expect(onPlayback.mock.lastCall?.[0].buffered?.end(0)).toBe(3);
});
it("togglePlay invokes audio.play when paused, audio.pause otherwise", () => {
const { result } = renderAudiobookPlayback();
const audio = makeAudio();
@@ -86,10 +86,11 @@ function safeNumber(value: number): number {
export function audiobookAbsoluteTime(
partStartSeconds: number,
streamOriginSeconds: number,
timelineOffsetSeconds: number,
playerSeconds: number,
): number {
return safeNumber(partStartSeconds) + safeNumber(streamOriginSeconds) + safeNumber(playerSeconds);
const timelineOffset = Number.isFinite(timelineOffsetSeconds) ? timelineOffsetSeconds : 0;
return Math.max(0, safeNumber(partStartSeconds) + timelineOffset + safeNumber(playerSeconds));
}
function buildParts(files: AudiobookFile[]): AudiobookPart[] {
@@ -145,20 +146,21 @@ function absoluteBufferedRanges(
ranges: TimeRanges,
part: AudiobookPart | undefined,
bookDuration: number,
streamOriginSeconds = 0,
timelineOffsetSeconds = 0,
): TimeRanges {
if (!part) {
return { length: 0, start: () => 0, end: () => 0 } as TimeRanges;
}
const timelineOffset = Number.isFinite(timelineOffsetSeconds) ? timelineOffsetSeconds : 0;
const out: Array<{ start: number; end: number }> = [];
for (let i = 0; i < ranges.length; i++) {
const start = Math.min(
bookDuration,
part.start + streamOriginSeconds + safeNumber(ranges.start(i)),
const start = Math.max(
0,
Math.min(bookDuration, part.start + timelineOffset + safeNumber(ranges.start(i))),
);
const end = Math.min(
bookDuration,
part.start + streamOriginSeconds + safeNumber(ranges.end(i)),
const end = Math.max(
0,
Math.min(bookDuration, part.start + timelineOffset + safeNumber(ranges.end(i))),
);
if (end > start) {
out.push({ start, end });
@@ -258,7 +260,7 @@ export function useAudiobookPlayback({
const attemptCountRef = useRef(1);
const replanInFlightPlanKeyRef = useRef<string | null>(null);
const failedPlanKeyRef = useRef<string | null>(null);
const streamOriginSecondsRef = useRef(0);
const timelineOffsetSecondsRef = useRef(0);
const canSeekAnywhereRef = useRef(true);
const reportRef = useRef<(pos: number) => void>(() => {});
const reportSessionRef = useRef<(pos: number, isPaused: boolean, keepalive?: boolean) => void>(
@@ -326,7 +328,7 @@ export function useAudiobookPlayback({
planAttemptIdRef.current = planAttemptId;
sessionIdRef.current = sessionId ?? null;
failedPlanKeyRef.current = null;
streamOriginSecondsRef.current = plan.timeline.stream_origin_seconds;
timelineOffsetSecondsRef.current = plan.timeline.timeline_offset_seconds;
canSeekAnywhereRef.current = plan.timeline.can_seek_anywhere;
// The plan owns where the stream is anchored. On a converted part the
// server anchors the stream at the seek position and restarts the player
@@ -511,7 +513,7 @@ export function useAudiobookPlayback({
const target = clampedBookTime(initialPositionSeconds, duration);
const index = findPartIndex(parts, target);
pendingLocalSeekRef.current = localTimeForPart(parts[index], target);
streamOriginSecondsRef.current = 0;
timelineOffsetSecondsRef.current = 0;
canSeekAnywhereRef.current = true;
autoPlayPendingRef.current = autoPlay;
setBuffered(null);
@@ -643,7 +645,7 @@ export function useAudiobookPlayback({
if (!audio || !fileId || !activePart) return;
const absoluteFromAudio = () =>
audiobookAbsoluteTime(activePart.start, streamOriginSecondsRef.current, audio.currentTime);
audiobookAbsoluteTime(activePart.start, timelineOffsetSecondsRef.current, audio.currentTime);
const onTimeUpdate = () => setAbsoluteTime(absoluteFromAudio());
const onProgress = () =>
@@ -652,7 +654,7 @@ export function useAudiobookPlayback({
audio.buffered,
activePart,
duration,
streamOriginSecondsRef.current,
timelineOffsetSecondsRef.current,
),
);
const onDurationChange = () =>
@@ -661,7 +663,7 @@ export function useAudiobookPlayback({
audio.buffered,
activePart,
duration,
streamOriginSecondsRef.current,
timelineOffsetSecondsRef.current,
),
);
const onLoadedMetadata = () => {
@@ -805,7 +807,7 @@ export function useAudiobookPlayback({
setBuffered(null);
setActiveFileIndex(nextIndex);
} else if (audio && canSeekAnywhereRef.current) {
audio.currentTime = Math.max(0, local - streamOriginSecondsRef.current);
audio.currentTime = Math.max(0, local - timelineOffsetSecondsRef.current);
} else {
pendingLocalSeekRef.current = local;
playAfterSourceSwitchRef.current = shouldContinuePlaying;
+2 -1
View File
@@ -13,7 +13,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Pause, PictureInPicture2, Play, SkipBack, SkipForward, Tv, X } from "lucide-react";
import { useLocation, useNavigate } from "react-router";
import type { WatchDetail } from "@/api/types";
import { getAccessToken, getProfileToken } from "@/api/client";
import { getAccessToken, getOrCreateDeviceId, getProfileToken } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
@@ -452,6 +452,7 @@ export function WatchPlaybackHost() {
getAccessToken: () => getAccessToken(),
getProfileId: () => storage.get(storage.KEYS.PROFILE_ID),
getProfileToken: () => getProfileToken(),
getDeviceId: () => getOrCreateDeviceId(),
}),
[],
);
+19 -1
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { detectHLSSupport } from "./client-context-v3";
import { buildDeliveriesV3, detectHLSSupport } from "./client-context-v3";
afterEach(() => {
vi.unstubAllGlobals();
@@ -25,3 +25,21 @@ describe("detectHLSSupport", () => {
expect(detectHLSSupport()).toBe(true);
});
});
describe("buildDeliveriesV3", () => {
it("advertises the embedded text artifacts rendered by the web player", () => {
const deliveries = buildDeliveriesV3({
containers: ["mp4"],
codecsVideo: ["h264"],
codecsAudio: ["aac"],
maxResolution: "1080p",
hdr: false,
hls: true,
});
for (const delivery of Object.values(deliveries)) {
expect(delivery.subtitles.embedded_text).toBe(true);
expect(delivery.subtitles.sidecar_text).toBe(true);
}
});
});
+5 -4
View File
@@ -26,12 +26,13 @@ const WEB_APP_VERSION = "web";
/**
* Subtitle capabilities of the web player, identical for every delivery class:
* it renders sidecar text (WebVTT and ASS via JASSUB, with container font
* attachments) but has no bitmap subtitle renderer, so PGS/VOBSUB tracks must
* be burned in server-side.
* the server exposes external and embedded text as session-scoped sidecars,
* which it renders as WebVTT or ASS via JASSUB (with container font
* attachments). It has no bitmap subtitle renderer, so PGS/VOBSUB tracks must
* still be burned in server-side.
*/
const WEB_SUBTITLE_CAPABILITIES: DeliverySubtitleCapabilitiesV3 = {
embedded_text: false,
embedded_text: true,
sidecar_text: true,
ass_styling: true,
embedded_bitmap: false,
+61 -2
View File
@@ -17,6 +17,10 @@ const controls = vi.hoisted(() => ({
subtitleTracks: PlayerSubtitleInfo[];
},
}));
const subtitleTimeline = vi.hoisted(() => ({
textOffsetSeconds: null as number | null,
assOffsetSeconds: null as number | null,
}));
vi.mock("../hooks/usePlaybackRealtime", () => ({
usePlaybackRealtime: vi.fn((options) => {
@@ -31,9 +35,17 @@ vi.mock("../hooks/useKeyboardShortcuts", () => ({ useKeyboardShortcuts: vi.fn()
vi.mock("../hooks/useRemuxSeeking", () => ({
useRemuxSeeking: () => ({ handleSeek: vi.fn() }),
}));
vi.mock("../hooks/useSubtitleTracks", () => ({ useSubtitleTracks: () => [] }));
vi.mock("../hooks/useSubtitleTracks", () => ({
useSubtitleTracks: (...args: unknown[]) => {
subtitleTimeline.textOffsetSeconds = args[3] as number;
return [];
},
}));
vi.mock("../hooks/useASSSubtitles", () => ({
useASSSubtitles: () => ({ isActive: false }),
useASSSubtitles: (...args: unknown[]) => {
subtitleTimeline.assOffsetSeconds = args[4] as number;
return { isActive: false };
},
}));
vi.mock("../hooks/useSubtitleAppearance", () => ({
useSubtitleAppearance: () => ({
@@ -59,6 +71,7 @@ const playerConfig: PlayerConfig = {
apiBaseUrl: "/api/v1",
getAccessToken: () => "token",
getProfileId: () => "profile-1",
getDeviceId: () => "test-device",
getProfileToken: () => null,
};
@@ -115,6 +128,8 @@ describe("VideoPlayer plan failure recovery", () => {
beforeEach(() => {
realtimeOptions.current = null;
controls.current = null;
subtitleTimeline.textOffsetSeconds = null;
subtitleTimeline.assOffsetSeconds = null;
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
@@ -169,12 +184,54 @@ describe("VideoPlayer plan failure recovery", () => {
fireEvent.error(video);
expect(onPlanFailure).toHaveBeenCalledTimes(2);
});
it("does not retry an auto-selected subtitle after its replan is refused", async () => {
const onSubtitleTrackChange = vi.fn();
const sidecarTrack: PlayerSubtitleInfo = {
index: 2,
media_file_id: 7,
track_id: "file:7:subtitle:2",
language: "en",
codec: "srt",
label: "English",
source: "external",
url: "/stream/session-1/subtitles/2.vtt",
};
const { rerenderPlayer } = renderPlayer({
subtitleUrls: [sidecarTrack],
subtitleMode: "always",
preferredSubtitleLanguage: "en",
onSubtitleTrackChange,
});
await waitFor(() => expect(onSubtitleTrackChange).toHaveBeenCalledOnce());
expect(onSubtitleTrackChange).toHaveBeenCalledWith(2, 0);
rerenderPlayer({ replanError: "Silo could not apply the subtitle selection." });
await waitFor(() => expect(controls.current?.activeSubtitleIndex).toBeNull());
expect(onSubtitleTrackChange).toHaveBeenCalledOnce();
const nextPlan = fixturePlanV3({
...directPlan,
plan_id: "plan:next-session",
plan_attempt_key: "v3:next-session",
session_id: "session-2",
});
rerenderPlayer({ sessionId: "session-2", plan: nextPlan, replanError: null });
await waitFor(() => expect(controls.current?.activeSubtitleIndex).toBe(2));
expect(onSubtitleTrackChange).toHaveBeenCalledTimes(2);
expect(onSubtitleTrackChange).toHaveBeenLastCalledWith(2, 0);
});
});
describe("VideoPlayer native HLS timeline", () => {
beforeEach(() => {
realtimeOptions.current = null;
controls.current = null;
subtitleTimeline.textOffsetSeconds = null;
subtitleTimeline.assOffsetSeconds = null;
vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
vi.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
@@ -214,6 +271,8 @@ describe("VideoPlayer native HLS timeline", () => {
fireEvent.loadedMetadata(video);
expect(video.currentTime).toBe(7);
expect(subtitleTimeline.textOffsetSeconds).toBe(0);
expect(subtitleTimeline.assOffsetSeconds).toBe(0);
});
});
+28 -18
View File
@@ -255,7 +255,7 @@ export function VideoPlayer({
const lastRecoveryRef = useRef(0);
const reportedPlanFailureKeyRef = useRef<string | null>(null);
const transportFailedForPlanRevisionRef = useRef<number | null>(null);
const streamOriginRef = useRef(0);
const timelineOffsetRef = useRef(0);
const subtitleFetchAnchorRef = useRef(initialPosition);
const backendDurationRef = useRef(propDuration ?? 0);
const autoEnterPictureInPictureAttemptedRef = useRef(false);
@@ -490,7 +490,7 @@ export function VideoPlayer({
roomConnection: watchTogether,
sessionId,
videoRef,
streamOriginRef,
streamOriginRef: timelineOffsetRef,
});
const roomPlaybackActive = !!watchTogetherRoomId && !watchTogether.closedReason;
const roomSyncWaiting = watchTogether.room?.playback_state === "waiting";
@@ -531,14 +531,14 @@ export function VideoPlayer({
const isCopyOriginalHLS = plan.delivery === "server_remux_hls";
// Keep the player-local clock mapped onto the canonical media timeline.
const streamOriginSeconds = plan.timeline.stream_origin_seconds;
streamOriginRef.current = streamOriginSeconds;
const timelineOffsetSeconds = plan.timeline.timeline_offset_seconds;
timelineOffsetRef.current = timelineOffsetSeconds;
// Media-time position playback is heading to, for consumers that need a
// position before the element has media loaded (when currentTime still
// reads 0): an in-flight seek target, else the session's start position.
subtitleFetchAnchorRef.current =
pendingSeekTime ?? toMediaTime(effectiveInitialPosition, streamOriginSeconds);
pendingSeekTime ?? toMediaTime(effectiveInitialPosition, timelineOffsetSeconds);
useEffect(() => {
if (backendDuration > 0) {
@@ -701,7 +701,7 @@ export function VideoPlayer({
setPendingSeekTime(seconds);
setCurrentTime(seconds);
const nativeSeconds = toPlayerTime(seconds, streamOriginRef.current);
const nativeSeconds = toPlayerTime(seconds, timelineOffsetRef.current);
if (canSeekAnywhere) {
if (isHlsStream) video.currentTime = nativeSeconds;
else handleSeek(nativeSeconds);
@@ -773,17 +773,20 @@ export function VideoPlayer({
// before dispatching the seek request.
const handleKeyboardSeek = useCallback(
(seconds: number) => {
handlePlayerSeek(toMediaTime(seconds, streamOriginRef.current));
handlePlayerSeek(toMediaTime(seconds, timelineOffsetRef.current));
},
[handlePlayerSeek],
);
// -- Watch progress reporting --
const flushWatchProgress = useWatchProgress(sessionId, videoRef, streamOriginRef);
const flushWatchProgress = useWatchProgress(sessionId, videoRef, timelineOffsetRef);
const buildExitState = useCallback((): PlaybackExitState => {
const video = videoRef.current;
const positionSeconds = toMediaTime(video?.currentTime ?? currentTime, streamOriginRef.current);
const positionSeconds = toMediaTime(
video?.currentTime ?? currentTime,
timelineOffsetRef.current,
);
// positionSeconds is media time, so the runtime paired with it must be too.
const durationSeconds = mediaDurationSeconds(backendDurationRef.current, duration);
@@ -1588,7 +1591,7 @@ export function VideoPlayer({
setAwaitingFirstFrame(false);
};
const onTimeUpdate = () => {
const nextTime = toMediaTime(video.currentTime, streamOriginRef.current);
const nextTime = toMediaTime(video.currentTime, timelineOffsetRef.current);
const resolved = resolvePendingSeekTime(nextTime, pendingSeekTime);
setCurrentTime(resolved.currentTime);
if (resolved.pendingSeekTime !== pendingSeekTime) {
@@ -1602,7 +1605,7 @@ export function VideoPlayer({
};
const onSeeked = () => {
setPendingSeekTime(null);
setCurrentTime(toMediaTime(video.currentTime, streamOriginRef.current));
setCurrentTime(toMediaTime(video.currentTime, timelineOffsetRef.current));
markPlaybackStarted();
clearBuffering();
if (roomSyncWaiting && watchTogetherSync.attachedSessionId === sessionId) {
@@ -1864,7 +1867,7 @@ export function VideoPlayer({
videoRef,
effectiveSubtitleTracks,
activeSubtitleIndex,
streamOriginSeconds,
timelineOffsetSeconds,
subtitleDelayMs,
durationRef,
subtitleFetchAnchorRef,
@@ -1879,7 +1882,7 @@ export function VideoPlayer({
subtitleUrls,
activeSubtitleIndex,
isDetached,
streamOriginSeconds,
timelineOffsetSeconds,
subtitleDelayMs,
);
@@ -1932,14 +1935,24 @@ export function VideoPlayer({
// A refused replan leaves the previous stream playing, so the selection has
// to roll back too: without this the menu would keep claiming a track is on
// that the server never rendered, and re-picking it would be suppressed as an
// unchanged selection instead of retrying.
// unchanged selection instead of retrying. Pin the accepted selection just
// like a manual choice so auto-selection does not immediately request the
// rejected track again; a later user choice can still retry it explicitly.
useEffect(() => {
if (requestedSubtitleTrackChangeRef.current && replanError && !replanning) {
subtitleSelectionWasManualRef.current = true;
setActiveSubtitleIndex(plan.selected_tracks.subtitle?.index ?? null);
requestedSubtitleTrackChangeRef.current = null;
}
}, [plan.selected_tracks.subtitle?.index, replanError, replanning]);
// A refusal pin belongs only to the session that rejected the automatic
// selection. Clear it before the auto-selection effect evaluates a new
// session so the viewer's persisted subtitle mode applies to the next title.
useEffect(() => {
subtitleSelectionWasManualRef.current = false;
}, [sessionId]);
// -- Auto-select subtitle track based on mode --
useEffect(() => {
if (subtitleSelectionWasManualRef.current) {
@@ -1991,12 +2004,9 @@ export function VideoPlayer({
audioTracks,
activeAudioIndex,
selectedVersion,
sessionId,
]);
useEffect(() => {
subtitleSelectionWasManualRef.current = false;
}, [sessionId]);
// -- Control callbacks --
const handlePlayPause = useCallback(() => {
const video = videoRef.current;
@@ -21,6 +21,7 @@ vi.mock("../context/PlayerConfigContext", () => ({
apiBaseUrl: "/api/v1",
getAccessToken: () => "token",
getProfileId: () => "profile-1",
getDeviceId: () => "test-device",
}),
}));
vi.mock("@tanstack/react-query", () => ({
@@ -14,6 +14,8 @@ export interface PlayerConfig {
getProfileId: () => string | null;
/** Sync getter for the current verified profile token, when one exists. */
getProfileToken?: () => string | null;
/** Stable device identity used for device-scoped playback settings. */
getDeviceId: () => string;
}
const PlayerConfigCtx = createContext<PlayerConfig | null>(null);
@@ -19,6 +19,7 @@ const playerConfig: PlayerConfig = {
apiBaseUrl: "/api/v1",
getAccessToken: () => "token",
getProfileId: () => "profile-1",
getDeviceId: () => "test-device",
getProfileToken: () => null,
};
+15
View File
@@ -7,6 +7,7 @@ const config: PlayerConfig = {
apiBaseUrl: "/api/v1",
getAccessToken: () => null,
getProfileId: () => null,
getDeviceId: () => "web-player-device",
};
afterEach(() => {
@@ -41,4 +42,18 @@ describe("playerFetch", () => {
await expect(playerFetch<void>(config, "/playback/route-events")).resolves.toBeUndefined();
});
it("sends the host application's stable device identity", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
await playerFetch<void>(config, "/playback/start", { method: "POST" });
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/playback/start",
expect.objectContaining({
headers: expect.objectContaining({ "X-Silo-Device-Id": "web-player-device" }),
}),
);
});
});
+2
View File
@@ -55,6 +55,8 @@ export async function playerFetch<T>(
headers["X-Profile-Token"] = profileToken;
}
headers["X-Silo-Device-Id"] = config.getDeviceId();
const res = await fetch(`${config.apiBaseUrl}${path}`, {
...options,
headers,
+4 -4
View File
@@ -1,11 +1,11 @@
export function toMediaTime(playerTimeSeconds: number, streamOriginSeconds = 0): number {
return Math.max(0, playerTimeSeconds + streamOriginSeconds);
export function toMediaTime(playerTimeSeconds: number, timelineOffsetSeconds = 0): number {
return Math.max(0, playerTimeSeconds + timelineOffsetSeconds);
}
export function toPlayerTime(mediaTimeSeconds: number, streamOriginSeconds = 0): number {
export function toPlayerTime(mediaTimeSeconds: number, timelineOffsetSeconds = 0): number {
// Preserve negative offsets so callers can distinguish a target before the
// current HLS window from the locally seekable position at zero.
return mediaTimeSeconds - streamOriginSeconds;
return mediaTimeSeconds - timelineOffsetSeconds;
}
export function subtitleStartPositionSeconds(