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>
185 lines
6.1 KiB
Go
185 lines
6.1 KiB
Go
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)
|
|
}
|
|
}
|