0.2.55 - Remote config/Request fixes

This commit is contained in:
ponzischeme89
2026-08-12 09:57:56 +12:00
parent 4b31946635
commit 9777eb0952
35 changed files with 1086 additions and 78 deletions
+2 -2
View File
@@ -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 -2
View File
@@ -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');
+4
View File
@@ -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.
+36
View File
@@ -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)
}
+33
View File
@@ -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)
}
}
+48 -10
View File
@@ -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,
) {
+27 -1
View File
@@ -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)
}
}
}
+8
View File
@@ -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")), "/"),
+169
View File
@@ -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: "Matts 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")
}
}
+149 -2
View File
@@ -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
+27
View File
@@ -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,
+6 -4
View File
@@ -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
+6 -6
View File
@@ -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)
+6 -5
View File
@@ -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
+9 -9
View File
@@ -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 {