App v0.2.26 and gateway 0.1.20

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>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+167
View File
@@ -0,0 +1,167 @@
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 ""
}