refactor(settings): resolve metadata language canonically in access and policy

Repoint the last legacy column readers onto canonical contract resolution
(settings cutover task A4a):

- access.Resolver and policy.ViewerResolver now resolve
  catalog.metadata_language through settingsresolve (profile scope ->
  contract default) via a shared access.PreferredMetadataLanguage helper,
  instead of reading user_profiles.preferred_metadata_language. Resolution
  is deliberately unconstrained: the policy input this preference feeds is
  the one a constraint would have to reference, which is circular — see the
  key's manifest notes.
- playback start now resolves playback.audio_language canonically for the
  profile default instead of reading user_profiles.language, matching the
  catalog detail path. Series and library override handling is unchanged.
- items.go needed no change: it already consumes the resolver-produced
  scope.PreferredMetadataLanguage.

The legacy columns keep their values but are no longer read on these
paths; a profile with only a column value now resolves to the contract
default, and a stored canonical value wins. Tests pin both directions in
access, policy (including scope parity, where the column is now a decoy),
and the playback handler. Read cost is one batched store read per
resolution, same as the profile-row read it replaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Quick
2026-07-28 13:14:09 +00:00
co-authored by Claude Fable 5
parent 4fe417ce27
commit 22e9d7f11d
8 changed files with 326 additions and 20 deletions
+50
View File
@@ -0,0 +1,50 @@
package access
import (
"context"
"encoding/json"
"strings"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// PreferredMetadataLanguage resolves catalog.metadata_language canonically for
// one profile: the stored profile-scope value, else the contract default. The
// legacy user_profiles.preferred_metadata_language column is deliberately not
// consulted — it migrated to the canonical store, and reading both would let
// them disagree.
//
// Resolution is unconstrained on purpose. The manifest gives this key no
// constrained_by because the policy input that could constrain it
// (profile_preferred_metadata_language) is populated from this very
// preference; a constraint here would be circular. See the key's notes in
// contracts/settings/v1/manifest.json.
//
// A resolution failure degrades to "" — the contract default, meaning "inherit
// the library's metadata language" — rather than failing scope resolution: the
// language is a presentation preference, not an access boundary.
func PreferredMetadataLanguage(ctx context.Context, store userstore.UserStore, profileID string) string {
if store == nil || profileID == "" {
return ""
}
contract, err := settingscontract.Load()
if err != nil {
return ""
}
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]string{settingskeys.CatalogMetadataLanguage}, nil)
if err != nil || len(resolved) == 0 {
return ""
}
// The contract default is null — "no preference" — which unmarshals to "",
// the same spelling the legacy column used for unset.
var language string
if json.Unmarshal(resolved[0].Value, &language) != nil {
return ""
}
return strings.TrimSpace(language)
}
+1 -1
View File
@@ -83,7 +83,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.MaxContentRating = profile.MaxContentRating
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
scope.PreferredMetadataLanguage = profile.PreferredMetadataLanguage
scope.PreferredMetadataLanguage = PreferredMetadataLanguage(ctx, store, input.ProfileID)
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
if err != nil {
+61 -1
View File
@@ -8,6 +8,8 @@ import (
"time"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -40,6 +42,10 @@ type stubStore struct {
profile *userstore.Profile
err error
settings map[string]string
// settingValues are the canonical setting rows the resolver may read
// through ListSettingValuesForResolution. Scope matching is the
// resolver's job, so the stub returns them unfiltered.
settingValues []userstore.SettingValue
}
func (s stubStore) CreateProfile(context.Context, userstore.Profile) error { panic("unused") }
@@ -276,7 +282,7 @@ func (s stubStore) GetSettingValue(context.Context, userstore.SettingIdentity) (
panic("unused")
}
func (s stubStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
panic("unused")
return s.settingValues, nil
}
func (s stubStore) UpsertSettingValue(context.Context, userstore.SettingIdentity, json.RawMessage) (*userstore.SettingValue, error) {
panic("unused")
@@ -461,6 +467,60 @@ func TestResolver_DisabledLibraries_NoProfile(t *testing.T) {
}
}
func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
// The canonical catalog.metadata_language row wins; the legacy profile
// column carries a decoy value that must no longer be read.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{
ID: "prof-1",
PreferredMetadataLanguage: "fr",
},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`"de"`),
}},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if scope.PreferredMetadataLanguage != "de" {
t.Fatalf("PreferredMetadataLanguage = %q, want canonical value %q", scope.PreferredMetadataLanguage, "de")
}
}
func TestResolver_MetadataLanguageIgnoresLegacyColumn(t *testing.T) {
// A profile with only the legacy column value falls to the contract
// default ("" — inherit), proving the column is no longer read.
resolver := NewResolver(
stubUserRepo{user: &models.User{ID: 1, AccessPolicyRevision: 5}},
stubStoreProvider{store: stubStore{
profile: &userstore.Profile{
ID: "prof-1",
PreferredMetadataLanguage: "fr",
},
}},
nil,
)
scope, err := resolver.Resolve(context.Background(), ResolveInput{UserID: 1, ProfileID: "prof-1"})
if err != nil {
t.Fatalf("Resolve() error: %v", err)
}
if scope.PreferredMetadataLanguage != "" {
t.Fatalf("PreferredMetadataLanguage = %q, want contract default \"\"", scope.PreferredMetadataLanguage)
}
}
func TestResolver_AppliesGroupPolicy(t *testing.T) {
resolver := NewResolver(
stubUserRepo{user: &models.User{
+32 -3
View File
@@ -30,6 +30,9 @@ 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/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/subtitles"
"github.com/Silo-Server/silo-server/internal/transcodenode"
@@ -1211,6 +1214,34 @@ 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 ""
}
contract, err := settingscontract.Load()
if err != nil {
return ""
}
resolved, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]string{settingskeys.PlaybackAudioLanguage}, nil)
if err != nil || len(resolved) == 0 {
return ""
}
var language string
if json.Unmarshal(resolved[0].Value, &language) != nil {
return ""
}
return strings.TrimSpace(language)
}
func (h *PlaybackHandler) restoreSessionProgress(
ctx context.Context,
session *playback.Session,
@@ -1715,9 +1746,7 @@ func (h *PlaybackHandler) handleStartPlaybackLegacy(w http.ResponseWriter, r *ht
if seriesPref != nil && seriesPref.AudioLanguage == playback.OriginalLanguageSentinel {
seriesPref.AudioLanguage = h.resolveOriginalLanguage(r.Context(), file)
}
if profile, profErr := store.GetProfile(r.Context(), profileID); profErr == nil && profile != nil {
preferredLang = profile.Language
}
preferredLang = resolvedProfileAudioLanguage(r.Context(), store, profileID)
// Resolve library override (if no series sticky pref exists).
var libraryAudioLang string
+82
View File
@@ -28,6 +28,8 @@ 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/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/streamtoken"
"github.com/Silo-Server/silo-server/internal/transcodenode"
"github.com/Silo-Server/silo-server/internal/userdb"
@@ -693,6 +695,86 @@ func TestHandleStartPlayback_DoesNotPersistSeriesPlaybackPreferenceOnFailure(t *
}
}
func TestHandleStartPlayback_AudioLanguageResolvesCanonically(t *testing.T) {
// The default audio track comes from the canonical playback.audio_language
// value resolved through the settings contract, not from the legacy
// user_profiles.language column. The column always carries the language of
// a different track than the canonical answer, so a regression to reading
// it flips the selected index.
newFile := func(t *testing.T) *models.MediaFile {
return &models.MediaFile{
ID: 42,
ContentID: "movie-1",
FilePath: writePlaybackTestMediaFile(t, "movie.mkv"),
Duration: 3600,
AudioTracks: []models.AudioTrack{
{Language: "eng", Codec: "aac", Default: true},
{Language: "jpn", Codec: "aac"},
},
}
}
setLegacyLanguage := func(t *testing.T, store userstore.UserStore, language string) {
t.Helper()
if err := store.UpdateProfile(context.Background(), "profile-1", userstore.UpdateProfileInput{
Language: &language,
}); err != nil {
t.Fatalf("seed legacy language column: %v", err)
}
}
startPlayback := func(t *testing.T, store userstore.UserStore, file *models.MediaFile) playbackSessionResponse {
t.Helper()
handler := NewPlaybackHandler(playback.NewSessionManager(0, 0), testPlaybackFileResolver{file: file})
handler.StoreProvider = testUserStoreProvider{store: store}
handler.ItemAccess = allowAllPlaybackItemAccess{}
req := httptest.NewRequest("POST", "/api/v1/playback/start",
strings.NewReader(`{"file_id":42,"profile_id":"profile-1","play_method":"direct"}`))
req = req.WithContext(newAuthorizedPlaybackContext())
rr := httptest.NewRecorder()
handler.HandleStartPlayback(rr, req)
if rr.Code != http.StatusCreated {
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
}
var resp playbackSessionResponse
if err := json.NewDecoder(rr.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
return resp
}
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)
}
resp := startPlayback(t, store, newFile(t))
if resp.AudioTrackIndex != 1 {
t.Fatalf("AudioTrackIndex = %d, want 1 (canonical \"ja\" track)", resp.AudioTrackIndex)
}
})
t.Run("legacy column alone no longer selects a track", func(t *testing.T) {
store := newPlaybackTestStore(t)
setLegacyLanguage(t, store, "jpn")
resp := startPlayback(t, store, newFile(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 resp.AudioTrackIndex != 0 {
t.Fatalf("AudioTrackIndex = %d, want 0 (file default track)", resp.AudioTrackIndex)
}
})
}
func TestHandleChangeAudioTrack_PersistsSeriesAudioPreferenceSignature(t *testing.T) {
store := newPlaybackTestStore(t)
file := &models.MediaFile{
+42 -10
View File
@@ -10,9 +10,31 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// parityMetadataLang is the canonically stored catalog.metadata_language for
// the parity profile. parityProfile's legacy column deliberately carries a
// different value, so a resolver that regresses to reading the column breaks
// parity instead of passing by coincidence.
const parityMetadataLang = "fr"
func parityMetadataLangValues(profile *userstore.Profile) []userstore.SettingValue {
if profile == nil {
return nil
}
return []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: profile.ID,
},
Value: json.RawMessage(`"` + parityMetadataLang + `"`),
}}
}
func TestResolveViewerScopeParity(t *testing.T) {
ctx := context.Background()
engine, err := NewEngine(ctx)
@@ -84,8 +106,9 @@ func TestResolveViewerScopeParity(t *testing.T) {
profile.MaxContentRating = ratingCase.value
}
store := parityStore{
profile: profile,
settings: disabledSetting(disabledCase.ids),
profile: profile,
settings: disabledSetting(disabledCase.ids),
settingValues: parityMetadataLangValues(profile),
}
resolver := access.NewResolver(
parityUserRepo{user: user},
@@ -170,11 +193,13 @@ func profileRatingCases(profile *userstore.Profile) []namedString {
func parityProfile(restricted bool, allowed []int) *userstore.Profile {
return &userstore.Profile{
ID: "prof-1",
PINHash: "pin-hash",
MaxContentRating: "PG-13",
MaxPlaybackQuality: "720p",
PreferredMetadataLanguage: "fr",
ID: "prof-1",
PINHash: "pin-hash",
MaxContentRating: "PG-13",
MaxPlaybackQuality: "720p",
// A decoy: the canonical value is parityMetadataLang, stored through
// parityMetadataLangValues. This column must no longer be read.
PreferredMetadataLanguage: "hu",
LibraryRestrictionsEnabled: restricted,
AllowedLibraryIDs: cloneParityInts(allowed),
}
@@ -205,7 +230,9 @@ func scopeInputFromParity(user *models.User, profile *userstore.Profile, disable
input.ProfileLibraryIDs = cloneParityInts(profile.AllowedLibraryIDs)
input.ProfileHasPIN = profile.PINHash != ""
input.ProfileVerified = verified
input.ProfileMetadataLang = profile.PreferredMetadataLanguage
// Canonically resolved, mirroring ViewerResolver — the legacy profile
// column is no longer a policy input.
input.ProfileMetadataLang = parityMetadataLang
}
return input
}
@@ -287,8 +314,13 @@ func (p parityStoreProvider) Close() error {
type parityStore struct {
userstore.UserStore
profile *userstore.Profile
settings map[string]string
profile *userstore.Profile
settings map[string]string
settingValues []userstore.SettingValue
}
func (s parityStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
return s.settingValues, nil
}
func (s parityStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
+6 -1
View File
@@ -103,7 +103,12 @@ func (r *ViewerResolver) Resolve(ctx context.Context, input access.ResolveInput)
policyInput.ProfileLibraryLimited = profile.LibraryRestrictionsEnabled
policyInput.ProfileLibraryIDs = slices.Clone(profile.AllowedLibraryIDs)
policyInput.ProfileHasPIN = profile.PINHash != ""
policyInput.ProfileMetadataLang = profile.PreferredMetadataLanguage
// Resolved canonically (profile scope -> contract default), not read off
// the legacy profile column it migrated from. scope.rego relays this
// value unchanged as a preference; the manifest deliberately declares no
// constraint on it, since constraining a setting by a policy input fed
// from that same setting would be circular.
policyInput.ProfileMetadataLang = access.PreferredMetadataLanguage(ctx, store, input.ProfileID)
}
if r.pdp == nil {
+52 -4
View File
@@ -2,6 +2,7 @@ package policy
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
@@ -10,6 +11,8 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -22,11 +25,13 @@ func TestViewerResolverParityWithLegacyResolver(t *testing.T) {
user *models.User
profile *userstore.Profile
settings map[string]string
settingValues []userstore.SettingValue
input access.ResolveInput
tokens access.ProfileTokenValidator
wantNilAllowed bool
wantEmptyAllowed bool
wantDisabled []int
wantMetadataLang string
}{
{
name: "no profile unrestricted",
@@ -167,13 +172,39 @@ func TestViewerResolverParityWithLegacyResolver(t *testing.T) {
input: access.ResolveInput{UserID: 1, SessionID: "sess-1", ProfileID: "prof-1"},
wantNilAllowed: true,
},
{
// The canonical catalog.metadata_language row feeds the policy input
// and comes back out on the scope; the legacy profile column carries
// a decoy value that must no longer be read.
name: "metadata language resolves canonically",
user: &models.User{
ID: 1,
AccessPolicyRevision: 5,
},
profile: &userstore.Profile{
ID: "prof-1",
PreferredMetadataLanguage: "fr",
},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`"de"`),
}},
input: access.ResolveInput{UserID: 1, SessionID: "sess-1", ProfileID: "prof-1"},
wantNilAllowed: true,
wantMetadataLang: "de",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := viewerResolverTestStore{
profile: tt.profile,
settings: tt.settings,
profile: tt.profile,
settings: tt.settings,
settingValues: tt.settingValues,
}
users := viewerResolverUserRepo{user: tt.user}
stores := viewerResolverStoreProvider{store: store}
@@ -199,8 +230,14 @@ func TestViewerResolverParityWithLegacyResolver(t *testing.T) {
if tt.wantDisabled != nil && !reflect.DeepEqual(policyScope.DisabledLibraryIDs, tt.wantDisabled) {
t.Fatalf("DisabledLibraryIDs = %#v, want %#v", policyScope.DisabledLibraryIDs, tt.wantDisabled)
}
// Always asserted: cases with only the legacy profile column expect
// "" — the canonical resolution's contract default — proving the
// column is no longer read.
if policyScope.PreferredMetadataLanguage != tt.wantMetadataLang {
t.Fatalf("PreferredMetadataLanguage = %q, want %q", policyScope.PreferredMetadataLanguage, tt.wantMetadataLang)
}
decisionInput := viewerResolverExpectedInput(tt.user, tt.profile, tt.input, policyScope.ProfileVerified, access.DisabledLibraryIDs(ctx, store))
decisionInput := viewerResolverExpectedInput(tt.user, tt.profile, tt.input, policyScope.ProfileVerified, access.DisabledLibraryIDs(ctx, store), access.PreferredMetadataLanguage(ctx, store, tt.input.ProfileID))
decision, _, err := pdp.ResolveViewerScope(ctx, decisionInput)
if err != nil {
t.Fatalf("ResolveViewerScope() error: %v", err)
@@ -451,6 +488,10 @@ type viewerResolverTestStore struct {
profile *userstore.Profile
err error
settings map[string]string
// settingValues are the canonical setting rows the resolver may read
// through ListSettingValuesForResolution. Scope matching is the
// resolver's job, so the store returns them unfiltered.
settingValues []userstore.SettingValue
}
func (s viewerResolverTestStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
@@ -467,12 +508,17 @@ func (s viewerResolverTestStore) GetSetting(_ context.Context, key string) (stri
return s.settings[key], nil
}
func (s viewerResolverTestStore) ListSettingValuesForResolution(context.Context, userstore.SettingResolutionQuery) ([]userstore.SettingValue, error) {
return s.settingValues, nil
}
func viewerResolverExpectedInput(
user *models.User,
profile *userstore.Profile,
input access.ResolveInput,
profileVerified bool,
disabled []int,
metadataLang string,
) ScopeInput {
out := ScopeInput{
SchemaVersion: 1,
@@ -495,7 +541,9 @@ func viewerResolverExpectedInput(
out.ProfileLibraryLimited = profile.LibraryRestrictionsEnabled
out.ProfileLibraryIDs = cloneViewerResolverInts(profile.AllowedLibraryIDs)
out.ProfileHasPIN = profile.PINHash != ""
out.ProfileMetadataLang = profile.PreferredMetadataLanguage
// Canonically resolved, mirroring ViewerResolver — the legacy profile
// column is no longer a policy input.
out.ProfileMetadataLang = metadataLang
}
return out
}