feat(metadata): add original-language preferences (#526)

* feat(metadata): add original-language preferences

* fix(settings): show metadata exceptions immediately

* docs(settings): add metadata language screenshot

* fix(settings): make language exceptions responsive

* fix(settings): standardize language display names
This commit is contained in:
Quick
2026-07-31 15:23:30 -04:00
committed by GitHub
parent d70b291bb8
commit 73488d1bfa
46 changed files with 1572 additions and 437 deletions
+43
View File
@@ -2,12 +2,15 @@ package access
import (
"context"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"testing"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -107,3 +110,43 @@ func TestPreferredMetadataLanguageStaysQuietWhenNothingIsStored(t *testing.T) {
t.Errorf("healthy resolution logged %d records, want none", len(records))
}
}
func TestResolveViewerPreferencesIncludesMetadataLanguageOverrides(t *testing.T) {
store := settingStoreStub{rows: []userstore.SettingValue{
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.UiDisabledLibraryIds, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`[]`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`"en"`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguageOverrides, Scope: settingscontract.ScopeProfile,
ProfileID: "profile-1",
},
Value: json.RawMessage(`{"nor":"x-silo-original","JA":"de"}`),
},
}}
preferences := ResolveViewerPreferences(context.Background(), store, "profile-1")
if preferences.PreferredMetadataLanguage != "en" {
t.Fatalf("fallback language = %q, want en", preferences.PreferredMetadataLanguage)
}
want := map[string]string{"no": OriginalMetadataLanguage, "ja": "de"}
if len(preferences.MetadataLanguageOverrides) != len(want) {
t.Fatalf("metadata overrides = %#v, want %#v", preferences.MetadataLanguageOverrides, want)
}
for source, target := range want {
if preferences.MetadataLanguageOverrides[source] != target {
t.Errorf("metadata override %q = %q, want %q", source, preferences.MetadataLanguageOverrides[source], target)
}
}
}
+1
View File
@@ -88,6 +88,7 @@ func (r *Resolver) Resolve(ctx context.Context, input ResolveInput) (Scope, erro
scope.MaxContentRating = profile.MaxContentRating
scope.MaxPlaybackQuality = MinQuality(scope.MaxPlaybackQuality, NormalizePlaybackQuality(profile.MaxPlaybackQuality))
scope.PreferredMetadataLanguage = preferences.PreferredMetadataLanguage
scope.MetadataLanguageOverrides = preferences.MetadataLanguageOverrides
scope.AllowedLibraryIDs, scope.LibrariesRestricted = effectiveLibraries(effective.LibraryIDs, profile)
verified, err := VerifyProfileForRequest(profile, input, user.ID, user.AccessPolicyRevision, r.tokens)
if err != nil {
+20 -7
View File
@@ -579,14 +579,24 @@ func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
ID: "prof-1",
PreferredMetadataLanguage: "fr",
},
settingValues: []userstore.SettingValue{{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
settingValues: []userstore.SettingValue{
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguage,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`"de"`),
},
Value: json.RawMessage(`"de"`),
}},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguageOverrides,
Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`{"no":"x-silo-original"}`),
},
},
}},
nil,
)
@@ -598,6 +608,9 @@ func TestResolver_MetadataLanguageResolvesCanonically(t *testing.T) {
if scope.PreferredMetadataLanguage != "de" {
t.Fatalf("PreferredMetadataLanguage = %q, want canonical value %q", scope.PreferredMetadataLanguage, "de")
}
if got := scope.MetadataLanguageOverrides["no"]; got != OriginalMetadataLanguage {
t.Fatalf("MetadataLanguageOverrides[no] = %q, want %q", got, OriginalMetadataLanguage)
}
}
func TestResolver_MetadataLanguageIgnoresLegacyColumn(t *testing.T) {
+3
View File
@@ -12,6 +12,9 @@ type Scope struct {
// PreferredMetadataLanguage is the profile's metadata (presentation)
// language; "" inherits the library's metadata language.
PreferredMetadataLanguage string
// MetadataLanguageOverrides maps a media item's canonical original-language
// code to the profile's target metadata language for that source.
MetadataLanguageOverrides map[string]string
PolicyRevision int64
ProfileVerified bool
}
+55 -1
View File
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"log/slog"
"sort"
"strings"
"github.com/Silo-Server/silo-server/internal/lang"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
@@ -18,6 +20,7 @@ import (
type ViewerPreferences struct {
DisabledLibraryIDs []int
PreferredMetadataLanguage string
MetadataLanguageOverrides map[string]string
}
// ResolveViewerPreferences resolves the profile's viewer-scope preferences in
@@ -57,7 +60,11 @@ func resolveCanonicalViewerPreferences(
}
values, err := settingsresolve.New(contract).Resolve(ctx, store,
settingsresolve.Context{ProfileID: profileID},
[]string{settingskeys.UiDisabledLibraryIds, settingskeys.CatalogMetadataLanguage}, nil)
[]string{
settingskeys.UiDisabledLibraryIds,
settingskeys.CatalogMetadataLanguage,
settingskeys.CatalogMetadataLanguageOverrides,
}, nil)
if err != nil {
slog.WarnContext(ctx, "viewer preference resolution degraded: reading setting values failed",
"component", "access", "profile_id", profileID, "error", err)
@@ -77,11 +84,58 @@ func resolveCanonicalViewerPreferences(
if json.Unmarshal(value.Value, &language) == nil {
out.preferences.PreferredMetadataLanguage = strings.TrimSpace(language)
}
case settingskeys.CatalogMetadataLanguageOverrides:
out.preferences.MetadataLanguageOverrides = parseMetadataLanguageOverrides(value.Value)
}
}
return out, true
}
// OriginalMetadataLanguage is a private-use BCP 47 tag stored in
// catalog.metadata_language (or as an override target) to mean "resolve this
// media item's original language." Keeping the sentinel a valid language tag
// preserves the existing setting's wire type.
const OriginalMetadataLanguage = "x-silo-original"
// parseMetadataLanguageOverrides normalizes source aliases and target tag
// casing before the map reaches catalog serving. The contract only accepts
// canonical source keys from normal clients, but normalization also makes old
// or manually-authored rows deterministic.
func parseMetadataLanguageOverrides(raw json.RawMessage) map[string]string {
var stored map[string]string
if json.Unmarshal(raw, &stored) != nil || len(stored) == 0 {
return nil
}
keys := make([]string, 0, len(stored))
for source := range stored {
keys = append(keys, source)
}
sort.Strings(keys)
out := make(map[string]string, len(stored))
for _, source := range keys {
canonicalSource := lang.Canonical(source)
if canonicalSource == "" {
continue
}
// When aliases collapse (for example en and eng), the canonical key
// wins because it sorts first and is never overwritten.
if _, exists := out[canonicalSource]; exists {
continue
}
target, ok := settingscontract.NormalizeLanguageTag(stored[source])
if !ok {
continue
}
out[canonicalSource] = target
}
if len(out) == 0 {
return nil
}
return out
}
func legacyDisabledLibraryIDs(ctx context.Context, store userstore.UserStore) []int {
raw, err := store.GetSetting(ctx, settingKeyDisabledLibraryIDs)
if err != nil || raw == "" {
+6 -1
View File
@@ -228,6 +228,11 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
}
if len(seasons) > 0 {
if h.items.detailSvc != nil {
if localized, locErr := h.items.detailSvc.LocalizeSeasonModels(r.Context(), seasons, filter); locErr == nil && len(localized) == len(seasons) {
seasons = localized
}
}
episodesBySeason, err := h.items.episodeRepo.ListBySeriesGroupedBySeason(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list seasons")
@@ -245,7 +250,7 @@ func (h *CatalogResourceHandler) HandleGetSeasons(w http.ResponseWriter, r *http
if hasProgressMap {
userData = catalog.EpisodeRollupUserData(episodes, progressMap)
}
sr := h.items.toSeasonResponseFromEpisodes(r, id, s, episodes, userData)
sr := h.items.seasonResponseFromEpisodes(r, s, episodes, userData)
resp = append(resp, sr)
}
+21 -9
View File
@@ -1478,7 +1478,18 @@ func (h *ItemsHandler) toSeasonResponseFromEpisodes(
s = localized
}
}
return h.seasonResponseFromEpisodes(r, s, episodes, userData)
}
// seasonResponseFromEpisodes maps a season that has already been localized.
// List endpoints use this after LocalizeSeasonModels so they do not repeat the
// localization query for every row.
func (h *ItemsHandler) seasonResponseFromEpisodes(
r *http.Request,
s *models.Season,
episodes []*models.Episode,
userData *catalog.SeasonUserData,
) seasonResponse {
resp := seasonResponse{
ContentID: s.ContentID,
SeasonNumber: s.SeasonNumber,
@@ -1814,15 +1825,16 @@ func (h *ItemsHandler) accessFilter(r *http.Request) catalog.AccessFilter {
if scope, ok := access.GetScope(r.Context()); ok {
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
PresentationLibraryID: presentationLibraryID,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
MetadataLanguageOverrides: scope.MetadataLanguageOverrides,
SelectedFileID: selectedFileID,
UserID: apimw.GetUserID(r.Context()),
ProfileID: apimw.GetProfileID(r.Context()),
}
}
+14 -2
View File
@@ -55,6 +55,7 @@ type SettingValuesHandler struct {
}
type languageSuggestionSource interface {
ListOriginalLanguages(context.Context, catalog.BrowseFilters) ([]string, error)
ListAudioLanguages(context.Context, catalog.BrowseFilters) ([]string, error)
ListSubtitleLanguages(context.Context, catalog.BrowseFilters) ([]string, error)
}
@@ -1073,16 +1074,18 @@ func (h *SettingValuesHandler) observedLanguageSuggestions(
return result
}
wantsAudio, wantsSubtitles := false, false
wantsMetadata, wantsAudio, wantsSubtitles := false, false, false
for _, eff := range resolved {
switch eff.Key {
case settingskeys.CatalogMetadataLanguage:
wantsMetadata = true
case settingskeys.PlaybackAudioLanguage:
wantsAudio = true
case settingskeys.PlaybackSubtitleLanguage:
wantsSubtitles = true
}
}
if !wantsAudio && !wantsSubtitles {
if !wantsMetadata && !wantsAudio && !wantsSubtitles {
return result
}
@@ -1092,6 +1095,15 @@ func (h *SettingValuesHandler) observedLanguageSuggestions(
filters.DisabledLibraryIDs = scope.DisabledLibraryIDs
filters.MaxContentRating = scope.MaxContentRating
}
if wantsMetadata {
values, err := h.languageSource.ListOriginalLanguages(r.Context(), filters)
if err != nil {
slog.WarnContext(r.Context(), "settings: listing metadata language suggestions",
"component", "settings", "error", err)
} else {
result[settingskeys.CatalogMetadataLanguage] = values
}
}
if wantsAudio {
values, err := h.languageSource.ListAudioLanguages(r.Context(), filters)
if err != nil {
@@ -20,6 +20,8 @@ import (
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/settingscontract"
"github.com/Silo-Server/silo-server/internal/settingskeys"
"github.com/Silo-Server/silo-server/internal/settingsresolve"
"github.com/Silo-Server/silo-server/internal/userdb"
"github.com/Silo-Server/silo-server/internal/userstore"
)
@@ -852,3 +854,52 @@ func TestMergeLanguageSuggestionsUsesExactCurrentAlias(t *testing.T) {
t.Errorf("mergeLanguageSuggestions() = %v, want %v", got, want)
}
}
type recordingLanguageSuggestionSource struct {
filters catalog.BrowseFilters
original []string
}
func (s *recordingLanguageSuggestionSource) ListOriginalLanguages(
_ context.Context, filters catalog.BrowseFilters,
) ([]string, error) {
s.filters = filters
return s.original, nil
}
func (*recordingLanguageSuggestionSource) ListAudioLanguages(
context.Context, catalog.BrowseFilters,
) ([]string, error) {
return nil, nil
}
func (*recordingLanguageSuggestionSource) ListSubtitleLanguages(
context.Context, catalog.BrowseFilters,
) ([]string, error) {
return nil, nil
}
func TestObservedLanguageSuggestionsIncludesAccessibleOriginalLanguages(t *testing.T) {
source := &recordingLanguageSuggestionSource{original: []string{"is", "no"}}
handler, _ := newValuesTestHandler(t)
handler.SetLanguageSuggestionSource(source)
req := valuesRequest(http.MethodGet, "/settings/values/effective", nil)
req = req.WithContext(access.SetScope(req.Context(), access.Scope{
AllowedLibraryIDs: []int{4, 9},
DisabledLibraryIDs: []int{12},
MaxContentRating: "PG-13",
}))
observed := handler.observedLanguageSuggestions(req, []settingsresolve.Effective{{
Key: settingskeys.CatalogMetadataLanguage,
}})
if !slices.Equal(observed[settingskeys.CatalogMetadataLanguage], []string{"is", "no"}) {
t.Fatalf("metadata suggestions = %v", observed[settingskeys.CatalogMetadataLanguage])
}
if !slices.Equal(source.filters.LibraryIDs, []int{4, 9}) ||
!slices.Equal(source.filters.DisabledLibraryIDs, []int{12}) ||
source.filters.MaxContentRating != "PG-13" {
t.Fatalf("catalog filters = %#v", source.filters)
}
}
+11 -5
View File
@@ -19,11 +19,17 @@ type AccessFilter struct {
// language. Presentation language resolves: explicit PresentationLanguage
// → ProfilePreferredLanguage → the library's metadata_language.
ProfilePreferredLanguage string
MaxContentRating string
MaxPlaybackQuality string
SelectedFileID int
UserID int
ProfileID string
// MetadataLanguageOverrides maps an item's original language to a target
// language before ProfilePreferredLanguage is used as the fallback.
MetadataLanguageOverrides map[string]string
// PresentationOriginalLanguage supplies a parent series' original language
// while localizing season and episode rows, which do not duplicate it.
PresentationOriginalLanguage string
MaxContentRating string
MaxPlaybackQuality string
SelectedFileID int
UserID int
ProfileID string
// NamePrefix, when non-empty, restricts results to items whose
// LOWER(COALESCE(NULLIF(BTRIM(sort_title),''), title)) starts with the
// given (case-insensitive) prefix. Pushed into the SQL WHERE clause so
+347 -76
View File
@@ -15,6 +15,7 @@ import (
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/artworkkey"
"github.com/Silo-Server/silo-server/internal/lang"
"github.com/Silo-Server/silo-server/internal/models"
"github.com/Silo-Server/silo-server/internal/overlays"
"github.com/Silo-Server/silo-server/internal/playback"
@@ -750,27 +751,146 @@ func cloneEpisode(ep *models.Episode) *models.Episode {
return &cp
}
// resolvePresentationLanguage picks the display language for a request:
// explicit request language → viewer profile preference → the presentation
// library's metadata language.
func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter AccessFilter) (string, error) {
if strings.TrimSpace(filter.PresentationLanguage) != "" {
return strings.TrimSpace(filter.PresentationLanguage), nil
type presentationLanguageBase struct {
target string
libraryFallback string
explicit bool
}
// resolvePresentationLanguageBase picks the request-wide fallback once. The
// item-specific original-language rule is applied separately, because one
// result page can contain several source languages and therefore several
// localization targets.
func (s *DetailService) resolvePresentationLanguageBase(ctx context.Context, filter AccessFilter) (presentationLanguageBase, error) {
base := presentationLanguageBase{}
if explicit := strings.TrimSpace(filter.PresentationLanguage); explicit != "" {
base.target = explicit
base.explicit = true
} else {
base.target = strings.TrimSpace(filter.ProfilePreferredLanguage)
}
if strings.TrimSpace(filter.ProfilePreferredLanguage) != "" {
return strings.TrimSpace(filter.ProfilePreferredLanguage), nil
// A concrete profile/explicit target never needs the library fallback. The
// original-language sentinel does: items with no known original language
// should still inherit the library rather than lose localization entirely.
if base.target != "" && !sameMetadataLanguage(base.target, access.OriginalMetadataLanguage) {
return base, nil
}
if filter.PresentationLibraryID == nil || s.folderRepo == nil {
return "", nil
return base, nil
}
folder, err := s.folderRepo.GetByID(ctx, *filter.PresentationLibraryID)
if err != nil {
if errors.Is(err, ErrFolderNotFound) {
return "", ErrItemNotFound
return presentationLanguageBase{}, ErrItemNotFound
}
return presentationLanguageBase{}, err
}
base.libraryFallback = strings.TrimSpace(folder.MetadataLanguage)
if base.target == "" {
base.target = base.libraryFallback
}
return base, nil
}
// presentationLanguageForOriginal applies a profile's per-source exception,
// then resolves the original-language sentinel to the item's concrete catalog
// language. An explicit request language remains authoritative and bypasses
// profile exceptions.
func presentationLanguageForOriginal(base presentationLanguageBase, originalLanguage string, filter AccessFilter) string {
original := lang.Canonical(originalLanguage)
target := base.target
if !base.explicit && original != "" {
if override := strings.TrimSpace(filter.MetadataLanguageOverrides[original]); override != "" {
target = override
}
}
if sameMetadataLanguage(target, access.OriginalMetadataLanguage) {
if original != "" {
return original
}
return base.libraryFallback
}
return strings.TrimSpace(target)
}
func (s *DetailService) resolvePresentationLanguage(ctx context.Context, filter AccessFilter, originalLanguage string) (string, error) {
base, err := s.resolvePresentationLanguageBase(ctx, filter)
if err != nil {
return "", err
}
return strings.TrimSpace(folder.MetadataLanguage), nil
return presentationLanguageForOriginal(base, originalLanguage, filter), nil
}
func metadataLanguageMayUseOriginal(filter AccessFilter) bool {
if explicit := strings.TrimSpace(filter.PresentationLanguage); explicit != "" {
return sameMetadataLanguage(explicit, access.OriginalMetadataLanguage)
}
return sameMetadataLanguage(filter.ProfilePreferredLanguage, access.OriginalMetadataLanguage) ||
len(filter.MetadataLanguageOverrides) > 0
}
// seriesOriginalLanguage supplies the source language for season and episode
// rows, which deliberately inherit original_language from their parent series
// instead of duplicating it in each table.
func (s *DetailService) seriesOriginalLanguage(ctx context.Context, seriesID string, filter AccessFilter) string {
if original := strings.TrimSpace(filter.PresentationOriginalLanguage); original != "" {
return original
}
if !metadataLanguageMayUseOriginal(filter) || s.itemRepo == nil || strings.TrimSpace(seriesID) == "" {
return ""
}
series, err := s.itemRepo.GetByID(ctx, seriesID)
if err != nil || series == nil {
return ""
}
return series.OriginalLanguage
}
// seriesOriginalLanguages is the batch equivalent of seriesOriginalLanguage.
// A season or episode page usually contains many children of one series; load
// each distinct parent once so original-language preferences do not introduce
// an N+1 query on those pages.
func (s *DetailService) seriesOriginalLanguages(
ctx context.Context,
seriesIDs []string,
filter AccessFilter,
) (map[string]string, error) {
originalBySeries := make(map[string]string)
seen := make(map[string]struct{}, len(seriesIDs))
distinct := make([]string, 0, len(seriesIDs))
for _, seriesID := range seriesIDs {
seriesID = strings.TrimSpace(seriesID)
if seriesID == "" {
continue
}
if _, exists := seen[seriesID]; exists {
continue
}
seen[seriesID] = struct{}{}
distinct = append(distinct, seriesID)
}
if supplied := strings.TrimSpace(filter.PresentationOriginalLanguage); supplied != "" {
for _, seriesID := range distinct {
originalBySeries[seriesID] = supplied
}
return originalBySeries, nil
}
if len(distinct) == 0 || !metadataLanguageMayUseOriginal(filter) || s.itemRepo == nil {
return originalBySeries, nil
}
series, err := s.itemRepo.GetByIDs(ctx, distinct)
if err != nil {
return nil, err
}
for _, item := range series {
if item != nil {
originalBySeries[item.ContentID] = item.OriginalLanguage
}
}
return originalBySeries, nil
}
// PendingTranslationLanguage reports the presentation language the item's
@@ -783,7 +903,7 @@ func (s *DetailService) PendingTranslationLanguage(ctx context.Context, item *mo
if item == nil || strings.TrimSpace(item.Overview) == "" || s.itemLocRepo == nil {
return ""
}
language, err := s.resolvePresentationLanguage(ctx, filter)
language, err := s.resolvePresentationLanguage(ctx, filter, item.OriginalLanguage)
if err != nil || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) {
return ""
}
@@ -815,7 +935,7 @@ func (s *DetailService) PendingSeasonTranslationLanguage(ctx context.Context, se
if season == nil || strings.TrimSpace(season.Overview) == "" || s.seasonLocRepo == nil {
return ""
}
language, err := s.resolvePresentationLanguage(ctx, filter)
language, err := s.resolvePresentationLanguage(ctx, filter, s.seriesOriginalLanguage(ctx, season.SeriesID, filter))
if err != nil || language == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, language) {
return ""
}
@@ -832,7 +952,7 @@ func (s *DetailService) PendingEpisodeTranslationLanguage(ctx context.Context, e
if episode == nil || strings.TrimSpace(episode.Overview) == "" || s.episodeLocRepo == nil {
return ""
}
language, err := s.resolvePresentationLanguage(ctx, filter)
language, err := s.resolvePresentationLanguage(ctx, filter, s.seriesOriginalLanguage(ctx, episode.SeriesID, filter))
if err != nil || language == "" || sameMetadataLanguage(episode.DefaultMetadataLanguage, language) {
return ""
}
@@ -865,7 +985,7 @@ func (s *DetailService) LocalizeItemModel(ctx context.Context, item *models.Medi
if item == nil {
return nil, nil
}
language, err := s.resolvePresentationLanguage(ctx, filter)
language, err := s.resolvePresentationLanguage(ctx, filter, item.OriginalLanguage)
if err != nil || language == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) || s.itemLocRepo == nil {
return cloneMediaItem(item), err
}
@@ -876,6 +996,64 @@ func (s *DetailService) LocalizeItemModel(ctx context.Context, item *models.Medi
return s.localizeItemModelWith(item, language, loc), nil
}
// loadItemLocalizations resolves each item's target and groups repository reads
// by that language. The former single-language lookup was correct only while a
// profile had one global target; source-language exceptions make the grouping
// necessary to preserve batching without serving one item's localization to
// another language group.
func (s *DetailService) loadItemLocalizations(
ctx context.Context,
items []*models.MediaItem,
filter AccessFilter,
) (map[string]string, map[string]*models.MediaItemLocalization, error) {
base, err := s.resolvePresentationLanguageBase(ctx, filter)
if err != nil {
return nil, nil, err
}
targets := make(map[string]string, len(items))
if s.itemLocRepo == nil {
return targets, nil, nil
}
idsByLanguage := make(map[string][]string)
seenByLanguage := make(map[string]map[string]struct{})
for _, item := range items {
if item == nil || item.ContentID == "" {
continue
}
target := presentationLanguageForOriginal(base, item.OriginalLanguage, filter)
targets[item.ContentID] = target
if target == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, target) {
continue
}
if seenByLanguage[target] == nil {
seenByLanguage[target] = make(map[string]struct{})
}
if _, seen := seenByLanguage[target][item.ContentID]; seen {
continue
}
seenByLanguage[target][item.ContentID] = struct{}{}
idsByLanguage[target] = append(idsByLanguage[target], item.ContentID)
}
languages := make([]string, 0, len(idsByLanguage))
for language := range idsByLanguage {
languages = append(languages, language)
}
sort.Strings(languages)
localizations := make(map[string]*models.MediaItemLocalization)
for _, language := range languages {
rows, err := s.itemLocRepo.GetByContentIDs(ctx, idsByLanguage[language], language)
if err != nil {
return nil, nil, err
}
for contentID, localization := range rows {
localizations[contentID] = localization
}
}
return targets, localizations, nil
}
// localizeItemModelWith applies a pre-resolved localization to item, returning a
// clone when there is nothing to localize (no language, base language already
// matches, no localization repo, or no localization row). Shared core of
@@ -899,29 +1077,8 @@ func (s *DetailService) LocalizeItemModels(ctx context.Context, items []*models.
for i, item := range items {
localized[i] = cloneMediaItem(item)
}
language, err := s.resolvePresentationLanguage(ctx, filter)
if err != nil || language == "" || s.itemLocRepo == nil {
return localized, err
}
ids := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
if item == nil || item.ContentID == "" || sameMetadataLanguage(item.DefaultMetadataLanguage, language) {
continue
}
if _, ok := seen[item.ContentID]; ok {
continue
}
seen[item.ContentID] = struct{}{}
ids = append(ids, item.ContentID)
}
if len(ids) == 0 {
return localized, nil
}
locs, err := s.itemLocRepo.GetByContentIDs(ctx, ids, language)
if err != nil || len(locs) == 0 {
targets, locs, err := s.loadItemLocalizations(ctx, items, filter)
if err != nil || s.itemLocRepo == nil {
return localized, err
}
for i, item := range items {
@@ -929,7 +1086,7 @@ func (s *DetailService) LocalizeItemModels(ctx context.Context, items []*models.
continue
}
if loc := locs[item.ContentID]; loc != nil {
localized[i] = applyItemLocalization(item, loc)
localized[i] = s.localizeItemModelWith(item, targets[item.ContentID], loc)
}
}
return localized, nil
@@ -939,7 +1096,8 @@ func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.
if season == nil {
return nil, nil
}
language, err := s.resolvePresentationLanguage(ctx, filter)
original := s.seriesOriginalLanguage(ctx, season.SeriesID, filter)
language, err := s.resolvePresentationLanguage(ctx, filter, original)
if err != nil || language == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, language) || s.seasonLocRepo == nil {
return cloneSeason(season), err
}
@@ -950,11 +1108,89 @@ func (s *DetailService) LocalizeSeasonModel(ctx context.Context, season *models.
return applySeasonLocalization(season, loc), nil
}
// LocalizeSeasonModels applies presentation-language localization to a batch
// of seasons. Parent series and localization rows are fetched in batches, so
// original-language preferences do not add one query per season. The result
// preserves input order and length.
func (s *DetailService) LocalizeSeasonModels(ctx context.Context, seasons []*models.Season, filter AccessFilter) ([]*models.Season, error) {
if len(seasons) == 0 {
return seasons, nil
}
localized := make([]*models.Season, len(seasons))
seriesIDs := make([]string, 0, len(seasons))
for i, season := range seasons {
localized[i] = cloneSeason(season)
if season != nil {
seriesIDs = append(seriesIDs, season.SeriesID)
}
}
base, err := s.resolvePresentationLanguageBase(ctx, filter)
if err != nil || s.seasonLocRepo == nil {
return localized, err
}
originalBySeries, err := s.seriesOriginalLanguages(ctx, seriesIDs, filter)
if err != nil {
return localized, err
}
targets := make(map[string]string, len(seasons))
idsByLanguage := make(map[string][]string)
seenByLanguage := make(map[string]map[string]struct{})
for _, season := range seasons {
if season == nil || season.ContentID == "" {
continue
}
target := presentationLanguageForOriginal(base, originalBySeries[season.SeriesID], filter)
targets[season.ContentID] = target
if target == "" || sameMetadataLanguage(season.DefaultMetadataLanguage, target) {
continue
}
if seenByLanguage[target] == nil {
seenByLanguage[target] = make(map[string]struct{})
}
if _, seen := seenByLanguage[target][season.ContentID]; seen {
continue
}
seenByLanguage[target][season.ContentID] = struct{}{}
idsByLanguage[target] = append(idsByLanguage[target], season.ContentID)
}
languages := make([]string, 0, len(idsByLanguage))
for language := range idsByLanguage {
languages = append(languages, language)
}
sort.Strings(languages)
locs := make(map[string]*models.SeasonLocalization)
for _, language := range languages {
rows, err := s.seasonLocRepo.GetBySeasonIDs(ctx, idsByLanguage[language], language)
if err != nil {
return localized, err
}
for seasonID, localization := range rows {
locs[seasonID] = localization
}
}
for i, season := range seasons {
if season == nil {
continue
}
if loc := locs[season.ContentID]; loc != nil {
target := targets[season.ContentID]
if target != "" && !sameMetadataLanguage(season.DefaultMetadataLanguage, target) {
localized[i] = applySeasonLocalization(season, loc)
}
}
}
return localized, nil
}
func (s *DetailService) LocalizeEpisodeModel(ctx context.Context, episode *models.Episode, filter AccessFilter) (*models.Episode, error) {
if episode == nil {
return nil, nil
}
language, err := s.resolvePresentationLanguage(ctx, filter)
original := s.seriesOriginalLanguage(ctx, episode.SeriesID, filter)
language, err := s.resolvePresentationLanguage(ctx, filter, original)
if err != nil || language == "" || sameMetadataLanguage(episode.DefaultMetadataLanguage, language) || s.episodeLocRepo == nil {
return cloneEpisode(episode), err
}
@@ -972,23 +1208,50 @@ func (s *DetailService) LocalizeEpisodeModels(ctx context.Context, episodes []*m
if len(episodes) == 0 {
return episodes, nil
}
language, err := s.resolvePresentationLanguage(ctx, filter)
if err != nil || language == "" || s.episodeLocRepo == nil {
base, err := s.resolvePresentationLanguageBase(ctx, filter)
if err != nil || s.episodeLocRepo == nil {
return episodes, err
}
ids := make([]string, 0, len(episodes))
seriesIDs := make([]string, 0, len(episodes))
for _, episode := range episodes {
if episode != nil {
seriesIDs = append(seriesIDs, episode.SeriesID)
}
}
originalBySeries, err := s.seriesOriginalLanguages(ctx, seriesIDs, filter)
if err != nil {
return episodes, err
}
targets := make(map[string]string, len(episodes))
idsByLanguage := make(map[string][]string)
for _, ep := range episodes {
if ep == nil || sameMetadataLanguage(ep.DefaultMetadataLanguage, language) {
if ep == nil || ep.ContentID == "" {
continue
}
ids = append(ids, ep.ContentID)
target := presentationLanguageForOriginal(base, originalBySeries[ep.SeriesID], filter)
targets[ep.ContentID] = target
if target == "" || sameMetadataLanguage(ep.DefaultMetadataLanguage, target) {
continue
}
idsByLanguage[target] = append(idsByLanguage[target], ep.ContentID)
}
if len(ids) == 0 {
return episodes, nil
languages := make([]string, 0, len(idsByLanguage))
for language := range idsByLanguage {
languages = append(languages, language)
}
locs, err := s.episodeLocRepo.GetByEpisodeIDs(ctx, ids, language)
if err != nil || len(locs) == 0 {
return episodes, err
sort.Strings(languages)
locs := make(map[string]*models.EpisodeLocalization)
for _, language := range languages {
rows, err := s.episodeLocRepo.GetByEpisodeIDs(ctx, idsByLanguage[language], language)
if err != nil {
return episodes, err
}
for episodeID, localization := range rows {
locs[episodeID] = localization
}
}
localized := make([]*models.Episode, len(episodes))
for i, ep := range episodes {
@@ -997,7 +1260,10 @@ func (s *DetailService) LocalizeEpisodeModels(ctx context.Context, episodes []*m
continue
}
if loc := locs[ep.ContentID]; loc != nil {
localized[i] = applyEpisodeLocalization(ep, loc)
target := targets[ep.ContentID]
if target != "" && !sameMetadataLanguage(ep.DefaultMetadataLanguage, target) {
localized[i] = applyEpisodeLocalization(ep, loc)
}
}
}
return localized, nil
@@ -1287,21 +1553,13 @@ func (s *DetailService) GetItemDetailsByIDs(ctx context.Context, contentIDs []st
return result, nil
}
// Localization: resolve the presentation language once, then one bulk
// localization lookup. A resolution failure surfaces the same wrapped error
// GetItemDetail would produce from buildMediaItemDetail, so the caller
// degrades to the per-item path.
language, err := s.resolvePresentationLanguage(ctx, filter)
// Localization keeps one lookup per target language. Most profiles still
// produce one query; profiles with source-language exceptions produce one
// query for each target represented on this page.
targetByID, locByID, err := s.loadItemLocalizations(ctx, visible, filter)
if err != nil {
return nil, fmt.Errorf("localizing item detail: %w", err)
}
var locByID map[string]*models.MediaItemLocalization
if language != "" && s.itemLocRepo != nil {
locByID, err = s.itemLocRepo.GetByContentIDs(ctx, visibleIDs, language)
if err != nil {
return nil, fmt.Errorf("localizing item detail: %w", err)
}
}
// Credits for the whole page in one query.
var creditsByID map[string][]models.ItemPerson
@@ -1361,6 +1619,7 @@ func (s *DetailService) GetItemDetailsByIDs(ctx context.Context, contentIDs []st
for _, item := range visible {
id := item.ContentID
language := targetByID[id]
loc := locByID[id]
pending := ""
if s.itemLocRepo != nil {
@@ -2319,17 +2578,19 @@ func clearSentinel(s string) string {
}
func (s *DetailService) buildSeasonDetail(ctx context.Context, season *models.Season, filter AccessFilter) (*ItemDetail, error) {
pendingTranslation := s.PendingSeasonTranslationLanguage(ctx, season, filter)
localizedSeason, err := s.LocalizeSeasonModel(ctx, season, filter)
if err != nil {
return nil, fmt.Errorf("localizing season detail: %w", err)
}
season = localizedSeason
series, err := s.itemRepo.GetByID(ctx, season.SeriesID)
if err != nil {
return nil, fmt.Errorf("loading parent series: %w", err)
}
series, err = s.LocalizeItemModel(ctx, series, filter)
localizationFilter := filter
localizationFilter.PresentationOriginalLanguage = series.OriginalLanguage
pendingTranslation := s.PendingSeasonTranslationLanguage(ctx, season, localizationFilter)
localizedSeason, err := s.LocalizeSeasonModel(ctx, season, localizationFilter)
if err != nil {
return nil, fmt.Errorf("localizing season detail: %w", err)
}
season = localizedSeason
series, err = s.LocalizeItemModel(ctx, series, localizationFilter)
if err != nil {
return nil, fmt.Errorf("localizing season series detail: %w", err)
}
@@ -2384,8 +2645,10 @@ func (s *DetailService) buildSeasonDetail(ctx context.Context, season *models.Se
}
func (s *DetailService) buildEpisodeDetail(ctx context.Context, episode *models.Episode, seriesCtx *seriesDetailContext, filter AccessFilter) (*ItemDetail, error) {
pendingTranslation := s.PendingEpisodeTranslationLanguage(ctx, episode, filter)
localizedEpisode, err := s.LocalizeEpisodeModel(ctx, episode, filter)
localizationFilter := filter
localizationFilter.PresentationOriginalLanguage = seriesCtx.series.OriginalLanguage
pendingTranslation := s.PendingEpisodeTranslationLanguage(ctx, episode, localizationFilter)
localizedEpisode, err := s.LocalizeEpisodeModel(ctx, episode, localizationFilter)
if err != nil {
return nil, fmt.Errorf("localizing episode detail: %w", err)
}
@@ -2520,7 +2783,15 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi
if err := s.validatePresentationItemAccess(ctx, filter, episode.ContentID); err != nil {
return nil, err
}
episode, err = s.LocalizeEpisodeModel(ctx, episode, filter)
// Seasons and episodes inherit original_language from the parent series.
// Load it before localization and reuse the same row for SeriesTitle below,
// avoiding an extra lookup only for profiles that use language exceptions.
series, seriesErr := s.itemRepo.GetByID(ctx, episode.SeriesID)
localizationFilter := filter
if seriesErr == nil && series != nil {
localizationFilter.PresentationOriginalLanguage = series.OriginalLanguage
}
episode, err = s.LocalizeEpisodeModel(ctx, episode, localizationFilter)
if err != nil {
return nil, fmt.Errorf("localizing episode watch detail: %w", err)
}
@@ -2557,8 +2828,8 @@ func (s *DetailService) GetWatchDetail(ctx context.Context, contentID string, fi
}
}
if series, err := s.itemRepo.GetByID(ctx, episode.SeriesID); err == nil {
series, err = s.LocalizeItemModel(ctx, series, filter)
if seriesErr == nil && series != nil {
series, err = s.LocalizeItemModel(ctx, series, localizationFilter)
if err != nil {
return nil, fmt.Errorf("localizing series watch detail: %w", err)
}
@@ -10,6 +10,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/access"
"github.com/Silo-Server/silo-server/internal/models"
)
@@ -147,21 +148,22 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) {
batchEquivExec(t, pool, `DELETE FROM media_items WHERE content_id = ANY($1)`, ids)
})
insertItem := func(contentID, mediaType, title, overview, rating string) {
insertItem := func(contentID, mediaType, title, overview, rating, originalLanguage string) {
batchEquivExec(t, pool, `
INSERT INTO media_items (content_id, type, title, genres, overview, content_rating, default_metadata_language)
VALUES ($1, $2, $3, '{}'::text[], $4, $5, 'en')
`, contentID, mediaType, title, overview, rating)
INSERT INTO media_items (content_id, type, title, genres, overview, content_rating, default_metadata_language, original_language)
VALUES ($1, $2, $3, '{}'::text[], $4, $5, 'en', $6)
`, contentID, mediaType, title, overview, rating, originalLanguage)
}
// movieA: localized into fr, credited, has files. content_rating PG -> visible.
insertItem(movieA, "movie", "Movie A", "Movie A overview (en)", "PG")
// movieB: overview present, default lang en, NO fr localization row -> the
// pending-translation-language path must fire and match between paths.
insertItem(movieB, "movie", "Movie B", "Movie B overview (en)", "PG")
insertItem(movieA, "movie", "Movie A", "Movie A overview (en)", "PG", "en")
// movieB: Norwegian original, default metadata language en, and no no
// localization row. The per-source original-language exception must produce
// a Norwegian pending-translation target in both batch and per-item paths.
insertItem(movieB, "movie", "Movie B", "Movie B overview (en)", "PG", "no")
// series: has a work summary, no playable files (series skip the file path).
insertItem(series, "series", "Series One", "Series overview (en)", "PG")
insertItem(series, "series", "Series One", "Series overview (en)", "PG", "en")
// movieR: R-rated -> filtered out by MaxContentRating=PG-13 on BOTH paths.
insertItem(movieR, "movie", "Movie R", "Movie R overview (en)", "R")
insertItem(movieR, "movie", "Movie R", "Movie R overview (en)", "R", "en")
// fr localization for movieA only.
batchEquivExec(t, pool, `
@@ -251,13 +253,17 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) {
}
// Single shared access filter for BOTH paths: PG-13 ceiling (so PG items are
// visible, the R item is not) and an explicit fr presentation language (so
// the localization path actually runs rather than short-circuiting).
// visible, the R item is not), a French profile fallback, and Norwegian
// original-language metadata for Norwegian titles. This forces the batch
// path to group one page into two localization targets.
filter := AccessFilter{
PresentationLanguage: "fr",
MaxContentRating: "PG-13",
UserID: 1,
ProfileID: "profile-1",
ProfilePreferredLanguage: "fr",
MetadataLanguageOverrides: map[string]string{
"no": access.OriginalMetadataLanguage,
},
MaxContentRating: "PG-13",
UserID: 1,
ProfileID: "profile-1",
}
visibleIDs := []string{movieA, movieB, series}
@@ -306,8 +312,8 @@ func TestGetItemDetailsByIDs_MatchesGetItemDetail(t *testing.T) {
if got := batch[movieA]; len(got.Versions) != 2 {
t.Fatalf("movieA versions = %d, want 2 (file prefetch not applied)", len(got.Versions))
}
if got := batch[movieB]; got.PendingTranslationLanguage != "fr" {
t.Fatalf("movieB pending translation language = %q, want fr", got.PendingTranslationLanguage)
if got := batch[movieB]; got.PendingTranslationLanguage != "no" {
t.Fatalf("movieB pending translation language = %q, want no", got.PendingTranslationLanguage)
}
if got := batch[series]; got.WorkID != "work-series-1" {
t.Fatalf("series work summary not applied: WorkID=%q", got.WorkID)
@@ -0,0 +1,111 @@
package catalog
import (
"context"
"testing"
"github.com/Silo-Server/silo-server/internal/access"
)
func TestSeriesOriginalLanguagesUsesSuppliedParentContext(t *testing.T) {
svc := &DetailService{}
got, err := svc.seriesOriginalLanguages(
context.Background(),
[]string{"series-a", "series-a", "", "series-b"},
AccessFilter{PresentationOriginalLanguage: "no"},
)
if err != nil {
t.Fatalf("seriesOriginalLanguages: %v", err)
}
want := map[string]string{"series-a": "no", "series-b": "no"}
if len(got) != len(want) {
t.Fatalf("original languages = %#v, want %#v", got, want)
}
for seriesID, language := range want {
if got[seriesID] != language {
t.Errorf("original language for %q = %q, want %q", seriesID, got[seriesID], language)
}
}
}
func TestPresentationLanguageForOriginal(t *testing.T) {
tests := []struct {
name string
base presentationLanguageBase
original string
filter AccessFilter
want string
}{
{
name: "fixed fallback",
base: presentationLanguageBase{target: "en"},
original: "ja",
want: "en",
},
{
name: "source stays original",
base: presentationLanguageBase{target: "en"},
original: "nor",
filter: AccessFilter{MetadataLanguageOverrides: map[string]string{
"no": access.OriginalMetadataLanguage,
}},
want: "no",
},
{
name: "source maps to another target",
base: presentationLanguageBase{target: "en"},
original: "ja",
filter: AccessFilter{MetadataLanguageOverrides: map[string]string{
"ja": "de",
}},
want: "de",
},
{
name: "global original",
base: presentationLanguageBase{target: access.OriginalMetadataLanguage},
original: "jpn",
want: "ja",
},
{
name: "unknown original inherits library",
base: presentationLanguageBase{
target: access.OriginalMetadataLanguage,
libraryFallback: "fr",
},
want: "fr",
},
{
name: "explicit target bypasses exceptions",
base: presentationLanguageBase{target: "fr", explicit: true},
original: "no",
filter: AccessFilter{MetadataLanguageOverrides: map[string]string{
"no": access.OriginalMetadataLanguage,
}},
want: "fr",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := presentationLanguageForOriginal(tt.base, tt.original, tt.filter); got != tt.want {
t.Fatalf("presentation language = %q, want %q", got, tt.want)
}
})
}
}
func TestMetadataLanguageMayUseOriginalHonorsExplicitTarget(t *testing.T) {
filter := AccessFilter{
PresentationLanguage: "fr",
ProfilePreferredLanguage: access.OriginalMetadataLanguage,
MetadataLanguageOverrides: map[string]string{"no": access.OriginalMetadataLanguage},
}
if metadataLanguageMayUseOriginal(filter) {
t.Fatal("concrete explicit language must bypass profile original-language preferences")
}
filter.PresentationLanguage = access.OriginalMetadataLanguage
if !metadataLanguageMayUseOriginal(filter) {
t.Fatal("explicit original-language target must resolve the parent language")
}
}
+8 -7
View File
@@ -43,13 +43,14 @@ func NewScopeAccessFilter(resolver ScopeResolver) AccessFilterResolver {
}
}
return catalog.AccessFilter{
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
UserID: userID,
ProfileID: profileID,
AllowedLibraryIDs: scope.AllowedLibraryIDs,
DisabledLibraryIDs: scope.DisabledLibraryIDs,
MaxContentRating: scope.MaxContentRating,
MaxPlaybackQuality: scope.MaxPlaybackQuality,
ProfilePreferredLanguage: scope.PreferredMetadataLanguage,
MetadataLanguageOverrides: scope.MetadataLanguageOverrides,
UserID: userID,
ProfileID: profileID,
}
}
}
+16 -6
View File
@@ -24,12 +24,16 @@ func (s *stubScopeResolver) Resolve(_ context.Context, input access.ResolveInput
func TestScopeAccessFilterMapsScope(t *testing.T) {
resolver := &stubScopeResolver{
scope: access.Scope{
UserID: 7,
ProfileID: "profile-1",
AllowedLibraryIDs: []int{2, 19},
LibrariesRestricted: true,
MaxContentRating: "PG-13",
MaxPlaybackQuality: "1080p",
UserID: 7,
ProfileID: "profile-1",
AllowedLibraryIDs: []int{2, 19},
LibrariesRestricted: true,
MaxContentRating: "PG-13",
MaxPlaybackQuality: "1080p",
PreferredMetadataLanguage: "fr",
MetadataLanguageOverrides: map[string]string{
"no": access.OriginalMetadataLanguage,
},
},
}
@@ -50,6 +54,12 @@ func TestScopeAccessFilterMapsScope(t *testing.T) {
if filter.MaxPlaybackQuality != "1080p" {
t.Fatalf("MaxPlaybackQuality = %q, want 1080p", filter.MaxPlaybackQuality)
}
if filter.ProfilePreferredLanguage != "fr" {
t.Fatalf("ProfilePreferredLanguage = %q, want fr", filter.ProfilePreferredLanguage)
}
if got := filter.MetadataLanguageOverrides["no"]; got != access.OriginalMetadataLanguage {
t.Fatalf("MetadataLanguageOverrides[no] = %q, want %q", got, access.OriginalMetadataLanguage)
}
if filter.UserID != 7 || filter.ProfileID != "profile-1" {
t.Fatalf("identity not propagated: %+v", filter)
}
+10 -10
View File
@@ -835,14 +835,14 @@ func (s *directContentService) ListSeasons(ctx context.Context, session *Session
progressMap := s.batchProgressForEpisodes(ctx, session, allEpisodeIDs)
if len(seasons) > 0 {
if s.detailSvc != nil {
if localized, locErr := s.detailSvc.LocalizeSeasonModels(ctx, seasons, filter); locErr == nil && len(localized) == len(seasons) {
seasons = localized
}
}
result := make([]upstreamSeason, 0, len(seasons))
for _, season := range seasons {
eps := groupedEpisodes[season.SeasonNumber]
if s.detailSvc != nil {
if localized, locErr := s.detailSvc.LocalizeSeasonModel(ctx, season, filter); locErr == nil && localized != nil {
season = localized
}
}
us := modelSeasonToUpstream(season, len(eps))
applySeasonUserData(&us, eps, progressMap)
result = append(result, us)
@@ -922,14 +922,14 @@ func (s *directContentService) ListEpisodes(ctx context.Context, session *Sessio
progressMap = progressEntries
}
}
if s.detailSvc != nil {
if localized, locErr := s.detailSvc.LocalizeEpisodeModels(ctx, episodes, filter); locErr == nil && len(localized) == len(episodes) {
episodes = localized
}
}
result := make([]upstreamEpisode, 0, len(episodes))
for _, ep := range episodes {
if s.detailSvc != nil {
if localized, locErr := s.detailSvc.LocalizeEpisodeModel(ctx, ep, filter); locErr == nil && localized != nil {
ep = localized
}
}
ue := modelEpisodeToUpstream(ep, seriesID)
if progress, ok := progressMap[ep.ContentID]; ok {
progressCopy := progress
+1
View File
@@ -148,6 +148,7 @@ func (r *ViewerResolver) Resolve(ctx context.Context, input access.ResolveInput)
MaxContentRating: decision.MaxContentRating,
MaxPlaybackQuality: decision.MaxPlaybackQuality,
PreferredMetadataLanguage: decision.PreferredMetadataLanguage,
MetadataLanguageOverrides: preferences.MetadataLanguageOverrides,
PolicyRevision: user.AccessPolicyRevision,
// The policy output is tighten-only (merged_profile_verified), so a
// custom override may revoke verification but never grant it. ANDing
+10
View File
@@ -525,6 +525,13 @@ func TestViewerResolverBatchesViewerPreferenceRead(t *testing.T) {
},
Value: json.RawMessage(`"de"`),
},
{
SettingIdentity: userstore.SettingIdentity{
Key: settingskeys.CatalogMetadataLanguageOverrides, Scope: settingscontract.ScopeProfile,
ProfileID: "prof-1",
},
Value: json.RawMessage(`{"no":"x-silo-original"}`),
},
},
}}
resolver := NewViewerResolver(
@@ -542,6 +549,9 @@ func TestViewerResolverBatchesViewerPreferenceRead(t *testing.T) {
if !reflect.DeepEqual(scope.DisabledLibraryIDs, []int{3, 5}) || scope.PreferredMetadataLanguage != "de" {
t.Errorf("resolved scope = %#v", scope)
}
if got := scope.MetadataLanguageOverrides["no"]; got != access.OriginalMetadataLanguage {
t.Errorf("metadata language override = %q, want %q", got, access.OriginalMetadataLanguage)
}
}
func (s viewerResolverTestStore) GetProfile(_ context.Context, id string) (*userstore.Profile, error) {
+10 -9
View File
@@ -1527,11 +1527,10 @@ func TestLibraryPageStateAcceptsTheWebClientsRealSearchStrings(t *testing.T) {
// TestObjectSchemasAcceptTheShapesClientsStore exercises every schema_ref
// against a real value.
//
// Nothing else does. Each of these definitions is nullable with a null default,
// so TestDefaultsValidateAgainstTheirOwnSchema returns at the null branch
// without ever compiling the reference — a schema_ref naming a file that does
// not exist, or a schema that rejects the shape its client actually stores,
// would pass every other test in this file.
// Defaults alone are insufficient: several definitions are nullable and stop
// at the null branch, while an empty-object default does not exercise dynamic
// properties. These real client shapes make every referenced schema prove its
// actual value vocabulary.
func TestObjectSchemasAcceptTheShapesClientsStore(t *testing.T) {
manifest, err := Load()
if err != nil {
@@ -1539,6 +1538,7 @@ func TestObjectSchemasAcceptTheShapesClientsStore(t *testing.T) {
}
valid := map[string]string{
"catalog.metadata_language_overrides": `{"ja":"en","no":"x-silo-original"}`,
"ui.card_overlays": `{"version":2,"preset":"minimal","order":["hdr","year"],` +
`"items":{"hdr":{"enabled":true,"position":"top-left","accentColor":"#f5c518"},` +
`"year":{"enabled":false,"position":"bottom-right","showIcon":true}}}`,
@@ -1569,10 +1569,11 @@ func TestObjectSchemasAcceptTheShapesClientsStore(t *testing.T) {
// Each schema must actually constrain something, or it is decoration.
invalid := map[string]string{
"ui.card_overlays": `{"version":2,"preset":"nonesuch","order":[],"items":{}}`,
"ui.sidebar_pins": `{"7":[{"type":"bogus","id":"x","label":"L"}]}`,
"ui.disabled_library_ids": `[0,-1]`,
"ui.library_order": `[1,1]`,
"catalog.metadata_language_overrides": `{"Norwegian":"not a language tag"}`,
"ui.card_overlays": `{"version":2,"preset":"nonesuch","order":[],"items":{}}`,
"ui.sidebar_pins": `{"7":[{"type":"bogus","id":"x","label":"L"}]}`,
"ui.disabled_library_ids": `[0,-1]`,
"ui.library_order": `[1,1]`,
}
for key, value := range invalid {
t.Run(key+" rejects", func(t *testing.T) {
+4 -1
View File
@@ -9,12 +9,14 @@
package settingskeys
// Revision is the manifest revision these bindings were generated from.
const Revision = 2
const Revision = 3
// Setting keys, one constant per definition.
const (
// Metadata language
CatalogMetadataLanguage = "catalog.metadata_language"
// Metadata language exceptions
CatalogMetadataLanguageOverrides = "catalog.metadata_language_overrides"
// Download quality
DownloadsDefaultQuality = "downloads.default_quality"
// Keep watched downloads
@@ -116,6 +118,7 @@ const (
// Remote lists every key the server stores.
var Remote = []string{
CatalogMetadataLanguage,
CatalogMetadataLanguageOverrides,
PlaybackAudioLanguage,
PlaybackAutoPlayNext,
PlaybackAutoPlayNextPreview,