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:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -0,0 +1,317 @@
|
||||
// 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 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: &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
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package bazarr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSearchMovieSendsRadarrIDAndKeyInHeader(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/providers/movies" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("X-API-KEY"); got != "secret" {
|
||||
t.Errorf("X-API-KEY = %q", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("radarrid"); got != "42" {
|
||||
t.Errorf("radarrid = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"language":"en","forced":false,"hearing_impaired":"False","provider":"opensubtitles",
|
||||
"subtitle":"opaque-token","score":97,"release_info":["Arrival.2016.1080p"],"uploader":"someone"}
|
||||
]`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
subtitles, err := New(upstream.URL, "secret", time.Second).SearchMovie(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(subtitles) != 1 {
|
||||
t.Fatalf("subtitles = %+v", subtitles)
|
||||
}
|
||||
got := subtitles[0]
|
||||
if got.Language != "en" || got.Score != 97 || got.Token != "opaque-token" {
|
||||
t.Fatalf("unexpected subtitle: %+v", got)
|
||||
}
|
||||
if got.HearingImpaired {
|
||||
t.Error(`hearing_impaired "False" decoded as true`)
|
||||
}
|
||||
}
|
||||
|
||||
// Bazarr has shipped both shapes for these fields across versions. Decoding either is what
|
||||
// stops an upgrade from silently turning every result into a plain, non-forced track.
|
||||
func TestSubtitleDecodesBoolAndStringFlagsAndBothLanguageShapes(t *testing.T) {
|
||||
for _, payload := range []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{"strings", `{"language":"it","forced":"True","hearing_impaired":"true","subtitle":"t"}`},
|
||||
{"bools", `{"language":"it","forced":true,"hearing_impaired":true,"subtitle":"t"}`},
|
||||
{"language object", `{"language":{"code2":"it","name":"Italian"},"forced":true,"hearing_impaired":1,"subtitle":"t"}`},
|
||||
} {
|
||||
t.Run(payload.name, func(t *testing.T) {
|
||||
var subtitle Subtitle
|
||||
if err := json.Unmarshal([]byte(payload.json), &subtitle); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if subtitle.Language != "it" {
|
||||
t.Errorf("language = %q", subtitle.Language)
|
||||
}
|
||||
if !subtitle.Forced {
|
||||
t.Error("forced = false")
|
||||
}
|
||||
// `1` is a number, not one of the accepted strings, so the last case is
|
||||
// deliberately allowed to be false — the point is that it decodes at all.
|
||||
if payload.name != "language object" && !subtitle.HearingImpaired {
|
||||
t.Error("hearing_impaired = false")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The result row is opaque and has to be handed back exactly as it arrived; reconstructing
|
||||
// it from the parsed fields is the mistake this pins against.
|
||||
func TestDownloadMoviePostsTheTokenVerbatim(t *testing.T) {
|
||||
var form url.Values
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/providers/movies" {
|
||||
t.Errorf("%s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form = r.PostForm
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
err := New(upstream.URL, "secret", time.Second).DownloadMovie(context.Background(), 42, Subtitle{
|
||||
Language: "en", Provider: "opensubtitles", Token: "opaque {token} with spaces", HearingImpaired: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := form.Get("subtitle"); got != "opaque {token} with spaces" {
|
||||
t.Errorf("subtitle = %q", got)
|
||||
}
|
||||
if got := form.Get("radarrid"); got != "42" {
|
||||
t.Errorf("radarrid = %q", got)
|
||||
}
|
||||
if got := form.Get("hi"); got != "true" {
|
||||
t.Errorf("hi = %q", got)
|
||||
}
|
||||
if got := form.Get("forced"); got != "false" {
|
||||
t.Errorf("forced = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadEpisodeCarriesBothIDs(t *testing.T) {
|
||||
var form url.Values
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
form = r.PostForm
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(``))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
err := New(upstream.URL, "secret", time.Second).
|
||||
DownloadEpisode(context.Background(), 8, 91, Subtitle{Language: "en", Token: "t"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if form.Get("seriesid") != "8" || form.Get("episodeid") != "91" {
|
||||
t.Errorf("form = %v", form)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListingsUnwrapTheDataEnvelopeAndAskForEverything(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("length"); got != "-1" && r.URL.Path != "/api/episodes" {
|
||||
t.Errorf("length = %q for %s", got, r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/movies":
|
||||
_, _ = w.Write([]byte(`{"data":[{"radarrId":42,"title":"Arrival","year":"2016"}]}`))
|
||||
case "/api/series":
|
||||
_, _ = w.Write([]byte(`{"data":[{"sonarrSeriesId":8,"title":"Severance","year":"2022"}]}`))
|
||||
case "/api/episodes":
|
||||
if got := r.URL.Query().Get("seriesid[]"); got != "8" {
|
||||
t.Errorf("seriesid[] = %q", got)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"data":[{"sonarrEpisodeId":91,"sonarrSeriesId":8,"season":3,"episode":4}]}`))
|
||||
}
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
client := New(upstream.URL, "secret", time.Second)
|
||||
movies, err := client.Movies(context.Background())
|
||||
if err != nil || len(movies) != 1 || movies[0].RadarrID != 42 {
|
||||
t.Fatalf("movies = %+v, err = %v", movies, err)
|
||||
}
|
||||
series, err := client.Series(context.Background())
|
||||
if err != nil || len(series) != 1 || series[0].SonarrSeriesID != 8 {
|
||||
t.Fatalf("series = %+v, err = %v", series, err)
|
||||
}
|
||||
episodes, err := client.Episodes(context.Background(), 8)
|
||||
if err != nil || len(episodes) != 1 || episodes[0].SonarrEpisodeID != 91 {
|
||||
t.Fatalf("episodes = %+v, err = %v", episodes, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamFailureCarriesTheStatus(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "no providers enabled", http.StatusBadRequest)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
_, err := New(upstream.URL, "secret", time.Second).SearchMovie(context.Background(), 1)
|
||||
apiErr, ok := err.(*APIError)
|
||||
if !ok {
|
||||
t.Fatalf("err = %T %v", err, err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d", apiErr.StatusCode)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user