From 22e9d7f11d8889d8639c6cd8e793812fb82e8c2a Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:14:09 +0000 Subject: [PATCH] refactor(settings): resolve metadata language canonically in access and policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/access/metadata_language.go | 50 +++++++++++++++ internal/access/resolver.go | 2 +- internal/access/resolver_test.go | 62 ++++++++++++++++++- internal/api/handlers/playback.go | 35 ++++++++++- internal/api/handlers/playback_test.go | 82 +++++++++++++++++++++++++ internal/policy/scope_parity_test.go | 52 +++++++++++++--- internal/policy/viewer_resolver.go | 7 ++- internal/policy/viewer_resolver_test.go | 56 +++++++++++++++-- 8 files changed, 326 insertions(+), 20 deletions(-) create mode 100644 internal/access/metadata_language.go diff --git a/internal/access/metadata_language.go b/internal/access/metadata_language.go new file mode 100644 index 00000000..d330a5c6 --- /dev/null +++ b/internal/access/metadata_language.go @@ -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) +} diff --git a/internal/access/resolver.go b/internal/access/resolver.go index be17a770..d8e7af85 100644 --- a/internal/access/resolver.go +++ b/internal/access/resolver.go @@ -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 { diff --git a/internal/access/resolver_test.go b/internal/access/resolver_test.go index dfe700be..3a1d1c91 100644 --- a/internal/access/resolver_test.go +++ b/internal/access/resolver_test.go @@ -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{ diff --git a/internal/api/handlers/playback.go b/internal/api/handlers/playback.go index 2d01acea..cbfc789a 100644 --- a/internal/api/handlers/playback.go +++ b/internal/api/handlers/playback.go @@ -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 diff --git a/internal/api/handlers/playback_test.go b/internal/api/handlers/playback_test.go index 48f93e71..7e02c0c9 100644 --- a/internal/api/handlers/playback_test.go +++ b/internal/api/handlers/playback_test.go @@ -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{ diff --git a/internal/policy/scope_parity_test.go b/internal/policy/scope_parity_test.go index 9ae72879..3167336f 100644 --- a/internal/policy/scope_parity_test.go +++ b/internal/policy/scope_parity_test.go @@ -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) { diff --git a/internal/policy/viewer_resolver.go b/internal/policy/viewer_resolver.go index 0ffd5bfa..22171962 100644 --- a/internal/policy/viewer_resolver.go +++ b/internal/policy/viewer_resolver.go @@ -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 { diff --git a/internal/policy/viewer_resolver_test.go b/internal/policy/viewer_resolver_test.go index 58246d45..b96efaea 100644 --- a/internal/policy/viewer_resolver_test.go +++ b/internal/policy/viewer_resolver_test.go @@ -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 }