Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e485b3ab9d | ||
|
|
e36612e619 | ||
|
|
75936cdae2 | ||
|
|
1dcdd4b27a | ||
|
|
2dc7d5e36c | ||
|
|
461b51c02c |
@@ -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`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user