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:
Quick
2026-08-11 16:19:30 -04:00
committed by GitHub
parent 461b51c02c
commit 2dc7d5e36c
20 changed files with 410 additions and 99 deletions
+3
View File
@@ -9,6 +9,7 @@ import (
)
func requestAccessFilter(r *http.Request) catalog.AccessFilter {
deviceID := deviceMetadataFromRequest(r).DeviceID
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
@@ -17,10 +18,12 @@ func requestAccessFilter(r *http.Request) catalog.AccessFilter {
MaxPlaybackQuality: scope.MaxPlaybackQuality,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
return catalog.AccessFilter{
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -0,0 +1,18 @@
package handlers
import (
"net/http/httptest"
"testing"
)
func TestCatalogAccessFiltersCarryExplicitDeviceIdentity(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v1/items/movie-1", nil)
req.Header.Set(deviceIDHeader, " apple-tv ")
if got := requestAccessFilter(req).DeviceID; got != "apple-tv" {
t.Fatalf("requestAccessFilter().DeviceID = %q, want apple-tv", got)
}
if got := (&ItemsHandler{}).accessFilter(req).DeviceID; got != "apple-tv" {
t.Fatalf("ItemsHandler.accessFilter().DeviceID = %q, want apple-tv", got)
}
}
+3
View File
@@ -2115,6 +2115,7 @@ func filterSortClause(sort, order string) string {
}
func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
deviceID := deviceMetadataFromRequest(r).DeviceID
selectedFileID := 0
if fileIDRaw := strings.TrimSpace(r.URL.Query().Get("fileId")); fileIDRaw != "" {
if fileID, err := strconv.Atoi(fileIDRaw); err == nil && fileID > 0 {
@@ -2141,6 +2142,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
@@ -2168,6 +2170,7 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
DeviceID: deviceID,
}
}
+19 -18
View File
@@ -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 ---
+104 -10
View File
@@ -551,6 +551,8 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
file.AudioTracks = []models.AudioTrack{
{Language: "eng", Codec: "aac", Channels: 2, Layout: "stereo", Default: true},
{Language: "jpn", Codec: "aac", Channels: 2, Layout: "stereo"},
{Language: "spa", Codec: "aac", Channels: 2, Layout: "stereo"},
{Language: "fra", Codec: "aac", Channels: 2, Layout: "stereo"},
}
return file
}
@@ -564,18 +566,49 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
}
}
startPlayback := func(t *testing.T, store userstore.UserStore, file *models.MediaFile) int {
setCanonicalLanguage := func(t *testing.T, store userstore.UserStore, identity userstore.SettingIdentity, language string) {
t.Helper()
identity.Key = settingskeys.PlaybackAudioLanguage
identity.ProfileID = "profile-1"
encoded, err := json.Marshal(language)
if err != nil {
t.Fatalf("encode canonical audio language: %v", err)
}
if _, err := store.UpsertSettingValue(context.Background(), identity, encoded); err != nil {
t.Fatalf("seed canonical audio language: %v", err)
}
}
startPlayback := func(
t *testing.T,
store userstore.UserStore,
file *models.MediaFile,
deviceID string,
mutate func(*playback.StartRequestV3),
) int {
t.Helper()
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: file})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.StoreProvider = testUserStoreProvider{store: store}
handler.ItemAccess = allowAllPlaybackItemAccess{}
if file.EpisodeID != "" {
handler.EpisodeLookup = testEpisodeLookup{episode: &models.Episode{ContentID: file.EpisodeID, SeriesID: "series-1"}}
}
startRequest := v3HandlerStartRequest()
startRequest.ClientPlaybackContext.Deliveries[playback.DeliveryClassProgressiveV3] = playback.DeliveryCapabilityV3{Enabled: true, SupportedOnDevice: true}
if mutate != nil {
mutate(&startRequest)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start",
strings.NewReader(marshalV3StartRequest(t, startRequest))).WithContext(newAuthorizedPlaybackContext())
// A client name or user agent is not a stable settings identity. Only
// the explicit device header may select a profile_device row.
req.Header.Set("User-Agent", "SiloTV/apple-tv")
if deviceID != "" {
req.Header.Set(deviceIDHeader, deviceID)
}
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
@@ -598,15 +631,11 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
t.Run("canonical value wins over legacy column", func(t *testing.T) {
store := newPlaybackTestStore(t)
setLegacyLanguage(t, store, "eng")
if _, err := store.UpsertSettingValue(context.Background(), userstore.SettingIdentity{
Key: settingskeys.PlaybackAudioLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
}, json.RawMessage(`"ja"`)); err != nil {
t.Fatalf("seed canonical audio language: %v", err)
}
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfile,
}, "ja")
if index := startPlayback(t, store, newFile(t)); index != 1 {
if index := startPlayback(t, store, newFile(t), "", nil); index != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (canonical \"ja\" track)", index)
}
})
@@ -617,10 +646,75 @@ func TestHandleStartPlaybackV3_AudioLanguageResolvesCanonically(t *testing.T) {
// No canonical value stored: the contract default is "no preference",
// so selection falls to the file's default track, not the column's.
if index := startPlayback(t, store, newFile(t)); index != 0 {
if index := startPlayback(t, store, newFile(t), "", nil); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (file default track)", index)
}
})
t.Run("device value applies only to the identified device", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{Scope: settingscontract.ScopeProfile}, "en")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
if index := startPlayback(t, store, newFile(t), "apple-tv", nil); index != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (device \"ja\" track)", index)
}
if index := startPlayback(t, store, newFile(t), "", nil); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (profile \"en\" track without device identity)", index)
}
if index := startPlayback(t, store, newFile(t), "iphone", nil); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (profile \"en\" track on another device)", index)
}
})
t.Run("series and library values outrank device value", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{Scope: settingscontract.ScopeProfile}, "en")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileLibrary,
LibraryID: 12,
}, "es")
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileSeries,
SeriesID: "series-1",
}, "fr")
movie := newFile(t)
movie.MediaFolderID = 12
if index := startPlayback(t, store, movie, "apple-tv", nil); index != 2 {
t.Fatalf("AudioTrackIndex = %d, want 2 (library \"es\" track)", index)
}
episode := newFile(t)
episode.MediaFolderID = 12
episode.EpisodeID = "episode-1"
if index := startPlayback(t, store, episode, "apple-tv", nil); index != 3 {
t.Fatalf("AudioTrackIndex = %d, want 3 (series \"fr\" track)", index)
}
})
t.Run("explicit track selection outranks saved settings", func(t *testing.T) {
store := newPlaybackTestStore(t)
setCanonicalLanguage(t, store, userstore.SettingIdentity{
Scope: settingscontract.ScopeProfileDevice,
DeviceID: "apple-tv",
}, "ja")
file := newFile(t)
if index := startPlayback(t, store, file, "apple-tv", func(request *playback.StartRequestV3) {
explicit := 0
request.AudioTrackIndex = &explicit
request.AudioTrackID = playback.TrackIDV3(file.ID, "audio", explicit)
}); index != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (explicit \"en\" track)", index)
}
})
}
// An omitted start_position asks the server for its resume policy; an explicit
+83 -54
View File
@@ -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) {
+93 -11
View File
@@ -3,6 +3,8 @@ package handlers
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -49,12 +51,12 @@ func (s failingProgressStoreV3) GetProgress(context.Context, string, string) (*u
return nil, s.err
}
type failingLibraryPlaybackPreferenceStoreV3 struct {
type failingSettingsResolutionStoreV3 struct {
userstore.UserStore
err error
}
func (s failingLibraryPlaybackPreferenceStoreV3) GetLibraryPlaybackPreference(context.Context, string, int) (*userstore.LibraryPlaybackPreference, error) {
func (s failingSettingsResolutionStoreV3) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
return nil, s.err
}
@@ -311,6 +313,86 @@ func TestHandleStartPlaybackV3CommitsStartSideEffectsOnce(t *testing.T) {
}
}
func TestHandleStartPlaybackV3RejectsAttemptReplayFromAnotherDevice(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
body := marshalV3StartRequest(t, v3HandlerStartRequest())
start := func(deviceID string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(body)).WithContext(newAuthorizedPlaybackContext())
req.Header.Set(deviceIDHeader, deviceID)
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
return rr
}
first := start(" living-room ")
sameDevice := start("living-room")
otherDevice := start("bedroom")
if first.Code != http.StatusCreated || sameDevice.Code != http.StatusCreated {
t.Fatalf("same-device statuses = %d, %d; first=%s replay=%s", first.Code, sameDevice.Code, first.Body.String(), sameDevice.Body.String())
}
if first.Body.String() != sameDevice.Body.String() {
t.Fatalf("normalized same-device replay changed response:\nfirst=%s\nreplay=%s", first.Body.String(), sameDevice.Body.String())
}
if otherDevice.Code != http.StatusConflict || !strings.Contains(otherDevice.Body.String(), "playback_attempt_reused") {
t.Fatalf("other-device status = %d, body = %s", otherDevice.Code, otherDevice.Body.String())
}
if got := len(manager.AllSessions()); got != 1 {
t.Fatalf("sessions = %d, want 1", got)
}
}
func TestHandleStartPlaybackV3AcceptsPreDeviceDigestReplay(t *testing.T) {
manager := playback.NewSessionManager(0, 0)
handler := NewPlaybackHandler(manager, testPlaybackFileResolver{file: v3HandlerFixtureFile(t)})
handler.SettingsRepo = &mutablePlaybackSettingsV3{values: map[string]string{}}
handler.ItemAccess = allowAllPlaybackItemAccess{}
startRequest := v3HandlerStartRequest()
body := marshalV3StartRequest(t, startRequest)
start := func(requestBody, deviceID string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/v1/playback/start", strings.NewReader(requestBody)).WithContext(newAuthorizedPlaybackContext())
req.Header.Set(deviceIDHeader, deviceID)
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
return rr
}
first := start(body, "living-room")
if first.Code != http.StatusCreated {
t.Fatalf("first status = %d, body = %s", first.Code, first.Body.String())
}
var response playback.DecisionResponseV3
if err := json.Unmarshal(first.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
record, err := handler.PlanStoreV3.GetAttempt(context.Background(), response.SessionID)
if err != nil {
t.Fatal(err)
}
legacyDigest := sha256.Sum256([]byte(body))
record.RequestDigest = hex.EncodeToString(legacyDigest[:])
handler.PlanStoreV3.(*playback.MemoryPlanStoreV3).ReplaceAttempt(context.Background(), *record)
replay := start(body, "living-room")
if replay.Code != http.StatusCreated || replay.Body.String() != first.Body.String() {
t.Fatalf("legacy replay status = %d, body = %s; first = %s", replay.Code, replay.Body.String(), first.Body.String())
}
changedRequest := startRequest
position := 42.0
changedRequest.StartPosition = &position
conflict := start(marshalV3StartRequest(t, changedRequest), "living-room")
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "playback_attempt_reused") {
t.Fatalf("changed legacy replay status = %d, body = %s", conflict.Code, conflict.Body.String())
}
}
func TestHandlePlaybackCapabilityV3AdvertisesTheFinalizedContract(t *testing.T) {
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
@@ -530,22 +612,21 @@ func TestPreferredAudioTrackIndexV3PropagatesSeriesPreferenceReadFailure(t *test
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
}
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", file); !errors.Is(err, wantErr) {
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", "", file); !errors.Is(err, wantErr) {
t.Fatalf("preferredAudioTrackIndexV3 error = %v, want %v", err, wantErr)
}
}
func TestPreferredAudioTrackIndexV3PropagatesLibraryPreferenceReadFailure(t *testing.T) {
wantErr := errors.New("library playback preference store unavailable")
store := failingLibraryPlaybackPreferenceStoreV3{UserStore: newPlaybackTestStore(t), err: wantErr}
func TestPreferredAudioTrackIndexV3PropagatesCanonicalPreferenceReadFailure(t *testing.T) {
wantErr := errors.New("settings resolution unavailable")
store := failingSettingsResolutionStoreV3{UserStore: newPlaybackTestStore(t), err: wantErr}
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0))
handler.StoreProvider = testUserStoreProvider{store: store}
file := &models.MediaFile{
MediaFolderID: 9,
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
AudioTracks: []models.AudioTrack{{Codec: "aac", Language: "eng"}, {Codec: "aac", Language: "spa"}},
}
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", file); !errors.Is(err, wantErr) {
if _, err := handler.preferredAudioTrackIndexV3(context.Background(), 1, "profile-1", "living-room", file); !errors.Is(err, wantErr) {
t.Fatalf("preferredAudioTrackIndexV3 error = %v, want %v", err, wantErr)
}
}
@@ -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)
}
+3
View File
@@ -30,6 +30,9 @@ type AccessFilter struct {
SelectedFileID int
UserID int
ProfileID string
// DeviceID identifies the requesting client for device-scoped setting
// resolution. It does not participate in catalog access control.
DeviceID string
// NamePrefix, when non-empty, restricts results to items whose
// LOWER(COALESCE(NULLIF(BTRIM(sort_title),''), title)) starts with the
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
+6 -3
View File
@@ -3105,6 +3105,7 @@ type audioPrefResolver struct {
store userstore.UserStore
valid bool
profileID string
deviceID string
contentID string
profileDone bool
@@ -3136,6 +3137,7 @@ func (s *DetailService) newAudioPrefResolver(ctx context.Context, filter AccessF
r := &audioPrefResolver{
svc: s,
profileID: filter.ProfileID,
deviceID: filter.DeviceID,
contentID: audioPreferenceContentID,
resolvedLang: map[int]string{},
}
@@ -3176,15 +3178,16 @@ func (r *audioPrefResolver) audioPreference(ctx context.Context) *playback.Audio
// audioLanguage resolves with every content identity in context. The language
// preference lives in the canonical table at profile_series/profile_library/
// profile scopes; the specialized audio row supplies only concrete track
// identity. Caching by library keeps a multi-file item at one canonical read
// per distinct folder rather than one read per file.
// profile_device/profile scopes; the specialized audio row supplies only
// concrete track identity. Caching by library keeps a multi-file item at one
// canonical read per distinct folder rather than one read per file.
func (r *audioPrefResolver) audioLanguage(ctx context.Context, libraryID int) string {
if lang, ok := r.resolvedLang[libraryID]; ok {
return lang
}
rc := settingsresolve.Context{
ProfileID: r.profileID,
DeviceID: r.deviceID,
LibraryIDs: []int{libraryID},
}
if strings.TrimSpace(r.contentID) != "" {
@@ -204,6 +204,42 @@ func TestEffectiveAudioTrackIndex_PrefersSeriesAudioPreferenceOverLibraryAndProf
}
}
func TestEffectiveAudioTrackIndex_ResolvesDeviceLanguageWithCanonicalPrecedence(t *testing.T) {
store := newDetailTestStore(t)
setProfileAudioLanguage(t, store, "en")
setScopedAudioLanguageForDevice(t, store, settingscontract.ScopeProfileDevice, "", 0, "apple-tv", "ja")
setScopedAudioLanguage(t, store, settingscontract.ScopeProfileLibrary, "", 12, "es")
setScopedAudioLanguage(t, store, settingscontract.ScopeProfileSeries, "series-1", 0, "fr")
service := &DetailService{}
service.SetUserStoreProvider(testDetailUserStoreProvider{store: store})
file := &models.MediaFile{
AudioTracks: []models.AudioTrack{
{Language: "en", Default: true},
{Language: "ja"},
{Language: "es"},
{Language: "fr"},
},
}
assertIndex := func(name, deviceID, contentID string, libraryID, want int) {
t.Helper()
file.MediaFolderID = libraryID
if got := service.effectiveAudioTrackIndex(context.Background(), AccessFilter{
UserID: 1,
ProfileID: "profile-1",
DeviceID: deviceID,
}, contentID, file); got != want {
t.Fatalf("%s: effectiveAudioTrackIndex() = %d, want %d", name, got, want)
}
}
assertIndex("device override", "apple-tv", "movie-1", 99, 1)
assertIndex("other device isolation", "iphone", "movie-1", 99, 0)
assertIndex("library over device", "apple-tv", "movie-1", 12, 2)
assertIndex("series over library and device", "apple-tv", "series-1", 12, 3)
}
func TestBuildPlaybackInfo_SetsEffectiveAudioLanguageFromOriginalWhenTrackLanguageMissing(t *testing.T) {
store := newDetailTestStore(t)
language := playback.OriginalLanguageSentinel
@@ -511,6 +547,19 @@ func setScopedAudioLanguage(
seriesID string,
libraryID int,
language string,
) {
t.Helper()
setScopedAudioLanguageForDevice(t, store, scope, seriesID, libraryID, "", language)
}
func setScopedAudioLanguageForDevice(
t *testing.T,
store userstore.UserStore,
scope settingscontract.Scope,
seriesID string,
libraryID int,
deviceID string,
language string,
) {
t.Helper()
encoded, err := json.Marshal(language)
@@ -523,6 +572,7 @@ func setScopedAudioLanguage(
ProfileID: "profile-1",
SeriesID: seriesID,
LibraryID: libraryID,
DeviceID: deviceID,
}, encoded); err != nil {
t.Fatalf("seeding %s: %v", settingskeys.PlaybackAudioLanguage, err)
}
+1 -1
View File
@@ -145,7 +145,7 @@ function isCapturedProfileAuthorityActive(snapshot: ProfileRequestContextSnapsho
);
}
function getOrCreateDeviceId(): string {
export function getOrCreateDeviceId(): string {
const existing = storage.get(storage.KEYS.DEVICE_ID);
if (existing) {
return existing;
@@ -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,
};
+2 -1
View File
@@ -13,7 +13,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Pause, PictureInPicture2, Play, SkipBack, SkipForward, Tv, X } from "lucide-react";
import { useLocation, useNavigate } from "react-router";
import type { WatchDetail } from "@/api/types";
import { getAccessToken, getProfileToken } from "@/api/client";
import { getAccessToken, getOrCreateDeviceId, getProfileToken } from "@/api/client";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
@@ -452,6 +452,7 @@ export function WatchPlaybackHost() {
getAccessToken: () => getAccessToken(),
getProfileId: () => storage.get(storage.KEYS.PROFILE_ID),
getProfileToken: () => getProfileToken(),
getDeviceId: () => getOrCreateDeviceId(),
}),
[],
);
@@ -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,
};
+15
View File
@@ -7,6 +7,7 @@ const config: PlayerConfig = {
apiBaseUrl: "/api/v1",
getAccessToken: () => null,
getProfileId: () => null,
getDeviceId: () => "web-player-device",
};
afterEach(() => {
@@ -41,4 +42,18 @@ describe("playerFetch", () => {
await expect(playerFetch<void>(config, "/playback/route-events")).resolves.toBeUndefined();
});
it("sends the host application's stable device identity", async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
await playerFetch<void>(config, "/playback/start", { method: "POST" });
expect(fetchMock).toHaveBeenCalledWith(
"/api/v1/playback/start",
expect.objectContaining({
headers: expect.objectContaining({ "X-Silo-Device-Id": "web-player-device" }),
}),
);
});
});
+2
View File
@@ -55,6 +55,8 @@ export async function playerFetch<T>(
headers["X-Profile-Token"] = profileToken;
}
headers["X-Silo-Device-Id"] = config.getDeviceId();
const res = await fetch(`${config.apiBaseUrl}${path}`, {
...options,
headers,