diff --git a/contracts/settings/v1/conformance.json b/contracts/settings/v1/conformance.json index 0545434b..1a53a6f9 100644 --- a/contracts/settings/v1/conformance.json +++ b/contracts/settings/v1/conformance.json @@ -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.", diff --git a/contracts/settings/v1/manifest.json b/contracts/settings/v1/manifest.json index dd8cbf08..d2559359 100644 --- a/contracts/settings/v1/manifest.json +++ b/contracts/settings/v1/manifest.json @@ -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", diff --git a/contracts/settings/v1/schemas/metadata-language-overrides.json b/contracts/settings/v1/schemas/metadata-language-overrides.json new file mode 100644 index 00000000..af5eabe6 --- /dev/null +++ b/contracts/settings/v1/schemas/metadata-language-overrides.json @@ -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 +} diff --git a/docs/screenshots/pr-526-metadata-language-exception.png b/docs/screenshots/pr-526-metadata-language-exception.png new file mode 100644 index 00000000..242a26c8 Binary files /dev/null and b/docs/screenshots/pr-526-metadata-language-exception.png differ diff --git a/docs/screenshots/pr-526-metadata-language-iso-names.png b/docs/screenshots/pr-526-metadata-language-iso-names.png new file mode 100644 index 00000000..54139509 Binary files /dev/null and b/docs/screenshots/pr-526-metadata-language-iso-names.png differ diff --git a/docs/screenshots/pr-526-metadata-language-mobile.png b/docs/screenshots/pr-526-metadata-language-mobile.png new file mode 100644 index 00000000..40632dc2 Binary files /dev/null and b/docs/screenshots/pr-526-metadata-language-mobile.png differ diff --git a/internal/access/metadata_language_test.go b/internal/access/metadata_language_test.go index 474cdbc2..839e3691 100644 --- a/internal/access/metadata_language_test.go +++ b/internal/access/metadata_language_test.go @@ -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) + } + } +} diff --git a/internal/access/resolver.go b/internal/access/resolver.go index 9866f044..c919d2b7 100644 --- a/internal/access/resolver.go +++ b/internal/access/resolver.go @@ -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 { diff --git a/internal/access/resolver_test.go b/internal/access/resolver_test.go index d9896431..e823b0bc 100644 --- a/internal/access/resolver_test.go +++ b/internal/access/resolver_test.go @@ -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) { diff --git a/internal/access/types.go b/internal/access/types.go index 3a34ffb4..09fc7cd8 100644 --- a/internal/access/types.go +++ b/internal/access/types.go @@ -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 } diff --git a/internal/access/viewer_preferences.go b/internal/access/viewer_preferences.go index 661e0b14..6bf5f8da 100644 --- a/internal/access/viewer_preferences.go +++ b/internal/access/viewer_preferences.go @@ -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 == "" { diff --git a/internal/api/handlers/catalog_resources.go b/internal/api/handlers/catalog_resources.go index b15e1ccf..ff085525 100644 --- a/internal/api/handlers/catalog_resources.go +++ b/internal/api/handlers/catalog_resources.go @@ -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) } diff --git a/internal/api/handlers/items.go b/internal/api/handlers/items.go index 2b8b543d..4c8047f7 100644 --- a/internal/api/handlers/items.go +++ b/internal/api/handlers/items.go @@ -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()), } } diff --git a/internal/api/handlers/settings_values.go b/internal/api/handlers/settings_values.go index 8f93183b..5a68a689 100644 --- a/internal/api/handlers/settings_values.go +++ b/internal/api/handlers/settings_values.go @@ -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 { diff --git a/internal/api/handlers/settings_values_test.go b/internal/api/handlers/settings_values_test.go index 67584aef..0ef30367 100644 --- a/internal/api/handlers/settings_values_test.go +++ b/internal/api/handlers/settings_values_test.go @@ -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) + } +} diff --git a/internal/catalog/access_filter.go b/internal/catalog/access_filter.go index fc83a268..342bcdf8 100644 --- a/internal/catalog/access_filter.go +++ b/internal/catalog/access_filter.go @@ -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 diff --git a/internal/catalog/detail.go b/internal/catalog/detail.go index bd5c82a3..d89b5c63 100644 --- a/internal/catalog/detail.go +++ b/internal/catalog/detail.go @@ -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) } diff --git a/internal/catalog/detail_batch_equivalence_test.go b/internal/catalog/detail_batch_equivalence_test.go index a4d401a8..44a253a6 100644 --- a/internal/catalog/detail_batch_equivalence_test.go +++ b/internal/catalog/detail_batch_equivalence_test.go @@ -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) diff --git a/internal/catalog/detail_metadata_language_test.go b/internal/catalog/detail_metadata_language_test.go new file mode 100644 index 00000000..9021a505 --- /dev/null +++ b/internal/catalog/detail_metadata_language_test.go @@ -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") + } +} diff --git a/internal/jellycompat/access_filter.go b/internal/jellycompat/access_filter.go index bbf6e017..b78c9ef3 100644 --- a/internal/jellycompat/access_filter.go +++ b/internal/jellycompat/access_filter.go @@ -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, } } } diff --git a/internal/jellycompat/access_filter_test.go b/internal/jellycompat/access_filter_test.go index f70fc395..1b1d4769 100644 --- a/internal/jellycompat/access_filter_test.go +++ b/internal/jellycompat/access_filter_test.go @@ -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) } diff --git a/internal/jellycompat/content_direct.go b/internal/jellycompat/content_direct.go index d67d5936..63bdba85 100644 --- a/internal/jellycompat/content_direct.go +++ b/internal/jellycompat/content_direct.go @@ -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 diff --git a/internal/policy/viewer_resolver.go b/internal/policy/viewer_resolver.go index 6921ab60..f87ed671 100644 --- a/internal/policy/viewer_resolver.go +++ b/internal/policy/viewer_resolver.go @@ -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 diff --git a/internal/policy/viewer_resolver_test.go b/internal/policy/viewer_resolver_test.go index ce0494ba..508e9f88 100644 --- a/internal/policy/viewer_resolver_test.go +++ b/internal/policy/viewer_resolver_test.go @@ -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) { diff --git a/internal/settingscontract/contract_test.go b/internal/settingscontract/contract_test.go index 2a1e84c5..67893f43 100644 --- a/internal/settingscontract/contract_test.go +++ b/internal/settingscontract/contract_test.go @@ -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) { diff --git a/internal/settingskeys/keys.go b/internal/settingskeys/keys.go index b8de3c81..01161a8f 100644 --- a/internal/settingskeys/keys.go +++ b/internal/settingskeys/keys.go @@ -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, diff --git a/web/package.json b/web/package.json index 4d9cb31c..c9e8e481 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d6a55cd1..c7e53586 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -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': diff --git a/web/src/components/settings/MetadataLanguageSetting.test.tsx b/web/src/components/settings/MetadataLanguageSetting.test.tsx new file mode 100644 index 00000000..d9efe80d --- /dev/null +++ b/web/src/components/settings/MetadataLanguageSetting.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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(() => {})); + render( + , + ); + + 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((_, reject) => { + rejectSave = reject; + }), + ); + render( + , + ); + + 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( + , + ); + + expect(screen.getByLabelText("Metadata language for Norwegian").textContent).toContain( + "Portuguese", + ); + }); +}); diff --git a/web/src/components/settings/MetadataLanguageSetting.tsx b/web/src/components/settings/MetadataLanguageSetting.tsx new file mode 100644 index 00000000..fcbd7c30 --- /dev/null +++ b/web/src/components/settings/MetadataLanguageSetting.tsx @@ -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; +} + +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 ( + ( + + onFallbackChange(value === NO_PREFERENCE ? null : value)} + > + + + + + Library default + Original language + {namedOptions.map((language) => ( + + {language.label} + + ))} + + + + {entries.length > 0 ? ( + + {entries.map(([source, target]) => ( + + {getLanguageName(source)} + + void changeOverrides( + withMetadataLanguageOverride(currentOverrides, source, value), + ) + } + > + + + + + Original language + {optionsForTarget(target).map((language) => ( + + {language.label} + + ))} + + + + void changeOverrides( + withoutMetadataLanguageOverride(currentOverrides, source), + ) + } + > + + + + ))} + + ) : null} + + {availableSources.length > 0 ? ( + + + + + + + {availableSources.map((language) => ( + + {language.label} + + ))} + + + + + Add exception + + + ) : null} + + )} + /> + ); +} diff --git a/web/src/lib/languageDisplay.ts b/web/src/lib/languageDisplay.ts index 65727259..8623208a 100644 --- a/web/src/lib/languageDisplay.ts +++ b/web/src/lib/languageDisplay.ts @@ -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"; diff --git a/web/src/lib/languageNames.test.ts b/web/src/lib/languageNames.test.ts new file mode 100644 index 00000000..e5040eef --- /dev/null +++ b/web/src/lib/languageNames.test.ts @@ -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)"); + }); +}); diff --git a/web/src/lib/languageNames.ts b/web/src/lib/languageNames.ts new file mode 100644 index 00000000..2c81ea4c --- /dev/null +++ b/web/src/lib/languageNames.ts @@ -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; +} diff --git a/web/src/lib/languageOptions.test.ts b/web/src/lib/languageOptions.test.ts index 6209a207..d91ff8a3 100644 --- a/web/src/lib/languageOptions.test.ts +++ b/web/src/lib/languageOptions.test.ts @@ -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" }, + ]); + }); }); diff --git a/web/src/lib/languageOptions.ts b/web/src/lib/languageOptions.ts index 592ac854..5c921179 100644 --- a/web/src/lib/languageOptions.ts +++ b/web/src/lib/languageOptions.ts @@ -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. */ diff --git a/web/src/lib/metadataLanguagePreferences.test.ts b/web/src/lib/metadataLanguagePreferences.test.ts new file mode 100644 index 00000000..17c76361 --- /dev/null +++ b/web/src/lib/metadataLanguagePreferences.test.ts @@ -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" }); + }); +}); diff --git a/web/src/lib/metadataLanguagePreferences.ts b/web/src/lib/metadataLanguagePreferences.ts new file mode 100644 index 00000000..a04f5cb5 --- /dev/null +++ b/web/src/lib/metadataLanguagePreferences.ts @@ -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; + +/** + * 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; +} diff --git a/web/src/lib/settingsConformance.json b/web/src/lib/settingsConformance.json index 0545434b..1a53a6f9 100644 --- a/web/src/lib/settingsConformance.json +++ b/web/src/lib/settingsConformance.json @@ -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.", diff --git a/web/src/lib/settingsContract.ts b/web/src/lib/settingsContract.ts index 5764a352..c329ca2d 100644 --- a/web/src/lib/settingsContract.ts +++ b/web/src/lib/settingsContract.ts @@ -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 = { 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", diff --git a/web/src/pages/ItemDetail/components/versionFormatUtils.test.ts b/web/src/pages/ItemDetail/components/versionFormatUtils.test.ts index d880574a..b0279d75 100644 --- a/web/src/pages/ItemDetail/components/versionFormatUtils.test.ts +++ b/web/src/pages/ItemDetail/components/versionFormatUtils.test.ts @@ -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(""); }); diff --git a/web/src/pages/ItemDetail/components/versionFormatUtils.ts b/web/src/pages/ItemDetail/components/versionFormatUtils.ts index 370ae0c2..a60a69cc 100644 --- a/web/src/pages/ItemDetail/components/versionFormatUtils.ts +++ b/web/src/pages/ItemDetail/components/versionFormatUtils.ts @@ -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 = { - 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 }> = [ diff --git a/web/src/pages/settings/PlaybackSettings.test.tsx b/web/src/pages/settings/PlaybackSettings.test.tsx index b3364d1b..37493f3b 100644 --- a/web/src/pages/settings/PlaybackSettings.test.tsx +++ b/web/src/pages/settings/PlaybackSettings.test.tsx @@ -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", () => { diff --git a/web/src/pages/settings/PlaybackSettings.tsx b/web/src/pages/settings/PlaybackSettings.tsx index fa9a0eca..924ba156 100644 --- a/web/src/pages/settings/PlaybackSettings.tsx +++ b/web/src/pages/settings/PlaybackSettings.tsx @@ -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() { )} /> - ( - - - saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, value === "none" ? null : value) - } - > - - - - - Library default - {metadataLanguageOptions.map((language) => ( - - {language.label} - - ))} - - - + (SETTING_KEYS.CATALOG_METADATA_LANGUAGE_OVERRIDES), )} + languageOptions={metadataLanguageOptions} + disabled={pending} + onFallbackChange={(language) => + saveValue(SETTING_KEYS.CATALOG_METADATA_LANGUAGE, language) + } + onOverridesChange={saveMetadataOverrides} /> = { - 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 = { - 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 = { - 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 = { - ...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)); diff --git a/web/src/player/utils/subtitleSort.test.ts b/web/src/player/utils/subtitleSort.test.ts index 56d3312d..083e137c 100644 --- a/web/src/player/utils/subtitleSort.test.ts +++ b/web/src/player/utils/subtitleSort.test.ts @@ -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", () => {