package api import ( "context" "encoding/json" "net/http" "strings" "github.com/ponzischeme89/memby/server/internal/store" ) // The console's half of subtitle downloads. // // The operator has two providers to choose between and they need different things said // about them. Bazarr is a service the household already runs, so the console can only turn // it on or off — its address is deployment configuration and stays an environment // variable. OpenSubtitles is an account, so its credentials live here and can be entered, // replaced or removed without a redeployment. // // Nothing on this page ever returns a credential. The console is told whether a key is // saved and whether an account is attached, which is what an operator needs to answer // "why is this not working", and never the values themselves — the stance the MDBList page // already takes. // subtitleAdminSettings is the page's whole view of the policy. type subtitleAdminSettings struct { // BazarrConfigured is whether this deployment has a Bazarr at all. It is separate from // BazarrEnabled so the page can say "no address configured" rather than drawing a // switch that would do nothing. BazarrConfigured bool `json:"bazarrConfigured"` BazarrEnabled bool `json:"bazarrEnabled"` BazarrURL string `json:"bazarrUrl,omitempty"` OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"` OpenSubtitlesKeyConfigured bool `json:"openSubtitlesKeyConfigured"` // OpenSubtitlesAccount is whether a username and password are saved. It matters more // than it looks: without one, downloads go against the anonymous allowance, which is a // handful of files a day and fails in front of a television rather than in a log. OpenSubtitlesAccount bool `json:"openSubtitlesAccount"` OpenSubtitlesUsername string `json:"openSubtitlesUsername,omitempty"` // FeatureEnabled is the `subtitle_download` flag. It is reported here because it // overrides both providers, and an operator who has turned it off on the features page // should not have to guess why these switches do nothing. FeatureEnabled bool `json:"featureEnabled"` // Available is the answer a television gets: the feature is on and at least one // provider can be asked. Available bool `json:"available"` Stored store.DownloadedSubtitleStats `json:"stored"` } func (s *Server) subtitleAdminSettings(ctx context.Context) subtitleAdminSettings { policy := s.subtitlePolicy(ctx) sources := s.subtitleSources(ctx) settings := subtitleAdminSettings{ BazarrConfigured: s.bazarr != nil, BazarrEnabled: policy.BazarrEnabled, BazarrURL: s.cfg.BazarrURL, OpenSubtitlesEnabled: policy.OpenSubtitlesEnabled, OpenSubtitlesKeyConfigured: policy.OpenSubtitlesAPIKey != "", OpenSubtitlesAccount: policy.OpenSubtitlesUsername != "" && policy.OpenSubtitlesPassword != "", OpenSubtitlesUsername: policy.OpenSubtitlesUsername, FeatureEnabled: s.featureEnabled(ctx, featureSubtitleDownload), Available: sources.any(), } if stats, err := s.store.DownloadedSubtitleStats(ctx); err == nil { settings.Stored = stats } else { s.loggerFor(ctx).Warn("downloaded subtitle stats failed", "error", err) } return settings } type subtitleSettingsRequest struct { Action string `json:"action"` BazarrEnabled bool `json:"bazarrEnabled"` OpenSubtitlesEnabled bool `json:"openSubtitlesEnabled"` // A blank key keeps whatever is saved, so an operator changing one switch does not // have to paste a credential back in to do it. Clearing is its own flag, because // "leave it alone" and "remove it" cannot both be the empty string. OpenSubtitlesAPIKey string `json:"openSubtitlesApiKey"` ClearOpenSubtitlesAPIKey bool `json:"clearOpenSubtitlesApiKey"` OpenSubtitlesUsername string `json:"openSubtitlesUsername"` OpenSubtitlesPassword string `json:"openSubtitlesPassword"` ClearOpenSubtitlesLogin bool `json:"clearOpenSubtitlesLogin"` } func (s *Server) handleAdminSubtitleSettings(w http.ResponseWriter, r *http.Request) { var req subtitleSettingsRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "malformed request body") return } ctx := r.Context() // Emptying the store is the page's one destructive control, and it is safe in the way // a cache purge is: every file can be fetched again, at the cost of the provider // allowance that fetched it. It is a separate action rather than a checkbox on the // save, so it cannot happen as a side effect of changing a switch. if strings.TrimSpace(req.Action) == "clear-stored" { removed, err := s.store.ClearDownloadedSubtitles(ctx) if err != nil { s.loggerFor(ctx).Error("clearing stored subtitles failed", "error", err) writeError(w, http.StatusInternalServerError, "could not clear stored subtitles") return } s.loggerFor(ctx).Info("stored subtitles cleared", "removed", removed) writeJSON(w, http.StatusOK, s.subtitleAdminSettings(ctx)) return } current, err := s.store.SubtitlePolicy(ctx) if err != nil { writeError(w, http.StatusInternalServerError, "could not read subtitle settings") return } next := store.SubtitlePolicy{ BazarrEnabled: req.BazarrEnabled, OpenSubtitlesEnabled: req.OpenSubtitlesEnabled, OpenSubtitlesAPIKey: current.OpenSubtitlesAPIKey, OpenSubtitlesUsername: current.OpenSubtitlesUsername, OpenSubtitlesPassword: current.OpenSubtitlesPassword, } if req.ClearOpenSubtitlesAPIKey { next.OpenSubtitlesAPIKey = "" } else if replacement := strings.TrimSpace(req.OpenSubtitlesAPIKey); replacement != "" { next.OpenSubtitlesAPIKey = replacement } if req.ClearOpenSubtitlesLogin { next.OpenSubtitlesUsername, next.OpenSubtitlesPassword = "", "" } else if username := strings.TrimSpace(req.OpenSubtitlesUsername); username != "" { next.OpenSubtitlesUsername = username // The password only moves when one was typed. Changing a username without // retyping the password is an ordinary edit, and the field is blank on every load. if password := req.OpenSubtitlesPassword; password != "" { next.OpenSubtitlesPassword = password } } if err := s.store.SetSubtitlePolicy(ctx, next); err != nil { s.loggerFor(ctx).Error("subtitle policy write failed", "error", err) writeError(w, http.StatusInternalServerError, "could not save subtitle settings") return } s.loggerFor(ctx).Info("subtitle providers changed", "bazarr", next.BazarrEnabled, "opensubtitles", next.OpenSubtitlesEnabled, "opensubtitles_account", next.OpenSubtitlesUsername != "", ) writeJSON(w, http.StatusOK, s.subtitleAdminSettings(ctx)) } // handleAdminSubtitleTest asks each enabled provider whether it is actually reachable. // // It exists because every other symptom of a wrong key looks identical from a television: // the search comes back empty. One button that says "the key is rejected" is the whole // difference between a five-minute fix and an evening of guessing. func (s *Server) handleAdminSubtitleTest(w http.ResponseWriter, r *http.Request) { if s.rejectWorkDuringQuietTime(w) { return } ctx := r.Context() type probe struct { Provider string `json:"provider"` OK bool `json:"ok"` Message string `json:"message"` } results := []probe{} if s.bazarr != nil { result := probe{Provider: "Bazarr", OK: true, Message: "Reachable."} if err := s.bazarr.Ping(ctx); err != nil { result.OK, result.Message = false, "Did not answer: "+err.Error() } results = append(results, result) } if client := s.openSubtitlesClient(ctx); client != nil { result := probe{Provider: "OpenSubtitles", OK: true} if err := client.Ping(ctx); err != nil { result.OK, result.Message = false, "Did not answer: "+err.Error() } else if client.HasAccount() { result.Message = "Reachable, signed in." } else { // Worth saying rather than reporting a plain success: an anonymous key works // perfectly for searching and runs out after a few downloads, which is the // failure this page exists to make findable. result.Message = "Reachable, but with no account — downloads use the small anonymous allowance." } results = append(results, result) } writeJSON(w, http.StatusOK, map[string]any{"results": results}) }