Files
memby/server/internal/bazarr/client.go
T
ponzischeme89andClaude Opus 5 4a4df7a73c 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>
2026-08-06 22:33:56 +12:00

318 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package bazarr provides the slice of Bazarr Memby uses to fetch a subtitle a title does
// not have yet.
//
// Bazarr is the subtitle companion to Sonarr and Radarr, which the gateway already talks
// to, and it is the reason this feature needs no storage of its own: Bazarr writes the
// subtitle file beside the media file, so Emby finds it on the next refresh and the track
// arrives through the same PlaybackInfo path as every other subtitle. Memby never holds a
// subtitle, never serves one, and never learns a provider's credentials.
//
// Two things about the API are worth stating because they are not obvious and both would
// otherwise be discovered as a bug. Bazarr keys everything on the *arr's id, not its own:
// a movie is a `radarrid` and an episode is an `episodeid` from Sonarr, which is why
// resolving an Emby item to one of them is a real step rather than a lookup. And the
// manual-search result rows are opaque — the `subtitle` field is a provider-specific token
// that has to be handed back verbatim on the download call, so nothing here parses or
// reconstructs it.
package bazarr
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
type Client struct {
baseURL string
apiKey string
http *http.Client
}
// Movie is one row of Bazarr's movie list. Only the fields Memby matches an Emby item
// against, plus the id it needs afterwards, are decoded.
type Movie struct {
RadarrID int `json:"radarrId"`
Title string `json:"title"`
Year string `json:"year"`
Path string `json:"path"`
}
type Series struct {
SonarrSeriesID int `json:"sonarrSeriesId"`
Title string `json:"title"`
Year string `json:"year"`
}
type Episode struct {
SonarrEpisodeID int `json:"sonarrEpisodeId"`
SonarrSeriesID int `json:"sonarrSeriesId"`
Season int `json:"season"`
Episode int `json:"episode"`
Title string `json:"title"`
}
// Subtitle is one candidate from a manual search.
//
// Score is Bazarr's own 0100 confidence that the subtitle matches this exact release, and
// it is the only thing on the row a viewer can sensibly choose by — provider names and
// release strings mean nothing on a television. Token is the opaque value the download
// call needs; it is never interpreted here.
type Subtitle struct {
Language string `json:"language"`
Forced bool `json:"forced"`
HearingImpaired bool `json:"hearing_impaired"`
Provider string `json:"provider"`
Token string `json:"subtitle"`
Score int `json:"score"`
ReleaseInfo []string `json:"release_info"`
Uploader string `json:"uploader"`
OriginalFormat bool `json:"original_format"`
}
// hearingImpaired and forced arrive from Bazarr as either a bool or one of its
// "True"/"False"/"" strings depending on the endpoint and version. Decoding them as
// json.RawMessage and folding here is what stops a version bump from silently turning
// every result into a non-forced one.
func (s *Subtitle) UnmarshalJSON(data []byte) error {
type raw struct {
Language json.RawMessage `json:"language"`
Forced json.RawMessage `json:"forced"`
HearingImpaired json.RawMessage `json:"hearing_impaired"`
Provider string `json:"provider"`
Token string `json:"subtitle"`
Score int `json:"score"`
ReleaseInfo []string `json:"release_info"`
Uploader string `json:"uploader"`
OriginalFormat json.RawMessage `json:"original_format"`
}
var decoded raw
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
s.Language = flexibleLanguage(decoded.Language)
s.Forced = flexibleBool(decoded.Forced)
s.HearingImpaired = flexibleBool(decoded.HearingImpaired)
s.Provider = decoded.Provider
s.Token = decoded.Token
s.Score = decoded.Score
s.ReleaseInfo = decoded.ReleaseInfo
s.Uploader = decoded.Uploader
s.OriginalFormat = flexibleBool(decoded.OriginalFormat)
return nil
}
// flexibleBool accepts true, "True", "true" and "1" as true, and treats anything else —
// including an absent field — as false.
func flexibleBool(raw json.RawMessage) bool {
if len(raw) == 0 {
return false
}
var asBool bool
if json.Unmarshal(raw, &asBool) == nil {
return asBool
}
var asString string
if json.Unmarshal(raw, &asString) != nil {
return false
}
switch strings.ToLower(strings.TrimSpace(asString)) {
case "true", "1", "yes":
return true
default:
return false
}
}
// flexibleLanguage takes the code out of either shape Bazarr uses for a language: a plain
// string, or an object carrying `code2`/`code3` and a name.
func flexibleLanguage(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var asString string
if json.Unmarshal(raw, &asString) == nil {
return strings.TrimSpace(asString)
}
var asObject struct {
Code2 string `json:"code2"`
Code3 string `json:"code3"`
Name string `json:"name"`
}
if json.Unmarshal(raw, &asObject) != nil {
return ""
}
for _, candidate := range []string{asObject.Code2, asObject.Code3, asObject.Name} {
if value := strings.TrimSpace(candidate); value != "" {
return value
}
}
return ""
}
type APIError struct {
StatusCode int
Body string
}
func (e *APIError) Error() string {
return fmt.Sprintf("bazarr: status %d: %s", e.StatusCode, e.Body)
}
func New(baseURL, apiKey string, timeout time.Duration) *Client {
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
http: &http.Client{
Timeout: timeout,
Transport: &http.Transport{
MaxIdleConns: 10,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
// Movies returns every movie Bazarr manages. Bazarr pages this endpoint, and asking for
// everything in one call is deliberate: the result is cached by the caller for minutes at
// a time, and walking pages would multiply that one cold request by the size of a library.
func (c *Client) Movies(ctx context.Context) ([]Movie, error) {
var payload struct {
Data []Movie `json:"data"`
}
if err := c.get(ctx, "/api/movies", url.Values{"length": {"-1"}}, &payload); err != nil {
return nil, err
}
return payload.Data, nil
}
func (c *Client) Series(ctx context.Context) ([]Series, error) {
var payload struct {
Data []Series `json:"data"`
}
if err := c.get(ctx, "/api/series", url.Values{"length": {"-1"}}, &payload); err != nil {
return nil, err
}
return payload.Data, nil
}
func (c *Client) Episodes(ctx context.Context, seriesID int) ([]Episode, error) {
var payload struct {
Data []Episode `json:"data"`
}
params := url.Values{"seriesid[]": {strconv.Itoa(seriesID)}}
if err := c.get(ctx, "/api/episodes", params, &payload); err != nil {
return nil, err
}
return payload.Data, nil
}
// SearchMovie runs Bazarr's manual search for a film. It is a live provider query, so it
// is slow — seconds, not milliseconds — which is why the television is told to expect a
// wait rather than being left with a spinner that looks stuck.
func (c *Client) SearchMovie(ctx context.Context, radarrID int) ([]Subtitle, error) {
return c.search(ctx, "/api/providers/movies", url.Values{"radarrid": {strconv.Itoa(radarrID)}})
}
func (c *Client) SearchEpisode(ctx context.Context, episodeID int) ([]Subtitle, error) {
return c.search(ctx, "/api/providers/episodes", url.Values{"episodeid": {strconv.Itoa(episodeID)}})
}
func (c *Client) search(ctx context.Context, path string, params url.Values) ([]Subtitle, error) {
var subtitles []Subtitle
if err := c.get(ctx, path, params, &subtitles); err != nil {
return nil, err
}
return subtitles, nil
}
// DownloadMovie tells Bazarr to fetch one of the search results and write it beside the
// film. The token is handed back exactly as it arrived.
func (c *Client) DownloadMovie(ctx context.Context, radarrID int, subtitle Subtitle) error {
return c.download(ctx, "/api/providers/movies", url.Values{
"radarrid": {strconv.Itoa(radarrID)},
}, subtitle)
}
func (c *Client) DownloadEpisode(ctx context.Context, seriesID, episodeID int, subtitle Subtitle) error {
return c.download(ctx, "/api/providers/episodes", url.Values{
"seriesid": {strconv.Itoa(seriesID)},
"episodeid": {strconv.Itoa(episodeID)},
}, subtitle)
}
func (c *Client) download(ctx context.Context, path string, target url.Values, subtitle Subtitle) error {
form := url.Values{}
for key, values := range target {
form[key] = values
}
form.Set("language", subtitle.Language)
form.Set("hi", strconv.FormatBool(subtitle.HearingImpaired))
form.Set("forced", strconv.FormatBool(subtitle.Forced))
form.Set("original_format", strconv.FormatBool(subtitle.OriginalFormat))
form.Set("provider", subtitle.Provider)
form.Set("subtitle", subtitle.Token)
req, err := http.NewRequestWithContext(
ctx, http.MethodPost, c.baseURL+path, strings.NewReader(form.Encode()),
)
if err != nil {
return err
}
req.Header.Set("X-API-KEY", c.apiKey)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
// Bazarr answers a successful download with an empty body, so there is nothing to
// decode — only a status to check.
return c.do(req, nil)
}
// Ping is the reachability check the admin console and startup use. Bazarr's status
// endpoint needs no arguments and returns quickly even when providers are slow.
func (c *Client) Ping(ctx context.Context) error {
var payload struct {
Data map[string]any `json:"data"`
}
return c.get(ctx, "/api/system/status", nil, &payload)
}
func (c *Client) get(ctx context.Context, path string, params url.Values, out any) error {
endpoint := c.baseURL + path
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("X-API-KEY", c.apiKey)
req.Header.Set("Accept", "application/json")
return c.do(req, out)
}
func (c *Client) do(req *http.Request, out any) error {
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("bazarr: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
return &APIError{StatusCode: resp.StatusCode, Body: strings.TrimSpace(string(body))}
}
if out == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("bazarr: decode response: %w", err)
}
return nil
}