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:
Quick
2026-07-31 15:23:30 -04:00
committed by GitHub
parent d70b291bb8
commit 73488d1bfa
46 changed files with 1572 additions and 437 deletions
+22 -1
View File
@@ -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.",
+20 -3
View File
@@ -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

+43
View File
@@ -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)
}
}
}
+1
View File
@@ -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 {
+20 -7
View File
@@ -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) {
+3
View File
@@ -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
}
+55 -1
View File
@@ -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 == "" {
+6 -1
View File
@@ -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)
}
+21 -9
View File
@@ -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()),
}
}
+14 -2
View File
@@ -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)
}
}
+11 -5
View File
@@ -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
View File
@@ -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")
}
}
+8 -7
View File
@@ -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,
}
}
}
+16 -6
View File
@@ -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)
}
+10 -10
View File
@@ -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
+1
View File
@@ -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
+10
View File
@@ -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) {
+10 -9
View File
@@ -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) {
+4 -1
View File
@@ -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,
+1
View File
@@ -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",
+22
View File
@@ -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
View File
@@ -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";
+33
View File
@@ -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)");
});
});
+90
View File
@@ -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;
}
+39 -1
View File
@@ -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" },
]);
});
});
+16 -18
View File
@@ -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;
}
+22 -1
View File
@@ -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.",
+19 -2
View File
@@ -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", () => {
+27 -24
View File
@@ -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";
+46 -172
View File
@@ -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));
+2 -2
View File
@@ -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", () => {