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
+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)
}
}