feat(metadata): add original-language preferences (#526)
* feat(metadata): add original-language preferences * fix(settings): show metadata exceptions immediately * docs(settings): add metadata language screenshot * fix(settings): make language exceptions responsive * fix(settings): standardize language display names
This commit is contained in:
@@ -2,12 +2,15 @@ package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
|
||||
@@ -107,3 +110,43 @@ func TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored(t *testing.T) {
|
||||
t.Errorf("healthy resolution logged %d records, want none", len(records))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveViewerPreferencesIncludesMetadataLanguageOverrides(t *testing.T) {
|
||||
store := settingStoreStub{rows: []userstore.SettingValue{
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "profile-1",
|
||||
},
|
||||
Value: json.RawMessage(`[]`),
|
||||
},
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguage, Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "profile-1",
|
||||
},
|
||||
Value: json.RawMessage(`"en"`),
|
||||
},
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguageOverrides, Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "profile-1",
|
||||
},
|
||||
Value: json.RawMessage(`{"nor":"x-silo-original","JA":"de"}`),
|
||||
},
|
||||
}}
|
||||
|
||||
preferences := ResolveViewerPreferences(context.Background(), store, "profile-1")
|
||||
if preferences.PreferredMetadataLanguage != "en" {
|
||||
t.Fatalf("fallback language = %q, want en", preferences.PreferredMetadataLanguage)
|
||||
}
|
||||
want := map[string]string{"no": OriginalMetadataLanguage, "ja": "de"}
|
||||
if len(preferences.MetadataLanguageOverrides) != len(want) {
|
||||
t.Fatalf("metadata overrides = %#v, want %#v", preferences.MetadataLanguageOverrides, want)
|
||||
}
|
||||
for source, target := range want {
|
||||
if preferences.MetadataLanguageOverrides[source] != target {
|
||||
t.Errorf("metadata override %q = %q, want %q", source, preferences.MetadataLanguageOverrides[source], target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,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 = preferences.PreferredMetadataLanguage
|
||||
scope.MetadataLanguageOverrides = preferences.MetadataLanguageOverrides
|
||||
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
|
||||
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
|
||||
if err != nil {
|
||||
|
||||
@@ -579,14 +579,24 @@ func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
|
||||
ID: "prof-1",
|
||||
PreferredMetadataLanguage: "fr",
|
||||
},
|
||||
settingValues: []userstore.SettingValue{{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguage,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
settingValues: []userstore.SettingValue{
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguage,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`"de"`),
|
||||
},
|
||||
Value: json.RawMessage(`"de"`),
|
||||
}},
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguageOverrides,
|
||||
Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`{"no":"x-silo-original"}`),
|
||||
},
|
||||
},
|
||||
}},
|
||||
nil,
|
||||
)
|
||||
@@ -598,6 +608,9 @@ func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
|
||||
if scope.PreferredMetadataLanguage != "de" {
|
||||
t.Fatalf("PreferredMetadataLanguage = %q, want canonical value %q", scope.PreferredMetadataLanguage, "de")
|
||||
}
|
||||
if got := scope.MetadataLanguageOverrides["no"]; got != OriginalMetadataLanguage {
|
||||
t.Fatalf("MetadataLanguageOverrides[no] = %q, want %q", got, OriginalMetadataLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MetadataLanguageIgnoresLegacyColumn(t *testing.T) {
|
||||
|
||||
@@ -12,6 +12,9 @@ type Scope struct {
|
||||
// PreferredMetadataLanguage is the profile's metadata (presentation)
|
||||
// language; "" inherits the library's metadata language.
|
||||
PreferredMetadataLanguage string
|
||||
// MetadataLanguageOverrides maps a media item's canonical original-language
|
||||
// code to the profile's target metadata language for that source.
|
||||
MetadataLanguageOverrides map[string]string
|
||||
PolicyRevision int64
|
||||
ProfileVerified bool
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/lang"
|
||||
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
||||
"github.com/Silo-Server/silo-server/internal/settingskeys"
|
||||
"github.com/Silo-Server/silo-server/internal/settingsresolve"
|
||||
@@ -18,6 +20,7 @@ import (
|
||||
type ViewerPreferences struct {
|
||||
DisabledLibraryIDs []int
|
||||
PreferredMetadataLanguage string
|
||||
MetadataLanguageOverrides map[string]string
|
||||
}
|
||||
|
||||
// ResolveViewerPreferences resolves the profile's viewer-scope preferences in
|
||||
@@ -57,7 +60,11 @@ func resolveCanonicalViewerPreferences(
|
||||
}
|
||||
values, err := settingsresolve.New(contract).Resolve(ctx, store,
|
||||
settingsresolve.Context{ProfileID: profileID},
|
||||
[]string{settingskeys.UiDisabledLibraryIds, settingskeys.CatalogMetadataLanguage}, nil)
|
||||
[]string{
|
||||
settingskeys.UiDisabledLibraryIds,
|
||||
settingskeys.CatalogMetadataLanguage,
|
||||
settingskeys.CatalogMetadataLanguageOverrides,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "viewer preference resolution degraded: reading setting values failed",
|
||||
"component", "access", "profile_id", profileID, "error", err)
|
||||
@@ -77,11 +84,58 @@ func resolveCanonicalViewerPreferences(
|
||||
if json.Unmarshal(value.Value, &language) == nil {
|
||||
out.preferences.PreferredMetadataLanguage = strings.TrimSpace(language)
|
||||
}
|
||||
case settingskeys.CatalogMetadataLanguageOverrides:
|
||||
out.preferences.MetadataLanguageOverrides = parseMetadataLanguageOverrides(value.Value)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// OriginalMetadataLanguage is a private-use BCP 47 tag stored in
|
||||
// catalog.metadata_language (or as an override target) to mean "resolve this
|
||||
// media item's original language." Keeping the sentinel a valid language tag
|
||||
// preserves the existing setting's wire type.
|
||||
const OriginalMetadataLanguage = "x-silo-original"
|
||||
|
||||
// parseMetadataLanguageOverrides normalizes source aliases and target tag
|
||||
// casing before the map reaches catalog serving. The contract only accepts
|
||||
// canonical source keys from normal clients, but normalization also makes old
|
||||
// or manually-authored rows deterministic.
|
||||
func parseMetadataLanguageOverrides(raw json.RawMessage) map[string]string {
|
||||
var stored map[string]string
|
||||
if json.Unmarshal(raw, &stored) != nil || len(stored) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(stored))
|
||||
for source := range stored {
|
||||
keys = append(keys, source)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
out := make(map[string]string, len(stored))
|
||||
for _, source := range keys {
|
||||
canonicalSource := lang.Canonical(source)
|
||||
if canonicalSource == "" {
|
||||
continue
|
||||
}
|
||||
// When aliases collapse (for example en and eng), the canonical key
|
||||
// wins because it sorts first and is never overwritten.
|
||||
if _, exists := out[canonicalSource]; exists {
|
||||
continue
|
||||
}
|
||||
target, ok := settingscontract.NormalizeLanguageTag(stored[source])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[canonicalSource] = target
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func legacyDisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
|
||||
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
|
||||
if err != nil || raw == "" {
|
||||
|
||||
Reference in New Issue
Block a user