Client: seek controls, Bazarr subtitle download and cast panel in the player; MDBList ratings strip; episode and schedule detail pages; series pace estimate; what's new panel; install-permission onboarding step; synced per-profile preferences; Emby outage banner. Gateway: rebuilt admin console (one fragment per page), preference history and restore, merged Continue Watching, Emby health probe, subtitle selection and Bazarr download, structured request logging with per-request identity, and embedded build version. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
168 lines
6.8 KiB
Go
168 lines
6.8 KiB
Go
package api
|
|
|
|
import "strings"
|
|
|
|
// Which subtitle track a viewer gets, decided here rather than on the television.
|
|
//
|
|
// The gateway is already the thing that asks Emby what tracks exist — it calls
|
|
// PlaybackInfo and enumerates the streams — so it is also the thing that should say which
|
|
// of them to turn on. The television used to guess (forced, then default, then whatever was
|
|
// first), which meant the answer depended on which set somebody happened to be watching on,
|
|
// and turning subtitles off in the bedroom taught the living room nothing.
|
|
//
|
|
// The preference itself lives in the synced settings document (`subtitlesEnabled`,
|
|
// `subtitleLanguage`), so it follows the person rather than the box, and an operator can
|
|
// read and push it from the accounts page like any other setting.
|
|
|
|
// subtitleLanguage is one entry in the vocabulary offered for `subtitleLanguage`.
|
|
//
|
|
// Value is ISO 639-1 because that is what a media3 track reports after normalisation;
|
|
// aliases are the ISO 639-2 forms Emby actually puts in a stream's Language field, which
|
|
// for several languages is two different three-letter codes (bibliographic and
|
|
// terminological) for the same thing.
|
|
type subtitleLanguage struct {
|
|
Value string
|
|
Label string
|
|
Aliases []string
|
|
}
|
|
|
|
var subtitleLanguages = []subtitleLanguage{
|
|
{Value: "en", Label: "English", Aliases: []string{"eng"}},
|
|
{Value: "it", Label: "Italian", Aliases: []string{"ita"}},
|
|
{Value: "es", Label: "Spanish", Aliases: []string{"spa", "esp"}},
|
|
{Value: "fr", Label: "French", Aliases: []string{"fre", "fra"}},
|
|
{Value: "de", Label: "German", Aliases: []string{"ger", "deu"}},
|
|
{Value: "pt", Label: "Portuguese", Aliases: []string{"por"}},
|
|
{Value: "nl", Label: "Dutch", Aliases: []string{"dut", "nld"}},
|
|
{Value: "sv", Label: "Swedish", Aliases: []string{"swe"}},
|
|
{Value: "da", Label: "Danish", Aliases: []string{"dan"}},
|
|
{Value: "no", Label: "Norwegian", Aliases: []string{"nor", "nob", "nno"}},
|
|
{Value: "fi", Label: "Finnish", Aliases: []string{"fin"}},
|
|
{Value: "pl", Label: "Polish", Aliases: []string{"pol"}},
|
|
{Value: "cs", Label: "Czech", Aliases: []string{"cze", "ces"}},
|
|
{Value: "hu", Label: "Hungarian", Aliases: []string{"hun"}},
|
|
{Value: "ro", Label: "Romanian", Aliases: []string{"rum", "ron"}},
|
|
{Value: "el", Label: "Greek", Aliases: []string{"gre", "ell"}},
|
|
{Value: "ru", Label: "Russian", Aliases: []string{"rus"}},
|
|
{Value: "uk", Label: "Ukrainian", Aliases: []string{"ukr"}},
|
|
{Value: "tr", Label: "Turkish", Aliases: []string{"tur"}},
|
|
{Value: "ar", Label: "Arabic", Aliases: []string{"ara"}},
|
|
{Value: "he", Label: "Hebrew", Aliases: []string{"heb", "iw"}},
|
|
{Value: "hi", Label: "Hindi", Aliases: []string{"hin"}},
|
|
{Value: "ja", Label: "Japanese", Aliases: []string{"jpn"}},
|
|
{Value: "ko", Label: "Korean", Aliases: []string{"kor"}},
|
|
{Value: "zh", Label: "Chinese", Aliases: []string{"chi", "zho", "cmn", "yue"}},
|
|
{Value: "th", Label: "Thai", Aliases: []string{"tha"}},
|
|
{Value: "vi", Label: "Vietnamese", Aliases: []string{"vie"}},
|
|
}
|
|
|
|
// subtitleLanguageAuto means "no preference": keep the old flag-driven behaviour, which is
|
|
// what somebody who has never opened the subtitle menu should still get.
|
|
const subtitleLanguageAuto = "auto"
|
|
|
|
func subtitleLanguageOptions() []preferenceOption {
|
|
options := make([]preferenceOption, 0, len(subtitleLanguages)+1)
|
|
options = append(options, option(subtitleLanguageAuto, "Automatic"))
|
|
for _, language := range subtitleLanguages {
|
|
options = append(options, option(language.Value, language.Label))
|
|
}
|
|
return options
|
|
}
|
|
|
|
// normalizeSubtitleLanguage folds a stream's language onto the catalogue's vocabulary.
|
|
// Anything unrecognised comes back as the lowercased original rather than empty, so an
|
|
// exact string match on a language Memby has no entry for still works.
|
|
func normalizeSubtitleLanguage(raw string) string {
|
|
value := strings.ToLower(strings.TrimSpace(raw))
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
// Emby writes plain codes, but a region tag ("pt-BR") or a script suffix is possible.
|
|
if cut := strings.IndexAny(value, "-_"); cut > 0 {
|
|
value = value[:cut]
|
|
}
|
|
for _, language := range subtitleLanguages {
|
|
if language.Value == value {
|
|
return language.Value
|
|
}
|
|
for _, alias := range language.Aliases {
|
|
if alias == value {
|
|
return language.Value
|
|
}
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
// selectSubtitle returns the id of the track to turn on, or "" for none.
|
|
//
|
|
// The rules, in order, and each one is there for a reason worth keeping:
|
|
//
|
|
// - Subtitles off means off. Nothing else is consulted.
|
|
// - A chosen language wins, and among tracks in it a plain full track beats a forced or
|
|
// hearing-impaired one — somebody who picked Italian wants the dialogue, not the
|
|
// signs-only track that happens to sort first.
|
|
// - A chosen language that the title does not have falls back to a *forced* track only.
|
|
// Forced subtitles carry the parts of a film that are foreign to its own audio, so they
|
|
// are wanted regardless; falling through to English instead would put a language the
|
|
// viewer did not ask for on screen.
|
|
// - With no language chosen, the old behaviour stands: forced, then default, then the
|
|
// first track. That is what an install that has never touched the setting keeps.
|
|
func selectSubtitle(subtitles []playableSubtitle, enabled bool, language string) string {
|
|
if !enabled || len(subtitles) == 0 {
|
|
return ""
|
|
}
|
|
preferred := normalizeSubtitleLanguage(language)
|
|
if preferred != "" && preferred != subtitleLanguageAuto {
|
|
if match := bestSubtitleInLanguage(subtitles, preferred); match != "" {
|
|
return match
|
|
}
|
|
return firstSubtitleWhere(subtitles, func(s playableSubtitle) bool { return s.IsForced })
|
|
}
|
|
if forced := firstSubtitleWhere(
|
|
subtitles, func(s playableSubtitle) bool { return s.IsForced },
|
|
); forced != "" {
|
|
return forced
|
|
}
|
|
if fallback := firstSubtitleWhere(
|
|
subtitles, func(s playableSubtitle) bool { return s.IsDefault },
|
|
); fallback != "" {
|
|
return fallback
|
|
}
|
|
return subtitles[0].ID
|
|
}
|
|
|
|
// bestSubtitleInLanguage ranks the tracks in one language: a plain track, then a default
|
|
// one, then hearing-impaired, then forced. Forced sorts last here precisely because it is
|
|
// not a substitute for a full track in the language somebody asked for.
|
|
func bestSubtitleInLanguage(subtitles []playableSubtitle, language string) string {
|
|
best, bestRank := "", 0
|
|
for _, subtitle := range subtitles {
|
|
if normalizeSubtitleLanguage(subtitle.Language) != language {
|
|
continue
|
|
}
|
|
rank := 4
|
|
switch {
|
|
case subtitle.IsForced:
|
|
rank = 1
|
|
case subtitle.IsHearingImpaired:
|
|
rank = 2
|
|
case subtitle.IsDefault:
|
|
rank = 5
|
|
}
|
|
if rank > bestRank {
|
|
best, bestRank = subtitle.ID, rank
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func firstSubtitleWhere(subtitles []playableSubtitle, match func(playableSubtitle) bool) string {
|
|
for _, subtitle := range subtitles {
|
|
if match(subtitle) {
|
|
return subtitle.ID
|
|
}
|
|
}
|
|
return ""
|
|
}
|