fix(playback): honor device audio language selection (#596)
* fix(playback): honor device audio language selection * fix(playback): harden device preference retries * fix(playback): preserve device-aware retries
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -624,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,6 +26,7 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/nodepool"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||||
"github.com/Silo-Server/silo-server/internal/subtitles"
|
||||
"github.com/Silo-Server/silo-server/internal/transcodenode"
|
||||
)
|
||||
@@ -362,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
|
||||
}
|
||||
@@ -404,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
|
||||
@@ -434,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
|
||||
@@ -457,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
|
||||
@@ -486,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)
|
||||
@@ -495,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
|
||||
@@ -506,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."}
|
||||
}
|
||||
@@ -574,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()
|
||||
@@ -583,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.
|
||||
@@ -597,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}
|
||||
}
|
||||
@@ -814,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
|
||||
}
|
||||
@@ -830,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)
|
||||
@@ -839,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
|
||||
}
|
||||
@@ -2677,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,
|
||||
@@ -2686,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
|
||||
}
|
||||
|
||||
@@ -2700,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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2437,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)
|
||||
}
|
||||
@@ -2448,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -71,6 +71,7 @@ const playerConfig: PlayerConfig = {
|
||||
apiBaseUrl: "/api/v1",
|
||||
getAccessToken: () => "token",
|
||||
getProfileId: () => "profile-1",
|
||||
getDeviceId: () => "test-device",
|
||||
getProfileToken: () => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user