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