0.2.55 - Remote config/Request fixes
This commit is contained in:
+2
-1
@@ -8,7 +8,7 @@ COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN mkdir -p /out/releases
|
||||
RUN mkdir -p /out/releases /out/logs
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -buildvcs=false \
|
||||
-ldflags="-s -w" -o /out/memby-server ./cmd/memby-server
|
||||
|
||||
@@ -18,6 +18,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/memby-server /app/memby-server
|
||||
COPY --from=build --chown=nonroot:nonroot /out/releases /data/releases
|
||||
COPY --from=build --chown=nonroot:nonroot /out/logs /data/logs
|
||||
EXPOSE 8080
|
||||
USER nonroot:nonroot
|
||||
ENTRYPOINT ["/app/memby-server"]
|
||||
|
||||
+7
-1
@@ -70,6 +70,11 @@ same order: **who and where** first (`component`, `user`, `device`, `client`), t
|
||||
the event is about, with `version` (the gateway build) and `error` last. `MEMBY_LOG_FORMAT`
|
||||
switches the whole stream to `logfmt` (slog's own text) or `json` for a collector.
|
||||
|
||||
The same structured records are appended to `MEMBY_LOG_HISTORY_PATH` as JSONL and restored
|
||||
into the admin log when the gateway starts. Compose mounts that path from the persistent
|
||||
`memby-logs` volume, so replacing the container for a new version keeps the previous
|
||||
history. The archive is compacted to the configured buffer capacity and remains bounded.
|
||||
|
||||
Every line from a request carries the viewer, the television, the app build and the
|
||||
`component` — the part of the app the call came from, derived from the route, so it is
|
||||
right even for an APK too old to report anything about itself.
|
||||
@@ -608,7 +613,8 @@ can review and revoke signed-in TVs from the app's Settings screen.
|
||||
| `MEMBY_REDIS_URL` | `redis://localhost:6379/0` | |
|
||||
| `MEMBY_LISTEN_ADDR` | `:8080` outside Compose; `:32768` in the NAS stack | |
|
||||
| `MEMBY_LOG_LEVEL` | `INFO` | Use `DEBUG` for successful probe, status-poll and artwork requests |
|
||||
| `MEMBY_LOG_BUFFER_CAPACITY` | `5000` | Bounded in-memory admin event ring; `0` disables capture |
|
||||
| `MEMBY_LOG_BUFFER_CAPACITY` | `5000` | Bounded persistent admin event history; `0` disables capture |
|
||||
| `MEMBY_LOG_HISTORY_PATH` | `/data/logs/events.jsonl` | JSONL history restored after container replacement |
|
||||
| `MEMBY_GOMEMLIMIT` | `384MiB` | Compose value passed to Go as `GOMEMLIMIT` |
|
||||
| `MEMBY_SERVER_MEMORY_LIMIT` | `512m` | Compose hard memory ceiling for the server container |
|
||||
| `MEMBY_TIMEZONE` | `Pacific/Auckland` | Local day and time labels for schedule rows |
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Remote Config
|
||||
|
||||
Memby’s Remote Config is deliberately limited to copy, an optional presentation flag and
|
||||
bounded navigation-rail measurements. It cannot control authentication, playback, routes or
|
||||
any behaviour needed to reach and use the library.
|
||||
|
||||
Set `MEMBY_REMOTE_CONFIG_JSON` in the deployment’s `.env`, as one JSON line, then redeploy
|
||||
the gateway. The value is one complete document; partial documents and unknown fields stop the gateway
|
||||
at start-up instead of silently inventing a mixed configuration. Increase `configVersion`
|
||||
for every change. A television will validate and store the new document in the background,
|
||||
then activate it only when a new app process starts.
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"configVersion": 2,
|
||||
"minimumAppVersion": "0.2.54",
|
||||
"maximumAppVersion": "",
|
||||
"copy": {
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"forYou": "For You",
|
||||
"search": "Discover",
|
||||
"movies": "Films",
|
||||
"tvShows": "TV Shows",
|
||||
"tvCalendar": "TV Calendar",
|
||||
"favourites": "Favourites",
|
||||
"user": "User",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"tagline": "Your library, made personal"
|
||||
},
|
||||
"features": {
|
||||
"showNavigationVersion": false
|
||||
},
|
||||
"presentation": {
|
||||
"navigationRailExpandedWidthDp": 196,
|
||||
"navigationContentShiftDp": 120
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The gateway sends a content-derived `ETag`; an unchanged client receives `304 Not Modified`.
|
||||
The APK accepts schema 1 only, requires a strictly newer positive `configVersion`, checks its
|
||||
own version against the optional inclusive bounds, limits every piece of copy to 64 characters
|
||||
without control whitespace, and bounds the two measurements. A missing, slow, malformed or
|
||||
incompatible response leaves the last-known-good document untouched. If no valid cache exists,
|
||||
the complete APK defaults are used.
|
||||
@@ -49,7 +49,18 @@ func main() {
|
||||
logLevel := logging.ParseLevel(os.Getenv("MEMBY_LOG_LEVEL"))
|
||||
logCapacity := logging.ParseCapacity(os.Getenv("MEMBY_LOG_BUFFER_CAPACITY"), 5_000)
|
||||
logFormat := logging.ParseFormat(os.Getenv("MEMBY_LOG_FORMAT"))
|
||||
log, events := logging.NewBuffered(os.Stdout, logLevel, logCapacity, logFormat)
|
||||
logHistoryPath := strings.TrimSpace(os.Getenv("MEMBY_LOG_HISTORY_PATH"))
|
||||
if logHistoryPath == "" {
|
||||
logHistoryPath = "/data/logs/events.jsonl"
|
||||
}
|
||||
log, events, err := logging.NewPersistentBuffered(
|
||||
os.Stdout, logLevel, logCapacity, logFormat, logHistoryPath,
|
||||
)
|
||||
if err != nil {
|
||||
os.Stderr.WriteString("open persistent log history: " + err.Error() + "\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer events.Close()
|
||||
// Every line names the build that wrote it. A gateway is deployed from a working
|
||||
// tree, often while a television is running an older app, so "which server said
|
||||
// this" is a real question that a reader should never have to scroll for.
|
||||
|
||||
@@ -53,8 +53,8 @@ Admin.onStatus((status) => {
|
||||
const mdblist = status.mdblist || {};
|
||||
const forYou = status.forYou || {};
|
||||
$('overview-services').innerHTML =
|
||||
row('Radarr', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
|
||||
row('Sonarr', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
|
||||
row('Movies', status.radarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
|
||||
row('Series', status.sonarrReady ? ui.tag('ready', 'ok') : ui.tag('not configured', 'idle')) +
|
||||
row('MDBList ratings', mdblist.enabled
|
||||
? ui.tag(fmt.number(mdblist.cachedTitles) + ' titles stored', 'ok')
|
||||
: ui.tag(mdblist.apiKeyConfigured ? 'off · key saved' : 'off · no key', 'idle')) +
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="inbox" data-icon-tone="info">Where a request goes</h2>
|
||||
<p class="card-note">A film is added to Radarr and a show to Sonarr, both unmonitored.
|
||||
No download search starts on its own.</p>
|
||||
<p class="card-note">A movie or series is monitored and searched for immediately.
|
||||
The configured download service handles it from there.</p>
|
||||
</div>
|
||||
<span class="row tight" id="request-services"></span>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,9 @@ const { fmt, ui, $ } = Admin;
|
||||
|
||||
Admin.onStatus((status) => {
|
||||
$('request-services').innerHTML =
|
||||
ui.tag('Radarr ' + (status.radarrReady ? 'ready' : 'not configured'),
|
||||
ui.tag('Movies ' + (status.radarrReady ? 'ready' : 'not configured'),
|
||||
status.radarrReady ? 'ok' : 'bad') +
|
||||
ui.tag('Sonarr ' + (status.sonarrReady ? 'ready' : 'not configured'),
|
||||
ui.tag('Series ' + (status.sonarrReady ? 'ready' : 'not configured'),
|
||||
status.sonarrReady ? 'ok' : 'bad');
|
||||
|
||||
const box = $('request-users');
|
||||
|
||||
@@ -205,6 +205,10 @@ func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
mux.HandleFunc("GET /readyz", s.handleReady)
|
||||
// Remote Config is app-scoped, contains presentation data only, and warms the next
|
||||
// process. Keep it outside authentication and maintenance so offline/start-up fallback
|
||||
// never depends on a session being available.
|
||||
mux.HandleFunc("GET /v1/config", s.handleRemoteConfig)
|
||||
// Update policy is app-scoped, not user-scoped. Keep it outside authentication and
|
||||
// maintenance so a fresh install, a signed-out TV, and a retired build can all learn
|
||||
// whether the server requires an update. A valid session enriches only its log context.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// handleRemoteConfig serves one app-scoped, immutable-at-runtime document. It is public
|
||||
// for the same reason the update verdict is public: a fresh install and a signed-out TV
|
||||
// must be able to warm the next launch. No viewer or session data belongs in this answer.
|
||||
func (s *Server) handleRemoteConfig(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := json.Marshal(s.cfg.RemoteConfig)
|
||||
if err != nil {
|
||||
// Config is validated during start-up, so this is defensive rather than an expected
|
||||
// operational failure.
|
||||
writeError(w, http.StatusInternalServerError, "remote configuration unavailable")
|
||||
return
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
etag := `"rc-` + hex.EncodeToString(digest[:12]) + `"`
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("X-Memby-Config-Version", configVersionHeader(s.cfg.RemoteConfig.ConfigVersion))
|
||||
if r.Header.Get("If-None-Match") == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
writeRaw(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func configVersionHeader(version int64) string {
|
||||
return strconv.FormatInt(version, 10)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/ponzischeme89/memby/server/internal/config"
|
||||
)
|
||||
|
||||
func TestRemoteConfigSupportsETagRevalidationWithoutAuthentication(t *testing.T) {
|
||||
server := &Server{cfg: config.Config{RemoteConfig: config.DefaultRemoteConfig()}}
|
||||
first := httptest.NewRecorder()
|
||||
server.handleRemoteConfig(first, httptest.NewRequest(http.MethodGet, "/v1/config", nil))
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", first.Code)
|
||||
}
|
||||
etag := first.Header().Get("ETag")
|
||||
if etag == "" {
|
||||
t.Fatal("missing ETag")
|
||||
}
|
||||
if got := first.Header().Get("X-Memby-Config-Version"); got != "1" {
|
||||
t.Fatalf("version header = %q", got)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/v1/config", nil)
|
||||
request.Header.Set("If-None-Match", etag)
|
||||
second := httptest.NewRecorder()
|
||||
server.handleRemoteConfig(second, request)
|
||||
if second.Code != http.StatusNotModified {
|
||||
t.Fatalf("revalidation status = %d", second.Code)
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,7 @@ func sonarrCoverURL(images []sonarr.Image, kind string) string {
|
||||
type requestPayload struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
ForeignID int `json:"foreignId"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess store.Session) {
|
||||
@@ -185,16 +186,22 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
writeError(w, http.StatusBadRequest, "foreignId is required")
|
||||
return
|
||||
}
|
||||
req.Title = strings.TrimSpace(req.Title)
|
||||
if titleRunes := []rune(req.Title); len(titleRunes) > 240 {
|
||||
req.Title = string(titleRunes[:240])
|
||||
}
|
||||
|
||||
switch req.MediaType {
|
||||
case "movie":
|
||||
if s.radarr == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "Radarr is not configured")
|
||||
s.logMediaRequest(r.Context(), req, "failed", errors.New("movie requests are not configured"))
|
||||
writeError(w, http.StatusServiceUnavailable, "movie requests are not configured")
|
||||
return
|
||||
}
|
||||
movies, err := s.radarr.Lookup(r.Context(), "tmdb:"+strconv.Itoa(req.ForeignID))
|
||||
if err != nil {
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "Radarr lookup failed")
|
||||
s.logMediaRequest(r.Context(), req, "failed", err)
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "movie lookup failed")
|
||||
return
|
||||
}
|
||||
for _, movie := range movies {
|
||||
@@ -205,26 +212,33 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
// Idempotent under a lost response: OkHttp may replay a repeatable POST after
|
||||
// a connection reset. If the first request already added it, the retry is the
|
||||
// same successful action rather than an error shown to the viewer.
|
||||
req.Title = movie.Title
|
||||
s.logMediaRequest(r.Context(), req, "already added", nil)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": movie.Title})
|
||||
return
|
||||
}
|
||||
added, err := s.radarr.AddUnmonitored(r.Context(), movie)
|
||||
req.Title = movie.Title
|
||||
added, err := s.radarr.AddRequested(r.Context(), movie)
|
||||
if err != nil {
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "could not add movie to Radarr")
|
||||
s.logMediaRequest(r.Context(), req, "failed", err)
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that movie")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "movie", "title", added.Title)
|
||||
req.Title = added.Title
|
||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
|
||||
return
|
||||
}
|
||||
case "series":
|
||||
if s.sonarr == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "Sonarr is not configured")
|
||||
s.logMediaRequest(r.Context(), req, "failed", errors.New("series requests are not configured"))
|
||||
writeError(w, http.StatusServiceUnavailable, "series requests are not configured")
|
||||
return
|
||||
}
|
||||
series, err := s.sonarr.Lookup(r.Context(), "tvdb:"+strconv.Itoa(req.ForeignID))
|
||||
if err != nil {
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "Sonarr lookup failed")
|
||||
s.logMediaRequest(r.Context(), req, "failed", err)
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "series lookup failed")
|
||||
return
|
||||
}
|
||||
for _, show := range series {
|
||||
@@ -232,25 +246,49 @@ func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request, sess stor
|
||||
continue
|
||||
}
|
||||
if show.ID > 0 {
|
||||
req.Title = show.Title
|
||||
s.logMediaRequest(r.Context(), req, "already added", nil)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "already_added", "title": show.Title})
|
||||
return
|
||||
}
|
||||
added, err := s.sonarr.AddUnmonitored(r.Context(), show)
|
||||
req.Title = show.Title
|
||||
added, err := s.sonarr.AddRequested(r.Context(), show)
|
||||
if err != nil {
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "could not add series to Sonarr")
|
||||
s.logMediaRequest(r.Context(), req, "failed", err)
|
||||
s.writeRequestUpstreamError(r.Context(), w, err, "could not request that series")
|
||||
return
|
||||
}
|
||||
s.loggerFor(r.Context()).Info("media requested", "user", sess.Username, "type", "series", "title", added.Title)
|
||||
req.Title = added.Title
|
||||
s.logMediaRequest(r.Context(), req, "successful", nil)
|
||||
writeJSON(w, http.StatusCreated, map[string]any{"status": "requested", "title": added.Title})
|
||||
return
|
||||
}
|
||||
default:
|
||||
s.logMediaRequest(r.Context(), req, "failed", errors.New("unsupported media type"))
|
||||
writeError(w, http.StatusBadRequest, `mediaType must be "movie" or "series"`)
|
||||
return
|
||||
}
|
||||
s.logMediaRequest(r.Context(), req, "failed", errors.New("title was not found"))
|
||||
writeError(w, http.StatusNotFound, "title was not found")
|
||||
}
|
||||
|
||||
func (s *Server) logMediaRequest(
|
||||
ctx context.Context, req requestPayload, outcome string, err error,
|
||||
) {
|
||||
fields := []any{
|
||||
"type", req.MediaType,
|
||||
"title", clientLogValue(req.Title),
|
||||
"foreign_id", req.ForeignID,
|
||||
"outcome", outcome,
|
||||
}
|
||||
if err != nil {
|
||||
fields = append(fields, "error", err)
|
||||
s.loggerFor(ctx).Warn("media request failed", fields...)
|
||||
return
|
||||
}
|
||||
s.loggerFor(ctx).Info("media request "+outcome, fields...)
|
||||
}
|
||||
|
||||
func (s *Server) writeRequestUpstreamError(
|
||||
ctx context.Context, w http.ResponseWriter, err error, message string,
|
||||
) {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
serverlogging "github.com/ponzischeme89/memby/server/internal/logging"
|
||||
)
|
||||
|
||||
func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) {
|
||||
if requestMatchScore("the office", "The Office") >=
|
||||
@@ -11,3 +19,21 @@ func TestRequestMatchScorePrefersTheSpecificTitle(t *testing.T) {
|
||||
t.Fatal("exact title should rank ahead of a contained match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaRequestLogNamesTitleAndOutcome(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
server := &Server{log: serverlogging.New(&output, slog.LevelInfo)}
|
||||
server.logMediaRequest(context.Background(), requestPayload{
|
||||
MediaType: "series", ForeignID: 123, Title: "Severance",
|
||||
}, "successful", nil)
|
||||
|
||||
line := output.String()
|
||||
for _, want := range []string{
|
||||
"media request successful", "type=series", "title=Severance",
|
||||
"foreign_id=123", "outcome=successful",
|
||||
} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("log %q does not contain %q", line, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ type Config struct {
|
||||
RecommendTimeout time.Duration
|
||||
// RecommendationWeights is an optional JSON overlay on the weighted defaults.
|
||||
RecommendationWeights string
|
||||
// RemoteConfig is the complete, validated presentation document served to TVs.
|
||||
// It is app-scoped and intentionally contains no account, playback or routing state.
|
||||
RemoteConfig RemoteConfig
|
||||
|
||||
UpstreamTimeout time.Duration
|
||||
|
||||
@@ -138,6 +141,10 @@ type Config struct {
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
remoteConfig, err := loadRemoteConfig(os.Getenv("MEMBY_REMOTE_CONFIG_JSON"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
c := Config{
|
||||
ListenAddr: env("MEMBY_LISTEN_ADDR", ":8080"),
|
||||
EmbyURL: strings.TrimRight(os.Getenv("MEMBY_EMBY_URL"), "/"),
|
||||
@@ -156,6 +163,7 @@ func Load() (Config, error) {
|
||||
RecommendationWeights: strings.TrimSpace(
|
||||
os.Getenv("MEMBY_RECOMMENDATION_WEIGHTS"),
|
||||
),
|
||||
RemoteConfig: remoteConfig,
|
||||
|
||||
AdminToken: strings.TrimSpace(os.Getenv("MEMBY_ADMIN_TOKEN")),
|
||||
PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("MEMBY_PUBLIC_URL")), "/"),
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RemoteConfig is the deliberately small, presentation-only document offered to TVs.
|
||||
// It must never contain authentication, playback or navigation-routing decisions: an
|
||||
// unavailable document is required to be indistinguishable from an ordinary offline
|
||||
// launch apart from its wording and safe presentation choices.
|
||||
type RemoteConfig struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
ConfigVersion int64 `json:"configVersion"`
|
||||
MinimumAppVersion string `json:"minimumAppVersion,omitempty"`
|
||||
MaximumAppVersion string `json:"maximumAppVersion,omitempty"`
|
||||
Copy RemoteConfigCopy `json:"copy"`
|
||||
Features RemoteConfigFeatures `json:"features"`
|
||||
Presentation RemoteConfigPresentation `json:"presentation"`
|
||||
}
|
||||
|
||||
type RemoteConfigCopy struct {
|
||||
Navigation RemoteConfigNavigationCopy `json:"navigation"`
|
||||
Tagline string `json:"tagline"`
|
||||
}
|
||||
|
||||
type RemoteConfigNavigationCopy struct {
|
||||
Home string `json:"home"`
|
||||
ForYou string `json:"forYou"`
|
||||
Search string `json:"search"`
|
||||
Movies string `json:"movies"`
|
||||
TVShows string `json:"tvShows"`
|
||||
TVCalendar string `json:"tvCalendar"`
|
||||
Favourites string `json:"favourites"`
|
||||
User string `json:"user"`
|
||||
Settings string `json:"settings"`
|
||||
}
|
||||
|
||||
type RemoteConfigFeatures struct {
|
||||
ShowNavigationVersion bool `json:"showNavigationVersion"`
|
||||
}
|
||||
|
||||
type RemoteConfigPresentation struct {
|
||||
NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"`
|
||||
NavigationContentShiftDp int `json:"navigationContentShiftDp"`
|
||||
}
|
||||
|
||||
// DefaultRemoteConfig mirrors the APK's bundled values. Serving it is still useful: it
|
||||
// establishes the schema and ETag contract before an operator chooses an override.
|
||||
func DefaultRemoteConfig() RemoteConfig {
|
||||
return RemoteConfig{
|
||||
SchemaVersion: 1,
|
||||
ConfigVersion: 1,
|
||||
Copy: RemoteConfigCopy{
|
||||
Tagline: "Matt’s Android TV client",
|
||||
Navigation: RemoteConfigNavigationCopy{
|
||||
Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies",
|
||||
TVShows: "TV Shows", TVCalendar: "TV Calendar", Favourites: "Favourites",
|
||||
User: "User", Settings: "Settings",
|
||||
},
|
||||
},
|
||||
Features: RemoteConfigFeatures{ShowNavigationVersion: true},
|
||||
Presentation: RemoteConfigPresentation{
|
||||
NavigationRailExpandedWidthDp: 184,
|
||||
NavigationContentShiftDp: 112,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadRemoteConfig(raw string) (RemoteConfig, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return DefaultRemoteConfig(), nil
|
||||
}
|
||||
var document RemoteConfig
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&document); err != nil {
|
||||
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON must contain exactly one document")
|
||||
}
|
||||
if err := validateRemoteConfig(document); err != nil {
|
||||
return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err)
|
||||
}
|
||||
return document, nil
|
||||
}
|
||||
|
||||
func validateRemoteConfig(document RemoteConfig) error {
|
||||
if document.SchemaVersion != 1 {
|
||||
return fmt.Errorf("schemaVersion must be 1")
|
||||
}
|
||||
if document.ConfigVersion < 1 {
|
||||
return fmt.Errorf("configVersion must be positive")
|
||||
}
|
||||
minimum, minimumSet, err := parseRemoteConfigVersion(document.MinimumAppVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("minimumAppVersion must be a three-part version")
|
||||
}
|
||||
maximum, maximumSet, err := parseRemoteConfigVersion(document.MaximumAppVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("maximumAppVersion must be a three-part version")
|
||||
}
|
||||
if minimumSet && maximumSet && compareRemoteConfigVersions(minimum, maximum) > 0 {
|
||||
return fmt.Errorf("minimumAppVersion must not be newer than maximumAppVersion")
|
||||
}
|
||||
labels := []string{
|
||||
document.Copy.Tagline,
|
||||
document.Copy.Navigation.Home,
|
||||
document.Copy.Navigation.ForYou,
|
||||
document.Copy.Navigation.Search,
|
||||
document.Copy.Navigation.Movies,
|
||||
document.Copy.Navigation.TVShows,
|
||||
document.Copy.Navigation.TVCalendar,
|
||||
document.Copy.Navigation.Favourites,
|
||||
document.Copy.Navigation.User,
|
||||
document.Copy.Navigation.Settings,
|
||||
}
|
||||
for _, label := range labels {
|
||||
trimmed := strings.TrimSpace(label)
|
||||
if trimmed == "" || len([]rune(trimmed)) > 64 || strings.ContainsAny(trimmed, "\r\n\t") {
|
||||
return fmt.Errorf("copy must be between 1 and 64 characters and contain no control whitespace")
|
||||
}
|
||||
}
|
||||
width := document.Presentation.NavigationRailExpandedWidthDp
|
||||
shift := document.Presentation.NavigationContentShiftDp
|
||||
if width < 160 || width > 240 {
|
||||
return fmt.Errorf("navigationRailExpandedWidthDp must be between 160 and 240")
|
||||
}
|
||||
if shift < 80 || shift > 160 || shift >= width {
|
||||
return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRemoteConfigVersion(raw string) ([3]int, bool, error) {
|
||||
var version [3]int
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return version, false, nil
|
||||
}
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) != len(version) {
|
||||
return version, false, fmt.Errorf("invalid version")
|
||||
}
|
||||
for index, part := range parts {
|
||||
value, err := strconv.Atoi(part)
|
||||
if err != nil || value < 0 {
|
||||
return version, false, fmt.Errorf("invalid version")
|
||||
}
|
||||
version[index] = value
|
||||
}
|
||||
return version, true, nil
|
||||
}
|
||||
|
||||
func compareRemoteConfigVersions(left, right [3]int) int {
|
||||
for index := range left {
|
||||
if left[index] < right[index] {
|
||||
return -1
|
||||
}
|
||||
if left[index] > right[index] {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoteConfigDefaultsAreComplete(t *testing.T) {
|
||||
document, err := loadRemoteConfig("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if document.SchemaVersion != 1 || document.ConfigVersion != 1 {
|
||||
t.Fatalf("unexpected versions: %+v", document)
|
||||
}
|
||||
if document.Copy.Navigation.Favourites != "Favourites" {
|
||||
t.Fatalf("favourites label = %q", document.Copy.Navigation.Favourites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteConfigRejectsMalformedAndUnsafeDocuments(t *testing.T) {
|
||||
document := DefaultRemoteConfig()
|
||||
document.Presentation.NavigationRailExpandedWidthDp = 500
|
||||
raw, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRemoteConfig(string(raw)); err == nil {
|
||||
t.Fatal("unsafe presentation value was accepted")
|
||||
}
|
||||
|
||||
if _, err := loadRemoteConfig(`{"schemaVersion":1,"unknown":true}`); err == nil {
|
||||
t.Fatal("unknown fields were accepted")
|
||||
}
|
||||
|
||||
document = DefaultRemoteConfig()
|
||||
document.MinimumAppVersion = "0.3.0"
|
||||
document.MaximumAppVersion = "0.2.54"
|
||||
raw, err = json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRemoteConfig(string(raw)); err == nil {
|
||||
t.Fatal("reversed app-version bounds were accepted")
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -90,6 +94,18 @@ type Buffer struct {
|
||||
events []Event
|
||||
start int
|
||||
next atomic.Int64
|
||||
history *historyFile
|
||||
}
|
||||
|
||||
// historyFile is an append-only JSONL archive of the same structured events the admin
|
||||
// console reads. It is compacted to the ring's retained tail at startup and after each
|
||||
// further ringful, so persistence cannot become an unbounded disk cost.
|
||||
type historyFile struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
file *os.File
|
||||
capacity int
|
||||
lastCompacted int64
|
||||
}
|
||||
|
||||
// ParseCapacity returns a non-negative log buffer capacity from configuration.
|
||||
@@ -131,6 +147,69 @@ func NewBuffered(
|
||||
return slog.New(&captureHandler{next: written, buffer: buffer, level: level}), buffer
|
||||
}
|
||||
|
||||
// NewPersistentBuffered restores retained events from path before accepting new ones and
|
||||
// appends every subsequent accepted record. The caller should close the returned Buffer.
|
||||
// An empty path keeps the in-memory behaviour used by unit tests and small embeddings.
|
||||
func NewPersistentBuffered(
|
||||
w io.Writer, level slog.Leveler, capacity int, format Format, path string,
|
||||
) (*slog.Logger, *Buffer, error) {
|
||||
logger, buffer := NewBuffered(w, level, capacity, format)
|
||||
path = strings.TrimSpace(path)
|
||||
if capacity <= 0 || path == "" {
|
||||
return logger, buffer, nil
|
||||
}
|
||||
history, restored, err := openHistory(path, capacity)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
buffer.history = history
|
||||
buffer.events = restored
|
||||
if len(restored) > 0 {
|
||||
buffer.next.Store(restored[len(restored)-1].Sequence)
|
||||
}
|
||||
return logger, buffer, nil
|
||||
}
|
||||
|
||||
func openHistory(path string, capacity int) (*historyFile, []Event, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
input, err := os.OpenFile(path, os.O_CREATE|os.O_RDONLY, 0o640)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
restored := make([]Event, 0, capacity)
|
||||
scanner := bufio.NewScanner(input)
|
||||
// Error attributes are capped upstream, but allow headroom for structured records.
|
||||
scanner.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for scanner.Scan() {
|
||||
var event Event
|
||||
if json.Unmarshal(scanner.Bytes(), &event) != nil || event.Sequence <= 0 {
|
||||
continue
|
||||
}
|
||||
restored = append(restored, event)
|
||||
if len(restored) > capacity {
|
||||
copy(restored, restored[len(restored)-capacity:])
|
||||
restored = restored[:capacity]
|
||||
}
|
||||
}
|
||||
closeErr := input.Close()
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, nil, closeErr
|
||||
}
|
||||
history := &historyFile{path: path, capacity: capacity}
|
||||
if len(restored) > 0 {
|
||||
history.lastCompacted = restored[len(restored)-1].Sequence
|
||||
}
|
||||
if err := history.rewrite(restored); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return history, restored, nil
|
||||
}
|
||||
|
||||
type captureHandler struct {
|
||||
next slog.Handler
|
||||
buffer *Buffer
|
||||
@@ -197,9 +276,77 @@ func (b *Buffer) append(event Event) {
|
||||
if len(b.events) == b.capacity {
|
||||
b.events[b.start] = event
|
||||
b.start = (b.start + 1) % b.capacity
|
||||
return
|
||||
} else {
|
||||
b.events = append(b.events, event)
|
||||
}
|
||||
b.events = append(b.events, event)
|
||||
if b.history != nil {
|
||||
ordered := b.orderedEventsLocked()
|
||||
_ = b.history.append(event, ordered)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Buffer) orderedEventsLocked() []Event {
|
||||
ordered := make([]Event, len(b.events))
|
||||
for i := range b.events {
|
||||
ordered[i] = b.events[(b.start+i)%len(b.events)]
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (h *historyFile) append(event Event, retained []Event) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if event.Sequence-h.lastCompacted >= int64(h.capacity) {
|
||||
if err := h.rewriteLocked(retained); err != nil {
|
||||
return err
|
||||
}
|
||||
h.lastCompacted = event.Sequence
|
||||
return nil
|
||||
}
|
||||
return json.NewEncoder(h.file).Encode(event)
|
||||
}
|
||||
|
||||
func (h *historyFile) rewrite(events []Event) error {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.rewriteLocked(events)
|
||||
}
|
||||
|
||||
func (h *historyFile) rewriteLocked(events []Event) error {
|
||||
if h.file != nil {
|
||||
if err := h.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
file, err := os.OpenFile(h.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoder := json.NewEncoder(file)
|
||||
for _, event := range events {
|
||||
if err := encoder.Encode(event); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
h.file, err = os.OpenFile(h.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o640)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close flushes the persistent archive. It is safe for an in-memory Buffer.
|
||||
func (b *Buffer) Close() error {
|
||||
if b == nil || b.history == nil {
|
||||
return nil
|
||||
}
|
||||
b.history.mu.Lock()
|
||||
defer b.history.mu.Unlock()
|
||||
if b.history.file == nil {
|
||||
return nil
|
||||
}
|
||||
return b.history.file.Close()
|
||||
}
|
||||
|
||||
// Events returns records strictly newer than after, up to limit. If the caller fell
|
||||
|
||||
@@ -3,6 +3,7 @@ package logging
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -114,6 +115,32 @@ func TestBufferedLoggerRetainsStructuredEventsWithCursorPagination(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistentBufferRestoresTheRetainedTail(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "events.jsonl")
|
||||
var output bytes.Buffer
|
||||
logger, first, err := NewPersistentBuffered(&output, slog.LevelInfo, 3, FormatConsole, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 1; i <= 5; i++ {
|
||||
logger.Info("request", "number", i)
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, restored, err := NewPersistentBuffered(&output, slog.LevelInfo, 3, FormatConsole, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer restored.Close()
|
||||
page := restored.Events(0, 10)
|
||||
if len(page.Events) != 3 || page.Events[0].Attributes["number"] != "3" ||
|
||||
page.Events[2].Attributes["number"] != "5" {
|
||||
t.Fatalf("restored events = %+v", page.Events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
tests := map[string]slog.Level{
|
||||
"": slog.LevelInfo,
|
||||
|
||||
@@ -116,8 +116,10 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Movie, error) {
|
||||
return movies, nil
|
||||
}
|
||||
|
||||
// AddUnmonitored adds a title without starting a search or monitoring future releases.
|
||||
func (c *Client) AddUnmonitored(ctx context.Context, movie Movie) (Movie, error) {
|
||||
// AddRequested adds a title, monitors it and asks Radarr to search for it immediately.
|
||||
// A request that merely creates an unmonitored catalogue row never reaches a downloader,
|
||||
// which is indistinguishable from a broken button to the viewer who made it.
|
||||
func (c *Client) AddRequested(ctx context.Context, movie Movie) (Movie, error) {
|
||||
var roots []RootFolder
|
||||
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
|
||||
return Movie{}, err
|
||||
@@ -132,11 +134,11 @@ func (c *Client) AddUnmonitored(ctx context.Context, movie Movie) (Movie, error)
|
||||
movie.ID = 0
|
||||
movie.RootFolderPath = roots[0].Path
|
||||
movie.QualityProfileID = profiles[0].ID
|
||||
movie.Monitored = false
|
||||
movie.Monitored = true
|
||||
body := struct {
|
||||
Movie
|
||||
AddOptions map[string]bool `json:"addOptions"`
|
||||
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": false}}
|
||||
}{Movie: movie, AddOptions: map[string]bool{"searchForMovie": true}}
|
||||
var added Movie
|
||||
if err := c.post(ctx, "/api/v3/movie", body, &added); err != nil {
|
||||
return Movie{}, err
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/rootfolder":
|
||||
@@ -60,13 +60,13 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["monitored"] != false || body["rootFolderPath"] != "/movies" ||
|
||||
if body["monitored"] != true || body["rootFolderPath"] != "/movies" ||
|
||||
body["qualityProfileId"] != float64(4) {
|
||||
t.Errorf("unexpected add body: %#v", body)
|
||||
}
|
||||
options := body["addOptions"].(map[string]any)
|
||||
if options["searchForMovie"] != false {
|
||||
t.Errorf("movie search was enabled: %#v", body)
|
||||
if options["searchForMovie"] != true {
|
||||
t.Errorf("movie search was not enabled: %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":9,"tmdbId":22,"title":"Arrival"}`))
|
||||
default:
|
||||
@@ -75,8 +75,8 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
|
||||
context.Background(), Movie{TMDBID: 22, Title: "Arrival", Monitored: true},
|
||||
added, err := New(upstream.URL, "secret", time.Second).AddRequested(
|
||||
context.Background(), Movie{TMDBID: 22, Title: "Arrival"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -168,8 +168,9 @@ func (c *Client) Lookup(ctx context.Context, term string) ([]Series, error) {
|
||||
return series, nil
|
||||
}
|
||||
|
||||
// AddUnmonitored adds a series without monitoring it or starting an episode search.
|
||||
func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, error) {
|
||||
// AddRequested adds a series, monitors its seasons and asks Sonarr to search for missing
|
||||
// episodes immediately. An unmonitored catalogue row does not fulfil a media request.
|
||||
func (c *Client) AddRequested(ctx context.Context, series Series) (Series, error) {
|
||||
var roots []RootFolder
|
||||
if err := c.get(ctx, "/api/v3/rootfolder", &roots); err != nil {
|
||||
return Series{}, err
|
||||
@@ -184,15 +185,15 @@ func (c *Client) AddUnmonitored(ctx context.Context, series Series) (Series, err
|
||||
series.ID = 0
|
||||
series.RootFolderPath = roots[0].Path
|
||||
series.QualityProfileID = profiles[0].ID
|
||||
series.Monitored = false
|
||||
series.Monitored = true
|
||||
series.SeasonFolder = true
|
||||
for i := range series.Seasons {
|
||||
series.Seasons[i].Monitored = false
|
||||
series.Seasons[i].Monitored = true
|
||||
}
|
||||
body := struct {
|
||||
Series
|
||||
AddOptions map[string]bool `json:"addOptions"`
|
||||
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": false}}
|
||||
}{Series: series, AddOptions: map[string]bool{"searchForMissingEpisodes": true}}
|
||||
var added Series
|
||||
if err := c.post(ctx, "/api/v3/series", body, &added); err != nil {
|
||||
return Series{}, err
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestCalendarUsesV3APIAndKeepsKeyInHeader(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
func TestAddRequestedUsesDefaultsAndStartsSearch(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/v3/rootfolder":
|
||||
@@ -58,17 +58,17 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["monitored"] != false || body["seasonFolder"] != true ||
|
||||
if body["monitored"] != true || body["seasonFolder"] != true ||
|
||||
body["rootFolderPath"] != "/tv" || body["qualityProfileId"] != float64(3) {
|
||||
t.Errorf("unexpected add body: %#v", body)
|
||||
}
|
||||
seasons := body["seasons"].([]any)
|
||||
if seasons[0].(map[string]any)["monitored"] != false {
|
||||
t.Errorf("season remained monitored: %#v", body)
|
||||
if seasons[0].(map[string]any)["monitored"] != true {
|
||||
t.Errorf("season was not monitored: %#v", body)
|
||||
}
|
||||
options := body["addOptions"].(map[string]any)
|
||||
if options["searchForMissingEpisodes"] != false {
|
||||
t.Errorf("episode search was enabled: %#v", body)
|
||||
if options["searchForMissingEpisodes"] != true {
|
||||
t.Errorf("episode search was not enabled: %#v", body)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"id":8,"tvdbId":44,"title":"Severance"}`))
|
||||
default:
|
||||
@@ -77,10 +77,10 @@ func TestAddUnmonitoredUsesDefaultsWithoutStartingSearch(t *testing.T) {
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
added, err := New(upstream.URL, "secret", time.Second).AddUnmonitored(
|
||||
added, err := New(upstream.URL, "secret", time.Second).AddRequested(
|
||||
context.Background(), Series{
|
||||
TVDBID: 44, Title: "Severance", Monitored: true,
|
||||
Seasons: []Season{{SeasonNumber: 1, Monitored: true}},
|
||||
TVDBID: 44, Title: "Severance",
|
||||
Seasons: []Season{{SeasonNumber: 1}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user