Files
2026-08-17 07:34:23 +12:00

196 lines
8.2 KiB
Go

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