Files
silo-server/internal/access/viewer_preferences.go
QuickandGitHub 73488d1bfa 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
2026-07-31 15:23:30 -04:00

146 lines
4.8 KiB
Go

package access
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"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// ViewerPreferences are the canonical preferences needed while constructing
// an access scope. They are resolved together because this path runs on nearly
// every authenticated request and one candidate read can answer both keys.
type ViewerPreferences struct {
DisabledLibraryIDs []int
PreferredMetadataLanguage string
MetadataLanguageOverrides map[string]string
}
// ResolveViewerPreferences resolves the profile's viewer-scope preferences in
// one canonical store read. The legacy disabled_library_ids account setting is
// consulted only when no canonical row decided that value.
func ResolveViewerPreferences(
ctx context.Context, store userstore.UserStore, profileID string,
) ViewerPreferences {
profileID = strings.TrimSpace(profileID)
if store == nil {
return ViewerPreferences{}
}
if profileID == "" {
return ViewerPreferences{DisabledLibraryIDs: legacyDisabledLibraryIDs(ctx, store)}
}
resolved, ok := resolveCanonicalViewerPreferences(ctx, store, profileID)
if !ok || !resolved.disabledLibraryIDsSet {
resolved.preferences.DisabledLibraryIDs = legacyDisabledLibraryIDs(ctx, store)
}
return resolved.preferences
}
type canonicalViewerPreferences struct {
preferences ViewerPreferences
disabledLibraryIDsSet bool
}
func resolveCanonicalViewerPreferences(
ctx context.Context, store userstore.UserStore, profileID string,
) (canonicalViewerPreferences, bool) {
contract, err := settingscontract.Load()
if err != nil {
slog.WarnContext(ctx, "viewer preference resolution degraded: loading settings contract failed",
"component", "access", "profile_id", profileID, "error", err)
return canonicalViewerPreferences{}, false
}
values, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]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)
return canonicalViewerPreferences{}, false
}
var out canonicalViewerPreferences
for _, value := range values {
switch value.Key {
case settingskeys.UiDisabledLibraryIds:
out.disabledLibraryIDsSet = value.Source != settingscontract.ScopeDefault
if out.disabledLibraryIDsSet {
out.preferences.DisabledLibraryIDs = parseLibraryIDList(value.Value)
}
case settingskeys.CatalogMetadataLanguage:
var language string
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 == "" {
return nil
}
return parseLibraryIDList(json.RawMessage(raw))
}