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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"fixture_version": 1,
|
||||
"manifest_revision": 2,
|
||||
"manifest_revision": 3,
|
||||
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
|
||||
"cases": [
|
||||
{
|
||||
@@ -438,6 +438,27 @@
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "metadata_language_exceptions_resolve_as_one_object",
|
||||
"description": "The original-language exception map is a single profile-scoped value; resolution preserves every source-to-target entry together.",
|
||||
"keys": ["catalog.metadata_language_overrides"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "ja": "en", "no": "x-silo-original" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"value": { "ja": "en", "no": "x-silo-original" },
|
||||
"source": "profile"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "locked_replaces_a_differing_choice",
|
||||
"description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"api_version": 1,
|
||||
"revision": 2,
|
||||
"revision": 3,
|
||||
"option_sets": {
|
||||
"playback_audio_languages": {
|
||||
"type": "language_tag",
|
||||
@@ -384,11 +384,28 @@
|
||||
"default_value": null,
|
||||
"category": "catalog",
|
||||
"label": "Metadata language",
|
||||
"description": "Language Silo prefers for titles, descriptions, and artwork.",
|
||||
"description": "Fallback language Silo prefers for titles, descriptions, and artwork.",
|
||||
"recommended_control": "select",
|
||||
"suggested_options": "catalog_metadata_languages",
|
||||
"unset_label": "Library default",
|
||||
"notes": "Migrates user_profiles.preferred_metadata_language; that column is NOT NULL DEFAULT '', and the empty string means unset, so migration writes a row only where it is non-empty. Deliberately carries no constrained_by. An earlier draft declared an allowlist on policy input profile_preferred_metadata_language, which is circular: internal/policy/input.go populates that field from this very column and vendor/scope.rego relays it unchanged as a preference. Policy narrows nothing here, and an allowlist bound to a scalar equal to the current value would either be a no-op or reject every change the user makes."
|
||||
"notes": "Migrates user_profiles.preferred_metadata_language; that column is NOT NULL DEFAULT '', and the empty string means unset, so migration writes a row only where it is non-empty. Deliberately carries no constrained_by. An earlier draft declared an allowlist on policy input profile_preferred_metadata_language, which is circular: internal/policy/input.go populates that field from this very column and vendor/scope.rego relays it unchanged as a preference. Policy narrows nothing here, and an allowlist bound to a scalar equal to the current value would either be a no-op or reject every change the user makes. From revision 3, the private-use tag x-silo-original means resolve the target from each media item's original_language. It remains a valid value of the existing language_tag schema, so this is additive rather than a response-field type change."
|
||||
},
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"introduced_in": 3,
|
||||
"persistence": "remote",
|
||||
"allowed_scopes": ["profile"],
|
||||
"resolution_order": ["profile", "default"],
|
||||
"value_schema": {
|
||||
"type": "object",
|
||||
"schema_ref": "metadata-language-overrides.json"
|
||||
},
|
||||
"default_value": {},
|
||||
"category": "catalog",
|
||||
"label": "Metadata language exceptions",
|
||||
"description": "Preferred metadata language for items in specific original languages.",
|
||||
"recommended_control": "panel",
|
||||
"notes": "Keys are canonical catalog original_language codes. Values are target BCP 47 language tags; x-silo-original means retain that source language. This key is separate from catalog.metadata_language so existing clients can continue changing the fallback without rewriting or discarding exceptions."
|
||||
},
|
||||
{
|
||||
"key": "player.hdr_enabled",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://silo-server.dev/contracts/settings/v1/schemas/metadata-language-overrides.json",
|
||||
"title": "Metadata language exceptions",
|
||||
"description": "Maps a media item's canonical original-language code to the metadata language a profile wants for that item. x-silo-original means use the source language itself.",
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"description": "Canonical ISO 639 language code as stored in media_items.original_language.",
|
||||
"type": "string",
|
||||
"pattern": "^[a-z]{2,3}$"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"description": "A BCP 47 target language or the x-silo-original private-use sentinel.",
|
||||
"type": "string",
|
||||
"minLength": 2,
|
||||
"maxLength": 255,
|
||||
"pattern": "^([a-zA-Z]{2,3}(-[a-zA-Z]{3}){0,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-([0-9a-zA-Z]{5,8}|[0-9][0-9a-zA-Z]{3}))*(-[0-9a-wy-zA-WY-Z](-[0-9a-zA-Z]{2,8})+)*(-[xX](-[0-9a-zA-Z]{1,8})+)?|[xX](-[0-9a-zA-Z]{1,8})+)$"
|
||||
},
|
||||
"maxProperties": 256
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 167 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -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 == "" {
|
||||
|
||||
@@ -228,6 +228,11 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
|
||||
}
|
||||
|
||||
if len(seasons) > 0 {
|
||||
if h.items.detailSvc != nil {
|
||||
if localized, locErr := h.items.detailSvc.LocalizeSeasonModels(r.Context(), seasons, filter); locErr == nil && len(localized) == len(seasons) {
|
||||
seasons = localized
|
||||
}
|
||||
}
|
||||
episodesBySeason, err := h.items.episodeRepo.ListBySeriesGroupedBySeason(r.Context(), id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
|
||||
@@ -245,7 +250,7 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
|
||||
if hasProgressMap {
|
||||
userData = catalog.EpisodeRollupUserData(episodes, progressMap)
|
||||
}
|
||||
sr := h.items.toSeasonResponseFromEpisodes(r, id, s, episodes, userData)
|
||||
sr := h.items.seasonResponseFromEpisodes(r, s, episodes, userData)
|
||||
resp = append(resp, sr)
|
||||
}
|
||||
|
||||
|
||||
@@ -1478,7 +1478,18 @@ func (h *ItemsHandler) toSeasonResponseFromEpisodes(
|
||||
s = localized
|
||||
}
|
||||
}
|
||||
return h.seasonResponseFromEpisodes(r, s, episodes, userData)
|
||||
}
|
||||
|
||||
// seasonResponseFromEpisodes maps a season that has already been localized.
|
||||
// List endpoints use this after LocalizeSeasonModels so they do not repeat the
|
||||
// localization query for every row.
|
||||
func (h *ItemsHandler) seasonResponseFromEpisodes(
|
||||
r *http.Request,
|
||||
s *models.Season,
|
||||
episodes []*models.Episode,
|
||||
userData *catalog.SeasonUserData,
|
||||
) seasonResponse {
|
||||
resp := seasonResponse{
|
||||
ContentID: s.ContentID,
|
||||
SeasonNumber: s.SeasonNumber,
|
||||
@@ -1814,15 +1825,16 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
|
||||
|
||||
if scope, ok := access.GetScope(r.Context()); ok {
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
PresentationLibraryID: presentationLibraryID,
|
||||
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
PresentationLibraryID: presentationLibraryID,
|
||||
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
|
||||
MetadataLanguageOverrides: scope.MetadataLanguageOverrides,
|
||||
SelectedFileID: selectedFileID,
|
||||
UserID: apimw.GetUserID(r.Context()),
|
||||
ProfileID: apimw.GetProfileID(r.Context()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ type SettingValuesHandler struct {
|
||||
}
|
||||
|
||||
type languageSuggestionSource interface {
|
||||
ListOriginalLanguages(context.Context, catalog.BrowseFilters) ([]string, error)
|
||||
ListAudioLanguages(context.Context, catalog.BrowseFilters) ([]string, error)
|
||||
ListSubtitleLanguages(context.Context, catalog.BrowseFilters) ([]string, error)
|
||||
}
|
||||
@@ -1073,16 +1074,18 @@ func (h *SettingValuesHandler) observedLanguageSuggestions(
|
||||
return result
|
||||
}
|
||||
|
||||
wantsAudio, wantsSubtitles := false, false
|
||||
wantsMetadata, wantsAudio, wantsSubtitles := false, false, false
|
||||
for _, eff := range resolved {
|
||||
switch eff.Key {
|
||||
case settingskeys.CatalogMetadataLanguage:
|
||||
wantsMetadata = true
|
||||
case settingskeys.PlaybackAudioLanguage:
|
||||
wantsAudio = true
|
||||
case settingskeys.PlaybackSubtitleLanguage:
|
||||
wantsSubtitles = true
|
||||
}
|
||||
}
|
||||
if !wantsAudio && !wantsSubtitles {
|
||||
if !wantsMetadata && !wantsAudio && !wantsSubtitles {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1092,6 +1095,15 @@ func (h *SettingValuesHandler) observedLanguageSuggestions(
|
||||
filters.DisabledLibraryIDs = scope.DisabledLibraryIDs
|
||||
filters.MaxContentRating = scope.MaxContentRating
|
||||
}
|
||||
if wantsMetadata {
|
||||
values, err := h.languageSource.ListOriginalLanguages(r.Context(), filters)
|
||||
if err != nil {
|
||||
slog.WarnContext(r.Context(), "settings: listing metadata language suggestions",
|
||||
"component", "settings", "error", err)
|
||||
} else {
|
||||
result[settingskeys.CatalogMetadataLanguage] = values
|
||||
}
|
||||
}
|
||||
if wantsAudio {
|
||||
values, err := h.languageSource.ListAudioLanguages(r.Context(), filters)
|
||||
if err != nil {
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/Silo-Server/silo-server/internal/catalog"
|
||||
"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/settingsresolve"
|
||||
"github.com/Silo-Server/silo-server/internal/userdb"
|
||||
"github.com/Silo-Server/silo-server/internal/userstore"
|
||||
)
|
||||
@@ -852,3 +854,52 @@ func TestMergeLanguageSuggestionsUsesExactCurrentAlias(t *testing.T) {
|
||||
t.Errorf("mergeLanguageSuggestions() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingLanguageSuggestionSource struct {
|
||||
filters catalog.BrowseFilters
|
||||
original []string
|
||||
}
|
||||
|
||||
func (s *recordingLanguageSuggestionSource) ListOriginalLanguages(
|
||||
_ context.Context, filters catalog.BrowseFilters,
|
||||
) ([]string, error) {
|
||||
s.filters = filters
|
||||
return s.original, nil
|
||||
}
|
||||
|
||||
func (*recordingLanguageSuggestionSource) ListAudioLanguages(
|
||||
context.Context, catalog.BrowseFilters,
|
||||
) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (*recordingLanguageSuggestionSource) ListSubtitleLanguages(
|
||||
context.Context, catalog.BrowseFilters,
|
||||
) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestObservedLanguageSuggestionsIncludesAccessibleOriginalLanguages(t *testing.T) {
|
||||
source := &recordingLanguageSuggestionSource{original: []string{"is", "no"}}
|
||||
handler, _ := newValuesTestHandler(t)
|
||||
handler.SetLanguageSuggestionSource(source)
|
||||
|
||||
req := valuesRequest(http.MethodGet, "/settings/values/effective", nil)
|
||||
req = req.WithContext(access.SetScope(req.Context(), access.Scope{
|
||||
AllowedLibraryIDs: []int{4, 9},
|
||||
DisabledLibraryIDs: []int{12},
|
||||
MaxContentRating: "PG-13",
|
||||
}))
|
||||
observed := handler.observedLanguageSuggestions(req, []settingsresolve.Effective{{
|
||||
Key: settingskeys.CatalogMetadataLanguage,
|
||||
}})
|
||||
|
||||
if !slices.Equal(observed[settingskeys.CatalogMetadataLanguage], []string{"is", "no"}) {
|
||||
t.Fatalf("metadata suggestions = %v", observed[settingskeys.CatalogMetadataLanguage])
|
||||
}
|
||||
if !slices.Equal(source.filters.LibraryIDs, []int{4, 9}) ||
|
||||
!slices.Equal(source.filters.DisabledLibraryIDs, []int{12}) ||
|
||||
source.filters.MaxContentRating != "PG-13" {
|
||||
t.Fatalf("catalog filters = %#v", source.filters)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,17 @@ type AccessFilter struct {
|
||||
// language. Presentation language resolves: explicit PresentationLanguage
|
||||
// → ProfilePreferredLanguage → the library's metadata_language.
|
||||
ProfilePreferredLanguage string
|
||||
MaxContentRating string
|
||||
MaxPlaybackQuality string
|
||||
SelectedFileID int
|
||||
UserID int
|
||||
ProfileID string
|
||||
// MetadataLanguageOverrides maps an item's original language to a target
|
||||
// language before ProfilePreferredLanguage is used as the fallback.
|
||||
MetadataLanguageOverrides map[string]string
|
||||
// PresentationOriginalLanguage supplies a parent series' original language
|
||||
// while localizing season and episode rows, which do not duplicate it.
|
||||
PresentationOriginalLanguage string
|
||||
MaxContentRating string
|
||||
MaxPlaybackQuality string
|
||||
SelectedFileID int
|
||||
UserID int
|
||||
ProfileID 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
|
||||
|
||||
+347
-76
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/artworkkey"
|
||||
"github.com/Silo-Server/silo-server/internal/lang"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
"github.com/Silo-Server/silo-server/internal/overlays"
|
||||
"github.com/Silo-Server/silo-server/internal/playback"
|
||||
@@ -750,27 +751,146 @@ func cloneEpisode(ep *models.Episode) *models.Episode {
|
||||
return &cp
|
||||
}
|
||||
|
||||
// resolvePresentationLanguage picks the display language for a request:
|
||||
// explicit request language → viewer profile preference → the presentation
|
||||
// library's metadata language.
|
||||
func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter AccessFilter) (string, error) {
|
||||
if strings.TrimSpace(filter.PresentationLanguage) != "" {
|
||||
return strings.TrimSpace(filter.PresentationLanguage), nil
|
||||
type presentationLanguageBase struct {
|
||||
target string
|
||||
libraryFallback string
|
||||
explicit bool
|
||||
}
|
||||
|
||||
// resolvePresentationLanguageBase picks the request-wide fallback once. The
|
||||
// item-specific original-language rule is applied separately, because one
|
||||
// result page can contain several source languages and therefore several
|
||||
// localization targets.
|
||||
func (s *DetailService) resolvePresentationLanguageBase(ctx context.Context, filter AccessFilter) (presentationLanguageBase, error) {
|
||||
base := presentationLanguageBase{}
|
||||
if explicit := strings.TrimSpace(filter.PresentationLanguage); explicit != "" {
|
||||
base.target = explicit
|
||||
base.explicit = true
|
||||
} else {
|
||||
base.target = strings.TrimSpace(filter.ProfilePreferredLanguage)
|
||||
}
|
||||
if strings.TrimSpace(filter.ProfilePreferredLanguage) != "" {
|
||||
return strings.TrimSpace(filter.ProfilePreferredLanguage), nil
|
||||
|
||||
// A concrete profile/explicit target never needs the library fallback. The
|
||||
// original-language sentinel does: items with no known original language
|
||||
// should still inherit the library rather than lose localization entirely.
|
||||
if base.target != "" && !sameMetadataLanguage(base.target, access.OriginalMetadataLanguage) {
|
||||
return base, nil
|
||||
}
|
||||
if filter.PresentationLibraryID == nil || s.folderRepo == nil {
|
||||
return "", nil
|
||||
return base, nil
|
||||
}
|
||||
folder, err := s.folderRepo.GetByID(ctx, *filter.PresentationLibraryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrFolderNotFound) {
|
||||
return "", ErrItemNotFound
|
||||
return presentationLanguageBase{}, ErrItemNotFound
|
||||
}
|
||||
return presentationLanguageBase{}, err
|
||||
}
|
||||
base.libraryFallback = strings.TrimSpace(folder.MetadataLanguage)
|
||||
if base.target == "" {
|
||||
base.target = base.libraryFallback
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// presentationLanguageForOriginal applies a profile's per-source exception,
|
||||
// then resolves the original-language sentinel to the item's concrete catalog
|
||||
// language. An explicit request language remains authoritative and bypasses
|
||||
// profile exceptions.
|
||||
func presentationLanguageForOriginal(base presentationLanguageBase, originalLanguage string, filter AccessFilter) string {
|
||||
original := lang.Canonical(originalLanguage)
|
||||
target := base.target
|
||||
if !base.explicit && original != "" {
|
||||
if override := strings.TrimSpace(filter.MetadataLanguageOverrides[original]); override != "" {
|
||||
target = override
|
||||
}
|
||||
}
|
||||
if sameMetadataLanguage(target, access.OriginalMetadataLanguage) {
|
||||
if original != "" {
|
||||
return original
|
||||
}
|
||||
return base.libraryFallback
|
||||
}
|
||||
return strings.TrimSpace(target)
|
||||
}
|
||||
|
||||
func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter AccessFilter, originalLanguage string) (string, error) {
|
||||
base, err := s.resolvePresentationLanguageBase(ctx, filter)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(folder.MetadataLanguage), nil
|
||||
return presentationLanguageForOriginal(base, originalLanguage, filter), nil
|
||||
}
|
||||
|
||||
func metadataLanguageMayUseOriginal(filter AccessFilter) bool {
|
||||
if explicit := strings.TrimSpace(filter.PresentationLanguage); explicit != "" {
|
||||
return sameMetadataLanguage(explicit, access.OriginalMetadataLanguage)
|
||||
}
|
||||
return sameMetadataLanguage(filter.ProfilePreferredLanguage, access.OriginalMetadataLanguage) ||
|
||||
len(filter.MetadataLanguageOverrides) > 0
|
||||
}
|
||||
|
||||
// seriesOriginalLanguage supplies the source language for season and episode
|
||||
// rows, which deliberately inherit original_language from their parent series
|
||||
// instead of duplicating it in each table.
|
||||
func (s *DetailService) seriesOriginalLanguage(ctx context.Context, seriesID string, filter AccessFilter) string {
|
||||
if original := strings.TrimSpace(filter.PresentationOriginalLanguage); original != "" {
|
||||
return original
|
||||
}
|
||||
if !metadataLanguageMayUseOriginal(filter) || s.itemRepo == nil || strings.TrimSpace(seriesID) == "" {
|
||||
return ""
|
||||
}
|
||||
series, err := s.itemRepo.GetByID(ctx, seriesID)
|
||||
if err != nil || series == nil {
|
||||
return ""
|
||||
}
|
||||
return series.OriginalLanguage
|
||||
}
|
||||
|
||||
// seriesOriginalLanguages is the batch equivalent of seriesOriginalLanguage.
|
||||
// A season or episode page usually contains many children of one series; load
|
||||
// each distinct parent once so original-language preferences do not introduce
|
||||
// an N+1 query on those pages.
|
||||
func (s *DetailService) seriesOriginalLanguages(
|
||||
ctx context.Context,
|
||||
seriesIDs []string,
|
||||
filter AccessFilter,
|
||||
) (map[string]string, error) {
|
||||
originalBySeries := make(map[string]string)
|
||||
seen := make(map[string]struct{}, len(seriesIDs))
|
||||
distinct := make([]string, 0, len(seriesIDs))
|
||||
for _, seriesID := range seriesIDs {
|
||||
seriesID = strings.TrimSpace(seriesID)
|
||||
if seriesID == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[seriesID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[seriesID] = struct{}{}
|
||||
distinct = append(distinct, seriesID)
|
||||
}
|
||||
|
||||
if supplied := strings.TrimSpace(filter.PresentationOriginalLanguage); supplied != "" {
|
||||
for _, seriesID := range distinct {
|
||||
originalBySeries[seriesID] = supplied
|
||||
}
|
||||
return originalBySeries, nil
|
||||
}
|
||||
if len(distinct) == 0 || !metadataLanguageMayUseOriginal(filter) || s.itemRepo == nil {
|
||||
return originalBySeries, nil
|
||||
}
|
||||
|
||||
series, err := s.itemRepo.GetByIDs(ctx, distinct)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range series {
|
||||
if item != nil {
|
||||
originalBySeries[item.ContentID] = item.OriginalLanguage
|
||||
}
|
||||
}
|
||||
return originalBySeries, nil
|
||||
}
|
||||
|
||||
// PendingTranslationLanguage reports the presentation language the item's
|
||||
@@ -783,7 +903,7 @@ func (s *DetailService) PendingTranslationLanguage(ctx context.Context, item *mo
|
||||
if item == nil || strings.TrimSpace(item.Overview) == "" || s.itemLocRepo == nil {
|
||||
return ""
|
||||
}
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter, item.OriginalLanguage)
|
||||
if err != nil || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) {
|
||||
return ""
|
||||
}
|
||||
@@ -815,7 +935,7 @@ func (s *DetailService) PendingSeasonTranslationLanguage(ctx context.Context, se
|
||||
if season == nil || strings.TrimSpace(season.Overview) == "" || s.seasonLocRepo == nil {
|
||||
return ""
|
||||
}
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter, s.seriesOriginalLanguage(ctx, season.SeriesID, filter))
|
||||
if err != nil || language == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, language) {
|
||||
return ""
|
||||
}
|
||||
@@ -832,7 +952,7 @@ func (s *DetailService) PendingEpisodeTranslationLanguage(ctx context.Context, e
|
||||
if episode == nil || strings.TrimSpace(episode.Overview) == "" || s.episodeLocRepo == nil {
|
||||
return ""
|
||||
}
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter, s.seriesOriginalLanguage(ctx, episode.SeriesID, filter))
|
||||
if err != nil || language == "" || sameMetadataLanguage(episode.DefaultMetadataLanguage, language) {
|
||||
return ""
|
||||
}
|
||||
@@ -865,7 +985,7 @@ func (s *DetailService) LocalizeItemModel(ctx context.Context, item *models.Medi
|
||||
if item == nil {
|
||||
return nil, nil
|
||||
}
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter, item.OriginalLanguage)
|
||||
if err != nil || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) || s.itemLocRepo == nil {
|
||||
return cloneMediaItem(item), err
|
||||
}
|
||||
@@ -876,6 +996,64 @@ func (s *DetailService) LocalizeItemModel(ctx context.Context, item *models.Medi
|
||||
return s.localizeItemModelWith(item, language, loc), nil
|
||||
}
|
||||
|
||||
// loadItemLocalizations resolves each item's target and groups repository reads
|
||||
// by that language. The former single-language lookup was correct only while a
|
||||
// profile had one global target; source-language exceptions make the grouping
|
||||
// necessary to preserve batching without serving one item's localization to
|
||||
// another language group.
|
||||
func (s *DetailService) loadItemLocalizations(
|
||||
ctx context.Context,
|
||||
items []*models.MediaItem,
|
||||
filter AccessFilter,
|
||||
) (map[string]string, map[string]*models.MediaItemLocalization, error) {
|
||||
base, err := s.resolvePresentationLanguageBase(ctx, filter)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
targets := make(map[string]string, len(items))
|
||||
if s.itemLocRepo == nil {
|
||||
return targets, nil, nil
|
||||
}
|
||||
|
||||
idsByLanguage := make(map[string][]string)
|
||||
seenByLanguage := make(map[string]map[string]struct{})
|
||||
for _, item := range items {
|
||||
if item == nil || item.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
target := presentationLanguageForOriginal(base, item.OriginalLanguage, filter)
|
||||
targets[item.ContentID] = target
|
||||
if target == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, target) {
|
||||
continue
|
||||
}
|
||||
if seenByLanguage[target] == nil {
|
||||
seenByLanguage[target] = make(map[string]struct{})
|
||||
}
|
||||
if _, seen := seenByLanguage[target][item.ContentID]; seen {
|
||||
continue
|
||||
}
|
||||
seenByLanguage[target][item.ContentID] = struct{}{}
|
||||
idsByLanguage[target] = append(idsByLanguage[target], item.ContentID)
|
||||
}
|
||||
|
||||
languages := make([]string, 0, len(idsByLanguage))
|
||||
for language := range idsByLanguage {
|
||||
languages = append(languages, language)
|
||||
}
|
||||
sort.Strings(languages)
|
||||
localizations := make(map[string]*models.MediaItemLocalization)
|
||||
for _, language := range languages {
|
||||
rows, err := s.itemLocRepo.GetByContentIDs(ctx, idsByLanguage[language], language)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for contentID, localization := range rows {
|
||||
localizations[contentID] = localization
|
||||
}
|
||||
}
|
||||
return targets, localizations, nil
|
||||
}
|
||||
|
||||
// localizeItemModelWith applies a pre-resolved localization to item, returning a
|
||||
// clone when there is nothing to localize (no language, base language already
|
||||
// matches, no localization repo, or no localization row). Shared core of
|
||||
@@ -899,29 +1077,8 @@ func (s *DetailService) LocalizeItemModels(ctx context.Context, items []*models.
|
||||
for i, item := range items {
|
||||
localized[i] = cloneMediaItem(item)
|
||||
}
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
if err != nil || language == "" || s.itemLocRepo == nil {
|
||||
return localized, err
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item == nil || item.ContentID == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.ContentID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.ContentID] = struct{}{}
|
||||
ids = append(ids, item.ContentID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return localized, nil
|
||||
}
|
||||
|
||||
locs, err := s.itemLocRepo.GetByContentIDs(ctx, ids, language)
|
||||
if err != nil || len(locs) == 0 {
|
||||
targets, locs, err := s.loadItemLocalizations(ctx, items, filter)
|
||||
if err != nil || s.itemLocRepo == nil {
|
||||
return localized, err
|
||||
}
|
||||
for i, item := range items {
|
||||
@@ -929,7 +1086,7 @@ func (s *DetailService) LocalizeItemModels(ctx context.Context, items []*models.
|
||||
continue
|
||||
}
|
||||
if loc := locs[item.ContentID]; loc != nil {
|
||||
localized[i] = applyItemLocalization(item, loc)
|
||||
localized[i] = s.localizeItemModelWith(item, targets[item.ContentID], loc)
|
||||
}
|
||||
}
|
||||
return localized, nil
|
||||
@@ -939,7 +1096,8 @@ func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.
|
||||
if season == nil {
|
||||
return nil, nil
|
||||
}
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
original := s.seriesOriginalLanguage(ctx, season.SeriesID, filter)
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter, original)
|
||||
if err != nil || language == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, language) || s.seasonLocRepo == nil {
|
||||
return cloneSeason(season), err
|
||||
}
|
||||
@@ -950,11 +1108,89 @@ func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.
|
||||
return applySeasonLocalization(season, loc), nil
|
||||
}
|
||||
|
||||
// LocalizeSeasonModels applies presentation-language localization to a batch
|
||||
// of seasons. Parent series and localization rows are fetched in batches, so
|
||||
// original-language preferences do not add one query per season. The result
|
||||
// preserves input order and length.
|
||||
func (s *DetailService) LocalizeSeasonModels(ctx context.Context, seasons []*models.Season, filter AccessFilter) ([]*models.Season, error) {
|
||||
if len(seasons) == 0 {
|
||||
return seasons, nil
|
||||
}
|
||||
localized := make([]*models.Season, len(seasons))
|
||||
seriesIDs := make([]string, 0, len(seasons))
|
||||
for i, season := range seasons {
|
||||
localized[i] = cloneSeason(season)
|
||||
if season != nil {
|
||||
seriesIDs = append(seriesIDs, season.SeriesID)
|
||||
}
|
||||
}
|
||||
|
||||
base, err := s.resolvePresentationLanguageBase(ctx, filter)
|
||||
if err != nil || s.seasonLocRepo == nil {
|
||||
return localized, err
|
||||
}
|
||||
originalBySeries, err := s.seriesOriginalLanguages(ctx, seriesIDs, filter)
|
||||
if err != nil {
|
||||
return localized, err
|
||||
}
|
||||
|
||||
targets := make(map[string]string, len(seasons))
|
||||
idsByLanguage := make(map[string][]string)
|
||||
seenByLanguage := make(map[string]map[string]struct{})
|
||||
for _, season := range seasons {
|
||||
if season == nil || season.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
target := presentationLanguageForOriginal(base, originalBySeries[season.SeriesID], filter)
|
||||
targets[season.ContentID] = target
|
||||
if target == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, target) {
|
||||
continue
|
||||
}
|
||||
if seenByLanguage[target] == nil {
|
||||
seenByLanguage[target] = make(map[string]struct{})
|
||||
}
|
||||
if _, seen := seenByLanguage[target][season.ContentID]; seen {
|
||||
continue
|
||||
}
|
||||
seenByLanguage[target][season.ContentID] = struct{}{}
|
||||
idsByLanguage[target] = append(idsByLanguage[target], season.ContentID)
|
||||
}
|
||||
|
||||
languages := make([]string, 0, len(idsByLanguage))
|
||||
for language := range idsByLanguage {
|
||||
languages = append(languages, language)
|
||||
}
|
||||
sort.Strings(languages)
|
||||
locs := make(map[string]*models.SeasonLocalization)
|
||||
for _, language := range languages {
|
||||
rows, err := s.seasonLocRepo.GetBySeasonIDs(ctx, idsByLanguage[language], language)
|
||||
if err != nil {
|
||||
return localized, err
|
||||
}
|
||||
for seasonID, localization := range rows {
|
||||
locs[seasonID] = localization
|
||||
}
|
||||
}
|
||||
for i, season := range seasons {
|
||||
if season == nil {
|
||||
continue
|
||||
}
|
||||
if loc := locs[season.ContentID]; loc != nil {
|
||||
target := targets[season.ContentID]
|
||||
if target != "" && !sameMetadataLanguage(season.DefaultMetadataLanguage, target) {
|
||||
localized[i] = applySeasonLocalization(season, loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return localized, nil
|
||||
}
|
||||
|
||||
func (s *DetailService) LocalizeEpisodeModel(ctx context.Context, episode *models.Episode, filter AccessFilter) (*models.Episode, error) {
|
||||
if episode == nil {
|
||||
return nil, nil
|
||||
}
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
original := s.seriesOriginalLanguage(ctx, episode.SeriesID, filter)
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter, original)
|
||||
if err != nil || language == "" || sameMetadataLanguage(episode.DefaultMetadataLanguage, language) || s.episodeLocRepo == nil {
|
||||
return cloneEpisode(episode), err
|
||||
}
|
||||
@@ -972,23 +1208,50 @@ func (s *DetailService) LocalizeEpisodeModels(ctx context.Context, episodes []*m
|
||||
if len(episodes) == 0 {
|
||||
return episodes, nil
|
||||
}
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
if err != nil || language == "" || s.episodeLocRepo == nil {
|
||||
base, err := s.resolvePresentationLanguageBase(ctx, filter)
|
||||
if err != nil || s.episodeLocRepo == nil {
|
||||
return episodes, err
|
||||
}
|
||||
ids := make([]string, 0, len(episodes))
|
||||
|
||||
seriesIDs := make([]string, 0, len(episodes))
|
||||
for _, episode := range episodes {
|
||||
if episode != nil {
|
||||
seriesIDs = append(seriesIDs, episode.SeriesID)
|
||||
}
|
||||
}
|
||||
originalBySeries, err := s.seriesOriginalLanguages(ctx, seriesIDs, filter)
|
||||
if err != nil {
|
||||
return episodes, err
|
||||
}
|
||||
|
||||
targets := make(map[string]string, len(episodes))
|
||||
idsByLanguage := make(map[string][]string)
|
||||
for _, ep := range episodes {
|
||||
if ep == nil || sameMetadataLanguage(ep.DefaultMetadataLanguage, language) {
|
||||
if ep == nil || ep.ContentID == "" {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, ep.ContentID)
|
||||
target := presentationLanguageForOriginal(base, originalBySeries[ep.SeriesID], filter)
|
||||
targets[ep.ContentID] = target
|
||||
if target == "" || sameMetadataLanguage(ep.DefaultMetadataLanguage, target) {
|
||||
continue
|
||||
}
|
||||
idsByLanguage[target] = append(idsByLanguage[target], ep.ContentID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return episodes, nil
|
||||
|
||||
languages := make([]string, 0, len(idsByLanguage))
|
||||
for language := range idsByLanguage {
|
||||
languages = append(languages, language)
|
||||
}
|
||||
locs, err := s.episodeLocRepo.GetByEpisodeIDs(ctx, ids, language)
|
||||
if err != nil || len(locs) == 0 {
|
||||
return episodes, err
|
||||
sort.Strings(languages)
|
||||
locs := make(map[string]*models.EpisodeLocalization)
|
||||
for _, language := range languages {
|
||||
rows, err := s.episodeLocRepo.GetByEpisodeIDs(ctx, idsByLanguage[language], language)
|
||||
if err != nil {
|
||||
return episodes, err
|
||||
}
|
||||
for episodeID, localization := range rows {
|
||||
locs[episodeID] = localization
|
||||
}
|
||||
}
|
||||
localized := make([]*models.Episode, len(episodes))
|
||||
for i, ep := range episodes {
|
||||
@@ -997,7 +1260,10 @@ func (s *DetailService) LocalizeEpisodeModels(ctx context.Context, episodes []*m
|
||||
continue
|
||||
}
|
||||
if loc := locs[ep.ContentID]; loc != nil {
|
||||
localized[i] = applyEpisodeLocalization(ep, loc)
|
||||
target := targets[ep.ContentID]
|
||||
if target != "" && !sameMetadataLanguage(ep.DefaultMetadataLanguage, target) {
|
||||
localized[i] = applyEpisodeLocalization(ep, loc)
|
||||
}
|
||||
}
|
||||
}
|
||||
return localized, nil
|
||||
@@ -1287,21 +1553,13 @@ func (s *DetailService) GetItemDetailsByIDs(ctx context.Context, contentIDs []st
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Localization: resolve the presentation language once, then one bulk
|
||||
// localization lookup. A resolution failure surfaces the same wrapped error
|
||||
// GetItemDetail would produce from buildMediaItemDetail, so the caller
|
||||
// degrades to the per-item path.
|
||||
language, err := s.resolvePresentationLanguage(ctx, filter)
|
||||
// Localization keeps one lookup per target language. Most profiles still
|
||||
// produce one query; profiles with source-language exceptions produce one
|
||||
// query for each target represented on this page.
|
||||
targetByID, locByID, err := s.loadItemLocalizations(ctx, visible, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localizing item detail: %w", err)
|
||||
}
|
||||
var locByID map[string]*models.MediaItemLocalization
|
||||
if language != "" && s.itemLocRepo != nil {
|
||||
locByID, err = s.itemLocRepo.GetByContentIDs(ctx, visibleIDs, language)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localizing item detail: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Credits for the whole page in one query.
|
||||
var creditsByID map[string][]models.ItemPerson
|
||||
@@ -1361,6 +1619,7 @@ func (s *DetailService) GetItemDetailsByIDs(ctx context.Context, contentIDs []st
|
||||
|
||||
for _, item := range visible {
|
||||
id := item.ContentID
|
||||
language := targetByID[id]
|
||||
loc := locByID[id]
|
||||
pending := ""
|
||||
if s.itemLocRepo != nil {
|
||||
@@ -2319,17 +2578,19 @@ func clearSentinel(s string) string {
|
||||
}
|
||||
|
||||
func (s *DetailService) buildSeasonDetail(ctx context.Context, season *models.Season, filter AccessFilter) (*ItemDetail, error) {
|
||||
pendingTranslation := s.PendingSeasonTranslationLanguage(ctx, season, filter)
|
||||
localizedSeason, err := s.LocalizeSeasonModel(ctx, season, filter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localizing season detail: %w", err)
|
||||
}
|
||||
season = localizedSeason
|
||||
series, err := s.itemRepo.GetByID(ctx, season.SeriesID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading parent series: %w", err)
|
||||
}
|
||||
series, err = s.LocalizeItemModel(ctx, series, filter)
|
||||
localizationFilter := filter
|
||||
localizationFilter.PresentationOriginalLanguage = series.OriginalLanguage
|
||||
pendingTranslation := s.PendingSeasonTranslationLanguage(ctx, season, localizationFilter)
|
||||
localizedSeason, err := s.LocalizeSeasonModel(ctx, season, localizationFilter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localizing season detail: %w", err)
|
||||
}
|
||||
season = localizedSeason
|
||||
series, err = s.LocalizeItemModel(ctx, series, localizationFilter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localizing season series detail: %w", err)
|
||||
}
|
||||
@@ -2384,8 +2645,10 @@ func (s *DetailService) buildSeasonDetail(ctx context.Context, season *models.Se
|
||||
}
|
||||
|
||||
func (s *DetailService) buildEpisodeDetail(ctx context.Context, episode *models.Episode, seriesCtx *seriesDetailContext, filter AccessFilter) (*ItemDetail, error) {
|
||||
pendingTranslation := s.PendingEpisodeTranslationLanguage(ctx, episode, filter)
|
||||
localizedEpisode, err := s.LocalizeEpisodeModel(ctx, episode, filter)
|
||||
localizationFilter := filter
|
||||
localizationFilter.PresentationOriginalLanguage = seriesCtx.series.OriginalLanguage
|
||||
pendingTranslation := s.PendingEpisodeTranslationLanguage(ctx, episode, localizationFilter)
|
||||
localizedEpisode, err := s.LocalizeEpisodeModel(ctx, episode, localizationFilter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localizing episode detail: %w", err)
|
||||
}
|
||||
@@ -2520,7 +2783,15 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi
|
||||
if err := s.validatePresentationItemAccess(ctx, filter, episode.ContentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
episode, err = s.LocalizeEpisodeModel(ctx, episode, filter)
|
||||
// Seasons and episodes inherit original_language from the parent series.
|
||||
// Load it before localization and reuse the same row for SeriesTitle below,
|
||||
// avoiding an extra lookup only for profiles that use language exceptions.
|
||||
series, seriesErr := s.itemRepo.GetByID(ctx, episode.SeriesID)
|
||||
localizationFilter := filter
|
||||
if seriesErr == nil && series != nil {
|
||||
localizationFilter.PresentationOriginalLanguage = series.OriginalLanguage
|
||||
}
|
||||
episode, err = s.LocalizeEpisodeModel(ctx, episode, localizationFilter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localizing episode watch detail: %w", err)
|
||||
}
|
||||
@@ -2557,8 +2828,8 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi
|
||||
}
|
||||
}
|
||||
|
||||
if series, err := s.itemRepo.GetByID(ctx, episode.SeriesID); err == nil {
|
||||
series, err = s.LocalizeItemModel(ctx, series, filter)
|
||||
if seriesErr == nil && series != nil {
|
||||
series, err = s.LocalizeItemModel(ctx, series, localizationFilter)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("localizing series watch detail: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
"github.com/Silo-Server/silo-server/internal/models"
|
||||
)
|
||||
|
||||
@@ -147,21 +148,22 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) {
|
||||
batchEquivExec(t, pool, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids)
|
||||
})
|
||||
|
||||
insertItem := func(contentID, mediaType, title, overview, rating string) {
|
||||
insertItem := func(contentID, mediaType, title, overview, rating, originalLanguage string) {
|
||||
batchEquivExec(t, pool, `
|
||||
INSERT INTO media_items (content_id, type, title, genres, overview, content_rating, default_metadata_language)
|
||||
VALUES ($1, $2, $3, '{}'::text[], $4, $5, 'en')
|
||||
`, contentID, mediaType, title, overview, rating)
|
||||
INSERT INTO media_items (content_id, type, title, genres, overview, content_rating, default_metadata_language, original_language)
|
||||
VALUES ($1, $2, $3, '{}'::text[], $4, $5, 'en', $6)
|
||||
`, contentID, mediaType, title, overview, rating, originalLanguage)
|
||||
}
|
||||
// movieA: localized into fr, credited, has files. content_rating PG -> visible.
|
||||
insertItem(movieA, "movie", "Movie A", "Movie A overview (en)", "PG")
|
||||
// movieB: overview present, default lang en, NO fr localization row -> the
|
||||
// pending-translation-language path must fire and match between paths.
|
||||
insertItem(movieB, "movie", "Movie B", "Movie B overview (en)", "PG")
|
||||
insertItem(movieA, "movie", "Movie A", "Movie A overview (en)", "PG", "en")
|
||||
// movieB: Norwegian original, default metadata language en, and no no
|
||||
// localization row. The per-source original-language exception must produce
|
||||
// a Norwegian pending-translation target in both batch and per-item paths.
|
||||
insertItem(movieB, "movie", "Movie B", "Movie B overview (en)", "PG", "no")
|
||||
// series: has a work summary, no playable files (series skip the file path).
|
||||
insertItem(series, "series", "Series One", "Series overview (en)", "PG")
|
||||
insertItem(series, "series", "Series One", "Series overview (en)", "PG", "en")
|
||||
// movieR: R-rated -> filtered out by MaxContentRating=PG-13 on BOTH paths.
|
||||
insertItem(movieR, "movie", "Movie R", "Movie R overview (en)", "R")
|
||||
insertItem(movieR, "movie", "Movie R", "Movie R overview (en)", "R", "en")
|
||||
|
||||
// fr localization for movieA only.
|
||||
batchEquivExec(t, pool, `
|
||||
@@ -251,13 +253,17 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) {
|
||||
}
|
||||
|
||||
// Single shared access filter for BOTH paths: PG-13 ceiling (so PG items are
|
||||
// visible, the R item is not) and an explicit fr presentation language (so
|
||||
// the localization path actually runs rather than short-circuiting).
|
||||
// visible, the R item is not), a French profile fallback, and Norwegian
|
||||
// original-language metadata for Norwegian titles. This forces the batch
|
||||
// path to group one page into two localization targets.
|
||||
filter := AccessFilter{
|
||||
PresentationLanguage: "fr",
|
||||
MaxContentRating: "PG-13",
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
ProfilePreferredLanguage: "fr",
|
||||
MetadataLanguageOverrides: map[string]string{
|
||||
"no": access.OriginalMetadataLanguage,
|
||||
},
|
||||
MaxContentRating: "PG-13",
|
||||
UserID: 1,
|
||||
ProfileID: "profile-1",
|
||||
}
|
||||
|
||||
visibleIDs := []string{movieA, movieB, series}
|
||||
@@ -306,8 +312,8 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) {
|
||||
if got := batch[movieA]; len(got.Versions) != 2 {
|
||||
t.Fatalf("movieA versions = %d, want 2 (file prefetch not applied)", len(got.Versions))
|
||||
}
|
||||
if got := batch[movieB]; got.PendingTranslationLanguage != "fr" {
|
||||
t.Fatalf("movieB pending translation language = %q, want fr", got.PendingTranslationLanguage)
|
||||
if got := batch[movieB]; got.PendingTranslationLanguage != "no" {
|
||||
t.Fatalf("movieB pending translation language = %q, want no", got.PendingTranslationLanguage)
|
||||
}
|
||||
if got := batch[series]; got.WorkID != "work-series-1" {
|
||||
t.Fatalf("series work summary not applied: WorkID=%q", got.WorkID)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Silo-Server/silo-server/internal/access"
|
||||
)
|
||||
|
||||
func TestSeriesOriginalLanguagesUsesSuppliedParentContext(t *testing.T) {
|
||||
svc := &DetailService{}
|
||||
got, err := svc.seriesOriginalLanguages(
|
||||
context.Background(),
|
||||
[]string{"series-a", "series-a", "", "series-b"},
|
||||
AccessFilter{PresentationOriginalLanguage: "no"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("seriesOriginalLanguages: %v", err)
|
||||
}
|
||||
want := map[string]string{"series-a": "no", "series-b": "no"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("original languages = %#v, want %#v", got, want)
|
||||
}
|
||||
for seriesID, language := range want {
|
||||
if got[seriesID] != language {
|
||||
t.Errorf("original language for %q = %q, want %q", seriesID, got[seriesID], language)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresentationLanguageForOriginal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
base presentationLanguageBase
|
||||
original string
|
||||
filter AccessFilter
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "fixed fallback",
|
||||
base: presentationLanguageBase{target: "en"},
|
||||
original: "ja",
|
||||
want: "en",
|
||||
},
|
||||
{
|
||||
name: "source stays original",
|
||||
base: presentationLanguageBase{target: "en"},
|
||||
original: "nor",
|
||||
filter: AccessFilter{MetadataLanguageOverrides: map[string]string{
|
||||
"no": access.OriginalMetadataLanguage,
|
||||
}},
|
||||
want: "no",
|
||||
},
|
||||
{
|
||||
name: "source maps to another target",
|
||||
base: presentationLanguageBase{target: "en"},
|
||||
original: "ja",
|
||||
filter: AccessFilter{MetadataLanguageOverrides: map[string]string{
|
||||
"ja": "de",
|
||||
}},
|
||||
want: "de",
|
||||
},
|
||||
{
|
||||
name: "global original",
|
||||
base: presentationLanguageBase{target: access.OriginalMetadataLanguage},
|
||||
original: "jpn",
|
||||
want: "ja",
|
||||
},
|
||||
{
|
||||
name: "unknown original inherits library",
|
||||
base: presentationLanguageBase{
|
||||
target: access.OriginalMetadataLanguage,
|
||||
libraryFallback: "fr",
|
||||
},
|
||||
want: "fr",
|
||||
},
|
||||
{
|
||||
name: "explicit target bypasses exceptions",
|
||||
base: presentationLanguageBase{target: "fr", explicit: true},
|
||||
original: "no",
|
||||
filter: AccessFilter{MetadataLanguageOverrides: map[string]string{
|
||||
"no": access.OriginalMetadataLanguage,
|
||||
}},
|
||||
want: "fr",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := presentationLanguageForOriginal(tt.base, tt.original, tt.filter); got != tt.want {
|
||||
t.Fatalf("presentation language = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataLanguageMayUseOriginalHonorsExplicitTarget(t *testing.T) {
|
||||
filter := AccessFilter{
|
||||
PresentationLanguage: "fr",
|
||||
ProfilePreferredLanguage: access.OriginalMetadataLanguage,
|
||||
MetadataLanguageOverrides: map[string]string{"no": access.OriginalMetadataLanguage},
|
||||
}
|
||||
if metadataLanguageMayUseOriginal(filter) {
|
||||
t.Fatal("concrete explicit language must bypass profile original-language preferences")
|
||||
}
|
||||
|
||||
filter.PresentationLanguage = access.OriginalMetadataLanguage
|
||||
if !metadataLanguageMayUseOriginal(filter) {
|
||||
t.Fatal("explicit original-language target must resolve the parent language")
|
||||
}
|
||||
}
|
||||
@@ -43,13 +43,14 @@ func NewScopeAccessFilter(resolver ScopeResolver) AccessFilterResolver {
|
||||
}
|
||||
}
|
||||
return catalog.AccessFilter{
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
AllowedLibraryIDs: scope.AllowedLibraryIDs,
|
||||
DisabledLibraryIDs: scope.DisabledLibraryIDs,
|
||||
MaxContentRating: scope.MaxContentRating,
|
||||
MaxPlaybackQuality: scope.MaxPlaybackQuality,
|
||||
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
|
||||
MetadataLanguageOverrides: scope.MetadataLanguageOverrides,
|
||||
UserID: userID,
|
||||
ProfileID: profileID,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,12 +24,16 @@ func (s *stubScopeResolver) Resolve(_ context.Context, input access.ResolveInput
|
||||
func TestScopeAccessFilterMapsScope(t *testing.T) {
|
||||
resolver := &stubScopeResolver{
|
||||
scope: access.Scope{
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
AllowedLibraryIDs: []int{2, 19},
|
||||
LibrariesRestricted: true,
|
||||
MaxContentRating: "PG-13",
|
||||
MaxPlaybackQuality: "1080p",
|
||||
UserID: 7,
|
||||
ProfileID: "profile-1",
|
||||
AllowedLibraryIDs: []int{2, 19},
|
||||
LibrariesRestricted: true,
|
||||
MaxContentRating: "PG-13",
|
||||
MaxPlaybackQuality: "1080p",
|
||||
PreferredMetadataLanguage: "fr",
|
||||
MetadataLanguageOverrides: map[string]string{
|
||||
"no": access.OriginalMetadataLanguage,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -50,6 +54,12 @@ func TestScopeAccessFilterMapsScope(t *testing.T) {
|
||||
if filter.MaxPlaybackQuality != "1080p" {
|
||||
t.Fatalf("MaxPlaybackQuality = %q, want 1080p", filter.MaxPlaybackQuality)
|
||||
}
|
||||
if filter.ProfilePreferredLanguage != "fr" {
|
||||
t.Fatalf("ProfilePreferredLanguage = %q, want fr", filter.ProfilePreferredLanguage)
|
||||
}
|
||||
if got := filter.MetadataLanguageOverrides["no"]; got != access.OriginalMetadataLanguage {
|
||||
t.Fatalf("MetadataLanguageOverrides[no] = %q, want %q", got, access.OriginalMetadataLanguage)
|
||||
}
|
||||
if filter.UserID != 7 || filter.ProfileID != "profile-1" {
|
||||
t.Fatalf("identity not propagated: %+v", filter)
|
||||
}
|
||||
|
||||
@@ -835,14 +835,14 @@ func (s *directContentService) ListSeasons(ctx context.Context, session *Session
|
||||
progressMap := s.batchProgressForEpisodes(ctx, session, allEpisodeIDs)
|
||||
|
||||
if len(seasons) > 0 {
|
||||
if s.detailSvc != nil {
|
||||
if localized, locErr := s.detailSvc.LocalizeSeasonModels(ctx, seasons, filter); locErr == nil && len(localized) == len(seasons) {
|
||||
seasons = localized
|
||||
}
|
||||
}
|
||||
result := make([]upstreamSeason, 0, len(seasons))
|
||||
for _, season := range seasons {
|
||||
eps := groupedEpisodes[season.SeasonNumber]
|
||||
if s.detailSvc != nil {
|
||||
if localized, locErr := s.detailSvc.LocalizeSeasonModel(ctx, season, filter); locErr == nil && localized != nil {
|
||||
season = localized
|
||||
}
|
||||
}
|
||||
us := modelSeasonToUpstream(season, len(eps))
|
||||
applySeasonUserData(&us, eps, progressMap)
|
||||
result = append(result, us)
|
||||
@@ -922,14 +922,14 @@ func (s *directContentService) ListEpisodes(ctx context.Context, session *Sessio
|
||||
progressMap = progressEntries
|
||||
}
|
||||
}
|
||||
if s.detailSvc != nil {
|
||||
if localized, locErr := s.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); locErr == nil && len(localized) == len(episodes) {
|
||||
episodes = localized
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]upstreamEpisode, 0, len(episodes))
|
||||
for _, ep := range episodes {
|
||||
if s.detailSvc != nil {
|
||||
if localized, locErr := s.detailSvc.LocalizeEpisodeModel(ctx, ep, filter); locErr == nil && localized != nil {
|
||||
ep = localized
|
||||
}
|
||||
}
|
||||
ue := modelEpisodeToUpstream(ep, seriesID)
|
||||
if progress, ok := progressMap[ep.ContentID]; ok {
|
||||
progressCopy := progress
|
||||
|
||||
@@ -148,6 +148,7 @@ func (r *ViewerResolver) Resolve(ctx context.Context, input access.ResolveInput)
|
||||
MaxContentRating: decision.MaxContentRating,
|
||||
MaxPlaybackQuality: decision.MaxPlaybackQuality,
|
||||
PreferredMetadataLanguage: decision.PreferredMetadataLanguage,
|
||||
MetadataLanguageOverrides: preferences.MetadataLanguageOverrides,
|
||||
PolicyRevision: user.AccessPolicyRevision,
|
||||
// The policy output is tighten-only (merged_profile_verified), so a
|
||||
// custom override may revoke verification but never grant it. ANDing
|
||||
|
||||
@@ -525,6 +525,13 @@ func TestViewerResolverBatchesViewerPreferenceRead(t *testing.T) {
|
||||
},
|
||||
Value: json.RawMessage(`"de"`),
|
||||
},
|
||||
{
|
||||
SettingIdentity: userstore.SettingIdentity{
|
||||
Key: settingskeys.CatalogMetadataLanguageOverrides, Scope: settingscontract.ScopeProfile,
|
||||
ProfileID: "prof-1",
|
||||
},
|
||||
Value: json.RawMessage(`{"no":"x-silo-original"}`),
|
||||
},
|
||||
},
|
||||
}}
|
||||
resolver := NewViewerResolver(
|
||||
@@ -542,6 +549,9 @@ func TestViewerResolverBatchesViewerPreferenceRead(t *testing.T) {
|
||||
if !reflect.DeepEqual(scope.DisabledLibraryIDs, []int{3, 5}) || scope.PreferredMetadataLanguage != "de" {
|
||||
t.Errorf("resolved scope = %#v", scope)
|
||||
}
|
||||
if got := scope.MetadataLanguageOverrides["no"]; got != access.OriginalMetadataLanguage {
|
||||
t.Errorf("metadata language override = %q, want %q", got, access.OriginalMetadataLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
func (s viewerResolverTestStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
|
||||
|
||||
@@ -1527,11 +1527,10 @@ func TestLibraryPageStateAcceptsTheWebClientsRealSearchStrings(t *testing.T) {
|
||||
// TestObjectSchemasAcceptTheShapesClientsStore exercises every schema_ref
|
||||
// against a real value.
|
||||
//
|
||||
// Nothing else does. Each of these definitions is nullable with a null default,
|
||||
// so TestDefaultsValidateAgainstTheirOwnSchema returns at the null branch
|
||||
// without ever compiling the reference — a schema_ref naming a file that does
|
||||
// not exist, or a schema that rejects the shape its client actually stores,
|
||||
// would pass every other test in this file.
|
||||
// Defaults alone are insufficient: several definitions are nullable and stop
|
||||
// at the null branch, while an empty-object default does not exercise dynamic
|
||||
// properties. These real client shapes make every referenced schema prove its
|
||||
// actual value vocabulary.
|
||||
func TestObjectSchemasAcceptTheShapesClientsStore(t *testing.T) {
|
||||
manifest, err := Load()
|
||||
if err != nil {
|
||||
@@ -1539,6 +1538,7 @@ func TestObjectSchemasAcceptTheShapesClientsStore(t *testing.T) {
|
||||
}
|
||||
|
||||
valid := map[string]string{
|
||||
"catalog.metadata_language_overrides": `{"ja":"en","no":"x-silo-original"}`,
|
||||
"ui.card_overlays": `{"version":2,"preset":"minimal","order":["hdr","year"],` +
|
||||
`"items":{"hdr":{"enabled":true,"position":"top-left","accentColor":"#f5c518"},` +
|
||||
`"year":{"enabled":false,"position":"bottom-right","showIcon":true}}}`,
|
||||
@@ -1569,10 +1569,11 @@ func TestObjectSchemasAcceptTheShapesClientsStore(t *testing.T) {
|
||||
|
||||
// Each schema must actually constrain something, or it is decoration.
|
||||
invalid := map[string]string{
|
||||
"ui.card_overlays": `{"version":2,"preset":"nonesuch","order":[],"items":{}}`,
|
||||
"ui.sidebar_pins": `{"7":[{"type":"bogus","id":"x","label":"L"}]}`,
|
||||
"ui.disabled_library_ids": `[0,-1]`,
|
||||
"ui.library_order": `[1,1]`,
|
||||
"catalog.metadata_language_overrides": `{"Norwegian":"not a language tag"}`,
|
||||
"ui.card_overlays": `{"version":2,"preset":"nonesuch","order":[],"items":{}}`,
|
||||
"ui.sidebar_pins": `{"7":[{"type":"bogus","id":"x","label":"L"}]}`,
|
||||
"ui.disabled_library_ids": `[0,-1]`,
|
||||
"ui.library_order": `[1,1]`,
|
||||
}
|
||||
for key, value := range invalid {
|
||||
t.Run(key+" rejects", func(t *testing.T) {
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
package settingskeys
|
||||
|
||||
// Revision is the manifest revision these bindings were generated from.
|
||||
const Revision = 2
|
||||
const Revision = 3
|
||||
|
||||
// Setting keys, one constant per definition.
|
||||
const (
|
||||
// Metadata language
|
||||
CatalogMetadataLanguage = "catalog.metadata_language"
|
||||
// Metadata language exceptions
|
||||
CatalogMetadataLanguageOverrides = "catalog.metadata_language_overrides"
|
||||
// Download quality
|
||||
DownloadsDefaultQuality = "downloads.default_quality"
|
||||
// Keep watched downloads
|
||||
@@ -116,6 +118,7 @@ const (
|
||||
// Remote lists every key the server stores.
|
||||
var Remote = []string{
|
||||
CatalogMetadataLanguage,
|
||||
CatalogMetadataLanguageOverrides,
|
||||
PlaybackAudioLanguage,
|
||||
PlaybackAutoPlayNext,
|
||||
PlaybackAutoPlayNextPreview,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fontsource/noto-sans-arabic": "^5.2.10",
|
||||
"@fontsource/noto-sans-thai": "^5.2.8",
|
||||
"@formatjs/intl-displaynames": "^7.3.13",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-virtual": "^3.13.19",
|
||||
"@uiw/react-codemirror": "^4.25.10",
|
||||
|
||||
Generated
+22
@@ -46,6 +46,9 @@ importers:
|
||||
'@fontsource/noto-sans-thai':
|
||||
specifier: ^5.2.8
|
||||
version: 5.2.8
|
||||
'@formatjs/intl-displaynames':
|
||||
specifier: ^7.3.13
|
||||
version: 7.3.13
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.21
|
||||
version: 5.90.21(react@19.2.4)
|
||||
@@ -633,6 +636,15 @@ packages:
|
||||
'@fontsource/noto-sans-thai@5.2.8':
|
||||
resolution: {integrity: sha512-HUQAxOzclod9PXQx4uMoGRAsor+64snW91QuBg39jT9lbsp7jJx1i3jAtHPJ7mQxHEHnhRjNp54fb8GqPPC6zg==}
|
||||
|
||||
'@formatjs/fast-memoize@3.1.7':
|
||||
resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==}
|
||||
|
||||
'@formatjs/intl-displaynames@7.3.13':
|
||||
resolution: {integrity: sha512-p+rSpn96AeUS5qW5sBAkvGFyw6oYcg8y2NNI7mrqfuyfRbIeHRLzO0+fdcKLqdXoLTHBfr3iZc/v6NPWWSdkmg==}
|
||||
|
||||
'@formatjs/intl-localematcher@0.8.13':
|
||||
resolution: {integrity: sha512-kHEAFOkeJSPNi7c5PaKaRjxcBrJwzzt81ifUu+8uve1EDW/VJl83KsxmqgqNZLzcFEhSliZGvx3+pk/RH0IOmg==}
|
||||
|
||||
'@humanfs/core@0.19.1':
|
||||
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -3541,6 +3553,16 @@ snapshots:
|
||||
|
||||
'@fontsource/noto-sans-thai@5.2.8': {}
|
||||
|
||||
'@formatjs/fast-memoize@3.1.7': {}
|
||||
|
||||
'@formatjs/intl-displaynames@7.3.13':
|
||||
dependencies:
|
||||
'@formatjs/intl-localematcher': 0.8.13
|
||||
|
||||
'@formatjs/intl-localematcher@0.8.13':
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.7
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
|
||||
'@humanfs/node@0.16.7':
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ORIGINAL_METADATA_LANGUAGE } from "@/lib/metadataLanguagePreferences";
|
||||
import { MetadataLanguageSetting } from "./MetadataLanguageSetting";
|
||||
|
||||
const languageOptions = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "ja", label: "Japanese" },
|
||||
{ value: "no", label: "Norwegian" },
|
||||
];
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver;
|
||||
window.HTMLElement.prototype.hasPointerCapture ??= () => false;
|
||||
window.HTMLElement.prototype.scrollIntoView ??= () => {};
|
||||
|
||||
describe("MetadataLanguageSetting", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("shows a source-language exception and removes only that rule", () => {
|
||||
const onOverridesChange = vi.fn();
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{ no: ORIGINAL_METADATA_LANGUAGE, ja: "en" }}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Norwegian")).toBeTruthy();
|
||||
expect(screen.getByText("Japanese")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Norwegian exception" }));
|
||||
|
||||
expect(onOverridesChange).toHaveBeenCalledWith({ ja: "en" });
|
||||
});
|
||||
|
||||
it("keeps the add action disabled until an original language is chosen", () => {
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback={ORIGINAL_METADATA_LANGUAGE}
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Add exception" }).hasAttribute("disabled")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(screen.getByLabelText("Original language for new exception")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a new exception immediately while its save is still pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOverridesChange = vi.fn(() => new Promise<void>(() => {}));
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText("Original language for new exception"));
|
||||
await user.click(screen.getByRole("option", { name: "Norwegian" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add exception" }));
|
||||
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian")).toHaveTextContent(
|
||||
"Original language",
|
||||
);
|
||||
expect(onOverridesChange).toHaveBeenCalledWith({ no: ORIGINAL_METADATA_LANGUAGE });
|
||||
});
|
||||
|
||||
it("rolls back an optimistic exception when its save fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
let rejectSave: (reason: Error) => void = () => {};
|
||||
const onOverridesChange = vi.fn(
|
||||
() =>
|
||||
new Promise<void>((_, reject) => {
|
||||
rejectSave = reject;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{}}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={onOverridesChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText("Original language for new exception"));
|
||||
await user.click(screen.getByRole("option", { name: "Norwegian" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add exception" }));
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian")).toBeInTheDocument();
|
||||
|
||||
rejectSave(new Error("save failed"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText("Metadata language for Norwegian")).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a custom target selectable when it is outside the advisory catalog", () => {
|
||||
render(
|
||||
<MetadataLanguageSetting
|
||||
fallback="en"
|
||||
overrides={{ no: "pt-BR" }}
|
||||
languageOptions={languageOptions}
|
||||
onFallbackChange={vi.fn()}
|
||||
onOverridesChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText("Metadata language for Norwegian").textContent).toContain(
|
||||
"Portuguese",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { withCurrentLanguageOption, type SettingOption } from "@/lib/languageOptions";
|
||||
import { getLanguageName } from "@/lib/languageNames";
|
||||
import {
|
||||
ORIGINAL_METADATA_LANGUAGE,
|
||||
withMetadataLanguageOverride,
|
||||
withoutMetadataLanguageOverride,
|
||||
type MetadataLanguageOverrides,
|
||||
} from "@/lib/metadataLanguagePreferences";
|
||||
|
||||
interface MetadataLanguageSettingProps {
|
||||
fallback: string | null;
|
||||
overrides: MetadataLanguageOverrides;
|
||||
languageOptions: readonly SettingOption[];
|
||||
disabled?: boolean;
|
||||
onFallbackChange: (language: string | null) => void;
|
||||
onOverridesChange: (overrides: MetadataLanguageOverrides) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const NO_PREFERENCE = "__library_default";
|
||||
|
||||
function overridesEqual(
|
||||
left: MetadataLanguageOverrides,
|
||||
right: MetadataLanguageOverrides,
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left);
|
||||
return (
|
||||
leftEntries.length === Object.keys(right).length &&
|
||||
leftEntries.every(([source, target]) => right[source] === target)
|
||||
);
|
||||
}
|
||||
|
||||
export function MetadataLanguageSetting({
|
||||
fallback,
|
||||
overrides,
|
||||
languageOptions,
|
||||
disabled = false,
|
||||
onFallbackChange,
|
||||
onOverridesChange,
|
||||
}: MetadataLanguageSettingProps) {
|
||||
const [newSource, setNewSource] = useState("");
|
||||
const [optimisticOverrides, setOptimisticOverrides] = useState<{
|
||||
base: MetadataLanguageOverrides;
|
||||
value: MetadataLanguageOverrides;
|
||||
} | null>(null);
|
||||
const currentOverrides =
|
||||
optimisticOverrides !== null && overridesEqual(overrides, optimisticOverrides.base)
|
||||
? optimisticOverrides.value
|
||||
: overrides;
|
||||
|
||||
const entries = useMemo(
|
||||
() =>
|
||||
Object.entries(currentOverrides).sort(([left], [right]) =>
|
||||
getLanguageName(left).localeCompare(getLanguageName(right)),
|
||||
),
|
||||
[currentOverrides],
|
||||
);
|
||||
const namedOptions = useMemo(
|
||||
() => languageOptions.filter((language) => language.value !== ORIGINAL_METADATA_LANGUAGE),
|
||||
[languageOptions],
|
||||
);
|
||||
const availableSources = namedOptions.filter((language) => !(language.value in currentOverrides));
|
||||
const optionsForTarget = (target: string) => {
|
||||
if (target === ORIGINAL_METADATA_LANGUAGE) return namedOptions;
|
||||
return withCurrentLanguageOption(namedOptions, target);
|
||||
};
|
||||
|
||||
const changeOverrides = async (next: MetadataLanguageOverrides) => {
|
||||
const pending = { base: overrides, value: next };
|
||||
setOptimisticOverrides(pending);
|
||||
try {
|
||||
await onOverridesChange(next);
|
||||
} catch {
|
||||
setOptimisticOverrides((current) => (current === pending ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
const addException = () => {
|
||||
if (!newSource) return;
|
||||
void changeOverrides(
|
||||
withMetadataLanguageOverride(currentOverrides, newSource, ORIGINAL_METADATA_LANGUAGE),
|
||||
);
|
||||
setNewSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
label="Metadata language"
|
||||
description="Choose the fallback for titles and descriptions, then add exceptions based on each item's original language. Missing descriptions can be translated automatically when AI translation is enabled."
|
||||
control={(id) => (
|
||||
<div className="w-full min-w-0 space-y-3 md:w-[430px]">
|
||||
<Select
|
||||
value={fallback ?? NO_PREFERENCE}
|
||||
onValueChange={(value) => onFallbackChange(value === NO_PREFERENCE ? null : value)}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full" disabled={disabled}>
|
||||
<SelectValue placeholder="Library default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_PREFERENCE}>Library default</SelectItem>
|
||||
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
|
||||
{namedOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<div className="border-border/60 divide-border/60 divide-y border-y">
|
||||
{entries.map(([source, target]) => (
|
||||
<div
|
||||
key={source}
|
||||
className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-2 py-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)_auto]"
|
||||
>
|
||||
<span className="truncate text-sm">{getLanguageName(source)}</span>
|
||||
<Select
|
||||
value={target}
|
||||
onValueChange={(value) =>
|
||||
void changeOverrides(
|
||||
withMetadataLanguageOverride(currentOverrides, source, value),
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={`Metadata language for ${getLanguageName(source)}`}
|
||||
className="col-span-2 col-start-1 row-start-2 h-9 w-full sm:col-span-1 sm:col-start-2 sm:row-start-1 sm:h-8"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ORIGINAL_METADATA_LANGUAGE}>Original language</SelectItem>
|
||||
{optionsForTarget(target).map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="col-start-2 row-start-1 size-9 sm:col-start-3 sm:size-8"
|
||||
disabled={disabled}
|
||||
aria-label={`Remove ${getLanguageName(source)} exception`}
|
||||
onClick={() =>
|
||||
void changeOverrides(
|
||||
withoutMetadataLanguageOverride(currentOverrides, source),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{availableSources.length > 0 ? (
|
||||
<div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Select value={newSource} onValueChange={setNewSource}>
|
||||
<SelectTrigger
|
||||
aria-label="Original language for new exception"
|
||||
className="h-10 w-full min-w-0 flex-1 sm:h-9 sm:w-auto"
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectValue placeholder="Choose original language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableSources.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 w-full sm:h-8 sm:w-auto"
|
||||
disabled={disabled || !newSource}
|
||||
onClick={addException}
|
||||
>
|
||||
<Plus className="mr-1.5 size-3.5" aria-hidden="true" />
|
||||
Add exception
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1 @@
|
||||
// Catalog filter values for `original_language` are ISO 639-1 codes ("en",
|
||||
// "fr", "ja"). Surfaces show friendly names ("English", "French", "Japanese")
|
||||
// while keeping the underlying value untouched so query rules stay valid.
|
||||
|
||||
let cachedDisplayNames: Intl.DisplayNames | null | undefined;
|
||||
|
||||
function getDisplayNames(): Intl.DisplayNames | null {
|
||||
if (cachedDisplayNames !== undefined) {
|
||||
return cachedDisplayNames;
|
||||
}
|
||||
try {
|
||||
cachedDisplayNames = new Intl.DisplayNames(["en"], { type: "language" });
|
||||
} catch {
|
||||
cachedDisplayNames = null;
|
||||
}
|
||||
return cachedDisplayNames;
|
||||
}
|
||||
|
||||
export function formatLanguage(code: string): string {
|
||||
const trimmed = code.trim();
|
||||
if (!trimmed) return "";
|
||||
const dn = getDisplayNames();
|
||||
if (!dn) return trimmed.toUpperCase();
|
||||
try {
|
||||
const name = dn.of(trimmed);
|
||||
if (name && name !== trimmed) {
|
||||
// Intl.DisplayNames returns lowercase for some locales — capitalize.
|
||||
return name.charAt(0).toUpperCase() + name.slice(1);
|
||||
}
|
||||
} catch {
|
||||
// Fall through to fallback.
|
||||
}
|
||||
return trimmed.toUpperCase();
|
||||
}
|
||||
export { formatLanguage } from "@/lib/languageNames";
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canonicalLanguageTag,
|
||||
englishLanguageName,
|
||||
getLanguageName,
|
||||
normalizeLanguageCode,
|
||||
} from "./languageNames";
|
||||
|
||||
describe("languageNames", () => {
|
||||
it("uses bundled English CLDR names for ISO 639-1 and ISO 639-3 values", () => {
|
||||
expect(englishLanguageName("sa")).toBe("Sanskrit");
|
||||
expect(englishLanguageName("se")).toBe("Northern Sami");
|
||||
expect(englishLanguageName("sm")).toBe("Samoan");
|
||||
expect(englishLanguageName("yue")).toBe("Cantonese");
|
||||
});
|
||||
|
||||
it("preserves region and script specificity in names", () => {
|
||||
expect(englishLanguageName("pt-BR")).toBe("Brazilian Portuguese");
|
||||
expect(englishLanguageName("sr-Latn")).toBe("Serbian (Latin)");
|
||||
});
|
||||
|
||||
it("canonicalizes ISO aliases without changing regional identity", () => {
|
||||
expect(canonicalLanguageTag("eng")).toBe("en");
|
||||
expect(canonicalLanguageTag("pt_BR")).toBe("pt-BR");
|
||||
expect(normalizeLanguageCode("fre-CA")).toBe("fr");
|
||||
});
|
||||
|
||||
it("distinguishes an unassigned tag from a translated language name", () => {
|
||||
expect(englishLanguageName("xx")).toBeNull();
|
||||
expect(getLanguageName("xx")).toBe("Unknown language (xx)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import "@formatjs/intl-displaynames/polyfill-force.js";
|
||||
import "@formatjs/intl-displaynames/locale-data/en.js";
|
||||
|
||||
const englishLanguageNames = new Intl.DisplayNames(["en"], {
|
||||
type: "language",
|
||||
fallback: "none",
|
||||
});
|
||||
const englishScriptNames = new Intl.DisplayNames(["en"], {
|
||||
type: "script",
|
||||
fallback: "none",
|
||||
});
|
||||
const englishRegionNames = new Intl.DisplayNames(["en"], {
|
||||
type: "region",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
function displayName(names: Intl.DisplayNames, value: string): string | null {
|
||||
try {
|
||||
return names.of(value) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical BCP 47 identity used only for comparison; wire values stay untouched. */
|
||||
export function canonicalLanguageTag(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return new Intl.Locale(trimmed.replace(/_/g, "-")).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable identity that de-duplicates ISO aliases without collapsing script or region subtags. */
|
||||
export function languageIdentity(value: string): string {
|
||||
return canonicalLanguageTag(value) ?? value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deterministic English CLDR name for an ISO/BCP 47 value.
|
||||
*
|
||||
* Some CLDR releases omit a precomposed language-and-script label. In that
|
||||
* case, compose the independently standardized language, script, and region
|
||||
* names so tags such as `sr-Latn` remain distinct from `sr`.
|
||||
*/
|
||||
export function englishLanguageName(value: string): string | null {
|
||||
const canonical = canonicalLanguageTag(value);
|
||||
if (!canonical) return null;
|
||||
|
||||
let locale: Intl.Locale;
|
||||
try {
|
||||
locale = new Intl.Locale(canonical);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const language = displayName(englishLanguageNames, locale.language);
|
||||
if (!language) return null;
|
||||
|
||||
const exact = displayName(englishLanguageNames, canonical);
|
||||
const qualifiers = [
|
||||
locale.script ? displayName(englishScriptNames, locale.script) : null,
|
||||
locale.region ? displayName(englishRegionNames, locale.region) : null,
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
if (exact && (qualifiers.length === 0 || exact !== language)) return exact;
|
||||
return qualifiers.length > 0 ? `${language} (${qualifiers.join(", ")})` : language;
|
||||
}
|
||||
|
||||
/** User-facing name with an explicit fallback for unassigned or invalid tags. */
|
||||
export function getLanguageName(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "Unknown";
|
||||
return englishLanguageName(trimmed) ?? `Unknown language (${trimmed})`;
|
||||
}
|
||||
|
||||
/** Empty-preserving variant for optional metadata and filter labels. */
|
||||
export function formatLanguage(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? getLanguageName(trimmed) : "";
|
||||
}
|
||||
|
||||
/** Canonical ISO language subtag used for language matching and override keys. */
|
||||
export function normalizeLanguageCode(value: string | null | undefined): string {
|
||||
const canonical = canonicalLanguageTag(value ?? "");
|
||||
if (!canonical) return "";
|
||||
return new Intl.Locale(canonical).language;
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SETTING_KEYS } from "./settingsContract";
|
||||
import { languageOptionsFor, namedLanguageOptionsFor } from "./languageOptions";
|
||||
import {
|
||||
languageOptionsFor,
|
||||
namedLanguageOptionsFor,
|
||||
withCurrentLanguageOption,
|
||||
} from "./languageOptions";
|
||||
|
||||
describe("languageOptions", () => {
|
||||
it("uses the definition-specific generated option set", () => {
|
||||
@@ -26,6 +30,25 @@ describe("languageOptions", () => {
|
||||
expect(options.map((option) => option.value)).toContain("pt");
|
||||
});
|
||||
|
||||
it("uses English names for ISO and BCP 47 values instead of exposing raw tags", () => {
|
||||
const options = namedLanguageOptionsFor(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, undefined, [
|
||||
"sa",
|
||||
"se",
|
||||
"yue",
|
||||
"pt-BR",
|
||||
"sr-Latn",
|
||||
"xx",
|
||||
]);
|
||||
const labels = Object.fromEntries(options.map((option) => [option.value, option.label]));
|
||||
|
||||
expect(labels.sa).toBe("Sanskrit");
|
||||
expect(labels.se).toBe("Northern Sami");
|
||||
expect(labels.yue).toBe("Cantonese");
|
||||
expect(labels["pt-BR"]).toBe("Brazilian Portuguese");
|
||||
expect(labels["sr-Latn"]).toBe("Serbian (Latin)");
|
||||
expect(labels.xx).toBe("Unknown language (xx)");
|
||||
});
|
||||
|
||||
it("uses each nullable definition's context-specific unset copy", () => {
|
||||
expect(languageOptionsFor(SETTING_KEYS.PLAYBACK_AUDIO_LANGUAGE)[0]).toEqual({
|
||||
value: "",
|
||||
@@ -40,4 +63,19 @@ describe("languageOptions", () => {
|
||||
label: "Library default",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an exact current ISO alias without showing its semantic duplicate", () => {
|
||||
const options = withCurrentLanguageOption(
|
||||
[
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "fr", label: "French" },
|
||||
],
|
||||
"eng",
|
||||
);
|
||||
|
||||
expect(options).toEqual([
|
||||
{ value: "eng", label: "English" },
|
||||
{ value: "fr", label: "French" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SETTINGS_REVISION,
|
||||
type SettingKey,
|
||||
} from "@/lib/settingsContract";
|
||||
import { getLanguageName, languageIdentity } from "@/lib/languageNames";
|
||||
|
||||
/** One choice in a settings dropdown. */
|
||||
export interface SettingOption {
|
||||
@@ -11,24 +12,21 @@ export interface SettingOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
const languageNames = new Intl.DisplayNames(undefined, { type: "language" });
|
||||
/** Preserve an exact open value while replacing any semantic ISO alias already shown. */
|
||||
export function withCurrentLanguageOption(
|
||||
options: readonly SettingOption[],
|
||||
currentValue: string,
|
||||
): SettingOption[] {
|
||||
const currentIdentity = languageIdentity(currentValue);
|
||||
const current = { value: currentValue, label: getLanguageName(currentValue) };
|
||||
const existing = options.findIndex(
|
||||
(option) => languageIdentity(option.value) === currentIdentity,
|
||||
);
|
||||
if (existing < 0) return [current, ...options];
|
||||
|
||||
function languageIdentity(value: string): string {
|
||||
try {
|
||||
// Canonicalize true aliases (eng/en) without discarding meaningful
|
||||
// script or region specificity (pt/pt-BR).
|
||||
return new Intl.Locale(value).toString();
|
||||
} catch {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
function languageLabel(value: string): string {
|
||||
try {
|
||||
return languageNames.of(value) ?? value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
const merged = [...options];
|
||||
merged[existing] = current;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +71,7 @@ export function namedLanguageOptionsFor(
|
||||
runtimeValues.forEach((value) => add(value, false));
|
||||
if (currentValue) add(currentValue, true);
|
||||
|
||||
return values.map((value) => ({ value, label: languageLabel(value) }));
|
||||
return values.map((value) => ({ value, label: getLanguageName(value) }));
|
||||
}
|
||||
|
||||
/** The nullable list, using the contract's context-specific unset copy. */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ORIGINAL_METADATA_LANGUAGE,
|
||||
normalizeMetadataLanguageOverrides,
|
||||
withMetadataLanguageOverride,
|
||||
withoutMetadataLanguageOverride,
|
||||
} from "./metadataLanguagePreferences";
|
||||
|
||||
describe("metadata language preferences", () => {
|
||||
it("normalizes source aliases and drops malformed cached entries", () => {
|
||||
expect(
|
||||
normalizeMetadataLanguageOverrides({
|
||||
nor: ORIGINAL_METADATA_LANGUAGE,
|
||||
JA: "en",
|
||||
invalid_language: "fr",
|
||||
de: 42,
|
||||
}),
|
||||
).toEqual({ no: ORIGINAL_METADATA_LANGUAGE, ja: "en" });
|
||||
});
|
||||
|
||||
it("adds and removes one original-language exception without mutating the input", () => {
|
||||
const initial = { ja: "en" };
|
||||
const added = withMetadataLanguageOverride(initial, "nor", ORIGINAL_METADATA_LANGUAGE);
|
||||
|
||||
expect(added).toEqual({ ja: "en", no: ORIGINAL_METADATA_LANGUAGE });
|
||||
expect(initial).toEqual({ ja: "en" });
|
||||
expect(withoutMetadataLanguageOverride(added, "no")).toEqual({ ja: "en" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { normalizeLanguageCode } from "@/lib/languageNames";
|
||||
|
||||
/** Valid private-use BCP 47 tag understood by the server as item-original. */
|
||||
export const ORIGINAL_METADATA_LANGUAGE = "x-silo-original";
|
||||
|
||||
export type MetadataLanguageOverrides = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Treat the settings response as untrusted JSON at the component boundary.
|
||||
* The server validates writes, but this also keeps a stale/broken cached value
|
||||
* from making the editor unusable.
|
||||
*/
|
||||
export function normalizeMetadataLanguageOverrides(value: unknown): MetadataLanguageOverrides {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
|
||||
const normalized: MetadataLanguageOverrides = {};
|
||||
for (const [source, target] of Object.entries(value)) {
|
||||
const sourceCode = normalizeLanguageCode(source);
|
||||
if (!/^[a-z]{2,3}$/.test(sourceCode) || typeof target !== "string" || !target.trim()) {
|
||||
continue;
|
||||
}
|
||||
normalized[sourceCode] = target.trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function withMetadataLanguageOverride(
|
||||
overrides: MetadataLanguageOverrides,
|
||||
source: string,
|
||||
target: string,
|
||||
): MetadataLanguageOverrides {
|
||||
const sourceCode = normalizeLanguageCode(source);
|
||||
if (!/^[a-z]{2,3}$/.test(sourceCode) || !target.trim()) return overrides;
|
||||
return { ...overrides, [sourceCode]: target.trim() };
|
||||
}
|
||||
|
||||
export function withoutMetadataLanguageOverride(
|
||||
overrides: MetadataLanguageOverrides,
|
||||
source: string,
|
||||
): MetadataLanguageOverrides {
|
||||
const next = { ...overrides };
|
||||
delete next[normalizeLanguageCode(source)];
|
||||
return next;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"fixture_version": 1,
|
||||
"manifest_revision": 2,
|
||||
"manifest_revision": 3,
|
||||
"description": "Cross-platform conformance cases for settings resolution. Every case runs against the shipped manifest in this directory: definitions are referenced by key, never restated, so an expectation can only be satisfied by resolving the real contract. Each platform's resolver (Go in internal/settingsresolve, TypeScript in web/src/lib/settingsResolve.ts, Kotlin and Swift in the client repos) runs every case through a hand-written runner; a runner must fail on any fixture field it does not know, because schema drift in the fixture itself is drift. A case's constraint_bindings attach a constraint to a copy of a real definition so constraint semantics stay testable even while no shipped definition carries that constraint kind. In expected entries, constrained:true requires stored_value and constraint_kind to be present, and stored_value may be null to mean the authored value was JSON null.",
|
||||
"cases": [
|
||||
{
|
||||
@@ -438,6 +438,27 @@
|
||||
"constraints": { "allowed_metadata_languages": ["en", "fr"] },
|
||||
"expected": [{ "key": "catalog.metadata_language", "value": "fr", "source": "profile" }]
|
||||
},
|
||||
{
|
||||
"name": "metadata_language_exceptions_resolve_as_one_object",
|
||||
"description": "The original-language exception map is a single profile-scoped value; resolution preserves every source-to-target entry together.",
|
||||
"keys": ["catalog.metadata_language_overrides"],
|
||||
"context": { "profile_id": "p1" },
|
||||
"stored": [
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"scope": "profile",
|
||||
"profile_id": "p1",
|
||||
"value": { "ja": "en", "no": "x-silo-original" }
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"key": "catalog.metadata_language_overrides",
|
||||
"value": { "ja": "en", "no": "x-silo-original" },
|
||||
"source": "profile"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "locked_replaces_a_differing_choice",
|
||||
"description": "locked is total: the policy value replaces the user's outright, and the authored choice is preserved as stored_value so it takes effect the day the lock lifts.",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* is a manifest change plus a regeneration, never a hand-written key.
|
||||
*/
|
||||
|
||||
export const SETTINGS_REVISION = 2;
|
||||
export const SETTINGS_REVISION = 3;
|
||||
|
||||
export interface SettingSuggestedOption {
|
||||
value: string;
|
||||
@@ -154,6 +154,8 @@ export type SettingOptionSetId = keyof typeof SETTING_OPTION_SETS;
|
||||
export const SETTING_KEYS = {
|
||||
/** Metadata language */
|
||||
CATALOG_METADATA_LANGUAGE: "catalog.metadata_language",
|
||||
/** Metadata language exceptions */
|
||||
CATALOG_METADATA_LANGUAGE_OVERRIDES: "catalog.metadata_language_overrides",
|
||||
/** Download quality */
|
||||
DOWNLOADS_DEFAULT_QUALITY: "downloads.default_quality",
|
||||
/** Keep watched downloads */
|
||||
@@ -306,12 +308,27 @@ export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: null,
|
||||
label: "Metadata language",
|
||||
description: "Language Silo prefers for titles, descriptions, and artwork.",
|
||||
description: "Fallback language Silo prefers for titles, descriptions, and artwork.",
|
||||
category: "catalog",
|
||||
control: "select",
|
||||
suggestedOptions: "catalog_metadata_languages",
|
||||
unsetLabel: "Library default",
|
||||
},
|
||||
"catalog.metadata_language_overrides": {
|
||||
key: "catalog.metadata_language_overrides",
|
||||
type: "object",
|
||||
nullable: false,
|
||||
persistence: "remote",
|
||||
introducedIn: 3,
|
||||
scopes: ["profile"],
|
||||
scopeIntroducedIn: [3],
|
||||
resolutionOrder: ["profile", "default"],
|
||||
defaultValue: {},
|
||||
label: "Metadata language exceptions",
|
||||
description: "Preferred metadata language for items in specific original languages.",
|
||||
category: "catalog",
|
||||
control: "panel",
|
||||
},
|
||||
"downloads.default_quality": {
|
||||
key: "downloads.default_quality",
|
||||
type: "enum",
|
||||
|
||||
@@ -26,6 +26,10 @@ describe("formatLanguageName", () => {
|
||||
expect(formatLanguageName("english subtitles")).toBe("English Subtitles");
|
||||
});
|
||||
|
||||
it("preserves an already readable language name", () => {
|
||||
expect(formatLanguageName("English")).toBe("English");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(formatLanguageName(undefined)).toBe("");
|
||||
});
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { VersionAudioTrack, VersionSubtitleTrack, VersionVideoTrack } from "@/api/types";
|
||||
import { englishLanguageName, getLanguageName } from "@/lib/languageNames";
|
||||
import { formatBitrate, formatChannels, formatSampleRate } from "@/lib/mediaFormat";
|
||||
|
||||
export const LANGUAGE_NAMES: Record<string, string> = {
|
||||
cze: "Czech",
|
||||
ces: "Czech",
|
||||
deu: "German",
|
||||
eng: "English",
|
||||
fra: "French",
|
||||
fre: "French",
|
||||
ita: "Italian",
|
||||
jpn: "Japanese",
|
||||
por: "Portuguese",
|
||||
spa: "Spanish",
|
||||
};
|
||||
|
||||
export function formatPageCount(pages?: number): string {
|
||||
if (!pages || pages <= 0) return "";
|
||||
return `${pages.toLocaleString()} ${pages === 1 ? "page" : "pages"}`;
|
||||
@@ -35,7 +23,9 @@ export function formatLanguageName(language?: string): string {
|
||||
const trimmed = language.trim();
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (!normalized) return "";
|
||||
if (LANGUAGE_NAMES[normalized]) return LANGUAGE_NAMES[normalized];
|
||||
|
||||
const standardizedName = englishLanguageName(trimmed);
|
||||
if (standardizedName) return standardizedName;
|
||||
|
||||
if (normalized.length > 3) {
|
||||
return trimmed
|
||||
@@ -44,18 +34,7 @@ export function formatLanguageName(language?: string): string {
|
||||
.map((part) => part[0]?.toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
if (typeof Intl !== "undefined" && "DisplayNames" in Intl) {
|
||||
try {
|
||||
const displayNames = new Intl.DisplayNames(undefined, { type: "language" });
|
||||
const match = displayNames.of(normalized);
|
||||
if (match) return match;
|
||||
} catch {
|
||||
// Fall back to the raw code below.
|
||||
}
|
||||
}
|
||||
|
||||
return normalized.toUpperCase();
|
||||
return getLanguageName(trimmed);
|
||||
}
|
||||
|
||||
const SOURCE_HINT_PATTERNS: Array<{ pattern: RegExp; canonical: string }> = [
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("PlaybackSettings", () => {
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.UI_NEXT_UP_MODE);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.CATALOG_METADATA_LANGUAGE);
|
||||
expect(batched?.[0].keys).toContain(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES);
|
||||
});
|
||||
|
||||
it("saves a toggle as typed JSON at profile scope", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SettingsGroup } from "@/components/settings/SettingsGroup";
|
||||
import { SettingRow } from "@/components/settings/SettingRow";
|
||||
import { MetadataLanguageSetting } from "@/components/settings/MetadataLanguageSetting";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { optionsFor } from "@/lib/settingsDisplay";
|
||||
import { namedLanguageOptionsFor } from "@/lib/languageOptions";
|
||||
import {
|
||||
normalizeMetadataLanguageOverrides,
|
||||
type MetadataLanguageOverrides,
|
||||
} from "@/lib/metadataLanguagePreferences";
|
||||
import { SETTING_DEFINITIONS, SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { QUALITY_PRESETS, describeQuality, presetById, presetIdFor } from "@/lib/qualityPresets";
|
||||
import { useEffectiveSettings } from "@/hooks/queries/settingValues";
|
||||
@@ -38,6 +43,7 @@ const PLAYBACK_KEYS: SettingKey[] = [
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_INTRO,
|
||||
SETTING_KEYS.PLAYBACK_AUTO_SKIP_RECAP,
|
||||
SETTING_KEYS.CATALOG_METADATA_LANGUAGE,
|
||||
SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES,
|
||||
SETTING_KEYS.UI_NEXT_UP_MODE,
|
||||
];
|
||||
|
||||
@@ -190,6 +196,17 @@ export default function PlaybackSettings() {
|
||||
const nextUpChosen = effective?.[SETTING_KEYS.UI_NEXT_UP_MODE]?.source === "profile";
|
||||
const pending = isSaving;
|
||||
|
||||
const saveMetadataOverrides = (overrides: MetadataLanguageOverrides) => {
|
||||
const request =
|
||||
Object.keys(overrides).length === 0
|
||||
? resetProfileDefault(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES)
|
||||
: saveProfileDefault(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES, overrides);
|
||||
return request.catch((error) => {
|
||||
toast.error("Failed to save metadata language exceptions");
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
async function resetNextUpMode() {
|
||||
// Nothing stored is the state a reset asks for, which the shared reset
|
||||
// already treats as success.
|
||||
@@ -244,31 +261,17 @@ export default function PlaybackSettings() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label="Metadata language"
|
||||
description="Show titles and descriptions in this language when available. Descriptions can be translated automatically when the server has AI translation enabled."
|
||||
control={(id) => (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={metadataLanguage ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, value === "none" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full sm:w-[220px]" disabled={pending}>
|
||||
<SelectValue placeholder="Library default" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Library default</SelectItem>
|
||||
{metadataLanguageOptions.map((language) => (
|
||||
<SelectItem key={language.value} value={language.value}>
|
||||
{language.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<MetadataLanguageSetting
|
||||
fallback={metadataLanguage}
|
||||
overrides={normalizeMetadataLanguageOverrides(
|
||||
read<unknown>(SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES),
|
||||
)}
|
||||
languageOptions={metadataLanguageOptions}
|
||||
disabled={pending}
|
||||
onFallbackChange={(language) =>
|
||||
saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, language)
|
||||
}
|
||||
onOverridesChange={saveMetadataOverrides}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { EffectiveSettingsMap, SettingIdentity } from "@/hooks/queries/settingValues";
|
||||
import { getLanguageName } from "@/lib/languageNames";
|
||||
import { SETTING_KEYS, type SettingKey } from "@/lib/settingsContract";
|
||||
import { optionsFor } from "@/lib/settingsDisplay";
|
||||
import { SETTING_DEFINITIONS } from "@/lib/settingsContract";
|
||||
import { getLanguageName } from "@/player/utils/languageNames";
|
||||
|
||||
export const INHERIT_VALUE = "inherit";
|
||||
export const NONE_VALUE = "none";
|
||||
|
||||
@@ -1,173 +1,46 @@
|
||||
/** ISO 639-1 (2-letter) language codes → full display name. */
|
||||
const LANGUAGE_NAMES_2: Record<string, string> = {
|
||||
en: "English",
|
||||
es: "Spanish",
|
||||
fr: "French",
|
||||
de: "German",
|
||||
it: "Italian",
|
||||
pt: "Portuguese",
|
||||
nl: "Dutch",
|
||||
pl: "Polish",
|
||||
ru: "Russian",
|
||||
zh: "Chinese",
|
||||
ja: "Japanese",
|
||||
ko: "Korean",
|
||||
ar: "Arabic",
|
||||
tr: "Turkish",
|
||||
sv: "Swedish",
|
||||
da: "Danish",
|
||||
no: "Norwegian",
|
||||
fi: "Finnish",
|
||||
hu: "Hungarian",
|
||||
cs: "Czech",
|
||||
ro: "Romanian",
|
||||
he: "Hebrew",
|
||||
th: "Thai",
|
||||
vi: "Vietnamese",
|
||||
el: "Greek",
|
||||
bg: "Bulgarian",
|
||||
hr: "Croatian",
|
||||
sk: "Slovak",
|
||||
sl: "Slovenian",
|
||||
uk: "Ukrainian",
|
||||
id: "Indonesian",
|
||||
ms: "Malay",
|
||||
hi: "Hindi",
|
||||
ta: "Tamil",
|
||||
te: "Telugu",
|
||||
bn: "Bengali",
|
||||
fa: "Persian",
|
||||
};
|
||||
import { getLanguageName } from "@/lib/languageNames";
|
||||
|
||||
/** ISO 639-2/B (3-letter) language codes → full display name. */
|
||||
const LANGUAGE_NAMES_3: Record<string, string> = {
|
||||
eng: "English",
|
||||
spa: "Spanish",
|
||||
fre: "French",
|
||||
fra: "French",
|
||||
ger: "German",
|
||||
deu: "German",
|
||||
ita: "Italian",
|
||||
por: "Portuguese",
|
||||
dut: "Dutch",
|
||||
nld: "Dutch",
|
||||
pol: "Polish",
|
||||
rus: "Russian",
|
||||
chi: "Chinese",
|
||||
zho: "Chinese",
|
||||
jpn: "Japanese",
|
||||
kor: "Korean",
|
||||
ara: "Arabic",
|
||||
tur: "Turkish",
|
||||
swe: "Swedish",
|
||||
dan: "Danish",
|
||||
nor: "Norwegian",
|
||||
fin: "Finnish",
|
||||
hun: "Hungarian",
|
||||
cze: "Czech",
|
||||
ces: "Czech",
|
||||
rum: "Romanian",
|
||||
ron: "Romanian",
|
||||
heb: "Hebrew",
|
||||
tha: "Thai",
|
||||
vie: "Vietnamese",
|
||||
gre: "Greek",
|
||||
ell: "Greek",
|
||||
bul: "Bulgarian",
|
||||
hrv: "Croatian",
|
||||
slo: "Slovak",
|
||||
slk: "Slovak",
|
||||
slv: "Slovenian",
|
||||
ukr: "Ukrainian",
|
||||
ind: "Indonesian",
|
||||
may: "Malay",
|
||||
msa: "Malay",
|
||||
hin: "Hindi",
|
||||
tam: "Tamil",
|
||||
tel: "Telugu",
|
||||
ben: "Bengali",
|
||||
per: "Persian",
|
||||
fas: "Persian",
|
||||
};
|
||||
export { getLanguageName, normalizeLanguageCode } from "@/lib/languageNames";
|
||||
|
||||
const LANGUAGE_CODE_ALIASES: Record<string, string> = {
|
||||
eng: "en",
|
||||
spa: "es",
|
||||
fre: "fr",
|
||||
fra: "fr",
|
||||
ger: "de",
|
||||
deu: "de",
|
||||
ita: "it",
|
||||
por: "pt",
|
||||
dut: "nl",
|
||||
nld: "nl",
|
||||
pol: "pl",
|
||||
rus: "ru",
|
||||
chi: "zh",
|
||||
zho: "zh",
|
||||
jpn: "ja",
|
||||
kor: "ko",
|
||||
ara: "ar",
|
||||
tur: "tr",
|
||||
swe: "sv",
|
||||
dan: "da",
|
||||
nor: "no",
|
||||
fin: "fi",
|
||||
hun: "hu",
|
||||
cze: "cs",
|
||||
ces: "cs",
|
||||
rum: "ro",
|
||||
ron: "ro",
|
||||
heb: "he",
|
||||
tha: "th",
|
||||
vie: "vi",
|
||||
gre: "el",
|
||||
ell: "el",
|
||||
bul: "bg",
|
||||
hrv: "hr",
|
||||
slo: "sk",
|
||||
slk: "sk",
|
||||
slv: "sl",
|
||||
ukr: "uk",
|
||||
ind: "id",
|
||||
may: "ms",
|
||||
msa: "ms",
|
||||
hin: "hi",
|
||||
tam: "ta",
|
||||
tel: "te",
|
||||
ben: "bn",
|
||||
per: "fa",
|
||||
fas: "fa",
|
||||
};
|
||||
|
||||
/** Combined lookup: supports both 2-letter and 3-letter codes. */
|
||||
const LANGUAGE_NAMES: Record<string, string> = {
|
||||
...LANGUAGE_NAMES_2,
|
||||
...LANGUAGE_NAMES_3,
|
||||
};
|
||||
|
||||
export function normalizeLanguageCode(code: string | null | undefined): string {
|
||||
const normalized = (code ?? "").trim().toLowerCase();
|
||||
if (!normalized) return "";
|
||||
const separator = normalized.search(/[-_]/);
|
||||
const base = separator >= 0 ? normalized.slice(0, separator) : normalized;
|
||||
return LANGUAGE_CODE_ALIASES[base] ?? base;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full language name for an ISO 639-1 or 639-2 code.
|
||||
* Falls back to the code itself (uppercased first letter) if unknown.
|
||||
*/
|
||||
export function getLanguageName(code: string): string {
|
||||
if (!code) return "Unknown";
|
||||
const lower = code.toLowerCase();
|
||||
const normalized = normalizeLanguageCode(code);
|
||||
return (
|
||||
LANGUAGE_NAMES_2[normalized] ??
|
||||
LANGUAGE_NAMES[lower] ??
|
||||
code.charAt(0).toUpperCase() + code.slice(1)
|
||||
);
|
||||
}
|
||||
const COMMON_LANGUAGE_CODES = [
|
||||
"en",
|
||||
"es",
|
||||
"fr",
|
||||
"de",
|
||||
"it",
|
||||
"pt",
|
||||
"nl",
|
||||
"pl",
|
||||
"ru",
|
||||
"zh",
|
||||
"ja",
|
||||
"ko",
|
||||
"ar",
|
||||
"tr",
|
||||
"sv",
|
||||
"da",
|
||||
"no",
|
||||
"fi",
|
||||
"hu",
|
||||
"cs",
|
||||
"ro",
|
||||
"he",
|
||||
"th",
|
||||
"vi",
|
||||
"el",
|
||||
"bg",
|
||||
"hr",
|
||||
"sk",
|
||||
"sl",
|
||||
"uk",
|
||||
"id",
|
||||
"ms",
|
||||
"hi",
|
||||
"ta",
|
||||
"te",
|
||||
"bn",
|
||||
"fa",
|
||||
] as const;
|
||||
|
||||
/** Language option for dropdowns (search modal, etc). */
|
||||
export interface LanguageOption {
|
||||
@@ -175,7 +48,8 @@ export interface LanguageOption {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Sorted list of languages for UI dropdowns (uses 2-letter codes for API compatibility). */
|
||||
export const LANGUAGES: LanguageOption[] = Object.entries(LANGUAGE_NAMES_2)
|
||||
.map(([code, label]) => ({ code, label }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
/** Sorted common-language list; labels come from the shared English CLDR resolver. */
|
||||
export const LANGUAGES: LanguageOption[] = COMMON_LANGUAGE_CODES.map((code) => ({
|
||||
code,
|
||||
label: getLanguageName(code),
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
@@ -36,8 +36,8 @@ describe("getLanguageName", () => {
|
||||
expect(getLanguageName("ENG")).toBe("English");
|
||||
});
|
||||
|
||||
it("falls back to capitalized code for unknown", () => {
|
||||
expect(getLanguageName("xx")).toBe("Xx");
|
||||
it("labels an unassigned code explicitly", () => {
|
||||
expect(getLanguageName("xx")).toBe("Unknown language (xx)");
|
||||
});
|
||||
|
||||
it("returns 'Unknown' for empty string", () => {
|
||||
|
||||
Reference in New Issue
Block a user