// 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" "github.com/ponzischeme89/memby/server/internal/timing" ) 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 0–100 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: timing.Instrument(&http.Client{ Timeout: timeout, Transport: &http.Transport{ MaxIdleConns: 10, MaxIdleConnsPerHost: 5, IdleConnTimeout: 90 * time.Second, }, }, timing.StageBazarr), } } // 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 }